mirror of
https://github.com/langgenius/dify.git
synced 2026-05-10 14:14:17 +08:00
Merge branch 'main' into 3-18-no-global-loading
This commit is contained in:
commit
8eb9f88c3b
2
.github/workflows/web-tests.yml
vendored
2
.github/workflows/web-tests.yml
vendored
@ -87,7 +87,7 @@ jobs:
|
||||
merge-multiple: true
|
||||
|
||||
- name: Merge reports
|
||||
run: vp test --merge-reports --reporter=json --reporter=agent --coverage
|
||||
run: vp test --merge-reports --coverage --silent=passed-only
|
||||
|
||||
- name: Report app/components baseline coverage
|
||||
run: node ./scripts/report-components-coverage-baseline.mjs
|
||||
|
||||
@ -103,13 +103,13 @@ class AppMCPServerController(Resource):
|
||||
raise NotFound()
|
||||
|
||||
description = payload.description
|
||||
if description is None:
|
||||
pass
|
||||
elif not description:
|
||||
if description is None or not description:
|
||||
server.description = app_model.description or ""
|
||||
else:
|
||||
server.description = description
|
||||
|
||||
server.name = app_model.name
|
||||
|
||||
server.parameters = json.dumps(payload.parameters, ensure_ascii=False)
|
||||
if payload.status:
|
||||
try:
|
||||
|
||||
@ -68,9 +68,12 @@ class SegmentRecord(TypedDict):
|
||||
|
||||
|
||||
class DefaultRetrievalModelDict(TypedDict):
|
||||
search_method: RetrievalMethod | str
|
||||
search_method: RetrievalMethod
|
||||
reranking_enable: bool
|
||||
reranking_model: RerankingModelDict
|
||||
reranking_mode: NotRequired[str]
|
||||
weights: NotRequired[WeightsDict | None]
|
||||
score_threshold: NotRequired[float]
|
||||
top_k: int
|
||||
score_threshold_enabled: bool
|
||||
|
||||
|
||||
@ -33,7 +33,7 @@ from core.prompt.entities.advanced_prompt_entities import ChatModelMessage, Comp
|
||||
from core.prompt.simple_prompt_transform import ModelMode
|
||||
from core.rag.data_post_processor.data_post_processor import DataPostProcessor, RerankingModelDict, WeightsDict
|
||||
from core.rag.datasource.keyword.jieba.jieba_keyword_table_handler import JiebaKeywordTableHandler
|
||||
from core.rag.datasource.retrieval_service import RetrievalService
|
||||
from core.rag.datasource.retrieval_service import DefaultRetrievalModelDict, RetrievalService
|
||||
from core.rag.entities.citation_metadata import RetrievalSourceMetadata
|
||||
from core.rag.entities.context_entities import DocumentContext
|
||||
from core.rag.entities.metadata_entities import Condition, MetadataCondition
|
||||
@ -87,7 +87,7 @@ from models.enums import CreatorUserRole, DatasetQuerySource
|
||||
from services.external_knowledge_service import ExternalDatasetService
|
||||
from services.feature_service import FeatureService
|
||||
|
||||
default_retrieval_model: dict[str, Any] = {
|
||||
default_retrieval_model: DefaultRetrievalModelDict = {
|
||||
"search_method": RetrievalMethod.SEMANTIC_SEARCH,
|
||||
"reranking_enable": False,
|
||||
"reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
|
||||
@ -666,7 +666,11 @@ class DatasetRetrieval:
|
||||
document_ids_filter = document_ids
|
||||
else:
|
||||
return []
|
||||
retrieval_model_config = dataset.retrieval_model or default_retrieval_model
|
||||
retrieval_model_config: DefaultRetrievalModelDict = (
|
||||
cast(DefaultRetrievalModelDict, dataset.retrieval_model)
|
||||
if dataset.retrieval_model
|
||||
else default_retrieval_model
|
||||
)
|
||||
|
||||
# get top k
|
||||
top_k = retrieval_model_config["top_k"]
|
||||
@ -1058,7 +1062,11 @@ class DatasetRetrieval:
|
||||
all_documents.append(document)
|
||||
else:
|
||||
# get retrieval model , if the model is not setting , using default
|
||||
retrieval_model = dataset.retrieval_model or default_retrieval_model
|
||||
retrieval_model: DefaultRetrievalModelDict = (
|
||||
cast(DefaultRetrievalModelDict, dataset.retrieval_model)
|
||||
if dataset.retrieval_model
|
||||
else default_retrieval_model
|
||||
)
|
||||
|
||||
if dataset.indexing_technique == "economy":
|
||||
# use keyword table query
|
||||
@ -1132,7 +1140,7 @@ class DatasetRetrieval:
|
||||
|
||||
if retrieve_config.retrieve_strategy == DatasetRetrieveConfigEntity.RetrieveStrategy.SINGLE:
|
||||
# get retrieval model config
|
||||
default_retrieval_model = {
|
||||
default_retrieval_model: DefaultRetrievalModelDict = {
|
||||
"search_method": RetrievalMethod.SEMANTIC_SEARCH,
|
||||
"reranking_enable": False,
|
||||
"reranking_model": {"reranking_provider_name": "", "reranking_model_name": ""},
|
||||
@ -1141,7 +1149,11 @@ class DatasetRetrieval:
|
||||
}
|
||||
|
||||
for dataset in available_datasets:
|
||||
retrieval_model_config = dataset.retrieval_model or default_retrieval_model
|
||||
retrieval_model_config: DefaultRetrievalModelDict = (
|
||||
cast(DefaultRetrievalModelDict, dataset.retrieval_model)
|
||||
if dataset.retrieval_model
|
||||
else default_retrieval_model
|
||||
)
|
||||
|
||||
# get top k
|
||||
top_k = retrieval_model_config["top_k"]
|
||||
|
||||
@ -256,9 +256,13 @@ def fetch_prompt_messages(
|
||||
):
|
||||
continue
|
||||
prompt_message_content.append(content_item)
|
||||
if prompt_message_content:
|
||||
if not prompt_message_content:
|
||||
continue
|
||||
if len(prompt_message_content) == 1 and prompt_message_content[0].type == PromptMessageContentType.TEXT:
|
||||
prompt_message.content = prompt_message_content[0].data
|
||||
else:
|
||||
prompt_message.content = prompt_message_content
|
||||
filtered_prompt_messages.append(prompt_message)
|
||||
filtered_prompt_messages.append(prompt_message)
|
||||
elif not prompt_message.is_empty():
|
||||
filtered_prompt_messages.append(prompt_message)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "dify-api"
|
||||
version = "1.13.1"
|
||||
version = "1.13.2"
|
||||
requires-python = ">=3.11,<3.13"
|
||||
|
||||
dependencies = [
|
||||
|
||||
106
api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py
Normal file
106
api/tests/unit_tests/core/workflow/nodes/llm/test_llm_utils.py
Normal file
@ -0,0 +1,106 @@
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from core.model_manager import ModelInstance
|
||||
from dify_graph.model_runtime.entities import ImagePromptMessageContent, PromptMessageRole, TextPromptMessageContent
|
||||
from dify_graph.model_runtime.entities.message_entities import SystemPromptMessage
|
||||
from dify_graph.nodes.llm import llm_utils
|
||||
from dify_graph.nodes.llm.entities import LLMNodeChatModelMessage
|
||||
from dify_graph.nodes.llm.exc import NoPromptFoundError
|
||||
from dify_graph.runtime import VariablePool
|
||||
|
||||
|
||||
def _fetch_prompt_messages_with_mocked_content(content):
|
||||
variable_pool = VariablePool.empty()
|
||||
model_instance = mock.MagicMock(spec=ModelInstance)
|
||||
prompt_template = [
|
||||
LLMNodeChatModelMessage(
|
||||
text="You are a classifier.",
|
||||
role=PromptMessageRole.SYSTEM,
|
||||
edition_type="basic",
|
||||
)
|
||||
]
|
||||
|
||||
with (
|
||||
mock.patch(
|
||||
"dify_graph.nodes.llm.llm_utils.fetch_model_schema",
|
||||
return_value=mock.MagicMock(features=[]),
|
||||
),
|
||||
mock.patch(
|
||||
"dify_graph.nodes.llm.llm_utils.handle_list_messages",
|
||||
return_value=[SystemPromptMessage(content=content)],
|
||||
),
|
||||
mock.patch(
|
||||
"dify_graph.nodes.llm.llm_utils.handle_memory_chat_mode",
|
||||
return_value=[],
|
||||
),
|
||||
):
|
||||
return llm_utils.fetch_prompt_messages(
|
||||
sys_query=None,
|
||||
sys_files=[],
|
||||
context=None,
|
||||
memory=None,
|
||||
model_instance=model_instance,
|
||||
prompt_template=prompt_template,
|
||||
stop=["END"],
|
||||
memory_config=None,
|
||||
vision_enabled=False,
|
||||
vision_detail=ImagePromptMessageContent.DETAIL.HIGH,
|
||||
variable_pool=variable_pool,
|
||||
jinja2_variables=[],
|
||||
template_renderer=None,
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_prompt_messages_skips_messages_when_all_contents_are_filtered_out():
|
||||
with pytest.raises(NoPromptFoundError):
|
||||
_fetch_prompt_messages_with_mocked_content(
|
||||
[
|
||||
ImagePromptMessageContent(
|
||||
format="url",
|
||||
url="https://example.com/image.png",
|
||||
mime_type="image/png",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def test_fetch_prompt_messages_flattens_single_text_content_after_filtering_unsupported_multimodal_items():
|
||||
prompt_messages, stop = _fetch_prompt_messages_with_mocked_content(
|
||||
[
|
||||
TextPromptMessageContent(data="You are a classifier."),
|
||||
ImagePromptMessageContent(
|
||||
format="url",
|
||||
url="https://example.com/image.png",
|
||||
mime_type="image/png",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert stop == ["END"]
|
||||
assert prompt_messages == [SystemPromptMessage(content="You are a classifier.")]
|
||||
|
||||
|
||||
def test_fetch_prompt_messages_keeps_list_content_when_multiple_supported_items_remain():
|
||||
prompt_messages, stop = _fetch_prompt_messages_with_mocked_content(
|
||||
[
|
||||
TextPromptMessageContent(data="You are"),
|
||||
TextPromptMessageContent(data=" a classifier."),
|
||||
ImagePromptMessageContent(
|
||||
format="url",
|
||||
url="https://example.com/image.png",
|
||||
mime_type="image/png",
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
assert stop == ["END"]
|
||||
assert prompt_messages == [
|
||||
SystemPromptMessage(
|
||||
content=[
|
||||
TextPromptMessageContent(data="You are"),
|
||||
TextPromptMessageContent(data=" a classifier."),
|
||||
]
|
||||
)
|
||||
]
|
||||
2
api/uv.lock
generated
2
api/uv.lock
generated
@ -1533,7 +1533,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "dify-api"
|
||||
version = "1.13.1"
|
||||
version = "1.13.2"
|
||||
source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "aliyun-log-python-sdk" },
|
||||
|
||||
@ -21,7 +21,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -63,7 +63,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -102,7 +102,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -132,7 +132,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.1
|
||||
image: langgenius/dify-web:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -728,7 +728,7 @@ services:
|
||||
|
||||
# API service
|
||||
api:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -770,7 +770,7 @@ services:
|
||||
# worker service
|
||||
# The Celery worker for processing all queues (dataset, workflow, mail, etc.)
|
||||
worker:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -809,7 +809,7 @@ services:
|
||||
# worker_beat service
|
||||
# Celery beat for scheduling periodic tasks.
|
||||
worker_beat:
|
||||
image: langgenius/dify-api:1.13.1
|
||||
image: langgenius/dify-api:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
# Use the shared environment variables.
|
||||
@ -839,7 +839,7 @@ services:
|
||||
|
||||
# Frontend web application.
|
||||
web:
|
||||
image: langgenius/dify-web:1.13.1
|
||||
image: langgenius/dify-web:1.13.2
|
||||
restart: always
|
||||
environment:
|
||||
CONSOLE_API_URL: ${CONSOLE_API_URL:-}
|
||||
|
||||
@ -22,33 +22,6 @@ vi.mock('@/service/plugins', () => ({
|
||||
checkTaskStatus: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMajor, aMinor = 0, aPatch = 0] = parse(a)
|
||||
const [bMajor, bMinor = 0, bPatch = 0] = parse(b)
|
||||
if (aMajor !== bMajor)
|
||||
return aMajor > bMajor ? 1 : -1
|
||||
if (aMinor !== bMinor)
|
||||
return aMinor > bMinor ? 1 : -1
|
||||
if (aPatch !== bPatch)
|
||||
return aPatch > bPatch ? 1 : -1
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const parse = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const [aMaj, aMin = 0, aPat = 0] = parse(a)
|
||||
const [bMaj, bMin = 0, bPat = 0] = parse(b)
|
||||
if (aMaj !== bMaj)
|
||||
return bMaj - aMaj
|
||||
if (aMin !== bMin)
|
||||
return bMin - aMin
|
||||
return bPat - aPat
|
||||
})[0]
|
||||
},
|
||||
}))
|
||||
|
||||
const { useGitHubReleases, useGitHubUpload } = await import(
|
||||
'@/app/components/plugins/install-plugin/hooks',
|
||||
)
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import * as React from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Button from '@/app/components/base/button'
|
||||
@ -36,18 +36,33 @@ const TransferOwnershipModal = ({ onClose, show }: Props) => {
|
||||
const [stepToken, setStepToken] = useState<string>('')
|
||||
const [newOwner, setNewOwner] = useState<string>('')
|
||||
const [isTransfer, setIsTransfer] = useState<boolean>(false)
|
||||
const timerIdRef = React.useRef<number | undefined>(undefined)
|
||||
|
||||
const retimeCountdown = useCallback((timerId?: number) => {
|
||||
if (timerIdRef.current !== undefined)
|
||||
window.clearInterval(timerIdRef.current)
|
||||
|
||||
timerIdRef.current = timerId
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!show)
|
||||
retimeCountdown()
|
||||
|
||||
return retimeCountdown
|
||||
}, [retimeCountdown, show])
|
||||
|
||||
const startCount = () => {
|
||||
setTime(60)
|
||||
const timer = setInterval(() => {
|
||||
retimeCountdown(window.setInterval(() => {
|
||||
setTime((prev) => {
|
||||
if (prev <= 0) {
|
||||
clearInterval(timer)
|
||||
if (prev <= 1) {
|
||||
retimeCountdown()
|
||||
return 0
|
||||
}
|
||||
return prev - 1
|
||||
})
|
||||
}, 1000)
|
||||
}, 1000))
|
||||
}
|
||||
|
||||
const sendEmail = async () => {
|
||||
|
||||
@ -16,34 +16,6 @@ vi.mock('@/service/plugins', () => ({
|
||||
uploadGitHub: (...args: unknown[]) => mockUploadGitHub(...args),
|
||||
}))
|
||||
|
||||
vi.mock('@/utils/semver', () => ({
|
||||
compareVersion: (a: string, b: string) => {
|
||||
const parseVersion = (v: string) => v.replace(/^v/, '').split('.').map(Number)
|
||||
const va = parseVersion(a)
|
||||
const vb = parseVersion(b)
|
||||
for (let i = 0; i < Math.max(va.length, vb.length); i++) {
|
||||
const diff = (va[i] || 0) - (vb[i] || 0)
|
||||
if (diff > 0)
|
||||
return 1
|
||||
if (diff < 0)
|
||||
return -1
|
||||
}
|
||||
return 0
|
||||
},
|
||||
getLatestVersion: (versions: string[]) => {
|
||||
return versions.sort((a, b) => {
|
||||
const pa = a.replace(/^v/, '').split('.').map(Number)
|
||||
const pb = b.replace(/^v/, '').split('.').map(Number)
|
||||
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||
const diff = (pa[i] || 0) - (pb[i] || 0)
|
||||
if (diff !== 0)
|
||||
return diff
|
||||
}
|
||||
return 0
|
||||
}).pop()!
|
||||
},
|
||||
}))
|
||||
|
||||
const mockFetch = vi.fn()
|
||||
globalThis.fetch = mockFetch
|
||||
|
||||
|
||||
@ -5,12 +5,12 @@ import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { uninstallPlugin } from '@/service/plugins'
|
||||
import { useInstallPackageFromLocal, usePluginTaskList } from '@/service/use-plugins'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import Card from '../../../card'
|
||||
import { TaskStatus } from '../../../types'
|
||||
import checkTaskStatus from '../../base/check-task-status'
|
||||
@ -111,13 +111,13 @@ const Installed: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, payload.meta.minimum_dify_version])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-md-regular">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
<p>
|
||||
<Trans
|
||||
@ -127,7 +127,7 @@ const Installed: FC<Props> = ({
|
||||
/>
|
||||
</p>
|
||||
{!isDifyVersionCompatible && (
|
||||
<p className="system-md-regular flex items-center gap-1 text-text-warning">
|
||||
<p className="flex items-center gap-1 text-text-warning system-md-regular">
|
||||
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: payload.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -5,11 +5,11 @@ import { RiLoader2Line } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Button from '@/app/components/base/button'
|
||||
import useCheckInstalled from '@/app/components/plugins/install-plugin/hooks/use-check-installed'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useInstallPackageFromMarketPlace, usePluginDeclarationFromMarketPlace, usePluginTaskList, useUpdatePackageFromMarketPlace } from '@/service/use-plugins'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import Card from '../../../card'
|
||||
// import { RiInformation2Line } from '@remixicon/react'
|
||||
import { TaskStatus } from '../../../types'
|
||||
@ -126,17 +126,17 @@ const Installed: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!pluginDeclaration || !langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, pluginDeclaration?.manifest.meta.minimum_dify_version ?? '0.0.0')
|
||||
}, [langGeniusVersionInfo.current_version, pluginDeclaration])
|
||||
|
||||
const { canInstall } = useInstallPluginLimit({ ...payload, from: 'marketplace' })
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col items-start justify-center gap-4 self-stretch px-6 py-3">
|
||||
<div className="system-md-regular text-text-secondary">
|
||||
<div className="text-text-secondary system-md-regular">
|
||||
<p>{t(`${i18nPrefix}.readyToInstall`, { ns: 'plugin' })}</p>
|
||||
{!isDifyVersionCompatible && (
|
||||
<p className="system-md-regular text-text-warning">
|
||||
<p className="text-text-warning system-md-regular">
|
||||
{t('difyVersionNotCompatible', { ns: 'plugin', minimalDifyVersion: pluginDeclaration?.manifest.meta.minimum_dify_version })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@ -76,16 +76,16 @@ afterAll(() => {
|
||||
|
||||
// Mock portal components for controlled positioning in tests
|
||||
// Use React context to properly scope open state per portal instance (for nested portals)
|
||||
const _PortalOpenContext = React.createContext(false)
|
||||
|
||||
vi.mock('@/app/components/base/portal-to-follow-elem', () => {
|
||||
// Context reference shared across mock components
|
||||
let sharedContext: React.Context<boolean> | null = null
|
||||
|
||||
// Lazily get or create the context
|
||||
const getContext = (): React.Context<boolean> => {
|
||||
if (!sharedContext)
|
||||
sharedContext = React.createContext(false)
|
||||
if (!sharedContext) {
|
||||
const PortalOpenContext = React.createContext(false)
|
||||
sharedContext = PortalOpenContext
|
||||
}
|
||||
return sharedContext
|
||||
}
|
||||
|
||||
@ -725,6 +725,39 @@ describe('AppPicker', () => {
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reset loadingRef when the picker closes before the debounce timeout finishes', () => {
|
||||
const onLoadMore = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />,
|
||||
)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
||||
|
||||
rerender(<AppPicker {...defaultProps} isShow={false} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
rerender(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('should reset loadingRef when the picker unmounts before the debounce timeout finishes', () => {
|
||||
const onLoadMore = vi.fn()
|
||||
const { unmount } = render(
|
||||
<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />,
|
||||
)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(1)
|
||||
|
||||
unmount()
|
||||
|
||||
render(<AppPicker {...defaultProps} isShow={true} hasMore={true} isLoading={false} onLoadMore={onLoadMore} />)
|
||||
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(onLoadMore).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Memoization', () => {
|
||||
@ -1539,7 +1572,7 @@ describe('AppSelector', () => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should manage isLoadingMore state during load more', () => {
|
||||
it('should render correctly during load more setup', () => {
|
||||
mockHasNextPage = true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 100)))
|
||||
|
||||
@ -1739,7 +1772,7 @@ describe('AppSelector', () => {
|
||||
expect(mockFetchNextPage).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set isLoadingMore and reset after delay in handleLoadMore', async () => {
|
||||
it('should avoid duplicate fetches while the picker debounce is active', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
@ -1756,34 +1789,15 @@ describe('AppSelector', () => {
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Try to trigger again immediately - should be blocked by isLoadingMore
|
||||
// Try to trigger again immediately - should be blocked by AppPicker loadingRef
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Still only one call due to isLoadingMore
|
||||
// Still only one call due to the picker-level debounce
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// This verifies the debounce logic is working - multiple calls are blocked
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1000)))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
// Open portals
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger intersection - this starts loading
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should skip handleLoadMore when isFetchingNextPage is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = true // This will block the handleLoadMore
|
||||
@ -1821,89 +1835,7 @@ describe('AppSelector', () => {
|
||||
// fetchNextPage should NOT be called because hasMore is false
|
||||
expect(mockFetchNextPage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should return early from handleLoadMore when isLoadingMore is true', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
// Make fetchNextPage slow to keep isLoadingMore true
|
||||
mockFetchNextPage.mockImplementation(() => new Promise(resolve => setTimeout(resolve, 5000)))
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// First call starts loading
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Second call should return early due to isLoadingMore
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Still only 1 call because isLoadingMore blocks it
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should reset isLoadingMore via setTimeout after fetchNextPage resolves', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger load more
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// Wait for fetchNextPage to complete and setTimeout to fire
|
||||
await act(async () => {
|
||||
await Promise.resolve()
|
||||
vi.advanceTimersByTime(350) // Past the 300ms setTimeout
|
||||
})
|
||||
|
||||
// Should be able to load more again
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
// This might trigger another fetch if loadingRef also reset
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('should reset isLoadingMore after fetchNextPage completes with setTimeout', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
|
||||
renderWithQueryClient(<AppSelector {...defaultProps} />)
|
||||
|
||||
// Open portals
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
const triggers = screen.getAllByTestId('portal-trigger')
|
||||
fireEvent.click(triggers[1])
|
||||
|
||||
// Trigger first intersection
|
||||
triggerIntersection([{ isIntersecting: true } as IntersectionObserverEntry])
|
||||
|
||||
expect(mockFetchNextPage).toHaveBeenCalledTimes(1)
|
||||
|
||||
// Advance timer past the 300ms setTimeout in finally block
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
// Also advance past the loadingRef timeout in AppPicker (500ms)
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(200)
|
||||
})
|
||||
|
||||
// Verify component is still rendered correctly
|
||||
expect(screen.getAllByTestId('portal-content').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Form Change Handling', () => {
|
||||
it('should handle form change with image file', () => {
|
||||
const onSelect = vi.fn()
|
||||
@ -2284,7 +2216,7 @@ describe('AppSelector Integration', () => {
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should set isLoadingMore to false after fetchNextPage completes', async () => {
|
||||
it('should stay stable after fetchNextPage completes', async () => {
|
||||
mockHasNextPage = true
|
||||
mockIsFetchingNextPage = false
|
||||
mockFetchNextPage.mockResolvedValue(undefined)
|
||||
@ -2293,16 +2225,10 @@ describe('AppSelector Integration', () => {
|
||||
|
||||
fireEvent.click(screen.getAllByTestId('portal-trigger')[0])
|
||||
|
||||
// Advance timers past the 300ms delay
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(400)
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('portal-content')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should not call fetchNextPage when conditions prevent it', () => {
|
||||
// isLoadingMore would be true internally
|
||||
mockHasNextPage = false
|
||||
mockIsFetchingNextPage = true
|
||||
|
||||
|
||||
@ -51,9 +51,30 @@ const AppPicker: FC<Props> = ({
|
||||
onSearchChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const observerTarget = useRef<HTMLDivElement>(null)
|
||||
const observerTargetRef = useRef<HTMLDivElement>(null)
|
||||
const observerRef = useRef<IntersectionObserver | null>(null)
|
||||
const loadingRef = useRef(false)
|
||||
const loadingResetTimerIdRef = useRef<number | undefined>(undefined)
|
||||
|
||||
const retimeLoadingReset = useCallback((timerId?: number) => {
|
||||
if (loadingResetTimerIdRef.current !== undefined)
|
||||
globalThis.clearTimeout(loadingResetTimerIdRef.current)
|
||||
|
||||
loadingResetTimerIdRef.current = timerId
|
||||
}, [])
|
||||
|
||||
const resetLoadingState = useCallback(() => {
|
||||
retimeLoadingReset()
|
||||
loadingRef.current = false
|
||||
}, [retimeLoadingReset])
|
||||
|
||||
const disconnectObserver = useCallback(() => {
|
||||
if (!observerRef.current)
|
||||
return
|
||||
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}, [])
|
||||
|
||||
const handleIntersection = useCallback((entries: IntersectionObserverEntry[]) => {
|
||||
const target = entries[0]
|
||||
@ -62,27 +83,27 @@ const AppPicker: FC<Props> = ({
|
||||
|
||||
loadingRef.current = true
|
||||
onLoadMore()
|
||||
// Reset loading state
|
||||
setTimeout(() => {
|
||||
retimeLoadingReset(window.setTimeout(() => {
|
||||
loadingRef.current = false
|
||||
}, 500)
|
||||
}, [hasMore, isLoading, onLoadMore])
|
||||
retimeLoadingReset()
|
||||
}, 500))
|
||||
}, [hasMore, isLoading, onLoadMore, retimeLoadingReset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isShow) {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
resetLoadingState()
|
||||
disconnectObserver()
|
||||
return
|
||||
}
|
||||
|
||||
let mutationObserver: MutationObserver | null = null
|
||||
|
||||
const setupIntersectionObserver = () => {
|
||||
if (!observerTarget.current)
|
||||
if (!observerTargetRef.current)
|
||||
return
|
||||
|
||||
disconnectObserver()
|
||||
|
||||
// Create new observer
|
||||
observerRef.current = new IntersectionObserver(handleIntersection, {
|
||||
root: null,
|
||||
@ -90,12 +111,12 @@ const AppPicker: FC<Props> = ({
|
||||
threshold: 0.1,
|
||||
})
|
||||
|
||||
observerRef.current.observe(observerTarget.current)
|
||||
observerRef.current.observe(observerTargetRef.current)
|
||||
}
|
||||
|
||||
// Set up MutationObserver to watch DOM changes
|
||||
mutationObserver = new MutationObserver((_mutations) => {
|
||||
if (observerTarget.current) {
|
||||
if (observerTargetRef.current) {
|
||||
setupIntersectionObserver()
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
@ -108,17 +129,15 @@ const AppPicker: FC<Props> = ({
|
||||
})
|
||||
|
||||
// If element exists, set up IntersectionObserver directly
|
||||
if (observerTarget.current)
|
||||
if (observerTargetRef.current)
|
||||
setupIntersectionObserver()
|
||||
|
||||
return () => {
|
||||
if (observerRef.current) {
|
||||
observerRef.current.disconnect()
|
||||
observerRef.current = null
|
||||
}
|
||||
resetLoadingState()
|
||||
disconnectObserver()
|
||||
mutationObserver?.disconnect()
|
||||
}
|
||||
}, [isShow, handleIntersection])
|
||||
}, [disconnectObserver, handleIntersection, isShow, resetLoadingState])
|
||||
|
||||
const getAppType = (app: App) => {
|
||||
switch (app.mode) {
|
||||
@ -180,7 +199,7 @@ const AppPicker: FC<Props> = ({
|
||||
background={app.icon_background}
|
||||
imageUrl={app.icon_url}
|
||||
/>
|
||||
<div title={`${app.name} (${app.id})`} className="system-sm-medium grow text-components-input-text-filled">
|
||||
<div title={`${app.name} (${app.id})`} className="grow text-components-input-text-filled system-sm-medium">
|
||||
<span className="mr-1">{app.name}</span>
|
||||
<span className="text-text-tertiary">
|
||||
(
|
||||
@ -188,10 +207,10 @@ const AppPicker: FC<Props> = ({
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
<div className="system-2xs-medium-uppercase shrink-0 text-text-tertiary">{getAppType(app)}</div>
|
||||
<div className="shrink-0 text-text-tertiary system-2xs-medium-uppercase">{getAppType(app)}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={observerTarget} className="h-4 w-full">
|
||||
<div ref={observerTargetRef} className="h-4 w-full">
|
||||
{isLoading && (
|
||||
<div className="flex justify-center py-2">
|
||||
<div className="text-sm text-gray-500">{t('loading', { ns: 'common' })}</div>
|
||||
|
||||
@ -47,9 +47,8 @@ const AppSelector: FC<Props> = ({
|
||||
onSelect,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [isShow, onShowChange] = useState(false)
|
||||
const [isShow, setIsShow] = useState(false)
|
||||
const [searchText, setSearchText] = useState('')
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
|
||||
const {
|
||||
data,
|
||||
@ -97,25 +96,16 @@ const AppSelector: FC<Props> = ({
|
||||
const hasMore = hasNextPage ?? true
|
||||
|
||||
const handleLoadMore = useCallback(async () => {
|
||||
if (isLoadingMore || isFetchingNextPage || !hasMore)
|
||||
if (isFetchingNextPage || !hasMore)
|
||||
return
|
||||
|
||||
setIsLoadingMore(true)
|
||||
try {
|
||||
await fetchNextPage()
|
||||
}
|
||||
finally {
|
||||
// Add a small delay to ensure state updates are complete
|
||||
setTimeout(() => {
|
||||
setIsLoadingMore(false)
|
||||
}, 300)
|
||||
}
|
||||
}, [isLoadingMore, isFetchingNextPage, hasMore, fetchNextPage])
|
||||
await fetchNextPage()
|
||||
}, [fetchNextPage, hasMore, isFetchingNextPage])
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
if (disabled)
|
||||
return
|
||||
onShowChange(true)
|
||||
setIsShow(true)
|
||||
}
|
||||
|
||||
const [isShowChooseApp, setIsShowChooseApp] = useState(false)
|
||||
@ -157,7 +147,7 @@ const AppSelector: FC<Props> = ({
|
||||
placement={placement}
|
||||
offset={offset}
|
||||
open={isShow}
|
||||
onOpenChange={onShowChange}
|
||||
onOpenChange={setIsShow}
|
||||
>
|
||||
<PortalToFollowElemTrigger
|
||||
className="w-full"
|
||||
@ -171,7 +161,7 @@ const AppSelector: FC<Props> = ({
|
||||
<PortalToFollowElemContent className="z-[1000]">
|
||||
<div className="relative min-h-20 w-[389px] rounded-xl border-[0.5px] border-components-panel-border bg-components-panel-bg-blur shadow-lg backdrop-blur-sm">
|
||||
<div className="flex flex-col gap-1 px-4 py-3">
|
||||
<div className="system-sm-semibold flex h-6 items-center text-text-secondary">{t('appSelector.label', { ns: 'app' })}</div>
|
||||
<div className="flex h-6 items-center text-text-secondary system-sm-semibold">{t('appSelector.label', { ns: 'app' })}</div>
|
||||
<AppPicker
|
||||
placement="bottom"
|
||||
offset={offset}
|
||||
@ -187,7 +177,7 @@ const AppSelector: FC<Props> = ({
|
||||
onSelect={handleSelectApp}
|
||||
scope={scope || 'all'}
|
||||
apps={appsForPicker}
|
||||
isLoading={isLoading || isLoadingMore || isFetchingNextPage}
|
||||
isLoading={isLoading || isFetchingNextPage}
|
||||
hasMore={hasMore}
|
||||
onLoadMore={handleLoadMore}
|
||||
searchText={searchText}
|
||||
|
||||
@ -11,7 +11,6 @@ import {
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { gte } from 'semver'
|
||||
import Tooltip from '@/app/components/base/tooltip'
|
||||
import useRefreshPluginList from '@/app/components/plugins/install-plugin/hooks/use-refresh-plugin-list'
|
||||
import { API_PREFIX } from '@/config'
|
||||
@ -20,6 +19,7 @@ import { useGlobalPublicStore } from '@/context/global-public-context'
|
||||
import { useRenderI18nObject } from '@/hooks/use-i18n'
|
||||
import useTheme from '@/hooks/use-theme'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isEqualOrLaterThanVersion } from '@/utils/semver'
|
||||
import { getMarketplaceUrl } from '@/utils/var'
|
||||
import Badge from '../../base/badge'
|
||||
import { Github } from '../../base/icons/src/public/common'
|
||||
@ -71,7 +71,7 @@ const PluginItem: FC<Props> = ({
|
||||
const isDifyVersionCompatible = useMemo(() => {
|
||||
if (!langGeniusVersionInfo.current_version)
|
||||
return true
|
||||
return gte(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
|
||||
return isEqualOrLaterThanVersion(langGeniusVersionInfo.current_version, declarationMeta.minimum_dify_version ?? '0.0.0')
|
||||
}, [declarationMeta.minimum_dify_version, langGeniusVersionInfo.current_version])
|
||||
|
||||
const isDeprecated = useMemo(() => {
|
||||
@ -164,8 +164,8 @@ const PluginItem: FC<Props> = ({
|
||||
/>
|
||||
{category === PluginCategoryEnum.extension && (
|
||||
<>
|
||||
<div className="system-xs-regular mx-2 text-text-quaternary">·</div>
|
||||
<div className="system-xs-regular flex items-center gap-x-1 overflow-hidden text-text-tertiary">
|
||||
<div className="mx-2 text-text-quaternary system-xs-regular">·</div>
|
||||
<div className="flex items-center gap-x-1 overflow-hidden text-text-tertiary system-xs-regular">
|
||||
<RiLoginCircleLine className="size-3 shrink-0" />
|
||||
<span
|
||||
className="truncate"
|
||||
@ -183,7 +183,7 @@ const PluginItem: FC<Props> = ({
|
||||
&& (
|
||||
<>
|
||||
<a href={`https://github.com/${meta!.repo}`} target="_blank" className="flex items-center gap-1">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">{t('from', { ns: 'plugin' })}</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">{t('from', { ns: 'plugin' })}</div>
|
||||
<div className="flex items-center space-x-0.5 text-text-secondary">
|
||||
<Github className="h-3 w-3" />
|
||||
<div className="system-2xs-semibold-uppercase">GitHub</div>
|
||||
@ -196,7 +196,7 @@ const PluginItem: FC<Props> = ({
|
||||
&& (
|
||||
<>
|
||||
<a href={getMarketplaceUrl(`/plugins/${author}/${name}`, { theme })} target="_blank" className="flex items-center gap-0.5">
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">
|
||||
{t('from', { ns: 'plugin' })}
|
||||
{' '}
|
||||
<span className="text-text-secondary">marketplace</span>
|
||||
@ -210,7 +210,7 @@ const PluginItem: FC<Props> = ({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<RiHardDrive3Line className="h-3 w-3 text-text-tertiary" />
|
||||
<div className="system-2xs-medium-uppercase text-text-tertiary">Local Plugin</div>
|
||||
<div className="text-text-tertiary system-2xs-medium-uppercase">Local Plugin</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@ -219,14 +219,14 @@ const PluginItem: FC<Props> = ({
|
||||
<>
|
||||
<div className="flex items-center gap-1">
|
||||
<RiBugLine className="h-3 w-3 text-text-warning" />
|
||||
<div className="system-2xs-medium-uppercase text-text-warning">Debugging Plugin</div>
|
||||
<div className="text-text-warning system-2xs-medium-uppercase">Debugging Plugin</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{/* Deprecated */}
|
||||
{source === PluginSource.marketplace && enable_marketplace && isDeprecated && (
|
||||
<div className="system-2xs-medium-uppercase flex shrink-0 items-center gap-x-2">
|
||||
<div className="flex shrink-0 items-center gap-x-2 system-2xs-medium-uppercase">
|
||||
<span className="text-text-tertiary">·</span>
|
||||
<span className="text-text-warning">
|
||||
{t('deprecated', { ns: 'plugin' })}
|
||||
|
||||
@ -104,20 +104,6 @@ vi.mock('../../install-plugin/install-from-github', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
// Mock semver
|
||||
vi.mock('semver', () => ({
|
||||
lt: (v1: string, v2: string) => {
|
||||
const parseVersion = (v: string) => v.split('.').map(Number)
|
||||
const [major1, minor1, patch1] = parseVersion(v1)
|
||||
const [major2, minor2, patch2] = parseVersion(v2)
|
||||
if (major1 !== major2)
|
||||
return major1 < major2
|
||||
if (minor1 !== minor2)
|
||||
return minor1 < minor2
|
||||
return patch1 < patch2
|
||||
},
|
||||
}))
|
||||
|
||||
// ================================
|
||||
// Test Data Factories
|
||||
// ================================
|
||||
|
||||
@ -4,7 +4,6 @@ import type { Placement } from '@/app/components/base/ui/placement'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { lt } from 'semver'
|
||||
import Badge from '@/app/components/base/badge'
|
||||
import {
|
||||
Popover,
|
||||
@ -14,6 +13,7 @@ import {
|
||||
import useTimestamp from '@/hooks/use-timestamp'
|
||||
import { useVersionListOfPlugin } from '@/service/use-plugins'
|
||||
import { cn } from '@/utils/classnames'
|
||||
import { isEarlierThanVersion } from '@/utils/semver'
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean
|
||||
@ -100,7 +100,7 @@ const PluginVersionPicker: FC<Props> = ({
|
||||
onClick={() => handleSelect({
|
||||
version: version.version,
|
||||
unique_identifier: version.unique_identifier,
|
||||
isDowngrade: lt(version.version, currentVersion),
|
||||
isDowngrade: isEarlierThanVersion(version.version, currentVersion),
|
||||
})}
|
||||
>
|
||||
<div className="flex grow items-center">
|
||||
|
||||
@ -4963,11 +4963,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/install-plugin/install-from-local-package/steps/install.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/install-plugin/install-from-local-package/steps/uploading.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
@ -4984,11 +4979,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/install-plugin/install-from-marketplace/steps/install.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/marketplace/description/index.tsx": {
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 9
|
||||
@ -5169,9 +5159,6 @@
|
||||
"app/components/plugins/plugin-detail-panel/app-selector/app-picker.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 2
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/app-selector/app-trigger.tsx": {
|
||||
@ -5182,9 +5169,6 @@
|
||||
"app/components/plugins/plugin-detail-panel/app-selector/index.tsx": {
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"app/components/plugins/plugin-detail-panel/datasource-action-list.tsx": {
|
||||
@ -5467,9 +5451,6 @@
|
||||
"no-restricted-imports": {
|
||||
"count": 1
|
||||
},
|
||||
"tailwindcss/enforce-consistent-class-order": {
|
||||
"count": 7
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "dify-web",
|
||||
"type": "module",
|
||||
"version": "1.13.1",
|
||||
"version": "1.13.2",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.32.1",
|
||||
"imports": {
|
||||
@ -151,9 +151,9 @@
|
||||
"remark-breaks": "4.0.0",
|
||||
"remark-directive": "4.0.0",
|
||||
"scheduler": "0.27.0",
|
||||
"semver": "7.7.4",
|
||||
"sharp": "0.34.5",
|
||||
"sortablejs": "1.15.7",
|
||||
"std-semver": "1.0.8",
|
||||
"streamdown": "2.5.0",
|
||||
"string-ts": "2.3.1",
|
||||
"tailwind-merge": "2.6.1",
|
||||
@ -206,7 +206,6 @@
|
||||
"@types/react-slider": "1.3.6",
|
||||
"@types/react-syntax-highlighter": "15.5.13",
|
||||
"@types/react-window": "1.8.8",
|
||||
"@types/semver": "7.7.1",
|
||||
"@types/sortablejs": "1.15.9",
|
||||
"@typescript-eslint/parser": "8.57.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260317.1",
|
||||
|
||||
20
web/pnpm-lock.yaml
generated
20
web/pnpm-lock.yaml
generated
@ -340,15 +340,15 @@ importers:
|
||||
scheduler:
|
||||
specifier: 0.27.0
|
||||
version: 0.27.0
|
||||
semver:
|
||||
specifier: 7.7.4
|
||||
version: 7.7.4
|
||||
sharp:
|
||||
specifier: 0.34.5
|
||||
version: 0.34.5
|
||||
sortablejs:
|
||||
specifier: 1.15.7
|
||||
version: 1.15.7
|
||||
std-semver:
|
||||
specifier: 1.0.8
|
||||
version: 1.0.8
|
||||
streamdown:
|
||||
specifier: 2.5.0
|
||||
version: 2.5.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@ -500,9 +500,6 @@ importers:
|
||||
'@types/react-window':
|
||||
specifier: 1.8.8
|
||||
version: 1.8.8
|
||||
'@types/semver':
|
||||
specifier: 7.7.1
|
||||
version: 7.7.1
|
||||
'@types/sortablejs':
|
||||
specifier: 1.15.9
|
||||
version: 1.15.9
|
||||
@ -3420,9 +3417,6 @@ packages:
|
||||
'@types/resolve@1.20.6':
|
||||
resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==}
|
||||
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
'@types/sortablejs@1.15.9':
|
||||
resolution: {integrity: sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ==}
|
||||
|
||||
@ -7115,6 +7109,10 @@ packages:
|
||||
std-env@4.0.0:
|
||||
resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==}
|
||||
|
||||
std-semver@1.0.8:
|
||||
resolution: {integrity: sha512-9SN0XIjBBXCT6ZXXVnScJN4KP2RyFg6B8sEoFlugVHMANysfaEni4LTWlvUQQ/R0wgZl1Ovt9KBQbzn21kHoZA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
storybook@10.2.19:
|
||||
resolution: {integrity: sha512-UUm5eGSm6BLhkcFP0WbxkmAHJZfVN2ViLpIZOqiIPS++q32VYn+CLFC0lrTYTDqYvaG7i4BK4uowXYujzE4NdQ==}
|
||||
hasBin: true
|
||||
@ -10755,8 +10753,6 @@ snapshots:
|
||||
|
||||
'@types/resolve@1.20.6': {}
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/sortablejs@1.15.9': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
@ -15205,6 +15201,8 @@ snapshots:
|
||||
|
||||
std-env@4.0.0: {}
|
||||
|
||||
std-semver@1.0.8: {}
|
||||
|
||||
storybook@10.2.19(@testing-library/dom@10.4.1)(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
'@storybook/global': 5.0.0
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { compareVersion, getLatestVersion, isEqualOrLaterThanVersion } from './semver'
|
||||
import { compareVersion, getLatestVersion, isEarlierThanVersion, isEqualOrLaterThanVersion } from './semver'
|
||||
|
||||
describe('semver utilities', () => {
|
||||
describe('getLatestVersion', () => {
|
||||
@ -72,4 +72,24 @@ describe('semver utilities', () => {
|
||||
expect(isEqualOrLaterThanVersion('1.0.0-alpha', '1.0.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isEarlierThanVersion', () => {
|
||||
it('should return true when baseVersion is less than targetVersion', () => {
|
||||
expect(isEarlierThanVersion('1.0.0', '1.1.0')).toBe(true)
|
||||
expect(isEarlierThanVersion('1.9.9', '2.0.0')).toBe(true)
|
||||
expect(isEarlierThanVersion('1.0.0', '1.0.1')).toBe(true)
|
||||
})
|
||||
|
||||
it('should return false when baseVersion is equal to or greater than targetVersion', () => {
|
||||
expect(isEarlierThanVersion('1.0.0', '1.0.0')).toBe(false)
|
||||
expect(isEarlierThanVersion('1.1.0', '1.0.0')).toBe(false)
|
||||
expect(isEarlierThanVersion('1.0.1', '1.0.0')).toBe(false)
|
||||
})
|
||||
|
||||
it('should handle pre-release versions correctly', () => {
|
||||
expect(isEarlierThanVersion('1.0.0-beta', '1.0.0')).toBe(true)
|
||||
expect(isEarlierThanVersion('1.0.0-alpha', '1.0.0-beta')).toBe(true)
|
||||
expect(isEarlierThanVersion('1.0.0', '1.0.0-beta')).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,13 +1,19 @@
|
||||
import semver from 'semver'
|
||||
import { compare, greaterOrEqual, lessThan, parse } from 'std-semver'
|
||||
|
||||
export const getLatestVersion = (versionList: string[]) => {
|
||||
return semver.rsort(versionList)[0]
|
||||
return [...versionList].sort((versionA, versionB) => {
|
||||
return compare(parse(versionB), parse(versionA))
|
||||
})[0]
|
||||
}
|
||||
|
||||
export const compareVersion = (v1: string, v2: string) => {
|
||||
return semver.compare(v1, v2)
|
||||
return compare(parse(v1), parse(v2))
|
||||
}
|
||||
|
||||
export const isEqualOrLaterThanVersion = (baseVersion: string, targetVersion: string) => {
|
||||
return semver.gte(baseVersion, targetVersion)
|
||||
return greaterOrEqual(parse(baseVersion), parse(targetVersion))
|
||||
}
|
||||
|
||||
export const isEarlierThanVersion = (baseVersion: string, targetVersion: string) => {
|
||||
return lessThan(parse(baseVersion), parse(targetVersion))
|
||||
}
|
||||
|
||||
@ -111,7 +111,6 @@ export default defineConfig(({ mode }) => {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
setupFiles: ['./vitest.setup.ts'],
|
||||
reporters: ['agent'],
|
||||
coverage: {
|
||||
provider: 'v8',
|
||||
reporter: isCI ? ['json', 'json-summary'] : ['text', 'json', 'json-summary'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user