- {
- options.map(option => (
-
{
- e.stopPropagation()
- e.nativeEvent.stopImmediatePropagation()
- onChange(option.value)
- setOpen(false)
- }}
- >
-
- {
- value === option.value && (
-
- )
- }
-
-
-
{option.label}
-
{option.description}
-
+ {options.map(option => (
+
+
+
- ))
- }
-
-
-
+
+
{option.label}
+
{option.description}
+
+
+ ))}
+
+
+
)
}
From 28a26f2d59472ebcc30ede6c00ed19ac7c54f968 Mon Sep 17 00:00:00 2001
From: yyh <92089059+lyzno1@users.noreply.github.com>
Date: Tue, 28 Apr 2026 14:09:02 +0800
Subject: [PATCH 2/9] refactor: improve scrollbar handling in plugin and model
selector UI (#35630)
---
.../create/step-two/language-select/index.tsx | 1 -
.../model-selector/__tests__/popup.spec.tsx | 25 +-
.../model-selector/index.tsx | 2 +-
.../model-selector/marketplace-section.tsx | 98 ++++++++
.../model-selector/popup-empty-state.tsx | 39 ++++
.../model-selector/popup-item.tsx | 3 +-
.../model-selector/popup-layout.tsx | 130 +++++++++++
.../model-selector/popup.tsx | 215 +++++-------------
.../components/plugin-section.tsx | 12 +-
.../components/plugin-task-list.tsx | 12 +-
.../plugin-page/plugin-tasks/index.tsx | 2 +-
11 files changed, 370 insertions(+), 169 deletions(-)
create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/marketplace-section.tsx
create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx
create mode 100644 web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx
diff --git a/web/app/components/datasets/create/step-two/language-select/index.tsx b/web/app/components/datasets/create/step-two/language-select/index.tsx
index fdef23ff27..bd1eee3df6 100644
--- a/web/app/components/datasets/create/step-two/language-select/index.tsx
+++ b/web/app/components/datasets/create/step-two/language-select/index.tsx
@@ -42,7 +42,6 @@ const LanguageSelect: FC
= ({
placement="bottom-start"
sideOffset={4}
popupClassName="w-max"
- listClassName="no-scrollbar"
>
{supportedLanguages.map(({ prompt_name }) => (
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
index 318b5bcd73..a440313b3c 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
@@ -219,8 +219,8 @@ describe('Popup', () => {
expect(screen.queryByText('common.modelProvider.selector.onlyCompatibleModelsShown')).not.toBeInTheDocument()
})
- it('should show compatible-only helper banner when scope features are applied', () => {
- const { container } = renderPopup(
+ it('should show compatible-only helper text when scope features are applied', () => {
+ renderPopup(
{
expect(screen.getByTestId('compatible-models-banner'))!.toBeInTheDocument()
expect(screen.getByText('common.modelProvider.selector.onlyCompatibleModelsShown'))!.toBeInTheDocument()
- expect(container.querySelector('.i-ri-information-2-fill'))!.toBeInTheDocument()
+ })
+
+ it('should keep search and footer outside the scrollable model list', () => {
+ renderPopup(
+ ,
+ )
+
+ const scrollRegion = screen.getByRole('region', { name: 'common.modelProvider.models' })
+ const searchInput = screen.getByPlaceholderText('datasetSettings.form.searchModel')
+ const settingsButton = screen.getByRole('button', { name: /common\.modelProvider\.selector\.modelProviderSettings/ })
+
+ expect(scrollRegion)!.toBeInTheDocument()
+ expect(scrollRegion).not.toContainElement(searchInput)
+ expect(scrollRegion).not.toContainElement(settingsButton)
+ expect(scrollRegion).toContainElement(screen.getByTestId('compatible-models-banner'))
})
it('should filter by scope features including toolCall and non-toolCall checks', () => {
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
index 9241c592f5..835821fd59 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/index.tsx
@@ -88,7 +88,7 @@ const ModelSelector: FC = ({
placement="bottom-start"
sideOffset={4}
className={popupClassName}
- popupClassName="overflow-hidden rounded-lg"
+ popupClassName="overflow-hidden rounded-xl"
popupProps={{ style: { minWidth: '320px', width: 'var(--anchor-width, auto)' } }}
>
void
+ onInstallPlugin: (key: ModelProviderQuotaGetPaid) => void | Promise
+}
+
+const MarketplaceSection: FC = ({
+ marketplaceProviders,
+ marketplaceCollapsed,
+ installingProvider,
+ isMarketplacePluginsLoading,
+ theme,
+ onMarketplaceCollapsedChange,
+ onInstallPlugin,
+}) => {
+ const { t } = useTranslation()
+
+ if (marketplaceProviders.length === 0)
+ return null
+
+ return (
+ <>
+
+
+
+
onMarketplaceCollapsedChange(!marketplaceCollapsed)}
+ >
+ {t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
+
+
+
+ {!marketplaceCollapsed && (
+
+ {marketplaceProviders.map((key) => {
+ const Icon = providerIconMap[key]
+ const isInstalling = installingProvider === key
+ return (
+
+
+
+ {modelNameMap[key]}
+
+
+
+ )
+ })}
+
+
+ {t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
+
+
+
+
+ )}
+
+ >
+ )
+}
+
+export default MarketplaceSection
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx
new file mode 100644
index 0000000000..dafd26387b
--- /dev/null
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-empty-state.tsx
@@ -0,0 +1,39 @@
+import type { FC } from 'react'
+import { Button } from '@langgenius/dify-ui/button'
+import { useTranslation } from 'react-i18next'
+
+type ModelSelectorEmptyStateProps = {
+ onConfigure: () => void
+}
+
+const ModelSelectorEmptyState: FC = ({
+ onConfigure,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
+
+ {t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
+
+
+ {t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
+
+
+
+
+ )
+}
+
+export default ModelSelectorEmptyState
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx
index 72c52a9429..ff9e6575bb 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-item.tsx
@@ -107,7 +107,8 @@ const PopupItem: FC = ({
return (
-
+ {/* Keep the sticky provider header above model rows while the list scrolls. */}
+
setCollapsed(prev => !prev)}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx
new file mode 100644
index 0000000000..50bd098af1
--- /dev/null
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup-layout.tsx
@@ -0,0 +1,130 @@
+import type { FC, ReactNode } from 'react'
+import {
+ ScrollAreaContent,
+ ScrollAreaRoot,
+ ScrollAreaScrollbar,
+ ScrollAreaThumb,
+ ScrollAreaViewport,
+} from '@langgenius/dify-ui/scroll-area'
+import { useTranslation } from 'react-i18next'
+
+type ModelSelectorPopupFrameProps = {
+ children: ReactNode
+}
+
+export const ModelSelectorPopupFrame: FC
= ({
+ children,
+}) => {
+ return (
+
+ {children}
+
+ )
+}
+
+type ModelSelectorSearchHeaderProps = {
+ searchText: string
+ onSearchTextChange: (value: string) => void
+}
+
+export const ModelSelectorSearchHeader: FC = ({
+ searchText,
+ onSearchTextChange,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ onSearchTextChange(e.target.value)}
+ />
+ {
+ searchText && (
+ onSearchTextChange('')}
+ />
+ )
+ }
+
+
+ )
+}
+
+type ModelSelectorScrollBodyProps = {
+ children: ReactNode
+ label: string
+}
+
+export const ModelSelectorScrollBody: FC = ({
+ children,
+ label,
+}) => {
+ return (
+
+
+
+ {children}
+
+
+ {/* Keep the overlay scrollbar above sticky provider headers inside this scroll area. */}
+
+
+
+
+ )
+}
+
+export const CompatibleModelsNotice = () => {
+ const { t } = useTranslation()
+
+ return (
+
+ {t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
+
+ )
+}
+
+type ModelProviderSettingsFooterProps = {
+ onOpenSettings: () => void
+}
+
+export const ModelProviderSettingsFooter: FC = ({
+ onOpenSettings,
+}) => {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+ )
+}
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
index 47ddb55b6c..e2224d18a8 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/popup.tsx
@@ -5,8 +5,6 @@ import type {
ModelItem,
} from '../declarations'
import type { ModelProviderQuotaGetPaid } from '@/types/model-provider'
-import { Button } from '@langgenius/dify-ui/button'
-import { cn } from '@langgenius/dify-ui/cn'
import { useSuspenseQuery } from '@tanstack/react-query'
import { useTheme } from 'next-themes'
import { useCallback, useMemo, useState } from 'react'
@@ -19,7 +17,6 @@ import { useProviderContext } from '@/context/provider-context'
import { systemFeaturesQueryOptions } from '@/service/system-features'
import { useInstallPackageFromMarketPlace } from '@/service/use-plugins'
import { supportFunctionCall } from '@/utils/tool-call'
-import { getMarketplaceUrl } from '@/utils/var'
import {
CustomConfigurationStatusEnum,
ModelFeatureEnum,
@@ -29,8 +26,17 @@ import { useLanguage, useMarketplaceAllPlugins } from '../hooks'
import CreditsExhaustedAlert from '../provider-added-card/model-auth-dropdown/credits-exhausted-alert'
import { useTrialCredits } from '../provider-added-card/use-trial-credits'
import { providerSupportsCredits } from '../supports-credits'
-import { MODEL_PROVIDER_QUOTA_GET_PAID, modelNameMap, providerIconMap, providerKeyToPluginId } from '../utils'
+import { MODEL_PROVIDER_QUOTA_GET_PAID, providerKeyToPluginId } from '../utils'
+import MarketplaceSection from './marketplace-section'
+import ModelSelectorEmptyState from './popup-empty-state'
import PopupItem from './popup-item'
+import {
+ CompatibleModelsNotice,
+ ModelProviderSettingsFooter,
+ ModelSelectorPopupFrame,
+ ModelSelectorScrollBody,
+ ModelSelectorSearchHeader,
+} from './popup-layout'
type PopupProps = {
defaultModel?: DefaultModel
@@ -181,166 +187,59 @@ const Popup: FC = ({
return MODEL_PROVIDER_QUOTA_GET_PAID.filter(key => !installedProviders.has(key))
}, [modelProviders])
+ const handleOpenSettings = useCallback(() => {
+ onHide()
+ setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
+ }, [onHide, setShowAccountSettingModal])
+
return (
-
-
-
-
- setSearchText(e.target.value)}
- />
- {
- searchText && (
- setSearchText('')}
- />
- )
- }
-
- {scopeFeatures.length > 0 && (
-
-
-
- {t('modelProvider.selector.onlyCompatibleModelsShown', { ns: 'common' })}
-
-
- )}
-
+
+
{showCreditsExhaustedAlert && (
)}
-
- {
- filteredModelList.map(model => (
-
+
+ {
+ filteredModelList.map(model => (
+
+ ))
+ }
+ {!filteredModelList.length && !installedModelList.length && (
+
- ))
- }
- {!filteredModelList.length && !installedModelList.length && (
-
-
-
+ )}
+ {!filteredModelList.length && installedModelList.length > 0 && (
+
+ {`No model found for \u201C${searchText}\u201D`}
-
-
- {t('modelProvider.selector.noProviderConfigured', { ns: 'common' })}
-
-
- {t('modelProvider.selector.noProviderConfiguredDesc', { ns: 'common' })}
-
-
-
-
- )}
- {!filteredModelList.length && installedModelList.length > 0 && (
-
- {`No model found for \u201C${searchText}\u201D`}
-
- )}
- {marketplaceProviders.length > 0 && (
- <>
-
-
-
-
setMarketplaceCollapsed(prev => !prev)}
- >
- {t('modelProvider.selector.fromMarketplace', { ns: 'common' })}
-
-
-
- {!marketplaceCollapsed && (
- <>
- {marketplaceProviders.map((key) => {
- const Icon = providerIconMap[key]
- const isInstalling = installingProvider === key
- return (
-
-
-
- {modelNameMap[key]}
-
-
-
- )
- })}
-
-
- {t('modelProvider.selector.discoverMoreInMarketplace', { ns: 'common' })}
-
-
-
- >
- )}
-
- >
- )}
-
-
{
- onHide()
- setShowAccountSettingModal({ payload: ACCOUNT_SETTING_TAB.PROVIDER })
- }}
- >
-
- {t('modelProvider.selector.modelProviderSettings', { ns: 'common' })}
-
-
+ )}
+ {scopeFeatures.length > 0 && (
+
+ )}
+
+
+
+
+
)
}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx
index 0d0c793741..7533d06d5f 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-section.tsx
@@ -1,6 +1,7 @@
import type { FC, ReactNode } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import type { Locale } from '@/i18n-config'
+import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import PluginItem from './plugin-item'
type PluginSectionProps = {
@@ -43,7 +44,14 @@ const PluginSection: FC
= ({
)
{headerAction}
-
+
{plugins.map(plugin => (
= ({
: undefined}
/>
))}
-
+
>
)
}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx
index 24fcf85cde..cc2eed1dbb 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/components/plugin-task-list.tsx
@@ -1,6 +1,7 @@
import type { FC } from 'react'
import type { PluginStatus } from '@/app/components/plugins/types'
import { Button } from '@langgenius/dify-ui/button'
+import { ScrollArea } from '@langgenius/dify-ui/scroll-area'
import { useTranslation } from 'react-i18next'
import { useGetLanguage } from '@/context/i18n'
import ErrorPluginItem from './error-plugin-item'
@@ -86,7 +87,14 @@ const PluginTaskList: FC = ({
{t('task.clearAll', { ns: 'plugin' })}
-
+
{errorPlugins.map(plugin => (
= ({
onClear={() => onClearSingle(plugin.taskId, plugin.plugin_unique_identifier)}
/>
))}
-
+
>
)}
diff --git a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
index f3102c4909..00fcb7e072 100644
--- a/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
+++ b/web/app/components/plugins/plugin-page/plugin-tasks/index.tsx
@@ -117,7 +117,7 @@ const PluginTasks = () => {
Date: Tue, 28 Apr 2026 14:29:48 +0800
Subject: [PATCH 3/9] test: cover shared workflow app run (#35634)
---
.github/workflows/web-e2e.yml | 2 +-
e2e/features/apps/share-app.feature | 7 +++++++
.../step-definitions/apps/share-app.steps.ts | 12 ++++++++++++
.../step-definitions/apps/workflow-run.steps.ts | 6 ++++--
4 files changed, 24 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/web-e2e.yml b/.github/workflows/web-e2e.yml
index a634830fef..bdc24887db 100644
--- a/.github/workflows/web-e2e.yml
+++ b/.github/workflows/web-e2e.yml
@@ -13,7 +13,7 @@ concurrency:
jobs:
test:
name: Web Full-Stack E2E
- runs-on: depot-ubuntu-24.04
+ runs-on: depot-ubuntu-24.04-4
defaults:
run:
shell: bash
diff --git a/e2e/features/apps/share-app.feature b/e2e/features/apps/share-app.feature
index 22f89f7ebb..1c707306ef 100644
--- a/e2e/features/apps/share-app.feature
+++ b/e2e/features/apps/share-app.feature
@@ -17,3 +17,10 @@ Feature: Share app publicly
Given a workflow app has been published and shared via API
When I open the shared app URL
Then the shared app page should be accessible
+
+ @unauthenticated
+ Scenario: Run a shared workflow app without authentication
+ Given a workflow app has been published and shared via API
+ When I open the shared app URL
+ And I run the shared workflow app
+ Then the shared workflow run should succeed
diff --git a/e2e/features/step-definitions/apps/share-app.steps.ts b/e2e/features/step-definitions/apps/share-app.steps.ts
index 24da05baab..d5742bdaa8 100644
--- a/e2e/features/step-definitions/apps/share-app.steps.ts
+++ b/e2e/features/step-definitions/apps/share-app.steps.ts
@@ -37,3 +37,15 @@ Then('the shared app page should be accessible', async function (this: DifyWorld
await expect(this.getPage()).toHaveURL(/\/(workflow|chat)\/[a-zA-Z0-9]+/, { timeout: 15_000 })
await expect(this.getPage().locator('body')).toBeVisible({ timeout: 10_000 })
})
+
+When('I run the shared workflow app', async function (this: DifyWorld) {
+ const page = this.getPage()
+ const runButton = page.getByTestId('run-button')
+
+ await expect(runButton).toBeEnabled({ timeout: 15_000 })
+ await runButton.click()
+})
+
+Then('the shared workflow run should succeed', async function (this: DifyWorld) {
+ await expect(this.getPage().getByTestId('status-icon-success')).toBeVisible({ timeout: 55_000 })
+})
diff --git a/e2e/features/step-definitions/apps/workflow-run.steps.ts b/e2e/features/step-definitions/apps/workflow-run.steps.ts
index 584a33e774..84c03bfa8f 100644
--- a/e2e/features/step-definitions/apps/workflow-run.steps.ts
+++ b/e2e/features/step-definitions/apps/workflow-run.steps.ts
@@ -12,8 +12,10 @@ Given('a minimal runnable workflow draft has been synced', async function (this:
When('I run the workflow', async function (this: DifyWorld) {
const page = this.getPage()
- await page.getByText('Test Run').click()
- await expect(page.getByText('Running').first()).toBeVisible({ timeout: 15_000 })
+ const testRunButton = page.getByText('Test Run')
+
+ await expect(testRunButton).toBeVisible({ timeout: 15_000 })
+ await testRunButton.click()
})
Then('the workflow run should succeed', async function (this: DifyWorld) {
From b8dea561989a70b18f2281779f714cd906b2707f Mon Sep 17 00:00:00 2001
From: L1nSn0w
Date: Tue, 28 Apr 2026 14:53:10 +0800
Subject: [PATCH 4/9] fix(ci): wait for mysql to accept queries before db
migration (#35631)
---
.github/workflows/db-migration-test.yml | 22 ++++++++++++++++++++++
docker/docker-compose.middleware.yaml | 14 ++++++++++----
2 files changed, 32 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/db-migration-test.yml b/.github/workflows/db-migration-test.yml
index b1ccf496df..65f0149a74 100644
--- a/.github/workflows/db-migration-test.yml
+++ b/.github/workflows/db-migration-test.yml
@@ -110,6 +110,28 @@ jobs:
sed -i 's/DB_PORT=5432/DB_PORT=3306/' .env
sed -i 's/DB_USERNAME=postgres/DB_USERNAME=root/' .env
+ # hoverkraft-tech/compose-action@v2.6.0 only waits for `docker compose up -d`
+ # to return (container processes started); it does not wait on healthcheck
+ # status. mysql:8.0's first-time init takes 15-30s, so without an explicit
+ # wait the migration runs while InnoDB is still initialising and gets
+ # killed with "Lost connection during query". Poll a real SELECT until it
+ # succeeds.
+ - name: Wait for MySQL to accept queries
+ run: |
+ set +e
+ for i in $(seq 1 60); do
+ if docker run --rm --network host mysql:8.0 \
+ mysql -h 127.0.0.1 -P 3306 -uroot -pdifyai123456 \
+ -e 'SELECT 1' >/dev/null 2>&1; then
+ echo "MySQL ready after ${i}s"
+ exit 0
+ fi
+ sleep 1
+ done
+ echo "MySQL not ready after 60s; dumping container logs:"
+ docker compose -f docker/docker-compose.middleware.yaml --profile mysql logs --tail=200 db_mysql
+ exit 1
+
- name: Run DB Migration
env:
DEBUG: true
diff --git a/docker/docker-compose.middleware.yaml b/docker/docker-compose.middleware.yaml
index 911da70a73..af3d54dfb3 100644
--- a/docker/docker-compose.middleware.yaml
+++ b/docker/docker-compose.middleware.yaml
@@ -59,19 +59,25 @@ services:
- ${MYSQL_HOST_VOLUME:-./volumes/mysql/data}:/var/lib/mysql
ports:
- "${EXPOSE_MYSQL_PORT:-3306}:3306"
+ # mysqladmin ping passes during mysql:8.0's TCP-listening stage even while
+ # the server is still finalising init, leading to "Lost connection during
+ # query" on the first real query. Verify with a real SELECT instead.
healthcheck:
test:
[
"CMD",
- "mysqladmin",
- "ping",
- "-u",
- "root",
+ "mysql",
+ "-h",
+ "127.0.0.1",
+ "-uroot",
"-p${DB_PASSWORD:-difyai123456}",
+ "-e",
+ "SELECT 1",
]
interval: 1s
timeout: 3s
retries: 30
+ start_period: 20s
# The redis cache.
redis:
From 1d3498f6595a31c27dc489d5bcb3cfb8caf9b8c5 Mon Sep 17 00:00:00 2001
From: Wu Tianwei <30284043+WTW0313@users.noreply.github.com>
Date: Tue, 28 Apr 2026 14:55:10 +0800
Subject: [PATCH 5/9] fix(web): filter model selector by model name (#35624)
---
.../model-selector/__tests__/popup.spec.tsx | 159 +++++++++++++++++-
.../model-selector/popup.tsx | 30 +++-
2 files changed, 179 insertions(+), 10 deletions(-)
diff --git a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
index a440313b3c..42232a71c0 100644
--- a/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
+++ b/web/app/components/header/account-setting/model-provider-page/model-selector/__tests__/popup.spec.tsx
@@ -55,7 +55,14 @@ vi.mock('../../hooks', async () => {
})
vi.mock('../popup-item', () => ({
- default: ({ model }: { model: Model }) => {model.provider}
,
+ default: ({ model }: { model: Model }) => (
+
+ {model.provider}
+ {model.models.map(modelItem => (
+ {modelItem.model}
+ ))}
+
+ ),
}))
vi.mock('@/context/provider-context', () => ({
@@ -207,6 +214,156 @@ describe('Popup', () => {
expect((input as HTMLInputElement).value).toBe('')
})
+ it('should show matching models when searching by model name', () => {
+ renderPopup(
+ ,
+ )
+
+ fireEvent.change(
+ screen.getByPlaceholderText('datasetSettings.form.searchModel'),
+ { target: { value: 'claude' } },
+ )
+
+ expect(screen.queryByText('openai')).not.toBeInTheDocument()
+ expect(screen.getByText('anthropic')).toBeInTheDocument()
+ expect(screen.getByText('claude-3')).toBeInTheDocument()
+ expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
+ expect(screen.queryByText('No model found for \u201Cclaude\u201D')).not.toBeInTheDocument()
+ })
+
+ it('should show empty search placeholder when no provider or model name matches', () => {
+ renderPopup(
+ ,
+ )
+
+ fireEvent.change(
+ screen.getByPlaceholderText('datasetSettings.form.searchModel'),
+ { target: { value: 'mistral' } },
+ )
+
+ expect(screen.getByText('No model found for \u201Cmistral\u201D'))!.toBeInTheDocument()
+ expect(screen.queryByText('openai')).not.toBeInTheDocument()
+ expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
+ })
+
+ it('should show all models of a provider when searching by provider label', () => {
+ renderPopup(
+ ,
+ )
+
+ fireEvent.change(
+ screen.getByPlaceholderText('datasetSettings.form.searchModel'),
+ { target: { value: 'openai' } },
+ )
+
+ expect(screen.getByText('openai'))!.toBeInTheDocument()
+ expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
+ expect(screen.getByText('gpt-4o'))!.toBeInTheDocument()
+ expect(screen.queryByText('anthropic')).not.toBeInTheDocument()
+ expect(screen.queryByText('claude-3')).not.toBeInTheDocument()
+ })
+
+ it('should match by model provider key when model label does not contain the search text', () => {
+ renderPopup(
+ ,
+ )
+
+ fireEvent.change(
+ screen.getByPlaceholderText('datasetSettings.form.searchModel'),
+ { target: { value: 'openai' } },
+ )
+
+ expect(screen.getByText('azure_openai'))!.toBeInTheDocument()
+ expect(screen.getByText('gpt-4'))!.toBeInTheDocument()
+ })
+
+ it('should still apply scope features when matching by provider label', () => {
+ mockSupportFunctionCall.mockReturnValue(false)
+
+ renderPopup(
+ ,
+ )
+
+ fireEvent.change(
+ screen.getByPlaceholderText('datasetSettings.form.searchModel'),
+ { target: { value: 'openai' } },
+ )
+
+ expect(screen.getByText('No model found for \u201Copenai\u201D'))!.toBeInTheDocument()
+ expect(screen.queryByText('gpt-4')).not.toBeInTheDocument()
+ expect(screen.queryByText('gpt-4-tool')).not.toBeInTheDocument()
+ })
+
it('should not show compatible-only helper text when no scope features are applied', () => {
renderPopup(
= ({
}, [aiCreditVisibleProviders, installedProviderMap, modelList])
const filteredModelList = useMemo(() => {
+ const normalizedSearch = searchText.toLowerCase()
+ const matchesLabel = (label: Record) => {
+ if (label[language] !== undefined)
+ return label[language].toLowerCase().includes(normalizedSearch)
+ return Object.values(label).some(value =>
+ value.toLowerCase().includes(normalizedSearch),
+ )
+ }
+
const filtered = installedModelList.map((model) => {
- const matchesProviderSearch = !searchText
- || model.provider.toLowerCase().includes(searchText.toLowerCase())
- || Object.values(model.label).some(label => label.toLowerCase().includes(searchText.toLowerCase()))
+ const providerMatched = !!searchText && (
+ matchesLabel(model.label)
+ || model.provider.toLowerCase().includes(normalizedSearch)
+ )
const filteredModels = model.models
.filter((modelItem) => {
- if (modelItem.label[language] !== undefined)
- return modelItem.label[language].toLowerCase().includes(searchText.toLowerCase())
- return Object.values(modelItem.label).some(label =>
- label.toLowerCase().includes(searchText.toLowerCase()),
- )
+ if (!searchText || providerMatched)
+ return true
+ return matchesLabel(modelItem.label)
})
.filter((modelItem) => {
if (scopeFeatures.length === 0)
@@ -165,8 +173,12 @@ const Popup: FC = ({
return modelItem.features?.includes(feature) ?? false
})
})
- if (!matchesProviderSearch || (filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider)))
+ if (
+ (searchText && filteredModels.length === 0)
+ || (!searchText && filteredModels.length === 0 && !aiCreditVisibleProviders.has(model.provider))
+ ) {
return null
+ }
return { ...model, models: filteredModels }
}).filter((model): model is Model => model !== null)
From d2e1da269c6a901ce199ec695bbb7d1a5b6fb158 Mon Sep 17 00:00:00 2001
From: Asuka Minato
Date: Tue, 28 Apr 2026 17:22:01 +0900
Subject: [PATCH 6/9] chore: port one api (#35609)
---
.../service_api/dataset/document.py | 186 ++++++++++--------
.../service_api/dataset/test_document.py | 82 ++++++--
2 files changed, 169 insertions(+), 99 deletions(-)
diff --git a/api/controllers/service_api/dataset/document.py b/api/controllers/service_api/dataset/document.py
index bc28ecb6b7..0b09facf58 100644
--- a/api/controllers/service_api/dataset/document.py
+++ b/api/controllers/service_api/dataset/document.py
@@ -468,15 +468,98 @@ class DocumentAddByFileApi(DatasetApiResource):
return documents_and_batch_fields, 200
+def _update_document_by_file(tenant_id: str, dataset_id: UUID, document_id: UUID) -> tuple[Mapping[str, object], int]:
+ """Update a document from an uploaded file for canonical and deprecated routes."""
+ dataset_id_str = str(dataset_id)
+ tenant_id_str = str(tenant_id)
+ dataset = db.session.scalar(
+ select(Dataset).where(Dataset.tenant_id == tenant_id_str, Dataset.id == dataset_id_str).limit(1)
+ )
+
+ if not dataset:
+ raise ValueError("Dataset does not exist.")
+
+ if dataset.provider == "external":
+ raise ValueError("External datasets are not supported.")
+
+ args: dict[str, object] = {}
+ if "data" in request.form:
+ args = json.loads(request.form["data"])
+ if "doc_form" not in args:
+ args["doc_form"] = dataset.chunk_structure or "text_model"
+ if "doc_language" not in args:
+ args["doc_language"] = "English"
+
+ # indexing_technique is already set in dataset since this is an update
+ args["indexing_technique"] = dataset.indexing_technique
+
+ if "file" in request.files:
+ # save file info
+ file = request.files["file"]
+
+ if len(request.files) > 1:
+ raise TooManyFilesError()
+
+ if not file.filename:
+ raise FilenameNotExistsError
+
+ if not current_user:
+ raise ValueError("current_user is required")
+
+ try:
+ upload_file = FileService(db.engine).upload_file(
+ filename=file.filename,
+ content=file.read(),
+ mimetype=file.mimetype,
+ user=current_user,
+ source="datasets",
+ )
+ except services.errors.file.FileTooLargeError as file_too_large_error:
+ raise FileTooLargeError(file_too_large_error.description)
+ except services.errors.file.UnsupportedFileTypeError:
+ raise UnsupportedFileTypeError()
+ data_source = {
+ "type": "upload_file",
+ "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
+ }
+ args["data_source"] = data_source
+
+ # validate args
+ args["original_document_id"] = str(document_id)
+
+ knowledge_config = KnowledgeConfig.model_validate(args)
+ DocumentService.document_create_args_validate(knowledge_config)
+
+ try:
+ documents, _ = DocumentService.save_document_with_dataset_id(
+ dataset=dataset,
+ knowledge_config=knowledge_config,
+ account=dataset.created_by_account,
+ dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
+ created_from="api",
+ )
+ except ProviderTokenNotInitError as ex:
+ raise ProviderNotInitializeError(ex.description)
+ document = documents[0]
+ documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
+ return documents_and_batch_fields, 200
+
+
@service_api_ns.route(
"/datasets//documents//update_by_file",
"/datasets//documents//update-by-file",
)
-class DocumentUpdateByFileApi(DatasetApiResource):
- """Resource for update documents."""
+class DeprecatedDocumentUpdateByFileApi(DatasetApiResource):
+ """Deprecated resource aliases for file document updates."""
- @service_api_ns.doc("update_document_by_file")
- @service_api_ns.doc(description="Update an existing document by uploading a file")
+ @service_api_ns.doc("update_document_by_file_deprecated")
+ @service_api_ns.doc(deprecated=True)
+ @service_api_ns.doc(
+ description=(
+ "Deprecated legacy alias for updating an existing document by uploading a file. "
+ "Use PATCH /datasets/{dataset_id}/documents/{document_id} instead."
+ )
+ )
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
@service_api_ns.doc(
responses={
@@ -487,82 +570,9 @@ class DocumentUpdateByFileApi(DatasetApiResource):
)
@cloud_edition_billing_resource_check("vector_space", "dataset")
@cloud_edition_billing_rate_limit_check("knowledge", "dataset")
- def post(self, tenant_id, dataset_id, document_id):
- """Update document by upload file."""
- dataset = db.session.scalar(
- select(Dataset).where(Dataset.tenant_id == tenant_id, Dataset.id == dataset_id).limit(1)
- )
-
- if not dataset:
- raise ValueError("Dataset does not exist.")
-
- if dataset.provider == "external":
- raise ValueError("External datasets are not supported.")
-
- args = {}
- if "data" in request.form:
- args = json.loads(request.form["data"])
- if "doc_form" not in args:
- args["doc_form"] = dataset.chunk_structure or "text_model"
- if "doc_language" not in args:
- args["doc_language"] = "English"
-
- # get dataset info
- dataset_id = str(dataset_id)
- tenant_id = str(tenant_id)
-
- # indexing_technique is already set in dataset since this is an update
- args["indexing_technique"] = dataset.indexing_technique
-
- if "file" in request.files:
- # save file info
- file = request.files["file"]
-
- if len(request.files) > 1:
- raise TooManyFilesError()
-
- if not file.filename:
- raise FilenameNotExistsError
-
- if not current_user:
- raise ValueError("current_user is required")
-
- try:
- upload_file = FileService(db.engine).upload_file(
- filename=file.filename,
- content=file.read(),
- mimetype=file.mimetype,
- user=current_user,
- source="datasets",
- )
- except services.errors.file.FileTooLargeError as file_too_large_error:
- raise FileTooLargeError(file_too_large_error.description)
- except services.errors.file.UnsupportedFileTypeError:
- raise UnsupportedFileTypeError()
- data_source = {
- "type": "upload_file",
- "info_list": {"data_source_type": "upload_file", "file_info_list": {"file_ids": [upload_file.id]}},
- }
- args["data_source"] = data_source
- # validate args
- args["original_document_id"] = str(document_id)
-
- knowledge_config = KnowledgeConfig.model_validate(args)
- DocumentService.document_create_args_validate(knowledge_config)
-
- try:
- documents, _ = DocumentService.save_document_with_dataset_id(
- dataset=dataset,
- knowledge_config=knowledge_config,
- account=dataset.created_by_account,
- dataset_process_rule=dataset.latest_process_rule if "process_rule" not in args else None,
- created_from="api",
- )
- except ProviderTokenNotInitError as ex:
- raise ProviderNotInitializeError(ex.description)
- document = documents[0]
- documents_and_batch_fields = {"document": marshal(document, document_fields), "batch": document.batch}
- return documents_and_batch_fields, 200
+ def post(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
+ """Update document by file through the deprecated file-update aliases."""
+ return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
@service_api_ns.route("/datasets//documents")
@@ -876,6 +886,22 @@ class DocumentApi(DatasetApiResource):
return response
+ @service_api_ns.doc("update_document_by_file")
+ @service_api_ns.doc(description="Update an existing document by uploading a file")
+ @service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
+ @service_api_ns.doc(
+ responses={
+ 200: "Document updated successfully",
+ 401: "Unauthorized - invalid API token",
+ 404: "Document not found",
+ }
+ )
+ @cloud_edition_billing_resource_check("vector_space", "dataset")
+ @cloud_edition_billing_rate_limit_check("knowledge", "dataset")
+ def patch(self, tenant_id: str, dataset_id: UUID, document_id: UUID):
+ """Update document by file on the canonical document resource."""
+ return _update_document_by_file(tenant_id=tenant_id, dataset_id=dataset_id, document_id=document_id)
+
@service_api_ns.doc("delete_document")
@service_api_ns.doc(description="Delete a document")
@service_api_ns.doc(params={"dataset_id": "Dataset ID", "document_id": "Document ID"})
diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py
index 1b391e67ec..230c51161f 100644
--- a/api/tests/unit_tests/controllers/service_api/dataset/test_document.py
+++ b/api/tests/unit_tests/controllers/service_api/dataset/test_document.py
@@ -23,6 +23,7 @@ from werkzeug.exceptions import Forbidden, NotFound
from controllers.service_api.dataset.document import (
DeprecatedDocumentAddByTextApi,
+ DeprecatedDocumentUpdateByFileApi,
DeprecatedDocumentUpdateByTextApi,
DocumentAddByFileApi,
DocumentAddByTextApi,
@@ -32,7 +33,6 @@ from controllers.service_api.dataset.document import (
DocumentListQuery,
DocumentTextCreatePayload,
DocumentTextUpdate,
- DocumentUpdateByFileApi,
DocumentUpdateByTextApi,
InvalidMetadataError,
)
@@ -1095,8 +1095,8 @@ class TestArchivedDocumentImmutableError:
assert error.code == 403
-class TestDocumentTextRouteDeprecation:
- """Test that legacy underscore text routes stay marked deprecated."""
+class TestDocumentRouteDeprecation:
+ """Test that legacy document routes stay marked deprecated."""
def test_create_by_text_legacy_alias_is_deprecated(self):
"""Ensure only the legacy create-by-text alias is marked deprecated."""
@@ -1108,10 +1108,15 @@ class TestDocumentTextRouteDeprecation:
assert DeprecatedDocumentUpdateByTextApi.post.__apidoc__["deprecated"] is True
assert DocumentUpdateByTextApi.post.__apidoc__.get("deprecated") is not True
+ def test_update_by_file_legacy_aliases_are_deprecated(self):
+ """Ensure only the legacy file-update aliases are marked deprecated."""
+ assert DeprecatedDocumentUpdateByFileApi.post.__apidoc__["deprecated"] is True
+ assert DocumentApi.patch.__apidoc__.get("deprecated") is not True
+
# =============================================================================
# Endpoint tests for DocumentUpdateByTextApi, DocumentAddByFileApi,
-# DocumentUpdateByFileApi.
+# and the canonical/deprecated document file update routes.
#
# These controllers use ``@cloud_edition_billing_resource_check`` (does NOT
# preserve ``__wrapped__``) and ``@cloud_edition_billing_rate_limit_check``
@@ -1359,13 +1364,52 @@ class TestDocumentAddByFileApiPost:
api.post(tenant_id=mock_tenant.id, dataset_id=mock_dataset.id)
-class TestDocumentUpdateByFileApiPost:
- """Test suite for DocumentUpdateByFileApi.post() endpoint.
+class TestDocumentUpdateByFileApiPatch:
+ """Test suite for the canonical document file update endpoint.
- ``post`` is wrapped by ``@cloud_edition_billing_resource_check`` and
+ ``patch`` is wrapped by ``@cloud_edition_billing_resource_check`` and
``@cloud_edition_billing_rate_limit_check``.
"""
+ @pytest.mark.parametrize("route_name", ["update_by_file", "update-by-file"])
+ @patch("controllers.service_api.dataset.document._update_document_by_file")
+ @patch("controllers.service_api.wraps.FeatureService")
+ @patch("controllers.service_api.wraps.validate_and_get_api_token")
+ def test_update_by_file_deprecated_aliases_delegate_to_shared_handler(
+ self,
+ mock_validate_token,
+ mock_feature_svc,
+ mock_update_document_by_file,
+ route_name,
+ app,
+ mock_tenant,
+ mock_dataset,
+ ):
+ """Test legacy POST aliases still dispatch while marked deprecated."""
+ _setup_billing_mocks(mock_validate_token, mock_feature_svc, mock_tenant.id)
+ mock_update_document_by_file.return_value = ({"document": {"id": "doc-1"}, "batch": "batch-1"}, 200)
+
+ doc_id = str(uuid.uuid4())
+ with app.test_request_context(
+ f"/datasets/{mock_dataset.id}/documents/{doc_id}/{route_name}",
+ method="POST",
+ headers={"Authorization": "Bearer test_token"},
+ ):
+ api = DeprecatedDocumentUpdateByFileApi()
+ response, status = api.post(
+ tenant_id=mock_tenant.id,
+ dataset_id=mock_dataset.id,
+ document_id=doc_id,
+ )
+
+ assert status == 200
+ assert response["batch"] == "batch-1"
+ mock_update_document_by_file.assert_called_once_with(
+ tenant_id=mock_tenant.id,
+ dataset_id=mock_dataset.id,
+ document_id=doc_id,
+ )
+
@patch("controllers.service_api.dataset.document.db")
@patch("controllers.service_api.wraps.FeatureService")
@patch("controllers.service_api.wraps.validate_and_get_api_token")
@@ -1387,15 +1431,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context(
- f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
- method="POST",
+ f"/datasets/{mock_dataset.id}/documents/{doc_id}",
+ method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
- api = DocumentUpdateByFileApi()
+ api = DocumentApi()
with pytest.raises(ValueError, match="Dataset does not exist"):
- api.post(
+ api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
@@ -1423,15 +1467,15 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"content"), "test.pdf", "application/pdf")}
with app.test_request_context(
- f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
- method="POST",
+ f"/datasets/{mock_dataset.id}/documents/{doc_id}",
+ method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
- api = DocumentUpdateByFileApi()
+ api = DocumentApi()
with pytest.raises(ValueError, match="External datasets"):
- api.post(
+ api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
@@ -1482,14 +1526,14 @@ class TestDocumentUpdateByFileApiPost:
doc_id = str(uuid.uuid4())
data = {"file": (BytesIO(b"file content"), "test.pdf", "application/pdf")}
with app.test_request_context(
- f"/datasets/{mock_dataset.id}/documents/{doc_id}/update_by_file",
- method="POST",
+ f"/datasets/{mock_dataset.id}/documents/{doc_id}",
+ method="PATCH",
content_type="multipart/form-data",
data=data,
headers={"Authorization": "Bearer test_token"},
):
- api = DocumentUpdateByFileApi()
- response, status = api.post(
+ api = DocumentApi()
+ response, status = api.patch(
tenant_id=mock_tenant.id,
dataset_id=mock_dataset.id,
document_id=doc_id,
From 38eb04dc98c8b21684cb10167bfe567c5b6f77e9 Mon Sep 17 00:00:00 2001
From: FFXN <31929997+FFXN@users.noreply.github.com>
Date: Tue, 28 Apr 2026 16:37:13 +0800
Subject: [PATCH 7/9] fix: hit-testing response failed because of Pydantic
check. (#35640)
Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
---
.../console/datasets/hit_testing_base.py | 49 +++++++++++++++++-
.../console/datasets/test_hit_testing_base.py | 36 +++++++++++++
.../service_api/dataset/test_hit_testing.py | 51 +++++++++++++++++++
3 files changed, 135 insertions(+), 1 deletion(-)
diff --git a/api/controllers/console/datasets/hit_testing_base.py b/api/controllers/console/datasets/hit_testing_base.py
index 699fa599c8..71ab1513ed 100644
--- a/api/controllers/console/datasets/hit_testing_base.py
+++ b/api/controllers/console/datasets/hit_testing_base.py
@@ -38,6 +38,48 @@ class HitTestingPayload(BaseModel):
class DatasetsHitTestingBase:
+ @staticmethod
+ def _normalize_hit_testing_query(query: Any) -> str:
+ """Return the user-visible query string from legacy and current response shapes."""
+ if isinstance(query, str):
+ return query
+
+ if isinstance(query, dict):
+ content = query.get("content")
+ if isinstance(content, str):
+ return content
+
+ raise ValueError("Invalid hit testing query response")
+
+ @staticmethod
+ def _normalize_hit_testing_records(records: Any) -> list[dict[str, Any]]:
+ """Coerce nullable collection fields into lists before response validation."""
+ if not isinstance(records, list):
+ return []
+
+ normalized_records: list[dict[str, Any]] = []
+ for record in records:
+ if not isinstance(record, dict):
+ continue
+
+ normalized_record = dict(record)
+ segment = normalized_record.get("segment")
+ if isinstance(segment, dict):
+ normalized_segment = dict(segment)
+ if normalized_segment.get("keywords") is None:
+ normalized_segment["keywords"] = []
+ normalized_record["segment"] = normalized_segment
+
+ if normalized_record.get("child_chunks") is None:
+ normalized_record["child_chunks"] = []
+
+ if normalized_record.get("files") is None:
+ normalized_record["files"] = []
+
+ normalized_records.append(normalized_record)
+
+ return normalized_records
+
@staticmethod
def get_and_validate_dataset(dataset_id: str):
assert isinstance(current_user, Account)
@@ -75,7 +117,12 @@ class DatasetsHitTestingBase:
attachment_ids=args.get("attachment_ids"),
limit=10,
)
- return {"query": response["query"], "records": marshal(response["records"], hit_testing_record_fields)}
+ return {
+ "query": DatasetsHitTestingBase._normalize_hit_testing_query(response.get("query")),
+ "records": DatasetsHitTestingBase._normalize_hit_testing_records(
+ marshal(response.get("records", []), hit_testing_record_fields)
+ ),
+ }
except services.errors.index.IndexNotInitializedError:
raise DatasetNotInitializedError()
except ProviderTokenNotInitError as ex:
diff --git a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py
index e4acd91b76..d29b34beb2 100644
--- a/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py
+++ b/api/tests/unit_tests/controllers/console/datasets/test_hit_testing_base.py
@@ -134,6 +134,42 @@ class TestPerformHitTesting:
assert result["query"] == "hello"
assert result["records"] == []
+ def test_success_normalizes_legacy_query_and_nullable_list_fields(self, dataset):
+ response = {
+ "query": {"content": "hello"},
+ "records": [
+ {
+ "segment": {"id": "segment-1", "keywords": None},
+ "child_chunks": None,
+ "files": None,
+ "score": 0.8,
+ }
+ ],
+ }
+
+ with (
+ patch.object(
+ HitTestingService,
+ "retrieve",
+ return_value=response,
+ ),
+ patch(
+ "controllers.console.datasets.hit_testing_base.marshal",
+ return_value=response["records"],
+ ),
+ ):
+ result = DatasetsHitTestingBase.perform_hit_testing(dataset, {"query": "hello"})
+
+ assert result["query"] == "hello"
+ assert result["records"] == [
+ {
+ "segment": {"id": "segment-1", "keywords": []},
+ "child_chunks": [],
+ "files": [],
+ "score": 0.8,
+ }
+ ]
+
def test_index_not_initialized(self, dataset):
with patch.object(
HitTestingService,
diff --git a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py
index 95c2f5cf92..9be8e56f56 100644
--- a/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py
+++ b/api/tests/unit_tests/controllers/service_api/dataset/test_hit_testing.py
@@ -171,6 +171,57 @@ class TestHitTestingApiPost:
assert passed_retrieval_model["search_method"] == "semantic_search"
assert passed_retrieval_model["top_k"] == 10
+ @patch("controllers.service_api.dataset.hit_testing.service_api_ns")
+ @patch("controllers.console.datasets.hit_testing_base.marshal")
+ @patch("controllers.console.datasets.hit_testing_base.HitTestingService")
+ @patch("controllers.console.datasets.hit_testing_base.DatasetService")
+ @patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
+ def test_post_normalizes_legacy_query_and_nullable_list_fields(
+ self,
+ mock_current_user,
+ mock_dataset_svc,
+ mock_hit_svc,
+ mock_marshal,
+ mock_ns,
+ app,
+ ):
+ """Test service API normalizes legacy query shape and nullable list fields."""
+ dataset_id = str(uuid.uuid4())
+ tenant_id = str(uuid.uuid4())
+
+ mock_dataset = Mock()
+ mock_dataset.id = dataset_id
+
+ mock_dataset_svc.get_dataset.return_value = mock_dataset
+ mock_dataset_svc.check_dataset_permission.return_value = None
+
+ mock_hit_svc.retrieve.return_value = {"query": {"content": "legacy query"}, "records": ["placeholder"]}
+ mock_hit_svc.hit_testing_args_check.return_value = None
+ mock_marshal.return_value = [
+ {
+ "segment": {"id": "segment-1", "keywords": None},
+ "child_chunks": None,
+ "files": None,
+ "score": 0.9,
+ }
+ ]
+
+ mock_ns.payload = {"query": "legacy query"}
+
+ with app.test_request_context():
+ api = HitTestingApi()
+ response = HitTestingApi.post.__wrapped__(api, tenant_id, dataset_id)
+
+ assert response["query"] == "legacy query"
+ assert response["records"] == [
+ {
+ "segment": {"id": "segment-1", "keywords": []},
+ "child_chunks": [],
+ "files": [],
+ "score": 0.9,
+ }
+ ]
+
@patch("controllers.service_api.dataset.hit_testing.service_api_ns")
@patch("controllers.console.datasets.hit_testing_base.DatasetService")
@patch("controllers.console.datasets.hit_testing_base.current_user", new_callable=lambda: Mock(spec=Account))
From d0956039e7a4ca66dc3e20873a4d84fb025572cd Mon Sep 17 00:00:00 2001
From: knyazz
Date: Wed, 29 Apr 2026 04:59:17 +0300
Subject: [PATCH 8/9] chore: correction of ru translation (#35645)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Смирнов Евгений Владимирович
---
web/i18n/ru-RU/app-overview.json | 4 +--
web/i18n/ru-RU/dataset-documents.json | 46 +++++++++++++--------------
web/i18n/ru-RU/workflow.json | 10 +++---
3 files changed, 30 insertions(+), 30 deletions(-)
diff --git a/web/i18n/ru-RU/app-overview.json b/web/i18n/ru-RU/app-overview.json
index 20f6a2b768..c361441ceb 100644
--- a/web/i18n/ru-RU/app-overview.json
+++ b/web/i18n/ru-RU/app-overview.json
@@ -10,7 +10,7 @@
"analysis.ms": "мс",
"analysis.title": "Анализ",
"analysis.tokenPS": "Токен/с",
- "analysis.tokenUsage.consumed": "Потрачено",
+ "analysis.tokenUsage.consumed": "Потреблено",
"analysis.tokenUsage.explanation": "Отражает ежедневное использование токенов языковой модели для приложения, полезно для целей контроля затрат.",
"analysis.tokenUsage.title": "Использование токенов",
"analysis.totalConversations.explanation": "Ежедневное количество чатов с LLM; проектирование/отладка не учитываются.",
@@ -62,7 +62,7 @@
"overview.appInfo.enableTooltip.description": "Чтобы включить эту функцию, добавьте на холст узел ввода пользователя. (Может уже существовать в черновике, вступает в силу после публикации)",
"overview.appInfo.enableTooltip.learnMore": "Узнать больше",
"overview.appInfo.explanation": "Готовое к использованию веб-приложение ИИ",
- "overview.appInfo.launch": "Баркас",
+ "overview.appInfo.launch": "Запустить",
"overview.appInfo.preUseReminder": "Пожалуйста, включите веб-приложение перед продолжением.",
"overview.appInfo.preview": "Предварительный просмотр",
"overview.appInfo.qrcode.download": "Скачать QR-код",
diff --git a/web/i18n/ru-RU/dataset-documents.json b/web/i18n/ru-RU/dataset-documents.json
index 8d5b1a656b..d2b77c1a74 100644
--- a/web/i18n/ru-RU/dataset-documents.json
+++ b/web/i18n/ru-RU/dataset-documents.json
@@ -1,13 +1,13 @@
{
"embedding.automatic": "Автоматически",
- "embedding.childMaxTokens": "Ребёнок",
+ "embedding.childMaxTokens": "Наследник",
"embedding.completed": "Встраивание завершено",
"embedding.custom": "Пользовательский",
- "embedding.docName": "Предварительная обработка документа",
+ "embedding.docName": "Имя документа",
"embedding.economy": "Экономичный режим",
"embedding.error": "Ошибка расчета эмбеддингов",
- "embedding.estimate": "Оценочное потребление",
- "embedding.hierarchical": "Родитель-дочерний",
+ "embedding.estimate": "Оценка",
+ "embedding.hierarchical": "Иерархический",
"embedding.highQuality": "Режим высокого качества",
"embedding.mode": "Правило сегментации",
"embedding.parentMaxTokens": "Родитель",
@@ -16,7 +16,7 @@
"embedding.previewTip": "Предварительный просмотр абзацев будет доступен после завершения расчета эмбеддингов",
"embedding.processing": "Расчет эмбеддингов...",
"embedding.resume": "Возобновить обработку",
- "embedding.segmentLength": "Длина фрагментов",
+ "embedding.segmentLength": "Длина сегментов",
"embedding.segments": "Абзацы",
"embedding.stop": "Остановить обработку",
"embedding.textCleaning": "Предварительная очистка текста",
@@ -279,25 +279,25 @@
"metadata.type.webPage": "Веб-страница",
"metadata.type.wikipediaEntry": "Статья в Википедии",
"segment.addAnother": "Добавить еще один",
- "segment.addChildChunk": "Добавить дочерний чанк",
- "segment.addChunk": "Добавить чанк",
+ "segment.addChildChunk": "Добавить дочерний фрагмент",
+ "segment.addChunk": "Добавить фрагмент",
"segment.addKeyWord": "Добавить ключевое слово",
"segment.allFilesUploaded": "Все файлы должны быть загружены перед сохранением",
"segment.answerEmpty": "Ответ не может быть пустым",
"segment.answerPlaceholder": "добавьте ответ здесь",
- "segment.characters_one": "характер",
- "segment.characters_other": "письмена",
- "segment.childChunk": "Чайлд-Чанк",
- "segment.childChunkAdded": "Добавлен 1 дочерний чанк",
- "segment.childChunks_one": "ДОЧЕРНИЙ ЧАНК",
- "segment.childChunks_other": "ДЕТСКИЕ КУСОЧКИ",
- "segment.chunk": "Ломоть",
- "segment.chunkAdded": "Добавлен 1 блок",
- "segment.chunkDetail": "Деталь Чанка",
- "segment.chunks_one": "ЛОМОТЬ",
- "segment.chunks_other": "КУСКИ",
+ "segment.characters_one": "символ",
+ "segment.characters_other": "символы",
+ "segment.childChunk": "Дочерний фрагмент",
+ "segment.childChunkAdded": "Добавлен 1 дочерний фрагмент",
+ "segment.childChunks_one": "ДОЧЕРНИЙ ФРАГМЕНТ",
+ "segment.childChunks_other": "ДОЧЕРНИЕ ФРАГМЕНТЫ",
+ "segment.chunk": "Фрагмент",
+ "segment.chunkAdded": "Добавлен 1 фрагмент",
+ "segment.chunkDetail": "Детали фрагмента",
+ "segment.chunks_one": "ФРАГМЕНТ",
+ "segment.chunks_other": "ФРАГМЕНТЫ",
"segment.clearFilter": "Очистить фильтр",
- "segment.collapseChunks": "Сворачивание кусков",
+ "segment.collapseChunks": "Свернуть фрагменты",
"segment.contentEmpty": "Содержимое не может быть пустым",
"segment.contentPlaceholder": "добавьте содержимое здесь",
"segment.dateTimeFormat": "MM/DD/YYYY HH:mm",
@@ -307,15 +307,15 @@
"segment.editParentChunk": "Редактирование родительского блока",
"segment.edited": "ОТРЕДАКТИРОВАНЫ",
"segment.editedAt": "Отредактировано в",
- "segment.empty": "Чанк не найден",
- "segment.expandChunks": "Развернуть чанки",
+ "segment.empty": "Фрагмент не найден",
+ "segment.expandChunks": "Развернуть фрагменты",
"segment.hitCount": "Количество обращений",
"segment.keywordDuplicate": "Ключевое слово уже существует",
"segment.keywordEmpty": "Ключевое слово не может быть пустым",
"segment.keywordError": "Максимальная длина ключевого слова - 20",
"segment.keywords": "Ключевые слова",
- "segment.newChildChunk": "Новый дочерний чанк",
- "segment.newChunk": "Новый чанк",
+ "segment.newChildChunk": "Новый дочерний фрагмент",
+ "segment.newChunk": "Новый фрагмент",
"segment.newQaSegment": "Новый сегмент вопрос-ответ",
"segment.newTextSegment": "Новый текстовый сегмент",
"segment.paragraphs": "Абзацы",
diff --git a/web/i18n/ru-RU/workflow.json b/web/i18n/ru-RU/workflow.json
index 89d2657208..aa5292f1cc 100644
--- a/web/i18n/ru-RU/workflow.json
+++ b/web/i18n/ru-RU/workflow.json
@@ -1,7 +1,7 @@
{
"blocks.agent": "Агент",
"blocks.answer": "Ответ",
- "blocks.assigner": "Назначение переменной",
+ "blocks.assigner": "Назначение переменных",
"blocks.code": "Код",
"blocks.datasource": "Источник данных",
"blocks.datasource-empty": "Пустой источник данных",
@@ -17,10 +17,10 @@
"blocks.list-operator": "Оператор списка",
"blocks.llm": "LLM",
"blocks.loop": "Цикл",
- "blocks.loop-end": "Выйти из цикла",
+ "blocks.loop-end": "Конец цикла",
"blocks.loop-start": "Начало цикла",
"blocks.originalStartNode": "исходный начальный узел",
- "blocks.parameter-extractor": "Извлечение параметров",
+ "blocks.parameter-extractor": "Экстрактор параметров",
"blocks.question-classifier": "Классификатор вопросов",
"blocks.start": "Начало",
"blocks.template-transform": "Шаблон",
@@ -29,7 +29,7 @@
"blocks.trigger-schedule": "Триггер расписания",
"blocks.trigger-webhook": "Вебхук-триггер",
"blocks.variable-aggregator": "Агрегатор переменных",
- "blocks.variable-assigner": "Агрегатор переменных",
+ "blocks.variable-assigner": "Назначение переменных",
"blocksAbout.agent": "Вызов больших языковых моделей для ответа на вопросы или обработки естественного языка",
"blocksAbout.answer": "Определите содержимое ответа в чате",
"blocksAbout.assigner": "Узел назначения переменной используется для назначения значений записываемым переменным (например, переменным разговора).",
@@ -485,7 +485,7 @@
"nodes.common.pluginNotInstalled": "Плагин не установлен",
"nodes.common.pluginsNotInstalled": "{{count}} плагинов не установлено",
"nodes.common.retry.maxRetries": "максимальное количество повторных попыток",
- "nodes.common.retry.ms": "госпожа",
+ "nodes.common.retry.ms": "мс",
"nodes.common.retry.retries": "{{num}} Повторных попыток",
"nodes.common.retry.retry": "Снова пробовать",
"nodes.common.retry.retryFailed": "Повторная попытка не удалась",
From 0536549f735d25ced0e15b9f755bceef8002bd70 Mon Sep 17 00:00:00 2001
From: kenwoodjw
Date: Wed, 29 Apr 2026 10:27:02 +0800
Subject: [PATCH 9/9] fix: flaky WordExtractor close test in CI (#35652)
Signed-off-by: kenwoodjw
---
.../core/rag/extractor/test_word_extractor.py | 24 ++++++++-----------
1 file changed, 10 insertions(+), 14 deletions(-)
diff --git a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py
index 0220fb6d4a..b9f2449cfb 100644
--- a/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py
+++ b/api/tests/unit_tests/core/rag/extractor/test_word_extractor.py
@@ -1,14 +1,12 @@
"""Primarily used for testing merged cell scenarios"""
-import gc
import io
import os
import tempfile
-import warnings
from collections import UserDict
from pathlib import Path
from types import SimpleNamespace
-from unittest.mock import AsyncMock, MagicMock
+from unittest.mock import MagicMock
import pytest
from docx import Document
@@ -377,23 +375,21 @@ def test_close_is_idempotent():
extractor.temp_file.close.assert_called_once()
-def test_close_handles_async_close_mock():
+async def _async_close() -> None:
+ return None
+
+
+def test_close_closes_awaitable_close_result():
extractor = object.__new__(WordExtractor)
extractor._closed = False
extractor.temp_file = MagicMock()
- extractor.temp_file.close = AsyncMock()
+ close_result = _async_close()
+ extractor.temp_file.close = MagicMock(return_value=close_result)
- with warnings.catch_warnings(record=True) as caught:
- warnings.simplefilter("always")
- extractor.close()
- gc.collect()
+ extractor.close()
+ assert close_result.cr_frame is None
extractor.temp_file.close.assert_called_once()
- assert not [
- warning
- for warning in caught
- if issubclass(warning.category, RuntimeWarning) and "AsyncMockMixin._execute_mock_call" in str(warning.message)
- ]
def test_extract_images_handles_invalid_external_cases(monkeypatch):