mirror of
https://github.com/langgenius/dify.git
synced 2026-09-08 02:43:49 +08:00
fix: can't debug model plugins (#38500)
This commit is contained in:
parent
3ddfba5ca5
commit
6edce14e88
@ -92,6 +92,7 @@ class PluginService:
|
|||||||
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
||||||
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
|
PLUGIN_MODEL_PROVIDERS_GENERATION_REDIS_KEY_PREFIX = "plugin_model_providers_generation:tenant_id:"
|
||||||
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
|
PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX = "plugin_model_providers_refresh_lock:tenant_id:"
|
||||||
|
PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX = "plugin_model_providers_remote_debug:tenant_id:"
|
||||||
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
|
PLUGIN_MODEL_PROVIDERS_LOCK_TTL = 30
|
||||||
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
|
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_TIMEOUT = 2.0
|
||||||
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
|
PLUGIN_MODEL_PROVIDERS_LOCK_WAIT_INTERVAL = 0.05
|
||||||
@ -117,6 +118,10 @@ class PluginService:
|
|||||||
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
|
def _get_plugin_model_providers_lock_key(cls, tenant_id: str, generation: int) -> str:
|
||||||
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
|
return f"{cls.PLUGIN_MODEL_PROVIDERS_LOCK_REDIS_KEY_PREFIX}{tenant_id}:generation:{generation}"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_plugin_model_providers_remote_debug_cache_key(cls, tenant_id: str) -> str:
|
||||||
|
return f"{cls.PLUGIN_MODEL_PROVIDERS_REMOTE_DEBUG_REDIS_KEY_PREFIX}{tenant_id}"
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
|
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
|
||||||
"""
|
"""
|
||||||
@ -259,6 +264,111 @@ class PluginService:
|
|||||||
except (RedisError, RuntimeError):
|
except (RedisError, RuntimeError):
|
||||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _get_remote_model_plugin_cache_marker(cls, plugins: Sequence[PluginEntity]) -> str | None:
|
||||||
|
remote_model_plugins = sorted(
|
||||||
|
f"{plugin.plugin_id}:{plugin.plugin_unique_identifier}"
|
||||||
|
for plugin in plugins
|
||||||
|
if plugin.source == PluginInstallationSource.Remote
|
||||||
|
)
|
||||||
|
if not remote_model_plugins:
|
||||||
|
return None
|
||||||
|
|
||||||
|
return "\n".join(remote_model_plugins)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_cached_remote_model_plugin_marker(cls, tenant_id: str) -> str | None:
|
||||||
|
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
|
||||||
|
try:
|
||||||
|
cached_marker = redis_client.get(cache_key)
|
||||||
|
except (RedisError, RuntimeError):
|
||||||
|
logger.warning("Failed to read remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
if cached_marker is None:
|
||||||
|
return None
|
||||||
|
if isinstance(cached_marker, bytes):
|
||||||
|
try:
|
||||||
|
return cached_marker.decode()
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
logger.warning(
|
||||||
|
"Invalid remote debug model plugin marker for tenant %s; deleting cache marker.",
|
||||||
|
tenant_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
redis_client.delete(cache_key)
|
||||||
|
except (RedisError, RuntimeError):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
|
||||||
|
tenant_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
if isinstance(cached_marker, str):
|
||||||
|
return cached_marker
|
||||||
|
|
||||||
|
logger.warning("Invalid remote debug model plugin marker for tenant %s; deleting cache marker.", tenant_id)
|
||||||
|
try:
|
||||||
|
redis_client.delete(cache_key)
|
||||||
|
except (RedisError, RuntimeError):
|
||||||
|
logger.warning(
|
||||||
|
"Failed to delete invalid remote debug model plugin marker for tenant %s.",
|
||||||
|
tenant_id,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _store_cached_remote_model_plugin_marker(cls, tenant_id: str, marker: str | None) -> None:
|
||||||
|
cache_key = cls._get_plugin_model_providers_remote_debug_cache_key(tenant_id)
|
||||||
|
try:
|
||||||
|
if marker is None:
|
||||||
|
redis_client.delete(cache_key)
|
||||||
|
else:
|
||||||
|
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, marker)
|
||||||
|
except (RedisError, RuntimeError):
|
||||||
|
logger.warning("Failed to cache remote debug model plugin marker for tenant %s.", tenant_id, exc_info=True)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _load_cached_plugin_model_provider_plugin_ids(cls, tenant_id: str) -> set[str] | None:
|
||||||
|
"""Return plugin ids represented by the current provider cache, or None when no usable cache exists."""
|
||||||
|
generation = cls._load_plugin_model_providers_generation(tenant_id)
|
||||||
|
cached_providers, _ = cls._load_cached_plugin_model_providers_for_generation(tenant_id, generation)
|
||||||
|
if cached_providers is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
plugin_ids: set[str] = set()
|
||||||
|
for provider in cached_providers:
|
||||||
|
last_slash = provider.provider.rfind("/")
|
||||||
|
if last_slash > 0:
|
||||||
|
plugin_ids.add(provider.provider[:last_slash])
|
||||||
|
|
||||||
|
return plugin_ids
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||||
|
cls,
|
||||||
|
tenant_id: str,
|
||||||
|
plugins: Sequence[PluginEntity],
|
||||||
|
) -> bool:
|
||||||
|
remote_model_plugin_marker = cls._get_remote_model_plugin_cache_marker(plugins)
|
||||||
|
cached_remote_model_plugin_marker = cls._load_cached_remote_model_plugin_marker(tenant_id)
|
||||||
|
if remote_model_plugin_marker is None:
|
||||||
|
return cached_remote_model_plugin_marker is not None
|
||||||
|
|
||||||
|
if remote_model_plugin_marker != cached_remote_model_plugin_marker:
|
||||||
|
return True
|
||||||
|
|
||||||
|
remote_model_plugin_ids = {
|
||||||
|
plugin.plugin_id for plugin in plugins if plugin.source == PluginInstallationSource.Remote
|
||||||
|
}
|
||||||
|
cached_plugin_ids = cls._load_cached_plugin_model_provider_plugin_ids(tenant_id)
|
||||||
|
if cached_plugin_ids is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
return not remote_model_plugin_ids.issubset(cached_plugin_ids)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@contextmanager
|
@contextmanager
|
||||||
def _plugin_model_providers_refresh_lock(
|
def _plugin_model_providers_refresh_lock(
|
||||||
@ -571,7 +681,21 @@ class PluginService:
|
|||||||
This keeps pagination usable before category is persisted on installation rows.
|
This keeps pagination usable before category is persisted on installation rows.
|
||||||
"""
|
"""
|
||||||
manager = PluginInstaller()
|
manager = PluginInstaller()
|
||||||
return manager.list_plugins_by_category(tenant_id, category, page, page_size)
|
plugins = manager.list_plugins_by_category(tenant_id, category, page, page_size)
|
||||||
|
if category == PluginCategory.Model:
|
||||||
|
should_invalidate_model_provider_cache = (
|
||||||
|
PluginService._should_invalidate_model_provider_cache_for_remote_model_plugins(
|
||||||
|
tenant_id,
|
||||||
|
plugins.list,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if should_invalidate_model_provider_cache:
|
||||||
|
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||||
|
|
||||||
|
remote_model_plugin_marker = PluginService._get_remote_model_plugin_cache_marker(plugins.list)
|
||||||
|
PluginService._store_cached_remote_model_plugin_marker(tenant_id, remote_model_plugin_marker)
|
||||||
|
|
||||||
|
return plugins
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _normalize_endpoint_count(value: object) -> int:
|
def _normalize_endpoint_count(value: object) -> int:
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import zstandard
|
|||||||
from pydantic import TypeAdapter
|
from pydantic import TypeAdapter
|
||||||
from redis import RedisError
|
from redis import RedisError
|
||||||
|
|
||||||
from core.plugin.entities.plugin import PluginInstallationSource
|
from core.plugin.entities.plugin import PluginCategory, PluginInstallationSource
|
||||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||||
@ -71,6 +71,16 @@ def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskSta
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_remote_model_plugin(
|
||||||
|
*, plugin_id: str = "langgenius/debug-model", plugin_unique_identifier: str = "langgenius/debug-model:1.0.0"
|
||||||
|
) -> SimpleNamespace:
|
||||||
|
return SimpleNamespace(
|
||||||
|
plugin_id=plugin_id,
|
||||||
|
plugin_unique_identifier=plugin_unique_identifier,
|
||||||
|
source=PluginInstallationSource.Remote,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str:
|
def _provider_cache_key(tenant_id: str, generation: int | None = None) -> str:
|
||||||
if generation is None:
|
if generation is None:
|
||||||
return f"plugin_model_providers:tenant_id:{tenant_id}"
|
return f"plugin_model_providers:tenant_id:{tenant_id}"
|
||||||
@ -797,6 +807,144 @@ class TestPluginListEndpointCounts:
|
|||||||
|
|
||||||
|
|
||||||
class TestPluginModelProviderCacheInvalidation:
|
class TestPluginModelProviderCacheInvalidation:
|
||||||
|
def test_get_debugging_key_does_not_invalidate_model_provider_cache(self) -> None:
|
||||||
|
"""Reading a debug key does not mean a debug runtime has registered a model provider."""
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.PluginDebuggingClient") as debugging_client_cls,
|
||||||
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||||
|
):
|
||||||
|
debugging_client_cls.return_value.get_debugging_key.return_value = "debug-key"
|
||||||
|
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
|
result = PluginService.get_debugging_key("tenant-1")
|
||||||
|
|
||||||
|
assert result == "debug-key"
|
||||||
|
debugging_client_cls.return_value.get_debugging_key.assert_called_once_with("tenant-1")
|
||||||
|
invalidate_cache.assert_not_called()
|
||||||
|
|
||||||
|
def test_list_model_category_invalidates_when_remote_model_plugin_is_missing_from_provider_cache(self) -> None:
|
||||||
|
"""Remote model plugins are daemon-registered, so category reads repair a stale provider cache."""
|
||||||
|
remote_plugin = _build_remote_model_plugin()
|
||||||
|
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
||||||
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||||
|
return_value=remote_plugin_marker,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
||||||
|
return_value={"langgenius/openai"},
|
||||||
|
),
|
||||||
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||||
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||||
|
):
|
||||||
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||||
|
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
||||||
|
|
||||||
|
assert result is plugins
|
||||||
|
installer_cls.return_value.list_plugins_by_category.assert_called_once_with(
|
||||||
|
"tenant-1", PluginCategory.Model, 1, 100
|
||||||
|
)
|
||||||
|
invalidate_cache.assert_called_once_with("tenant-1")
|
||||||
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||||
|
|
||||||
|
def test_list_model_category_invalidates_when_remote_model_plugin_identity_changes(self) -> None:
|
||||||
|
"""A debug model plugin can share plugin_id with an installed plugin, so identity changes bust cache too."""
|
||||||
|
remote_plugin = _build_remote_model_plugin(
|
||||||
|
plugin_id="langgenius/openai",
|
||||||
|
plugin_unique_identifier="langgenius/openai:debug",
|
||||||
|
)
|
||||||
|
remote_plugin_marker = "langgenius/openai:langgenius/openai:debug"
|
||||||
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||||
|
return_value="langgenius/openai:langgenius/openai:1.0.0",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
||||||
|
return_value={"langgenius/openai"},
|
||||||
|
) as load_cached_provider_plugin_ids,
|
||||||
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||||
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||||
|
):
|
||||||
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||||
|
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
||||||
|
|
||||||
|
assert result is plugins
|
||||||
|
invalidate_cache.assert_called_once_with("tenant-1")
|
||||||
|
load_cached_provider_plugin_ids.assert_not_called()
|
||||||
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||||
|
|
||||||
|
def test_list_model_category_keeps_provider_cache_when_remote_model_plugin_is_already_cached(self) -> None:
|
||||||
|
"""A connected remote model plugin should not force provider cache churn once represented."""
|
||||||
|
remote_plugin = _build_remote_model_plugin()
|
||||||
|
remote_plugin_marker = "langgenius/debug-model:langgenius/debug-model:1.0.0"
|
||||||
|
plugins = SimpleNamespace(list=[remote_plugin], has_more=False)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||||
|
return_value=remote_plugin_marker,
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_plugin_model_provider_plugin_ids",
|
||||||
|
return_value={"langgenius/debug-model"},
|
||||||
|
),
|
||||||
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||||
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||||
|
):
|
||||||
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||||
|
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
||||||
|
|
||||||
|
assert result is plugins
|
||||||
|
invalidate_cache.assert_not_called()
|
||||||
|
store_marker.assert_called_once_with("tenant-1", remote_plugin_marker)
|
||||||
|
|
||||||
|
def test_list_model_category_invalidates_when_remote_model_plugin_disconnects(self) -> None:
|
||||||
|
"""The current model category result clears provider cache when the previous debug model disappears."""
|
||||||
|
installed_plugin = SimpleNamespace(
|
||||||
|
plugin_id="langgenius/openai",
|
||||||
|
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||||
|
source=PluginInstallationSource.Marketplace,
|
||||||
|
)
|
||||||
|
plugins = SimpleNamespace(list=[installed_plugin], has_more=True)
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||||
|
patch(
|
||||||
|
f"{MODULE}.PluginService._load_cached_remote_model_plugin_marker",
|
||||||
|
return_value="langgenius/debug-model:langgenius/debug-model:1.0.0",
|
||||||
|
),
|
||||||
|
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||||
|
patch(f"{MODULE}.PluginService._store_cached_remote_model_plugin_marker") as store_marker,
|
||||||
|
):
|
||||||
|
installer_cls.return_value.list_plugins_by_category.return_value = plugins
|
||||||
|
|
||||||
|
from core.plugin.plugin_service import PluginService
|
||||||
|
|
||||||
|
result = PluginService.list_by_category("tenant-1", PluginCategory.Model, 1, 100)
|
||||||
|
|
||||||
|
assert result is plugins
|
||||||
|
invalidate_cache.assert_called_once_with("tenant-1")
|
||||||
|
store_marker.assert_called_once_with("tenant-1", None)
|
||||||
|
|
||||||
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
||||||
"""Finished plugin install tasks invalidate tenant provider cache."""
|
"""Finished plugin install tasks invalidate tenant provider cache."""
|
||||||
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
||||||
|
|||||||
@ -41,6 +41,7 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', () => ({
|
||||||
useDefaultModel: () => ({ data: null, isLoading: false }),
|
useDefaultModel: () => ({ data: null, isLoading: false }),
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../provider-added-card', () => ({
|
vi.mock('../provider-added-card', () => ({
|
||||||
@ -84,9 +85,11 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/service/use-plugins', () => ({
|
vi.mock('@/service/use-plugins', () => ({
|
||||||
useCheckInstalled: () => ({
|
useInstalledPluginList: () => ({
|
||||||
data: { plugins: [] },
|
data: { plugins: [] },
|
||||||
}),
|
}),
|
||||||
|
useInvalidateInstalledPluginList: () => vi.fn(),
|
||||||
|
useInvalidateCheckInstalled: () => vi.fn(),
|
||||||
usePluginAutoUpgradeSettings: () => ({
|
usePluginAutoUpgradeSettings: () => ({
|
||||||
data: {
|
data: {
|
||||||
category: 'model',
|
category: 'model',
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
import type { PluginDeclaration, PluginDetail } from '@/app/components/plugins/types'
|
||||||
import { act, fireEvent, screen } from '@testing-library/react'
|
import { act, fireEvent, screen } from '@testing-library/react'
|
||||||
import { describe, expect, it, vi } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
import { renderWithSystemFeatures } from '@/__tests__/utils/mock-system-features'
|
||||||
|
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||||
import {
|
import {
|
||||||
CurrentSystemQuotaTypeEnum,
|
CurrentSystemQuotaTypeEnum,
|
||||||
CustomConfigurationStatusEnum,
|
CustomConfigurationStatusEnum,
|
||||||
@ -41,10 +43,18 @@ const { mockReferenceSetting, mockAutoUpgradeError } = vi.hoisted(() => ({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const { mockProviderContextState } = vi.hoisted(() => ({
|
const { mockProviderContextState, mockRefreshModelProviders } = vi.hoisted(() => ({
|
||||||
mockProviderContextState: {
|
mockProviderContextState: {
|
||||||
isLoadingModelProviders: false,
|
isLoadingModelProviders: false,
|
||||||
},
|
},
|
||||||
|
mockRefreshModelProviders: vi.fn(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const { mockInstalledModelPlugins, mockUseInstalledPluginList } = vi.hoisted(() => ({
|
||||||
|
mockInstalledModelPlugins: {
|
||||||
|
value: [] as PluginDetail[],
|
||||||
|
},
|
||||||
|
mockUseInstalledPluginList: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const mockQuotaConfig = {
|
const mockQuotaConfig = {
|
||||||
@ -79,6 +89,70 @@ const saveUpdateSettings = () => {
|
|||||||
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
fireEvent.click(screen.getByRole('button', { name: 'common.operation.save' }))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const createPluginDeclaration = (overrides: Partial<PluginDeclaration> = {}): PluginDeclaration => ({
|
||||||
|
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
|
||||||
|
version: '1.0.0',
|
||||||
|
author: 'langgenius',
|
||||||
|
icon: 'debug-model.png',
|
||||||
|
icon_dark: 'debug-model-dark.png',
|
||||||
|
name: 'debug-model',
|
||||||
|
category: PluginCategoryEnum.model,
|
||||||
|
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||||
|
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
resource: null,
|
||||||
|
plugins: null,
|
||||||
|
verified: false,
|
||||||
|
endpoint: null,
|
||||||
|
tool: undefined,
|
||||||
|
datasource: undefined,
|
||||||
|
model: {},
|
||||||
|
tags: [],
|
||||||
|
agent_strategy: null,
|
||||||
|
meta: {
|
||||||
|
version: '1.0.0',
|
||||||
|
},
|
||||||
|
trigger: {} as unknown as PluginDeclaration['trigger'],
|
||||||
|
...overrides,
|
||||||
|
})
|
||||||
|
|
||||||
|
const createPluginDetail = (overrides: Partial<PluginDetail> = {}): PluginDetail => {
|
||||||
|
const {
|
||||||
|
declaration: overrideDeclaration,
|
||||||
|
plugin_id: overridePluginId,
|
||||||
|
...restOverrides
|
||||||
|
} = overrides
|
||||||
|
const declaration = overrideDeclaration ?? createPluginDeclaration()
|
||||||
|
const pluginId = overridePluginId ?? 'langgenius/debug-model'
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: 'plugin-installation-id',
|
||||||
|
created_at: '2024-01-01',
|
||||||
|
updated_at: '2024-01-01',
|
||||||
|
name: declaration.name,
|
||||||
|
plugin_id: pluginId,
|
||||||
|
plugin_unique_identifier: declaration.plugin_unique_identifier,
|
||||||
|
declaration,
|
||||||
|
installation_id: 'plugin-installation-id',
|
||||||
|
tenant_id: 'tenant-id',
|
||||||
|
endpoints_setups: 0,
|
||||||
|
endpoints_active: 0,
|
||||||
|
version: '1.0.0',
|
||||||
|
latest_version: '1.0.0',
|
||||||
|
latest_unique_identifier: declaration.plugin_unique_identifier,
|
||||||
|
source: PluginSource.debugging,
|
||||||
|
meta: {
|
||||||
|
repo: '',
|
||||||
|
version: '1.0.0',
|
||||||
|
package: '',
|
||||||
|
},
|
||||||
|
status: 'active',
|
||||||
|
deprecated_reason: '',
|
||||||
|
alternative_plugin_id: '',
|
||||||
|
...restOverrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const mockProviders = [
|
const mockProviders = [
|
||||||
{
|
{
|
||||||
provider: 'openai',
|
provider: 'openai',
|
||||||
@ -106,6 +180,7 @@ vi.mock('@/context/provider-context', () => ({
|
|||||||
useProviderContext: () => ({
|
useProviderContext: () => ({
|
||||||
modelProviders: mockProviders,
|
modelProviders: mockProviders,
|
||||||
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
|
isLoadingModelProviders: mockProviderContextState.isLoadingModelProviders,
|
||||||
|
refreshModelProviders: mockRefreshModelProviders,
|
||||||
}),
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@ -119,6 +194,7 @@ const mockDefaultModels: Record<string, { data: unknown, isLoading: boolean }> =
|
|||||||
|
|
||||||
vi.mock('../hooks', () => ({
|
vi.mock('../hooks', () => ({
|
||||||
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
useDefaultModel: (type: string) => mockDefaultModels[type] ?? { data: null, isLoading: false },
|
||||||
|
useLanguage: () => 'en_US',
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../install-from-marketplace', () => ({
|
vi.mock('../install-from-marketplace', () => ({
|
||||||
@ -126,7 +202,24 @@ vi.mock('../install-from-marketplace', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../provider-added-card', () => ({
|
vi.mock('../provider-added-card', () => ({
|
||||||
default: ({ provider }: { provider: { provider: string } }) => <div data-testid="provider-card">{provider.provider}</div>,
|
default: ({
|
||||||
|
notConfigured,
|
||||||
|
provider,
|
||||||
|
pluginDetail,
|
||||||
|
}: {
|
||||||
|
notConfigured?: boolean
|
||||||
|
provider: { provider: string }
|
||||||
|
pluginDetail?: { plugin_id: string, source?: string }
|
||||||
|
}) => (
|
||||||
|
<div
|
||||||
|
data-testid="provider-card"
|
||||||
|
data-not-configured={String(!!notConfigured)}
|
||||||
|
data-plugin-id={pluginDetail?.plugin_id ?? ''}
|
||||||
|
data-plugin-source={pluginDetail?.source ?? ''}
|
||||||
|
>
|
||||||
|
{provider.provider}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('../provider-added-card/quota-panel', () => ({
|
vi.mock('../provider-added-card/quota-panel', () => ({
|
||||||
@ -160,12 +253,12 @@ vi.mock('@/app/components/plugins/plugin-page/use-reference-setting', () => ({
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@/service/use-plugins', () => ({
|
vi.mock('@/service/use-plugins', () => ({
|
||||||
useInstalledPluginList: () => ({
|
useInstalledPluginList: (...args: unknown[]) => {
|
||||||
data: { plugins: [] },
|
mockUseInstalledPluginList(...args)
|
||||||
}),
|
return {
|
||||||
useCheckInstalled: () => ({
|
data: { plugins: mockInstalledModelPlugins.value },
|
||||||
data: { plugins: [] },
|
}
|
||||||
}),
|
},
|
||||||
usePluginAutoUpgradeSettings: () => ({
|
usePluginAutoUpgradeSettings: () => ({
|
||||||
data: mockReferenceSetting.auto_upgrade
|
data: mockReferenceSetting.auto_upgrade
|
||||||
? {
|
? {
|
||||||
@ -280,6 +373,9 @@ describe('ModelProviderPage', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
|
mockUseInstalledPluginList.mockClear()
|
||||||
|
mockRefreshModelProviders.mockClear()
|
||||||
|
mockInstalledModelPlugins.value = []
|
||||||
mockProviderContextState.isLoadingModelProviders = false
|
mockProviderContextState.isLoadingModelProviders = false
|
||||||
mockAutoUpgradeError.value = undefined
|
mockAutoUpgradeError.value = undefined
|
||||||
mockReferenceSetting.auto_upgrade = {
|
mockReferenceSetting.auto_upgrade = {
|
||||||
@ -418,6 +514,107 @@ describe('ModelProviderPage', () => {
|
|||||||
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
expect(screen.getByText('anthropic')).toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should use the model plugin installation list to attach plugin detail to provider cards', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'langgenius/openai/openai',
|
||||||
|
label: { en_US: 'OpenAI' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mockInstalledModelPlugins.value = [
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/openai',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
plugin_unique_identifier: 'langgenius/openai:1.0.0',
|
||||||
|
name: 'openai',
|
||||||
|
label: { en_US: 'OpenAI Plugin' } as unknown as PluginDeclaration['label'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
renderModelProviderPage()
|
||||||
|
|
||||||
|
expect(mockUseInstalledPluginList).toHaveBeenCalledWith(false, 100, { category: PluginCategoryEnum.model })
|
||||||
|
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
|
||||||
|
expect(screen.queryByText('OpenAI Plugin')).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render installed model plugins that are not registered as model providers', () => {
|
||||||
|
mockInstalledModelPlugins.value = [
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/debug-model',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||||
|
description: { en_US: 'Debug model provider' } as unknown as PluginDeclaration['description'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
renderModelProviderPage()
|
||||||
|
|
||||||
|
expect(screen.queryByText('Debug Model')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByText('langgenius/debug-model')).not.toBeInTheDocument()
|
||||||
|
expect(screen.queryByRole('button', { name: 'plugin actions langgenius/debug-model' })).not.toBeInTheDocument()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should refresh model providers once when a debugging model plugin is missing from providers', () => {
|
||||||
|
mockInstalledModelPlugins.value = [
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/debug-model',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
renderModelProviderPage()
|
||||||
|
|
||||||
|
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should prefer debugging plugin detail when an installed model plugin shares the same plugin id', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'langgenius/openai/openai',
|
||||||
|
label: { en_US: 'OpenAI' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mockInstalledModelPlugins.value = [
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/openai',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
plugin_unique_identifier: 'langgenius/openai:debug',
|
||||||
|
name: 'openai',
|
||||||
|
label: { en_US: 'OpenAI Debug Plugin' } as unknown as PluginDeclaration['label'],
|
||||||
|
}),
|
||||||
|
source: PluginSource.debugging,
|
||||||
|
}),
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/openai',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
plugin_unique_identifier: 'langgenius/openai:1.0.0',
|
||||||
|
name: 'openai',
|
||||||
|
label: { en_US: 'OpenAI Installed Plugin' } as unknown as PluginDeclaration['label'],
|
||||||
|
}),
|
||||||
|
source: PluginSource.marketplace,
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
renderModelProviderPage()
|
||||||
|
|
||||||
|
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-id', 'langgenius/openai')
|
||||||
|
expect(screen.getByTestId('provider-card')).toHaveAttribute('data-plugin-source', PluginSource.debugging)
|
||||||
|
expect(mockRefreshModelProviders).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
it('should show provider placeholders while model providers are loading', () => {
|
it('should show provider placeholders while model providers are loading', () => {
|
||||||
mockProviderContextState.isLoadingModelProviders = true
|
mockProviderContextState.isLoadingModelProviders = true
|
||||||
|
|
||||||
@ -572,4 +769,66 @@ describe('ModelProviderPage', () => {
|
|||||||
])
|
])
|
||||||
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
expect(screen.queryByText('common.modelProvider.toBeConfigured')).not.toBeInTheDocument()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should prioritize debugging model plugins within their provider section', () => {
|
||||||
|
mockProviders.splice(0, mockProviders.length, {
|
||||||
|
provider: 'langgenius/openai/openai',
|
||||||
|
label: { en_US: 'OpenAI Fixed' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
provider: 'zeta-provider',
|
||||||
|
label: { en_US: 'Zeta Provider' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.active },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
provider: 'langgenius/normal-model/normal-model',
|
||||||
|
label: { en_US: 'Normal Model' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
}, {
|
||||||
|
provider: 'langgenius/debug-model/debug-model',
|
||||||
|
label: { en_US: 'Debug Model' },
|
||||||
|
custom_configuration: { status: CustomConfigurationStatusEnum.noConfigure },
|
||||||
|
system_configuration: {
|
||||||
|
enabled: false,
|
||||||
|
current_quota_type: CurrentSystemQuotaTypeEnum.free,
|
||||||
|
quota_configurations: [mockQuotaConfig],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
mockInstalledModelPlugins.value = [
|
||||||
|
createPluginDetail({
|
||||||
|
plugin_id: 'langgenius/debug-model',
|
||||||
|
declaration: createPluginDeclaration({
|
||||||
|
plugin_unique_identifier: 'langgenius/debug-model:1.0.0',
|
||||||
|
name: 'debug-model',
|
||||||
|
label: { en_US: 'Debug Model' } as unknown as PluginDeclaration['label'],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
renderModelProviderPage()
|
||||||
|
|
||||||
|
const renderedProviders = screen.getAllByTestId('provider-card').map(item => item.textContent)
|
||||||
|
expect(renderedProviders).toEqual([
|
||||||
|
'langgenius/openai/openai',
|
||||||
|
'zeta-provider',
|
||||||
|
'langgenius/debug-model/debug-model',
|
||||||
|
'langgenius/normal-model/normal-model',
|
||||||
|
])
|
||||||
|
expect(screen.getAllByTestId('provider-card')[2]).toHaveAttribute('data-not-configured', 'true')
|
||||||
|
expect(screen.getByText('common.modelProvider.toBeConfigured')).toBeInTheDocument()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -6,15 +6,15 @@ import type { PluginDetail } from '@/app/components/plugins/types'
|
|||||||
import { useSuspenseQuery } from '@tanstack/react-query'
|
import { useSuspenseQuery } from '@tanstack/react-query'
|
||||||
import { useDebounce } from 'ahooks'
|
import { useDebounce } from 'ahooks'
|
||||||
import { noop } from 'es-toolkit/function'
|
import { noop } from 'es-toolkit/function'
|
||||||
import { useMemo } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { SearchInput } from '@/app/components/base/search-input'
|
import { SearchInput } from '@/app/components/base/search-input'
|
||||||
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
import { usePluginsWithLatestVersion } from '@/app/components/plugins/hooks'
|
||||||
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
import { usePluginSettingsAccess } from '@/app/components/plugins/plugin-page/use-reference-setting'
|
||||||
import { PluginCategoryEnum } from '@/app/components/plugins/types'
|
import { PluginCategoryEnum, PluginSource } from '@/app/components/plugins/types'
|
||||||
import { useProviderContext } from '@/context/provider-context'
|
import { useProviderContext } from '@/context/provider-context'
|
||||||
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
import { systemFeaturesQueryOptions } from '@/features/system-features/client'
|
||||||
import { useCheckInstalled } from '@/service/use-plugins'
|
import { useInstalledPluginList } from '@/service/use-plugins'
|
||||||
import UpdateSettingDialog from '../update-setting-dialog'
|
import UpdateSettingDialog from '../update-setting-dialog'
|
||||||
import {
|
import {
|
||||||
CustomConfigurationStatusEnum,
|
CustomConfigurationStatusEnum,
|
||||||
@ -25,7 +25,6 @@ import {
|
|||||||
} from './hooks'
|
} from './hooks'
|
||||||
import ModelProviderPageBody from './model-provider-page-body'
|
import ModelProviderPageBody from './model-provider-page-body'
|
||||||
import SystemModelSelector from './system-model-selector'
|
import SystemModelSelector from './system-model-selector'
|
||||||
import { providerToPluginId } from './utils'
|
|
||||||
|
|
||||||
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
|
type SystemModelConfigStatus = 'no-provider' | 'none-configured' | 'partially-configured' | 'fully-configured'
|
||||||
|
|
||||||
@ -58,23 +57,43 @@ const ModelProviderPage = ({
|
|||||||
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
const { data: rerankDefaultModel, isLoading: isRerankDefaultModelLoading } = useDefaultModel(ModelTypeEnum.rerank)
|
||||||
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
|
const { data: speech2textDefaultModel, isLoading: isSpeech2textDefaultModelLoading } = useDefaultModel(ModelTypeEnum.speech2text)
|
||||||
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
const { data: ttsDefaultModel, isLoading: isTTSDefaultModelLoading } = useDefaultModel(ModelTypeEnum.tts)
|
||||||
const { modelProviders: providers, isLoadingModelProviders } = useProviderContext()
|
const { modelProviders: providers, isLoadingModelProviders, refreshModelProviders } = useProviderContext()
|
||||||
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
const { data: systemFeatures } = useSuspenseQuery(systemFeaturesQueryOptions())
|
||||||
|
|
||||||
const allPluginIds = useMemo(() => {
|
const { data: installedModelPlugins } = useInstalledPluginList(false, 100, {
|
||||||
return [...new Set(providers.map(p => providerToPluginId(p.provider)).filter(Boolean))]
|
category: PluginCategoryEnum.model,
|
||||||
}, [providers])
|
|
||||||
const { data: installedPlugins } = useCheckInstalled({
|
|
||||||
pluginIds: allPluginIds,
|
|
||||||
enabled: allPluginIds.length > 0,
|
|
||||||
})
|
})
|
||||||
const enrichedPlugins = usePluginsWithLatestVersion(installedPlugins?.plugins)
|
const enrichedPlugins = usePluginsWithLatestVersion(installedModelPlugins?.plugins)
|
||||||
const pluginDetailMap = useMemo(() => {
|
const pluginDetailMap = useMemo(() => {
|
||||||
const map = new Map<string, PluginDetail>()
|
const map = new Map<string, PluginDetail>()
|
||||||
for (const plugin of enrichedPlugins)
|
for (const plugin of enrichedPlugins) {
|
||||||
map.set(plugin.plugin_id, plugin)
|
const existingPlugin = map.get(plugin.plugin_id)
|
||||||
|
if (!existingPlugin || plugin.source === PluginSource.debugging)
|
||||||
|
map.set(plugin.plugin_id, plugin)
|
||||||
|
}
|
||||||
return map
|
return map
|
||||||
}, [enrichedPlugins])
|
}, [enrichedPlugins])
|
||||||
|
const debuggingModelPluginKey = useMemo(() => {
|
||||||
|
const debuggingModelPluginIds = enrichedPlugins
|
||||||
|
.filter(plugin => plugin.source === PluginSource.debugging)
|
||||||
|
.map(plugin => `${plugin.plugin_id}:${plugin.plugin_unique_identifier}`)
|
||||||
|
.sort()
|
||||||
|
|
||||||
|
return debuggingModelPluginIds.join(',')
|
||||||
|
}, [enrichedPlugins])
|
||||||
|
const refreshedDebuggingModelPluginKeyRef = useRef('')
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debuggingModelPluginKey) {
|
||||||
|
refreshedDebuggingModelPluginKeyRef.current = ''
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshedDebuggingModelPluginKeyRef.current === debuggingModelPluginKey)
|
||||||
|
return
|
||||||
|
|
||||||
|
refreshedDebuggingModelPluginKeyRef.current = debuggingModelPluginKey
|
||||||
|
refreshModelProviders?.()
|
||||||
|
}, [debuggingModelPluginKey, refreshModelProviders])
|
||||||
const enableMarketplace = systemFeatures.enable_marketplace
|
const enableMarketplace = systemFeatures.enable_marketplace
|
||||||
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
const isDefaultModelLoading = isTextGenerationDefaultModelLoading
|
||||||
|| isEmbeddingsDefaultModelLoading
|
|| isEmbeddingsDefaultModelLoading
|
||||||
|
|||||||
@ -3,6 +3,7 @@ import type { ModelProvider } from './declarations'
|
|||||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||||
import { Trans, useTranslation } from 'react-i18next'
|
import { Trans, useTranslation } from 'react-i18next'
|
||||||
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
import { SkeletonContainer, SkeletonRectangle, SkeletonRow } from '@/app/components/base/skeleton'
|
||||||
|
import { PluginSource } from '@/app/components/plugins/types'
|
||||||
import { IS_CLOUD_EDITION } from '@/config'
|
import { IS_CLOUD_EDITION } from '@/config'
|
||||||
import InstallFromMarketplace from './install-from-marketplace'
|
import InstallFromMarketplace from './install-from-marketplace'
|
||||||
import ProviderAddedCard from './provider-added-card'
|
import ProviderAddedCard from './provider-added-card'
|
||||||
@ -99,21 +100,40 @@ type ProviderCardListProps = {
|
|||||||
notConfigured?: boolean
|
notConfigured?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isDebuggingProvider(provider: ModelProvider, pluginDetailMap: Map<string, PluginDetail>) {
|
||||||
|
return pluginDetailMap.get(providerToPluginId(provider.provider))?.source === PluginSource.debugging
|
||||||
|
}
|
||||||
|
|
||||||
function ProviderCardList({
|
function ProviderCardList({
|
||||||
providers,
|
providers,
|
||||||
pluginDetailMap,
|
pluginDetailMap,
|
||||||
notConfigured,
|
notConfigured,
|
||||||
}: ProviderCardListProps) {
|
}: ProviderCardListProps) {
|
||||||
|
const sortedProviders = [...providers]
|
||||||
|
.sort((a, b) => {
|
||||||
|
const aIsDebuggingPlugin = isDebuggingProvider(a, pluginDetailMap)
|
||||||
|
const bIsDebuggingPlugin = isDebuggingProvider(b, pluginDetailMap)
|
||||||
|
|
||||||
|
if (aIsDebuggingPlugin === bIsDebuggingPlugin)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
return aIsDebuggingPlugin ? -1 : 1
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative flex flex-col gap-2">
|
<div className="relative flex flex-col gap-2">
|
||||||
{providers.map(provider => (
|
{sortedProviders.map((provider) => {
|
||||||
<ProviderAddedCard
|
const pluginDetail = pluginDetailMap.get(providerToPluginId(provider.provider))
|
||||||
key={provider.provider}
|
|
||||||
notConfigured={notConfigured}
|
return (
|
||||||
provider={provider}
|
<ProviderAddedCard
|
||||||
pluginDetail={pluginDetailMap.get(providerToPluginId(provider.provider))}
|
key={provider.provider}
|
||||||
/>
|
notConfigured={notConfigured}
|
||||||
))}
|
provider={provider}
|
||||||
|
pluginDetail={pluginDetail}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -157,8 +177,8 @@ const ModelProviderPageBody: FC<ModelProviderPageBodyProps> = ({
|
|||||||
<div className="flex flex-col gap-2 pt-2">
|
<div className="flex flex-col gap-2 pt-2">
|
||||||
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
<div className="flex h-5 items-center system-md-semibold text-text-primary">{t('modelProvider.toBeConfigured', { ns: 'common' })}</div>
|
||||||
<ProviderCardList
|
<ProviderCardList
|
||||||
notConfigured
|
|
||||||
providers={filteredNotConfiguredProviders}
|
providers={filteredNotConfiguredProviders}
|
||||||
|
notConfigured
|
||||||
pluginDetailMap={pluginDetailMap}
|
pluginDetailMap={pluginDetailMap}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -158,6 +158,15 @@ describe('ProviderCardActions', () => {
|
|||||||
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
|
expect(mockHandleUpdate).toHaveBeenCalledWith(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show a compact debug badge after the version for debugging plugins', () => {
|
||||||
|
render(<ProviderCardActions detail={createDetail({ source: PluginSource.debugging })} />)
|
||||||
|
|
||||||
|
const version = screen.getByText('1.0.0')
|
||||||
|
const debugBadge = screen.getByText('appDebug.operation.debugConfig')
|
||||||
|
|
||||||
|
expect(version.compareDocumentPosition(debugBadge) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should trigger the latest marketplace update when clicking the update button', () => {
|
it('should trigger the latest marketplace update when clicking the update button', () => {
|
||||||
render(<ProviderCardActions detail={createDetail()} />)
|
render(<ProviderCardActions detail={createDetail()} />)
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
|||||||
const { source, version, latest_version, latest_unique_identifier, meta } = detail
|
const { source, version, latest_version, latest_unique_identifier, meta } = detail
|
||||||
const author = detail.declaration?.author ?? ''
|
const author = detail.declaration?.author ?? ''
|
||||||
const name = detail.declaration?.name ?? detail.name
|
const name = detail.declaration?.name ?? detail.name
|
||||||
|
const isDebuggingPlugin = source === PluginSource.debugging
|
||||||
|
|
||||||
const {
|
const {
|
||||||
modalStates,
|
modalStates,
|
||||||
@ -80,31 +81,41 @@ const ProviderCardActions: FC<Props> = ({ detail, onUpdate }) => {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!!version && (
|
{!!version && (
|
||||||
<PluginVersionPicker
|
<>
|
||||||
disabled={!isFromMarketplace || !canUpdatePlugin}
|
<PluginVersionPicker
|
||||||
isShow={versionPicker.isShow}
|
disabled={!isFromMarketplace || !canUpdatePlugin}
|
||||||
onShowChange={versionPicker.setIsShow}
|
isShow={versionPicker.isShow}
|
||||||
pluginID={detail.plugin_id}
|
onShowChange={versionPicker.setIsShow}
|
||||||
currentVersion={version}
|
pluginID={detail.plugin_id}
|
||||||
onSelect={handleVersionSelect}
|
currentVersion={version}
|
||||||
sideOffset={4}
|
onSelect={handleVersionSelect}
|
||||||
alignOffset={0}
|
sideOffset={4}
|
||||||
trigger={(
|
alignOffset={0}
|
||||||
|
trigger={(
|
||||||
|
<Badge
|
||||||
|
className={cn(
|
||||||
|
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
|
||||||
|
)}
|
||||||
|
uppercase={false}
|
||||||
|
text={(
|
||||||
|
<>
|
||||||
|
<span>{version}</span>
|
||||||
|
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
hasRedCornerMark={hasNewVersion}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{isDebuggingPlugin && (
|
||||||
<Badge
|
<Badge
|
||||||
className={cn(
|
className="border-state-warning-active bg-state-warning-hover text-text-warning"
|
||||||
canUpdatePlugin && isFromMarketplace && 'cursor-pointer hover:bg-state-base-hover',
|
size="xs"
|
||||||
)}
|
|
||||||
uppercase={false}
|
uppercase={false}
|
||||||
text={(
|
text={t('operation.debugConfig', { ns: 'appDebug' })}
|
||||||
<>
|
|
||||||
<span>{version}</span>
|
|
||||||
{canUpdatePlugin && isFromMarketplace && <span className="ml-1 i-ri-arrow-left-right-line size-3" />}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
hasRedCornerMark={hasNewVersion}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
/>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (
|
{canUpdatePlugin && (hasNewVersion || isFromGitHub) && (
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user