mirror of
https://github.com/langgenius/dify.git
synced 2026-05-24 10:57:52 +08:00
Compare commits
3 Commits
codex/dify
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c65975507 | |||
| 72ee50c74f | |||
| 8d99326fb3 |
@ -657,6 +657,7 @@ PLUGIN_REMOTE_INSTALL_PORT=5003
|
||||
PLUGIN_REMOTE_INSTALL_HOST=localhost
|
||||
PLUGIN_MAX_PACKAGE_SIZE=15728640
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
INNER_API_KEY_FOR_PLUGIN=QaHbTe77CtuXmsfyhR7+vRjI/+XbV1AaFy691iy+kGDv2Jvy0/eAh8Y1
|
||||
|
||||
# Marketplace configuration
|
||||
|
||||
@ -11,6 +11,7 @@ from configs import dify_config
|
||||
from core.helper import encrypter
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import encrypt_system_params
|
||||
from extensions.ext_database import db
|
||||
from models import Tenant
|
||||
@ -20,7 +21,6 @@ from models.source import DataSourceApiKeyAuthBinding, DataSourceOauthBinding
|
||||
from models.tools import ToolOAuthSystemClient
|
||||
from services.plugin.data_migration import PluginDataMigration
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -265,6 +265,11 @@ class PluginConfig(BaseSettings):
|
||||
default=60 * 60,
|
||||
)
|
||||
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL: PositiveInt = Field(
|
||||
description="TTL in seconds for caching tenant plugin model providers in Redis",
|
||||
default=60 * 60 * 24,
|
||||
)
|
||||
|
||||
PLUGIN_MAX_FILE_SIZE: PositiveInt = Field(
|
||||
description="Maximum allowed size (bytes) for plugin-generated files",
|
||||
default=50 * 1024 * 1024,
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic.fields import FieldInfo
|
||||
@ -48,6 +48,7 @@ class ApolloSettingsSource(RemoteSettingsSource):
|
||||
self.namespace = configs["APOLLO_NAMESPACE"]
|
||||
self.remote_configs = self.client.get_all_dicts(self.namespace)
|
||||
|
||||
@override
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
if not isinstance(self.remote_configs, dict):
|
||||
raise ValueError(f"remote configs is not dict, but {type(self.remote_configs)}")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import logging
|
||||
import os
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
from typing import Any, override
|
||||
|
||||
from pydantic.fields import FieldInfo
|
||||
|
||||
@ -41,6 +41,7 @@ class NacosSettingsSource(RemoteSettingsSource):
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to parse config: {e}")
|
||||
|
||||
@override
|
||||
def get_field_value(self, field: FieldInfo, field_name: str) -> tuple[Any, str, bool]:
|
||||
field_value = self.remote_configs.get(field_name)
|
||||
if field_value is None:
|
||||
|
||||
@ -10,7 +10,7 @@ import threading
|
||||
from abc import ABC, abstractmethod
|
||||
from collections.abc import Callable, Generator
|
||||
from contextlib import AbstractContextManager, contextmanager
|
||||
from typing import Any, Protocol, final, runtime_checkable
|
||||
from typing import Any, Protocol, final, override, runtime_checkable
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
@ -133,10 +133,12 @@ class NullAppContext(AppContext):
|
||||
self._config = config or {}
|
||||
self._extensions: dict[str, Any] = {}
|
||||
|
||||
@override
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value by key."""
|
||||
return self._config.get(key, default)
|
||||
|
||||
@override
|
||||
def get_extension(self, name: str) -> Any:
|
||||
"""Get extension by name."""
|
||||
return self._extensions.get(name)
|
||||
@ -146,6 +148,7 @@ class NullAppContext(AppContext):
|
||||
self._extensions[name] = extension
|
||||
|
||||
@contextmanager
|
||||
@override
|
||||
def enter(self) -> Generator[None, None, None]:
|
||||
"""Enter null context (no-op)."""
|
||||
yield
|
||||
|
||||
@ -6,7 +6,7 @@ import contextvars
|
||||
import threading
|
||||
from collections.abc import Generator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any, final
|
||||
from typing import Any, final, override
|
||||
|
||||
from flask import Flask, current_app, g
|
||||
|
||||
@ -30,15 +30,18 @@ class FlaskAppContext(AppContext):
|
||||
"""
|
||||
self._flask_app = flask_app
|
||||
|
||||
@override
|
||||
def get_config(self, key: str, default: Any = None) -> Any:
|
||||
"""Get configuration value from Flask app config."""
|
||||
return self._flask_app.config.get(key, default)
|
||||
|
||||
@override
|
||||
def get_extension(self, name: str) -> Any:
|
||||
"""Get Flask extension by name."""
|
||||
return self._flask_app.extensions.get(name)
|
||||
|
||||
@contextmanager
|
||||
@override
|
||||
def enter(self) -> Generator[None, None, None]:
|
||||
"""Enter Flask app context."""
|
||||
with self._flask_app.app_context():
|
||||
|
||||
@ -15,6 +15,7 @@ from controllers.console import console_ns
|
||||
from controllers.console.workspace import plugin_permission_required
|
||||
from controllers.console.wraps import account_initialization_required, is_admin_or_owner_required, setup_required
|
||||
from core.plugin.impl.exc import PluginDaemonClientSideError
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from fields.base import ResponseModel
|
||||
from graphon.model_runtime.utils.encoders import jsonable_encoder
|
||||
from libs.login import current_account_with_tenant, login_required
|
||||
@ -22,7 +23,6 @@ from models.account import TenantPluginAutoUpgradeStrategy, TenantPluginPermissi
|
||||
from services.plugin.plugin_auto_upgrade_service import PluginAutoUpgradeService
|
||||
from services.plugin.plugin_parameter_service import PluginParameterService
|
||||
from services.plugin.plugin_permission_service import PluginPermissionService
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
class ParserList(BaseModel):
|
||||
|
||||
@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import logging
|
||||
from collections.abc import Generator, Iterable, Sequence
|
||||
from threading import Lock
|
||||
from typing import IO, Any, Literal, cast, overload, override
|
||||
|
||||
from pydantic import ValidationError
|
||||
@ -13,9 +12,9 @@ from configs import dify_config
|
||||
from core.llm_generator.output_parser.structured_output import (
|
||||
invoke_llm_with_structured_output as invoke_llm_with_structured_output_helper,
|
||||
)
|
||||
from core.plugin.entities.plugin_daemon import PluginModelProviderEntity
|
||||
from core.plugin.impl.asset import PluginAssetManager
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.llm_entities import (
|
||||
LLMResult,
|
||||
@ -101,35 +100,36 @@ class _PluginStructuredOutputModelInstance:
|
||||
|
||||
|
||||
class PluginModelRuntime(ModelRuntime):
|
||||
"""Plugin-backed runtime adapter bound to tenant context and optional caller scope."""
|
||||
"""Plugin-backed runtime adapter bound to tenant context and optional caller scope.
|
||||
|
||||
Provider discovery goes through ``PluginService`` so the plugin lifecycle
|
||||
methods and provider reads share one tenant-scoped cache owner.
|
||||
"""
|
||||
|
||||
tenant_id: str
|
||||
user_id: str | None
|
||||
client: PluginModelClient
|
||||
_provider_entities: tuple[ProviderEntity, ...] | None
|
||||
_provider_entities_lock: Lock
|
||||
_plugin_service: type[PluginService]
|
||||
|
||||
def __init__(self, tenant_id: str, user_id: str | None, client: PluginModelClient) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
tenant_id: str,
|
||||
user_id: str | None,
|
||||
client: PluginModelClient,
|
||||
plugin_service: type[PluginService],
|
||||
) -> None:
|
||||
if client is None:
|
||||
raise ValueError("client is required.")
|
||||
if plugin_service is None:
|
||||
raise ValueError("plugin_service is required.")
|
||||
self.tenant_id = tenant_id
|
||||
self.user_id = user_id
|
||||
self.client = client
|
||||
self._provider_entities = None
|
||||
self._provider_entities_lock = Lock()
|
||||
self._plugin_service = plugin_service
|
||||
|
||||
@override
|
||||
def fetch_model_providers(self) -> Sequence[ProviderEntity]:
|
||||
if self._provider_entities is not None:
|
||||
return self._provider_entities
|
||||
|
||||
with self._provider_entities_lock:
|
||||
if self._provider_entities is None:
|
||||
self._provider_entities = tuple(
|
||||
self._to_provider_entity(provider) for provider in self.client.fetch_model_providers(self.tenant_id)
|
||||
)
|
||||
|
||||
return self._provider_entities
|
||||
return self._plugin_service.fetch_plugin_model_providers(tenant_id=self.tenant_id, client=self.client)
|
||||
|
||||
@override
|
||||
def get_provider_icon(self, *, provider: str, icon_type: str, lang: str) -> tuple[bytes, str]:
|
||||
@ -628,34 +628,6 @@ class PluginModelRuntime(ModelRuntime):
|
||||
text=text,
|
||||
)
|
||||
|
||||
def _get_provider_short_name_alias(self, provider: PluginModelProviderEntity) -> str:
|
||||
"""
|
||||
Expose a bare provider alias only for the canonical provider mapping.
|
||||
|
||||
Multiple plugins can publish the same short provider slug. If every
|
||||
provider entity keeps that slug in ``provider_name``, callers that still
|
||||
resolve by short name become order-dependent. Restrict the alias to the
|
||||
provider selected by ``ModelProviderID`` so legacy short-name lookups
|
||||
remain deterministic while the runtime surface stays canonical.
|
||||
"""
|
||||
try:
|
||||
canonical_provider_id = ModelProviderID(provider.provider)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if canonical_provider_id.plugin_id != provider.plugin_id:
|
||||
return ""
|
||||
if canonical_provider_id.provider_name != provider.provider:
|
||||
return ""
|
||||
|
||||
return provider.provider
|
||||
|
||||
def _to_provider_entity(self, provider: PluginModelProviderEntity) -> ProviderEntity:
|
||||
declaration = provider.declaration.model_copy(deep=True)
|
||||
declaration.provider = f"{provider.plugin_id}/{provider.provider}"
|
||||
declaration.provider_name = self._get_provider_short_name_alias(provider)
|
||||
return declaration
|
||||
|
||||
def _get_provider_schema(self, provider: str) -> ProviderEntity:
|
||||
providers = self.fetch_model_providers()
|
||||
provider_entity = next((item for item in providers if item.provider == provider), None)
|
||||
|
||||
@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.model_runtime.entities.model_entities import ModelType
|
||||
from graphon.model_runtime.entities.provider_entities import ProviderEntity
|
||||
from graphon.model_runtime.model_providers.base.ai_model import AIModel
|
||||
@ -117,6 +118,7 @@ def create_plugin_model_runtime(*, tenant_id: str, user_id: str | None = None) -
|
||||
tenant_id=tenant_id,
|
||||
user_id=user_id,
|
||||
client=PluginModelClient(),
|
||||
plugin_service=PluginService,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -1,8 +1,17 @@
|
||||
"""Core plugin service and tenant-scoped plugin metadata cache ownership.
|
||||
|
||||
This module owns plugin daemon management calls that are shared by API services
|
||||
and core runtimes. Plugin model provider discovery is cached here, alongside
|
||||
plugin install, uninstall, and upgrade invalidation, so all cache mutations for
|
||||
plugin-owned provider metadata stay tenant-scoped and in one place.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from collections.abc import Mapping, Sequence
|
||||
from mimetypes import guess_type
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
from redis import RedisError
|
||||
from sqlalchemy import delete, select, update
|
||||
from sqlalchemy.orm import Session
|
||||
from yarl import URL
|
||||
@ -22,16 +31,20 @@ from core.plugin.entities.plugin import (
|
||||
from core.plugin.entities.plugin_daemon import (
|
||||
PluginDecodeResponse,
|
||||
PluginInstallTask,
|
||||
PluginInstallTaskStatus,
|
||||
PluginListResponse,
|
||||
PluginModelProviderEntity,
|
||||
PluginVerification,
|
||||
)
|
||||
from core.plugin.impl.asset import PluginAssetManager
|
||||
from core.plugin.impl.debugging import PluginDebuggingClient
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.provider_entities import ProviderEntity
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from models.provider_ids import GenericProviderID
|
||||
from models.provider_ids import GenericProviderID, ModelProviderID
|
||||
from services.enterprise.plugin_manager_service import (
|
||||
PluginManagerService,
|
||||
PreUninstallPluginRequest,
|
||||
@ -40,6 +53,7 @@ from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import FeatureService, PluginInstallationScope
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_provider_entities_adapter: TypeAdapter[list[ProviderEntity]] = TypeAdapter(list[ProviderEntity])
|
||||
|
||||
|
||||
class PluginService:
|
||||
@ -53,6 +67,102 @@ class PluginService:
|
||||
|
||||
REDIS_KEY_PREFIX = "plugin_service:latest_plugin:"
|
||||
REDIS_TTL = 60 * 5 # 5 minutes
|
||||
PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX = "plugin_model_providers:tenant_id:"
|
||||
PLUGIN_INSTALL_TASK_TERMINAL_STATUSES = (PluginInstallTaskStatus.Success, PluginInstallTaskStatus.Failed)
|
||||
|
||||
@classmethod
|
||||
def _get_plugin_model_providers_cache_key(cls, tenant_id: str) -> str:
|
||||
return f"{cls.PLUGIN_MODEL_PROVIDERS_REDIS_KEY_PREFIX}{tenant_id}"
|
||||
|
||||
@staticmethod
|
||||
def _get_provider_short_name_alias(provider: PluginModelProviderEntity) -> str:
|
||||
"""
|
||||
Expose a bare provider alias only for the canonical provider mapping.
|
||||
|
||||
Multiple plugins can publish the same short provider slug. If every
|
||||
provider entity keeps that slug in ``provider_name``, callers that still
|
||||
resolve by short name become order-dependent. Restrict the alias to the
|
||||
provider selected by ``ModelProviderID`` so legacy short-name lookups
|
||||
remain deterministic while the runtime surface stays canonical.
|
||||
"""
|
||||
try:
|
||||
canonical_provider_id = ModelProviderID(provider.provider)
|
||||
except ValueError:
|
||||
return ""
|
||||
|
||||
if canonical_provider_id.plugin_id != provider.plugin_id:
|
||||
return ""
|
||||
if canonical_provider_id.provider_name != provider.provider:
|
||||
return ""
|
||||
|
||||
return provider.provider
|
||||
|
||||
@classmethod
|
||||
def _to_provider_entity(cls, provider: PluginModelProviderEntity) -> ProviderEntity:
|
||||
declaration = provider.declaration.model_copy(deep=True)
|
||||
declaration.provider = f"{provider.plugin_id}/{provider.provider}"
|
||||
declaration.provider_name = cls._get_provider_short_name_alias(provider)
|
||||
return declaration
|
||||
|
||||
@classmethod
|
||||
def _load_cached_plugin_model_providers(cls, tenant_id: str) -> tuple[ProviderEntity, ...] | None:
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id)
|
||||
try:
|
||||
cached_providers = redis_client.get(cache_key)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to read cached plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
return None
|
||||
|
||||
if not cached_providers:
|
||||
return None
|
||||
|
||||
try:
|
||||
return tuple(_provider_entities_adapter.validate_json(cached_providers))
|
||||
except (TypeError, ValueError, ValidationError):
|
||||
logger.warning(
|
||||
"Invalid cached plugin model providers for tenant %s; deleting cache.", tenant_id, exc_info=True
|
||||
)
|
||||
cls.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return None
|
||||
|
||||
@classmethod
|
||||
def _store_cached_plugin_model_providers(cls, tenant_id: str, providers: Sequence[ProviderEntity]) -> None:
|
||||
cache_key = cls._get_plugin_model_providers_cache_key(tenant_id)
|
||||
try:
|
||||
payload = _provider_entities_adapter.dump_json(list(providers)).decode("utf-8")
|
||||
redis_client.setex(cache_key, dify_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL, payload)
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to cache plugin model providers for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def invalidate_plugin_model_providers_cache(cls, tenant_id: str) -> None:
|
||||
"""Delete the tenant-scoped plugin model provider list cache."""
|
||||
try:
|
||||
redis_client.delete(cls._get_plugin_model_providers_cache_key(tenant_id))
|
||||
except (RedisError, RuntimeError):
|
||||
logger.warning("Failed to invalidate plugin model providers cache for tenant %s.", tenant_id, exc_info=True)
|
||||
|
||||
@classmethod
|
||||
def fetch_plugin_model_providers(
|
||||
cls, *, tenant_id: str, client: PluginModelClient | None = None
|
||||
) -> Sequence[ProviderEntity]:
|
||||
"""
|
||||
Fetch plugin model providers through the tenant-scoped plugin cache.
|
||||
|
||||
Plugin daemon provider discovery and plugin lifecycle cache invalidation
|
||||
are intentionally owned by this service so tenant isolation and cache
|
||||
expiry are handled in one place.
|
||||
"""
|
||||
cached_providers = cls._load_cached_plugin_model_providers(tenant_id)
|
||||
if cached_providers is not None:
|
||||
return cached_providers
|
||||
|
||||
model_client = client or PluginModelClient()
|
||||
providers = tuple(
|
||||
cls._to_provider_entity(provider) for provider in model_client.fetch_model_providers(tenant_id)
|
||||
)
|
||||
cls._store_cached_plugin_model_providers(tenant_id, providers)
|
||||
return providers
|
||||
|
||||
@staticmethod
|
||||
def fetch_latest_plugin_version(plugin_ids: Sequence[str]) -> Mapping[str, LatestPluginCache | None]:
|
||||
@ -248,12 +358,18 @@ class PluginService:
|
||||
Fetch plugin installation tasks
|
||||
"""
|
||||
manager = PluginInstaller()
|
||||
return manager.fetch_plugin_installation_tasks(tenant_id, page, page_size)
|
||||
tasks = manager.fetch_plugin_installation_tasks(tenant_id, page, page_size)
|
||||
if any(task.status in PluginService.PLUGIN_INSTALL_TASK_TERMINAL_STATUSES for task in tasks):
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return tasks
|
||||
|
||||
@staticmethod
|
||||
def fetch_install_task(tenant_id: str, task_id: str) -> PluginInstallTask:
|
||||
manager = PluginInstaller()
|
||||
return manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
task = manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
if task.status in PluginService.PLUGIN_INSTALL_TASK_TERMINAL_STATUSES:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return task
|
||||
|
||||
@staticmethod
|
||||
def delete_install_task(tenant_id: str, task_id: str) -> bool:
|
||||
@ -315,7 +431,7 @@ class PluginService:
|
||||
# check if the plugin is available to install
|
||||
PluginService._check_plugin_installation_scope(response.verification)
|
||||
|
||||
return manager.upgrade_plugin(
|
||||
result = manager.upgrade_plugin(
|
||||
tenant_id,
|
||||
original_plugin_unique_identifier,
|
||||
new_plugin_unique_identifier,
|
||||
@ -324,6 +440,8 @@ class PluginService:
|
||||
"plugin_unique_identifier": new_plugin_unique_identifier,
|
||||
},
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def upgrade_plugin_with_github(
|
||||
@ -339,7 +457,7 @@ class PluginService:
|
||||
"""
|
||||
PluginService._check_marketplace_only_permission()
|
||||
manager = PluginInstaller()
|
||||
return manager.upgrade_plugin(
|
||||
result = manager.upgrade_plugin(
|
||||
tenant_id,
|
||||
original_plugin_unique_identifier,
|
||||
new_plugin_unique_identifier,
|
||||
@ -350,6 +468,8 @@ class PluginService:
|
||||
"package": package,
|
||||
},
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def upload_pkg(tenant_id: str, pkg: bytes, verify_signature: bool = False) -> PluginDecodeResponse:
|
||||
@ -415,12 +535,14 @@ class PluginService:
|
||||
resp = manager.decode_plugin_from_identifier(tenant_id, plugin_unique_identifier)
|
||||
PluginService._check_plugin_installation_scope(resp.verification)
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
plugin_unique_identifiers,
|
||||
PluginInstallationSource.Package,
|
||||
[{}],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def install_from_github(tenant_id: str, plugin_unique_identifier: str, repo: str, version: str, package: str):
|
||||
@ -434,7 +556,7 @@ class PluginService:
|
||||
plugin_decode_response = manager.decode_plugin_from_identifier(tenant_id, plugin_unique_identifier)
|
||||
PluginService._check_plugin_installation_scope(plugin_decode_response.verification)
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
[plugin_unique_identifier],
|
||||
PluginInstallationSource.Github,
|
||||
@ -446,6 +568,8 @@ class PluginService:
|
||||
}
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def fetch_marketplace_pkg(tenant_id: str, plugin_unique_identifier: str) -> PluginDeclaration:
|
||||
@ -513,12 +637,14 @@ class PluginService:
|
||||
actual_plugin_unique_identifiers.append(response.unique_identifier)
|
||||
metas.append({"plugin_unique_identifier": response.unique_identifier})
|
||||
|
||||
return manager.install_from_identifiers(
|
||||
result = manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
actual_plugin_unique_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas,
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def uninstall(tenant_id: str, plugin_installation_id: str) -> bool:
|
||||
@ -529,7 +655,10 @@ class PluginService:
|
||||
plugin = next((p for p in plugins if p.installation_id == plugin_installation_id), None)
|
||||
|
||||
if not plugin:
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
result = manager.uninstall(tenant_id, plugin_installation_id)
|
||||
if result:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
if dify_config.ENTERPRISE_ENABLED:
|
||||
PluginManagerService.try_pre_uninstall_plugin(
|
||||
@ -559,37 +688,39 @@ class PluginService:
|
||||
|
||||
if not credential_ids:
|
||||
logger.info("No credentials found for plugin: %s", plugin_id)
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
else:
|
||||
provider_ids = session.scalars(
|
||||
select(Provider.id).where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name.like(f"{plugin_id}/%"),
|
||||
Provider.credential_id.in_(credential_ids),
|
||||
)
|
||||
).all()
|
||||
|
||||
provider_ids = session.scalars(
|
||||
select(Provider.id).where(
|
||||
Provider.tenant_id == tenant_id,
|
||||
Provider.provider_name.like(f"{plugin_id}/%"),
|
||||
Provider.credential_id.in_(credential_ids),
|
||||
session.execute(update(Provider).where(Provider.id.in_(provider_ids)).values(credential_id=None))
|
||||
|
||||
for provider_id in provider_ids:
|
||||
ProviderCredentialsCache(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
).delete()
|
||||
|
||||
session.execute(
|
||||
delete(ProviderCredential).where(
|
||||
ProviderCredential.id.in_(credential_ids),
|
||||
)
|
||||
)
|
||||
).all()
|
||||
|
||||
session.execute(update(Provider).where(Provider.id.in_(provider_ids)).values(credential_id=None))
|
||||
|
||||
for provider_id in provider_ids:
|
||||
ProviderCredentialsCache(
|
||||
tenant_id=tenant_id,
|
||||
identity_id=provider_id,
|
||||
cache_type=ProviderCredentialsCacheType.PROVIDER,
|
||||
).delete()
|
||||
|
||||
session.execute(
|
||||
delete(ProviderCredential).where(
|
||||
ProviderCredential.id.in_(credential_ids),
|
||||
logger.info(
|
||||
"Completed deleting credentials and cleaning provider associations for plugin: %s",
|
||||
plugin_id,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Completed deleting credentials and cleaning provider associations for plugin: %s",
|
||||
plugin_id,
|
||||
)
|
||||
|
||||
return manager.uninstall(tenant_id, plugin_installation_id)
|
||||
result = manager.uninstall(tenant_id, plugin_installation_id)
|
||||
if result:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def check_tools_existence(tenant_id: str, provider_ids: Sequence[GenericProviderID]) -> Sequence[bool]:
|
||||
@ -16,6 +16,7 @@ from core.plugin.entities.request import (
|
||||
TriggerSubscriptionResponse,
|
||||
)
|
||||
from core.plugin.impl.trigger import PluginTriggerClient
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.trigger.entities.api_entities import EventApiEntity, TriggerProviderApiEntity
|
||||
from core.trigger.entities.entities import (
|
||||
EventEntity,
|
||||
@ -30,7 +31,6 @@ from core.trigger.entities.entities import (
|
||||
)
|
||||
from core.trigger.errors import TriggerProviderCredentialValidationError
|
||||
from models.provider_ids import TriggerProviderID
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
from typing import Union
|
||||
from typing import Any, Union
|
||||
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, field_validator
|
||||
|
||||
from core.rag.entities import RerankingModelConfig, WeightedScoreConfig
|
||||
from core.rag.index_processor.index_processor_base import SummaryIndexSettingDict
|
||||
@ -101,3 +101,14 @@ class KnowledgeIndexNodeData(BaseNodeData):
|
||||
index_chunk_variable_selector: list[str]
|
||||
indexing_technique: str | None = None
|
||||
summary_index_setting: SummaryIndexSettingDict | None = None
|
||||
|
||||
@field_validator("summary_index_setting", mode="before")
|
||||
@classmethod
|
||||
def normalize_summary_index_setting(cls, v: Any) -> Any:
|
||||
"""Treat dicts with enable=None (or missing enable) as None (#36233)."""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, dict):
|
||||
if v.get("enable") is None:
|
||||
return None
|
||||
return v
|
||||
|
||||
@ -492,8 +492,8 @@ class App(Base):
|
||||
|
||||
@property
|
||||
def deleted_tools(self) -> list[DeletedToolInfo]:
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.tool_manager import ToolManager, ToolProviderType
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
# get agent mode tools
|
||||
app_model_config = self.app_model_config
|
||||
|
||||
@ -14,13 +14,13 @@ from core.helper.provider_cache import NoOpProviderCredentialCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.datasource import PluginDatasourceManager
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.encryption import ProviderConfigCache, ProviderConfigEncrypter, create_provider_encrypter
|
||||
from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from graphon.model_runtime.entities.provider_entities import FormType
|
||||
from models.oauth import DatasourceOauthParamConfig, DatasourceOauthTenantParamConfig, DatasourceProvider
|
||||
from models.provider_ids import DatasourceProviderID
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -22,6 +22,7 @@ from core.helper import marketplace
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTaskStatus
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.entities.tool_entities import ToolProviderType
|
||||
from extensions.ext_database import db
|
||||
from models.account import Tenant
|
||||
@ -29,7 +30,6 @@ from models.model import App, AppMode, AppModelConfig
|
||||
from models.provider_ids import ModelProviderID, ToolProviderID
|
||||
from models.tools import BuiltinToolProvider
|
||||
from models.workflow import Workflow
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -389,17 +389,19 @@ class PluginMigration:
|
||||
for plugin_id in batch_plugin_ids
|
||||
if plugin_id not in installed_plugins_ids and plugin_id in plugins["plugins"]
|
||||
]
|
||||
manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
batch_plugin_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
if batch_plugin_identifiers:
|
||||
manager.install_from_identifiers(
|
||||
tenant_id,
|
||||
batch_plugin_identifiers,
|
||||
PluginInstallationSource.Marketplace,
|
||||
metas=[
|
||||
{
|
||||
"plugin_unique_identifier": identifier,
|
||||
}
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
|
||||
with open(extracted_plugins) as f:
|
||||
"""
|
||||
@ -595,6 +597,7 @@ class PluginMigration:
|
||||
for identifier in batch_plugin_identifiers
|
||||
],
|
||||
)
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
except Exception:
|
||||
# add to failed
|
||||
failed.extend(batch_plugin_identifiers)
|
||||
@ -609,6 +612,7 @@ class PluginMigration:
|
||||
while not done:
|
||||
status = manager.fetch_plugin_installation_task(tenant_id, task_id)
|
||||
if status.status in [PluginInstallTaskStatus.Failed, PluginInstallTaskStatus.Success]:
|
||||
PluginService.invalidate_plugin_model_providers_cache(tenant_id)
|
||||
for plugin in status.plugins:
|
||||
if plugin.status == PluginInstallTaskStatus.Success:
|
||||
success.append(reverse_map[plugin.plugin_unique_identifier])
|
||||
|
||||
@ -12,6 +12,7 @@ from sqlalchemy import select
|
||||
from configs import dify_config
|
||||
from constants import DOCUMENT_EXTENSIONS
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.rag.index_processor.constant.index_type import IndexStructureType, IndexTechniqueType
|
||||
from core.rag.retrieval.retrieval_methods import RetrievalMethod
|
||||
from extensions.ext_database import db
|
||||
@ -22,7 +23,6 @@ from models.model import UploadFile
|
||||
from models.workflow import Workflow, WorkflowType
|
||||
from services.entities.knowledge_entities.rag_pipeline_entities import KnowledgeConfiguration, RetrievalSetting
|
||||
from services.plugin.plugin_migration import PluginMigration
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ from core.helper.name_generator import generate_incremental_name
|
||||
from core.helper.position_helper import is_filtered
|
||||
from core.helper.provider_cache import NoOpProviderCredentialCache, ToolProviderCredentialsCache
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
from core.tools.builtin_tool.providers._positions import BuiltinToolProviderSort
|
||||
from core.tools.entities.api_entities import (
|
||||
@ -31,7 +32,6 @@ from extensions.ext_database import db
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.provider_ids import ToolProviderID
|
||||
from models.tools import BuiltinToolProvider, ToolOAuthSystemClient, ToolOAuthTenantClient
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -9,6 +9,7 @@ from configs import dify_config
|
||||
from core.helper.provider_cache import ToolProviderCredentialsCache
|
||||
from core.mcp.types import Tool as MCPTool
|
||||
from core.plugin.entities.plugin_daemon import CredentialType, PluginDatasourceProviderEntity
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.__base.tool_runtime import ToolRuntime
|
||||
from core.tools.builtin_tool.provider import BuiltinToolProviderController
|
||||
@ -27,7 +28,6 @@ from core.tools.utils.encryption import create_provider_encrypter, create_tool_p
|
||||
from core.tools.workflow_as_tool.provider import WorkflowToolProviderController
|
||||
from core.tools.workflow_as_tool.tool import WorkflowTool
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from core.helper.provider_cache import NoOpProviderCredentialCache
|
||||
from core.helper.provider_encryption import ProviderConfigEncrypter, create_provider_encrypter
|
||||
from core.plugin.entities.plugin_daemon import CredentialType
|
||||
from core.plugin.impl.oauth import OAuthHandler
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.utils.system_encryption import decrypt_system_params
|
||||
from core.trigger.entities.api_entities import (
|
||||
TriggerProviderApiEntity,
|
||||
@ -37,7 +38,6 @@ from models.trigger import (
|
||||
TriggerSubscription,
|
||||
WorkflowPluginTrigger,
|
||||
)
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -6,11 +6,11 @@ from typing import Any, TypedDict
|
||||
from sqlalchemy import and_, func, or_, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.enums import WorkflowExecutionStatus
|
||||
from models import Account, App, EndUser, TenantAccountJoin, WorkflowAppLog, WorkflowArchiveLog, WorkflowRun
|
||||
from models.enums import AppTriggerType, CreatorUserRole
|
||||
from models.trigger import WorkflowTriggerLog
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.workflow.entities import TriggerMetadata
|
||||
|
||||
|
||||
|
||||
@ -9,9 +9,9 @@ from celery import shared_task
|
||||
from core.plugin.entities.marketplace import MarketplacePluginSnapshot
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.impl.plugin import PluginInstaller
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from extensions.ext_redis import redis_client
|
||||
from models.account import TenantPluginAutoUpgradeStrategy
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for services.plugin.plugin_service.PluginService.
|
||||
"""Tests for core.plugin.plugin_service.PluginService.
|
||||
|
||||
Covers: version caching with Redis, install permission/scope gates,
|
||||
icon URL construction, asset retrieval with MIME guessing, plugin
|
||||
@ -17,11 +17,11 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.entities.plugin import PluginInstallationSource
|
||||
from core.plugin.entities.plugin_daemon import PluginVerification
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from models import ProviderType
|
||||
from models.provider import Provider, ProviderCredential, TenantPreferredModelProvider
|
||||
from services.errors.plugin import PluginInstallationForbiddenError
|
||||
from services.feature_service import PluginInstallationScope
|
||||
from services.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
def _make_features(
|
||||
@ -35,8 +35,8 @@ def _make_features(
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_returns_cached_version(self, mock_redis, mock_marketplace):
|
||||
cached_json = PluginService.LatestPluginCache(
|
||||
plugin_id="p1",
|
||||
@ -53,8 +53,8 @@ class TestFetchLatestPluginVersion:
|
||||
assert result["p1"].version == "1.0.0"
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_not_called()
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_fetches_from_marketplace_on_cache_miss(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
manifest = MagicMock()
|
||||
@ -71,8 +71,8 @@ class TestFetchLatestPluginVersion:
|
||||
assert result["p1"].version == "2.0.0"
|
||||
mock_redis.setex.assert_called_once()
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_returns_none_for_unknown_plugin(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = []
|
||||
@ -81,8 +81,8 @@ class TestFetchLatestPluginVersion:
|
||||
|
||||
assert result["unknown"] is None
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.redis_client")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.redis_client")
|
||||
def test_handles_marketplace_exception_gracefully(self, mock_redis, mock_marketplace):
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.side_effect = RuntimeError("network error")
|
||||
@ -93,14 +93,14 @@ class TestFetchLatestPluginVersion:
|
||||
|
||||
|
||||
class TestCheckMarketplaceOnlyPermission:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_raises_when_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=True)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_marketplace_only_permission()
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_passes_when_not_restricted(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(restrict_to_marketplace=False)
|
||||
|
||||
@ -108,7 +108,7 @@ class TestCheckMarketplaceOnlyPermission:
|
||||
|
||||
|
||||
class TestCheckPluginInstallationScope:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_allows_langgenius(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
verification = MagicMock()
|
||||
@ -116,14 +116,14 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
PluginService._check_plugin_installation_scope(verification) # should not raise
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_only_rejects_third_party(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.OFFICIAL_ONLY)
|
||||
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_allows_partner(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
@ -133,7 +133,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
PluginService._check_plugin_installation_scope(verification) # should not raise
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_official_and_partners_rejects_none(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(
|
||||
scope=PluginInstallationScope.OFFICIAL_AND_SPECIFIC_PARTNERS
|
||||
@ -142,7 +142,7 @@ class TestCheckPluginInstallationScope:
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(None)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_none_scope_always_raises(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.NONE)
|
||||
verification = MagicMock()
|
||||
@ -151,7 +151,7 @@ class TestCheckPluginInstallationScope:
|
||||
with pytest.raises(PluginInstallationForbiddenError):
|
||||
PluginService._check_plugin_installation_scope(verification)
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
def test_all_scope_passes_any(self, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features(scope=PluginInstallationScope.ALL)
|
||||
|
||||
@ -159,7 +159,7 @@ class TestCheckPluginInstallationScope:
|
||||
|
||||
|
||||
class TestGetPluginIconUrl:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_constructs_url_with_params(self, mock_config):
|
||||
mock_config.CONSOLE_API_URL = "https://console.example.com"
|
||||
|
||||
@ -171,7 +171,7 @@ class TestGetPluginIconUrl:
|
||||
|
||||
|
||||
class TestGetAsset:
|
||||
@patch("services.plugin.plugin_service.PluginAssetManager")
|
||||
@patch("core.plugin.plugin_service.PluginAssetManager")
|
||||
def test_returns_bytes_and_guessed_mime(self, mock_asset_cls):
|
||||
mock_asset_cls.return_value.fetch_asset.return_value = b"<svg/>"
|
||||
|
||||
@ -180,7 +180,7 @@ class TestGetAsset:
|
||||
assert data == b"<svg/>"
|
||||
assert "svg" in mime
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginAssetManager")
|
||||
@patch("core.plugin.plugin_service.PluginAssetManager")
|
||||
def test_fallback_to_octet_stream_for_unknown(self, mock_asset_cls):
|
||||
mock_asset_cls.return_value.fetch_asset.return_value = b"\x00"
|
||||
|
||||
@ -190,13 +190,13 @@ class TestGetAsset:
|
||||
|
||||
|
||||
class TestIsPluginVerified:
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_returns_true_when_verified(self, mock_installer_cls):
|
||||
mock_installer_cls.return_value.fetch_plugin_manifest.return_value.verified = True
|
||||
|
||||
assert PluginService.is_plugin_verified("t1", "uid-1") is True
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_returns_false_on_exception(self, mock_installer_cls):
|
||||
mock_installer_cls.return_value.fetch_plugin_manifest.side_effect = RuntimeError("not found")
|
||||
|
||||
@ -204,24 +204,24 @@ class TestIsPluginVerified:
|
||||
|
||||
|
||||
class TestUpgradePluginWithMarketplace:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_marketplace_disabled(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="marketplace is not enabled"):
|
||||
PluginService.upgrade_plugin_with_marketplace("t1", "old-uid", "new-uid")
|
||||
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_same_identifier(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
|
||||
with pytest.raises(ValueError, match="same plugin"):
|
||||
PluginService.upgrade_plugin_with_marketplace("t1", "same-uid", "same-uid")
|
||||
|
||||
@patch("services.plugin.plugin_service.marketplace")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.marketplace")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_skips_download_when_already_installed(self, mock_config, mock_installer_cls, mock_fs, mock_marketplace):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -234,10 +234,10 @@ class TestUpgradePluginWithMarketplace:
|
||||
mock_marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
|
||||
installer.upgrade_plugin.assert_called_once()
|
||||
|
||||
@patch("services.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_installed(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -256,8 +256,8 @@ class TestUpgradePluginWithMarketplace:
|
||||
|
||||
|
||||
class TestUpgradePluginWithGithub:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_checks_marketplace_permission_and_delegates(self, mock_installer_cls, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
installer = mock_installer_cls.return_value
|
||||
@ -271,8 +271,8 @@ class TestUpgradePluginWithGithub:
|
||||
|
||||
|
||||
class TestUploadPkg:
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_runs_permission_and_scope_checks(self, mock_installer_cls, mock_fs):
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
upload_resp = MagicMock()
|
||||
@ -285,17 +285,17 @@ class TestUploadPkg:
|
||||
|
||||
|
||||
class TestInstallFromMarketplacePkg:
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_raises_when_marketplace_disabled(self, mock_config):
|
||||
mock_config.MARKETPLACE_ENABLED = False
|
||||
|
||||
with pytest.raises(ValueError, match="marketplace is not enabled"):
|
||||
PluginService.install_from_marketplace_pkg("t1", ["uid-1"])
|
||||
|
||||
@patch("services.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.download_plugin_pkg")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_downloads_when_not_cached(self, mock_config, mock_installer_cls, mock_fs, mock_download):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -315,9 +315,9 @@ class TestInstallFromMarketplacePkg:
|
||||
call_args = installer.install_from_identifiers.call_args[0]
|
||||
assert call_args[1] == ["resolved-uid"]
|
||||
|
||||
@patch("services.plugin.plugin_service.FeatureService")
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("services.plugin.plugin_service.dify_config")
|
||||
@patch("core.plugin.plugin_service.FeatureService")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.dify_config")
|
||||
def test_uses_cached_when_already_downloaded(self, mock_config, mock_installer_cls, mock_fs):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
mock_fs.get_system_features.return_value = _make_features()
|
||||
@ -336,7 +336,7 @@ class TestInstallFromMarketplacePkg:
|
||||
|
||||
|
||||
class TestUninstall:
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_direct_uninstall_when_plugin_not_found(self, mock_installer_cls):
|
||||
installer = mock_installer_cls.return_value
|
||||
installer.list_plugins.return_value = []
|
||||
@ -347,7 +347,7 @@ class TestUninstall:
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("t1", "install-1")
|
||||
|
||||
@patch("services.plugin.plugin_service.PluginInstaller")
|
||||
@patch("core.plugin.plugin_service.PluginInstaller")
|
||||
def test_cleans_credentials_when_plugin_found(
|
||||
self, mock_installer_cls, flask_app_with_containers: Flask, db_session_with_containers: Session
|
||||
):
|
||||
@ -389,7 +389,7 @@ class TestUninstall:
|
||||
installer.list_plugins.return_value = [plugin]
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
with patch("services.plugin.plugin_service.dify_config") as mock_config:
|
||||
with patch("core.plugin.plugin_service.dify_config") as mock_config:
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
result = PluginService.uninstall(tenant_id, "install-1")
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import pytest
|
||||
from faker import Faker
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from core.tools.__base.tool import Tool
|
||||
from core.tools.entities.api_entities import ToolApiEntity, ToolProviderApiEntity
|
||||
from core.tools.entities.common_entities import I18nObject
|
||||
@ -20,7 +21,6 @@ from core.tools.entities.tool_entities import (
|
||||
ToolProviderType,
|
||||
)
|
||||
from models.tools import ApiToolProvider, BuiltinToolProvider, MCPToolProvider, WorkflowToolProvider
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from services.tools.tools_transform_service import ToolTransformService
|
||||
|
||||
|
||||
@ -31,7 +31,7 @@ class TestToolTransformService:
|
||||
def mock_external_service_dependencies(self):
|
||||
"""Mock setup for external service dependencies."""
|
||||
with patch("services.tools.tools_transform_service.dify_config") as mock_dify_config:
|
||||
with patch("services.plugin.plugin_service.dify_config", new=mock_dify_config):
|
||||
with patch("core.plugin.plugin_service.dify_config", new=mock_dify_config):
|
||||
# Setup default mock returns
|
||||
mock_dify_config.CONSOLE_API_URL = "https://console.example.com"
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_assembly
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
|
||||
def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
@ -34,3 +35,11 @@ def test_plugin_model_assembly_reuses_single_runtime_across_views():
|
||||
mock_provider_factory_cls.assert_called_once_with(runtime=runtime)
|
||||
mock_provider_manager_cls.assert_called_once_with(model_runtime=runtime)
|
||||
mock_model_manager_cls.assert_called_once_with(provider_manager=provider_manager)
|
||||
|
||||
|
||||
def test_create_plugin_model_runtime_injects_plugin_service():
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
|
||||
runtime = create_plugin_model_runtime(tenant_id="tenant-1", user_id="user-1")
|
||||
|
||||
assert runtime._plugin_service is PluginService
|
||||
|
||||
@ -12,6 +12,7 @@ from core.plugin.impl import model_runtime as model_runtime_module
|
||||
from core.plugin.impl.model import PluginModelClient
|
||||
from core.plugin.impl.model_runtime import TENANT_SCOPE_SCHEMA_CACHE_USER_ID, PluginModelRuntime
|
||||
from core.plugin.impl.model_runtime_factory import create_plugin_model_runtime
|
||||
from core.plugin.plugin_service import PluginService
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.llm_entities import LLMResultChunk, LLMResultChunkDelta, LLMUsage
|
||||
from graphon.model_runtime.entities.message_entities import AssistantPromptMessage
|
||||
@ -19,6 +20,22 @@ from graphon.model_runtime.entities.model_entities import AIModelEntity, FetchFr
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
|
||||
|
||||
class _FakeRedis:
|
||||
def __init__(self) -> None:
|
||||
self._values: dict[str, str] = {}
|
||||
self.setex_calls: list[tuple[str, int, str]] = []
|
||||
|
||||
def get(self, key: str) -> str | None:
|
||||
return self._values.get(key)
|
||||
|
||||
def setex(self, key: str, ttl: int, value: str) -> None:
|
||||
self._values[key] = value
|
||||
self.setex_calls.append((key, ttl, value))
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._values.pop(key, None)
|
||||
|
||||
|
||||
def _build_model_schema() -> AIModelEntity:
|
||||
return AIModelEntity(
|
||||
model="gpt-4o-mini",
|
||||
@ -29,6 +46,24 @@ def _build_model_schema() -> AIModelEntity:
|
||||
)
|
||||
|
||||
|
||||
def _build_plugin_model_provider(*, tenant_id: str, provider: str = "openai") -> PluginModelProviderEntity:
|
||||
return PluginModelProviderEntity(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=datetime.datetime.now(),
|
||||
updated_at=datetime.datetime.now(),
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
plugin_unique_identifier=f"langgenius/{provider}/{provider}",
|
||||
plugin_id=f"langgenius/{provider}",
|
||||
declaration=ProviderEntity(
|
||||
provider=provider,
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
class TestPluginModelRuntime:
|
||||
"""Validate the adapter keeps plugin-specific routing out of the runtime port."""
|
||||
|
||||
@ -51,7 +86,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -95,7 +130,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
),
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -122,7 +157,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
providers = runtime.fetch_model_providers()
|
||||
|
||||
@ -131,7 +166,7 @@ class TestPluginModelRuntime:
|
||||
|
||||
def test_validate_provider_credentials_resolves_plugin_fields(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
runtime.validate_provider_credentials(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -173,7 +208,7 @@ class TestPluginModelRuntime:
|
||||
),
|
||||
]
|
||||
)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_llm(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -209,7 +244,7 @@ class TestPluginModelRuntime:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
stream_result = iter([])
|
||||
client.invoke_llm.return_value = stream_result
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_llm(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -240,7 +275,9 @@ class TestPluginModelRuntime:
|
||||
def test_invoke_llm_rejects_per_call_user_override(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.invoke_llm.return_value = sentinel.result
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="bound-user", client=client)
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="bound-user", client=client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="unexpected keyword argument 'user_id'"):
|
||||
runtime.invoke_llm( # type: ignore[call-arg]
|
||||
@ -260,7 +297,7 @@ class TestPluginModelRuntime:
|
||||
def test_invoke_tts_uses_bound_runtime_user_when_runtime_is_unbound(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.invoke_tts.return_value = iter([b"chunk"])
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.invoke_tts(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -282,15 +319,107 @@ class TestPluginModelRuntime:
|
||||
voice="alloy",
|
||||
)
|
||||
|
||||
def test_fetch_model_providers_uses_bound_runtime_cache(self) -> None:
|
||||
def test_fetch_model_providers_does_not_keep_bound_runtime_cache(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
client.fetch_model_providers.return_value = []
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(
|
||||
plugin_service_module,
|
||||
"redis_client",
|
||||
SimpleNamespace(
|
||||
get=Mock(return_value=None),
|
||||
delete=Mock(),
|
||||
setex=Mock(),
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
runtime.fetch_model_providers()
|
||||
runtime.fetch_model_providers()
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant")
|
||||
assert client.fetch_model_providers.call_count == 2
|
||||
|
||||
def test_fetch_model_providers_uses_tenant_ttl_cache_across_runtime_instances(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
redis = _FakeRedis()
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(plugin_service_module, "redis_client", redis)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
first_client = Mock(spec=PluginModelClient)
|
||||
first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant")]
|
||||
second_client = Mock(spec=PluginModelClient)
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=first_client, plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-b", client=second_client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
first_providers = first_runtime.fetch_model_providers()
|
||||
second_providers = second_runtime.fetch_model_providers()
|
||||
|
||||
assert [provider.provider for provider in first_providers] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second_providers] == ["langgenius/openai/openai"]
|
||||
first_client.fetch_model_providers.assert_called_once_with("tenant")
|
||||
second_client.fetch_model_providers.assert_not_called()
|
||||
assert redis.setex_calls[0][1] == 300
|
||||
|
||||
def test_fetch_model_providers_cache_is_tenant_isolated(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
redis = _FakeRedis()
|
||||
from core.plugin import plugin_service as plugin_service_module
|
||||
|
||||
monkeypatch.setattr(plugin_service_module, "redis_client", redis)
|
||||
monkeypatch.setattr(plugin_service_module.dify_config, "PLUGIN_MODEL_PROVIDERS_CACHE_TTL", 300)
|
||||
first_client = Mock(spec=PluginModelClient)
|
||||
first_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant-a")]
|
||||
second_client = Mock(spec=PluginModelClient)
|
||||
second_client.fetch_model_providers.return_value = [_build_plugin_model_provider(tenant_id="tenant-b")]
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant-a", user_id="user", client=first_client, plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant-b", user_id="user", client=second_client, plugin_service=PluginService
|
||||
)
|
||||
|
||||
first_providers = first_runtime.fetch_model_providers()
|
||||
second_providers = second_runtime.fetch_model_providers()
|
||||
|
||||
assert [provider.provider for provider in first_providers] == ["langgenius/openai/openai"]
|
||||
assert [provider.provider for provider in second_providers] == ["langgenius/openai/openai"]
|
||||
first_client.fetch_model_providers.assert_called_once_with("tenant-a")
|
||||
second_client.fetch_model_providers.assert_called_once_with("tenant-b")
|
||||
assert len(redis.setex_calls) == 2
|
||||
|
||||
def test_fetch_model_providers_delegates_cache_to_injected_plugin_service(self) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
service_result = [
|
||||
ProviderEntity(
|
||||
provider="langgenius/openai/openai",
|
||||
label=I18nObject(en_US="OpenAI"),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
]
|
||||
fetch_plugin_model_providers = Mock(return_value=service_result)
|
||||
|
||||
class TestPluginService(PluginService):
|
||||
pass
|
||||
|
||||
TestPluginService.fetch_plugin_model_providers = fetch_plugin_model_providers
|
||||
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user", client=client, plugin_service=TestPluginService
|
||||
)
|
||||
|
||||
result = runtime.fetch_model_providers()
|
||||
|
||||
assert result is service_result
|
||||
fetch_plugin_model_providers.assert_called_once_with(tenant_id="tenant", client=client)
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
|
||||
|
||||
def test_create_plugin_model_runtime_without_user_context() -> None:
|
||||
@ -301,7 +430,17 @@ def test_create_plugin_model_runtime_without_user_context() -> None:
|
||||
|
||||
def test_plugin_model_runtime_requires_client() -> None:
|
||||
with pytest.raises(ValueError, match="client is required"):
|
||||
PluginModelRuntime(tenant_id="tenant", user_id="user", client=None) # type: ignore[arg-type]
|
||||
PluginModelRuntime(tenant_id="tenant", user_id="user", client=None, plugin_service=PluginService) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def test_plugin_model_runtime_requires_plugin_service() -> None:
|
||||
with pytest.raises(ValueError, match="plugin_service is required"):
|
||||
PluginModelRuntime(
|
||||
tenant_id="tenant",
|
||||
user_id="user",
|
||||
client=Mock(spec=PluginModelClient),
|
||||
plugin_service=None, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
|
||||
def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
@ -317,7 +456,7 @@ def test_get_model_schema_uses_cached_schema_without_hitting_client(monkeypatch:
|
||||
),
|
||||
)
|
||||
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
result = runtime.get_model_schema(
|
||||
provider="langgenius/openai/openai",
|
||||
model_type=ModelType.LLM,
|
||||
@ -395,7 +534,7 @@ def test_structured_output_adapter_invokes_bound_runtime_non_streaming() -> None
|
||||
|
||||
def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
schema = _build_model_schema()
|
||||
runtime.get_model_schema = Mock(return_value=schema) # type: ignore[method-assign]
|
||||
|
||||
@ -436,7 +575,7 @@ def test_invoke_llm_with_structured_output_delegates_with_bound_adapter() -> Non
|
||||
|
||||
def test_invoke_llm_with_structured_output_raises_when_model_schema_is_missing() -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
runtime.get_model_schema = Mock(return_value=None) # type: ignore[method-assign]
|
||||
|
||||
with pytest.raises(ValueError, match="Model schema not found for gpt-4o-mini"):
|
||||
@ -468,7 +607,7 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes
|
||||
)
|
||||
monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_MODEL_SCHEMA_CACHE_TTL", 300)
|
||||
client.get_model_schema.return_value = schema
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
result = runtime.get_model_schema(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -494,7 +633,7 @@ def test_get_model_schema_deletes_invalid_cache_and_refetches(monkeypatch: pytes
|
||||
def test_get_llm_num_tokens_returns_zero_when_plugin_counting_is_disabled(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
client = Mock(spec=PluginModelClient)
|
||||
monkeypatch.setattr(model_runtime_module.dify_config, "PLUGIN_BASED_TOKEN_COUNTING_ENABLED", False)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
assert (
|
||||
runtime.get_llm_num_tokens(
|
||||
@ -533,7 +672,7 @@ def test_get_provider_icon_reads_requested_variant_and_detects_svg_mime(monkeypa
|
||||
]
|
||||
fetch_asset = Mock(return_value=b"<svg></svg>")
|
||||
monkeypatch.setattr(model_runtime_module.PluginAssetManager, "fetch_asset", fetch_asset)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
icon_bytes, mime_type = runtime.get_provider_icon(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -565,7 +704,7 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants() -> N
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
with pytest.raises(ValueError, match="does not have small dark icon"):
|
||||
runtime.get_provider_icon(
|
||||
@ -583,7 +722,9 @@ def test_get_provider_icon_rejects_unsupported_types_and_missing_variants() -> N
|
||||
|
||||
|
||||
def test_get_schema_cache_key_is_stable_across_credential_order() -> None:
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=Mock(spec=PluginModelClient))
|
||||
runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
first = runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -602,8 +743,12 @@ def test_get_schema_cache_key_is_stable_across_credential_order() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_distinct_user_scopes() -> None:
|
||||
first_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient))
|
||||
second_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-b", client=Mock(spec=PluginModelClient))
|
||||
first_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
second_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-b", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
first = first_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -622,8 +767,12 @@ def test_get_schema_cache_key_separates_distinct_user_scopes() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_tenant_scope_from_user_scope() -> None:
|
||||
tenant_runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient))
|
||||
user_runtime = PluginModelRuntime(tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient))
|
||||
tenant_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
user_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="user-a", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
tenant_key = tenant_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -643,8 +792,12 @@ def test_get_schema_cache_key_separates_tenant_scope_from_user_scope() -> None:
|
||||
|
||||
|
||||
def test_get_schema_cache_key_separates_tenant_scope_from_empty_string_user_scope() -> None:
|
||||
tenant_runtime = PluginModelRuntime(tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient))
|
||||
empty_user_runtime = PluginModelRuntime(tenant_id="tenant", user_id="", client=Mock(spec=PluginModelClient))
|
||||
tenant_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id=None, client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
empty_user_runtime = PluginModelRuntime(
|
||||
tenant_id="tenant", user_id="", client=Mock(spec=PluginModelClient), plugin_service=PluginService
|
||||
)
|
||||
|
||||
tenant_key = tenant_runtime._get_schema_cache_key(
|
||||
provider="langgenius/openai/openai",
|
||||
@ -683,7 +836,7 @@ def test_get_provider_schema_supports_short_alias_and_rejects_invalid_provider()
|
||||
),
|
||||
)
|
||||
]
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client)
|
||||
runtime = PluginModelRuntime(tenant_id="tenant", user_id="user", client=client, plugin_service=PluginService)
|
||||
|
||||
assert runtime._get_provider_schema("openai").provider == "langgenius/openai/openai"
|
||||
|
||||
|
||||
@ -11,6 +11,7 @@ This test suite covers:
|
||||
import json
|
||||
from datetime import UTC, datetime
|
||||
from decimal import Decimal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
@ -197,6 +198,55 @@ class TestAppModelValidation:
|
||||
# Assert
|
||||
assert result == AppMode.CHAT
|
||||
|
||||
def test_deleted_tools_checks_plugin_builtin_providers_through_core_plugin_service(self):
|
||||
"""Plugin-backed built-in tools are checked through core PluginService."""
|
||||
# Arrange
|
||||
app = App(
|
||||
tenant_id="tenant-1",
|
||||
name="Test App",
|
||||
mode=AppMode.CHAT,
|
||||
enable_site=True,
|
||||
enable_api=False,
|
||||
created_by=str(uuid4()),
|
||||
)
|
||||
app_model_config = AppModelConfig(
|
||||
app_id=str(uuid4()),
|
||||
agent_mode=json.dumps(
|
||||
{
|
||||
"enabled": True,
|
||||
"strategy": "function_call",
|
||||
"tools": [
|
||||
{
|
||||
"provider_type": "builtin",
|
||||
"provider_id": "langgenius/openai/openai",
|
||||
"tool_name": "chat",
|
||||
"tool_parameters": {},
|
||||
}
|
||||
],
|
||||
"prompt": None,
|
||||
}
|
||||
),
|
||||
)
|
||||
session_context = MagicMock()
|
||||
session_context.__enter__.return_value = MagicMock()
|
||||
session_factory = SimpleNamespace(begin=MagicMock(return_value=session_context))
|
||||
|
||||
# Act
|
||||
with (
|
||||
patch.object(App, "app_model_config", new_callable=lambda: property(lambda self: app_model_config)),
|
||||
patch("models.model.db", SimpleNamespace(engine=object())),
|
||||
patch("models.model.sessionmaker", return_value=session_factory),
|
||||
patch("core.tools.tool_manager.ToolManager.get_hardcoded_provider", side_effect=Exception),
|
||||
patch("core.plugin.plugin_service.PluginService.check_tools_existence", return_value=[False]) as exists,
|
||||
):
|
||||
result = app.deleted_tools
|
||||
|
||||
# Assert
|
||||
assert result == [{"type": "builtin", "tool_name": "chat", "provider_id": "langgenius/openai/openai"}]
|
||||
exists.assert_called_once()
|
||||
assert exists.call_args.args[0] == "tenant-1"
|
||||
assert [str(provider_id) for provider_id in exists.call_args.args[1]] == ["langgenius/openai/openai"]
|
||||
|
||||
|
||||
class TestAppModelConfig:
|
||||
"""Test suite for AppModelConfig model."""
|
||||
|
||||
@ -24,7 +24,7 @@ def make_features(
|
||||
def mock_installer(monkeypatch: pytest.MonkeyPatch):
|
||||
"""Patch PluginInstaller at the service import site."""
|
||||
mock = MagicMock()
|
||||
monkeypatch.setattr("services.plugin.plugin_service.PluginInstaller", lambda: mock)
|
||||
monkeypatch.setattr("core.plugin.plugin_service.PluginInstaller", lambda: mock)
|
||||
return mock
|
||||
|
||||
|
||||
@ -34,6 +34,6 @@ def mock_features():
|
||||
from unittest.mock import patch
|
||||
|
||||
features = make_features()
|
||||
with patch("services.plugin.plugin_service.FeatureService") as mock_fs:
|
||||
with patch("core.plugin.plugin_service.FeatureService") as mock_fs:
|
||||
mock_fs.get_system_features.return_value = features
|
||||
yield features
|
||||
|
||||
@ -61,6 +61,7 @@ class TestHandlePluginInstanceInstall:
|
||||
patch(f"{MIGRATION_MODULE}.dify_config") as mock_cfg,
|
||||
patch(f"{MIGRATION_MODULE}.marketplace") as mock_marketplace,
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_cfg.MARKETPLACE_ENABLED = True
|
||||
mock_marketplace.download_plugin_pkg.return_value = b"pkg_data"
|
||||
@ -73,4 +74,31 @@ class TestHandlePluginInstanceInstall:
|
||||
)
|
||||
|
||||
mock_marketplace.download_plugin_pkg.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
assert "success" in result or "failed" in result
|
||||
|
||||
def test_install_plugins_invalidates_cache_after_direct_tenant_install(self, tmp_path) -> None:
|
||||
extracted_plugins = tmp_path / "plugins.jsonl"
|
||||
output_file = tmp_path / "output.json"
|
||||
extracted_plugins.write_text('{"tenant_id":"tenant1","plugins":["langgenius/openai"]}\n')
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{MIGRATION_MODULE}.PluginMigration.extract_unique_plugins",
|
||||
return_value={
|
||||
"plugins": {"langgenius/openai": "langgenius/openai:1.0.0@abc"},
|
||||
"plugin_not_exist": [],
|
||||
},
|
||||
),
|
||||
patch(f"{MIGRATION_MODULE}.PluginMigration.handle_plugin_instance_install", return_value={}),
|
||||
patch(f"{MIGRATION_MODULE}.PluginInstaller") as mock_installer_cls,
|
||||
patch(f"{MIGRATION_MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_installer = MagicMock()
|
||||
mock_installer.list_plugins.return_value = []
|
||||
mock_installer_cls.return_value = mock_installer
|
||||
|
||||
PluginMigration.install_plugins(str(extracted_plugins), str(output_file), workers=1)
|
||||
|
||||
mock_installer.install_from_identifiers.assert_called_once()
|
||||
invalidate_cache.assert_called_once_with("tenant1")
|
||||
|
||||
@ -1,6 +1,71 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
import datetime
|
||||
import uuid
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, Mock, patch
|
||||
|
||||
MODULE = "services.plugin.plugin_service"
|
||||
from pydantic import TypeAdapter
|
||||
from redis import RedisError
|
||||
|
||||
from core.plugin.entities.plugin_daemon import PluginInstallTask, PluginInstallTaskStatus, PluginModelProviderEntity
|
||||
from graphon.model_runtime.entities.common_entities import I18nObject
|
||||
from graphon.model_runtime.entities.provider_entities import ConfigurateMethod, ProviderEntity
|
||||
|
||||
MODULE = "core.plugin.plugin_service"
|
||||
|
||||
|
||||
class _FakeSession:
|
||||
def __init__(self) -> None:
|
||||
self.execute = Mock()
|
||||
self.scalars = Mock(return_value=SimpleNamespace(all=Mock(return_value=[])))
|
||||
|
||||
def __enter__(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, traceback) -> None:
|
||||
return None
|
||||
|
||||
def begin(self) -> "_FakeSession":
|
||||
return self
|
||||
|
||||
|
||||
def _build_provider_entity(provider: str = "openai") -> ProviderEntity:
|
||||
return ProviderEntity(
|
||||
provider=f"langgenius/{provider}/{provider}",
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
)
|
||||
|
||||
|
||||
def _build_plugin_model_provider(*, tenant_id: str = "tenant-1", provider: str = "openai") -> PluginModelProviderEntity:
|
||||
return PluginModelProviderEntity(
|
||||
id=uuid.uuid4().hex,
|
||||
created_at=datetime.datetime.now(),
|
||||
updated_at=datetime.datetime.now(),
|
||||
provider=provider,
|
||||
tenant_id=tenant_id,
|
||||
plugin_unique_identifier=f"langgenius/{provider}/{provider}",
|
||||
plugin_id=f"langgenius/{provider}",
|
||||
declaration=ProviderEntity(
|
||||
provider=provider,
|
||||
label=I18nObject(en_US=provider.title()),
|
||||
supported_model_types=[],
|
||||
configurate_methods=[ConfigurateMethod.PREDEFINED_MODEL],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _build_install_task(*, task_id: str = "task-1", status: PluginInstallTaskStatus) -> PluginInstallTask:
|
||||
now = datetime.datetime.now()
|
||||
return PluginInstallTask(
|
||||
id=task_id,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
status=status,
|
||||
total_plugins=1,
|
||||
completed_plugins=1 if status != PluginInstallTaskStatus.Pending else 0,
|
||||
plugins=[],
|
||||
)
|
||||
|
||||
|
||||
class TestFetchLatestPluginVersion:
|
||||
@ -14,7 +79,7 @@ class TestFetchLatestPluginVersion:
|
||||
mock_cfg.MARKETPLACE_ENABLED = False
|
||||
mock_redis.get.return_value = None # all cache misses
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai", "langgenius/anthropic"])
|
||||
|
||||
@ -40,7 +105,7 @@ class TestFetchLatestPluginVersion:
|
||||
mock_redis.get.return_value = None
|
||||
mock_marketplace.batch_fetch_plugin_manifests.return_value = [manifest]
|
||||
|
||||
from services.plugin.plugin_service import PluginService
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_latest_plugin_version(["langgenius/openai"])
|
||||
|
||||
@ -48,3 +113,322 @@ class TestFetchLatestPluginVersion:
|
||||
mock_marketplace.batch_fetch_plugin_manifests.assert_called_once()
|
||||
assert result["langgenius/openai"] is not None
|
||||
assert result["langgenius/openai"].version == "1.0.0"
|
||||
|
||||
|
||||
class TestPluginModelProviderCache:
|
||||
def test_fetch_plugin_model_providers_returns_cached_provider_without_calling_daemon(self) -> None:
|
||||
"""A valid tenant cache entry is reused across runtime calls without plugin daemon access."""
|
||||
cached_provider = _build_provider_entity()
|
||||
cached_payload = TypeAdapter(list[ProviderEntity]).dump_json([cached_provider]).decode("utf-8")
|
||||
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = cached_payload
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
client = Mock()
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
client.fetch_model_providers.assert_not_called()
|
||||
redis_client.setex.assert_not_called()
|
||||
|
||||
def test_fetch_plugin_model_providers_deletes_invalid_cache_and_refetches(self) -> None:
|
||||
"""Invalid cache payloads are tenant-scoped invalidated before falling back to the daemon."""
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
):
|
||||
redis_client.get.return_value = "not-json"
|
||||
mock_config.PLUGIN_MODEL_PROVIDERS_CACHE_TTL = 86400
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
cache_key = "plugin_model_providers:tenant_id:tenant-1"
|
||||
redis_client.delete.assert_called_once_with(cache_key)
|
||||
redis_client.setex.assert_called_once()
|
||||
assert redis_client.setex.call_args.args[0] == cache_key
|
||||
assert redis_client.setex.call_args.args[1] == 86400
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_refetches_when_cache_read_fails(self) -> None:
|
||||
"""Redis read failures do not block provider discovery for the tenant."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_returns_fresh_result_when_cache_write_fails(self) -> None:
|
||||
"""Redis write failures are non-fatal after fresh provider data has been fetched."""
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.get.return_value = None
|
||||
redis_client.setex.side_effect = RedisError("redis unavailable")
|
||||
client = Mock()
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1", client=client)
|
||||
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_fetch_plugin_model_providers_creates_default_client_on_cache_miss(self) -> None:
|
||||
"""The service owns plugin daemon access when no runtime-provided client is injected."""
|
||||
with (
|
||||
patch(f"{MODULE}.redis_client") as redis_client,
|
||||
patch(f"{MODULE}.PluginModelClient") as client_cls,
|
||||
):
|
||||
redis_client.get.return_value = None
|
||||
client = client_cls.return_value
|
||||
client.fetch_model_providers.return_value = [_build_plugin_model_provider()]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_plugin_model_providers(tenant_id="tenant-1")
|
||||
|
||||
client_cls.assert_called_once_with()
|
||||
client.fetch_model_providers.assert_called_once_with("tenant-1")
|
||||
assert [provider.provider for provider in result] == ["langgenius/openai/openai"]
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_uses_tenant_cache_key(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
||||
|
||||
redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1")
|
||||
|
||||
def test_invalidate_plugin_model_providers_cache_ignores_redis_delete_failure(self) -> None:
|
||||
with patch(f"{MODULE}.redis_client") as redis_client:
|
||||
redis_client.delete.side_effect = RedisError("redis unavailable")
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
PluginService.invalidate_plugin_model_providers_cache("tenant-1")
|
||||
|
||||
redis_client.delete.assert_called_once_with("plugin_model_providers:tenant_id:tenant-1")
|
||||
|
||||
|
||||
class TestPluginModelProviderCacheInvalidation:
|
||||
def test_fetch_install_task_invalidates_model_provider_cache_when_finished(self) -> None:
|
||||
"""Finished plugin install tasks invalidate tenant provider cache."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_task.return_value = task
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_task("tenant-1", "task-1")
|
||||
|
||||
assert result is task
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_fetch_install_tasks_invalidates_model_provider_cache_for_finished_tasks(self) -> None:
|
||||
"""Finished tasks from task list polling also invalidate tenant provider cache."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Success)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
||||
|
||||
assert result == [task]
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_fetch_install_tasks_ignores_running_model_provider_cache_tasks(self) -> None:
|
||||
"""Running plugin install tasks do not invalidate provider cache until they reach a terminal state."""
|
||||
task = _build_install_task(status=PluginInstallTaskStatus.Running)
|
||||
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer_cls.return_value.fetch_plugin_installation_tasks.return_value = [task]
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.fetch_install_tasks("tenant-1", 1, 256)
|
||||
|
||||
assert result == [task]
|
||||
invalidate_cache.assert_not_called()
|
||||
|
||||
def test_upgrade_plugin_with_marketplace_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Marketplace upgrades invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.FeatureService") as feature_service,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.marketplace") as marketplace,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
feature_service.get_system_features.return_value = SimpleNamespace(
|
||||
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
||||
)
|
||||
installer = installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
installer.upgrade_plugin.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.upgrade_plugin_with_marketplace("tenant-1", "old-uid", "new-uid")
|
||||
|
||||
assert result == "task-id"
|
||||
marketplace.record_install_plugin_event.assert_called_once_with("new-uid")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_local_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Starting a plugin install invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_local_pkg("tenant-1", ["langgenius/openai:1.0.0"])
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_upgrade_plugin_with_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Starting a plugin upgrade invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
installer.upgrade_plugin.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.upgrade_plugin_with_github(
|
||||
"tenant-1", "old-uid", "new-uid", "langgenius/openai", "1.0.0", "openai.difypkg"
|
||||
)
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_github_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""GitHub installs invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginService._check_marketplace_only_permission"),
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_github(
|
||||
"tenant-1", "langgenius/openai:1.0.0", "langgenius/openai", "1.0.0", "openai.difypkg"
|
||||
)
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_install_from_marketplace_pkg_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Marketplace package installs invalidate only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.FeatureService") as feature_service,
|
||||
patch(f"{MODULE}.PluginService._check_plugin_installation_scope"),
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.MARKETPLACE_ENABLED = True
|
||||
feature_service.get_system_features.return_value = SimpleNamespace(
|
||||
plugin_installation_permission=SimpleNamespace(restrict_to_marketplace_only=False)
|
||||
)
|
||||
installer = installer_cls.return_value
|
||||
installer.fetch_plugin_manifest.return_value = MagicMock()
|
||||
decode_response = MagicMock()
|
||||
decode_response.verification = None
|
||||
installer.decode_plugin_from_identifier.return_value = decode_response
|
||||
installer.install_from_identifiers.return_value = "task-id"
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.install_from_marketplace_pkg("tenant-1", ["langgenius/openai:1.0.0"])
|
||||
|
||||
assert result == "task-id"
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_uninstall_invalidates_model_provider_cache_for_tenant(self) -> None:
|
||||
"""Successful uninstall invalidates only the mutated tenant provider cache."""
|
||||
with (
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
installer = installer_cls.return_value
|
||||
installer.list_plugins.return_value = []
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
def test_uninstall_existing_plugin_invalidates_cache_after_credential_cleanup(self) -> None:
|
||||
"""Successful uninstall with plugin metadata also invalidates the mutated tenant provider cache."""
|
||||
plugin = SimpleNamespace(
|
||||
installation_id="installation-1",
|
||||
plugin_id="langgenius/openai",
|
||||
plugin_unique_identifier="langgenius/openai:1.0.0",
|
||||
)
|
||||
session = _FakeSession()
|
||||
with (
|
||||
patch(f"{MODULE}.db", SimpleNamespace(engine=object())),
|
||||
patch(f"{MODULE}.dify_config") as mock_config,
|
||||
patch(f"{MODULE}.PluginInstaller") as installer_cls,
|
||||
patch(f"{MODULE}.Session", return_value=session),
|
||||
patch(f"{MODULE}.PluginService.invalidate_plugin_model_providers_cache") as invalidate_cache,
|
||||
):
|
||||
mock_config.ENTERPRISE_ENABLED = False
|
||||
installer = installer_cls.return_value
|
||||
installer.list_plugins.return_value = [plugin]
|
||||
installer.uninstall.return_value = True
|
||||
|
||||
from core.plugin.plugin_service import PluginService
|
||||
|
||||
result = PluginService.uninstall("tenant-1", "installation-1")
|
||||
|
||||
assert result is True
|
||||
installer.uninstall.assert_called_once_with("tenant-1", "installation-1")
|
||||
invalidate_cache.assert_called_once_with("tenant-1")
|
||||
|
||||
@ -60,6 +60,7 @@ SSRF_PROXY_HTTPS_URL=http://ssrf_proxy:3128
|
||||
PGDATA=/var/lib/postgresql/data/pgdata
|
||||
PLUGIN_MAX_PACKAGE_SIZE=52428800
|
||||
PLUGIN_MODEL_SCHEMA_CACHE_TTL=3600
|
||||
PLUGIN_MODEL_PROVIDERS_CACHE_TTL=86400
|
||||
ENDPOINT_URL_TEMPLATE=http://localhost/e/{hook_id}
|
||||
LOG_LEVEL=INFO
|
||||
LOG_OUTPUT_FORMAT=text
|
||||
|
||||
@ -1770,6 +1770,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/textarea/index.stories.tsx": {
|
||||
"no-console": {
|
||||
"count": 1
|
||||
},
|
||||
"ts/no-explicit-any": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"web/app/components/base/voice-input/__tests__/index.spec.tsx": {
|
||||
"ts/no-explicit-any": {
|
||||
"count": 3
|
||||
|
||||
@ -33,7 +33,6 @@ import { Drawer, DrawerPopup, DrawerTrigger } from '@langgenius/dify-ui/drawer'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Form } from '@langgenius/dify-ui/form'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@langgenius/dify-ui/popover'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import '@langgenius/dify-ui/styles.css' // once, in the app root
|
||||
```
|
||||
|
||||
@ -41,16 +40,16 @@ Importing from `@langgenius/dify-ui` (no subpath) is intentionally not supported
|
||||
|
||||
## Primitives
|
||||
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./textarea`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
| Category | Subpath | Notes |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------- |
|
||||
| Actions | `./button` | Design-system CTA primitive with `cva` variants. |
|
||||
| Feedback | `./meter`, `./toast` | Meter is inline status; Toast owns the `z-60` layer. |
|
||||
| Form | `./form`, `./field`, `./fieldset`, `./input`, `./checkbox`, `./checkbox-group`, `./radio`, `./radio-group`, `./number-field`, `./select`, `./slider`, `./switch` | Native form boundary, field semantics, and controls. |
|
||||
| Layout | `./scroll-area` | Custom-styled scrollbar over the host viewport. |
|
||||
| Media | `./avatar` | Avatar root, image, and fallback primitives. |
|
||||
| Navigation | `./tabs`, `./toggle-group` | Tabs for panels; ToggleGroup for segmented modes. |
|
||||
| Overlay / menu | `./alert-dialog`, `./context-menu`, `./dialog`, `./drawer`, `./dropdown-menu`, `./popover`, `./preview-card`, `./tooltip` | Portalled. See [Overlay & portal contract] below. |
|
||||
| Search / pickers | `./autocomplete`, `./combobox`, `./select` | Search input, searchable picker, and closed picker. |
|
||||
|
||||
Utilities:
|
||||
|
||||
@ -65,7 +64,7 @@ Use `Form` for the submit boundary. It renders a native `<form>`, preserves Ente
|
||||
|
||||
Use `FieldRoot` for each standalone named field. A field must have a stable `name`, a label relationship, and either a `FieldControl` or another control that participates in the same Base UI field context. Prefer a visible label for normal form rows; when the surrounding UI already supplies the visible text, use the matching label primitive visually hidden or put `aria-label` on the actual interactive control. `FieldDescription` and `FieldError` provide the message relationships that screen readers need, while the Dify wrapper adds the default Form Input Set styling from the design system.
|
||||
|
||||
Choose the label primitive by the control semantics. Text-like inputs, `Textarea`, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
|
||||
Choose the label primitive by the control semantics. Text-like inputs, input-based `Combobox` / `Autocomplete`, single `Checkbox` / `Radio`, `Switch`, and `NumberField` use `FieldLabel`. Trigger-based `Select` fields use `SelectLabel`; `Slider` fields use `SliderLabel`, with per-thumb `aria-label` only when the thumbs need distinct names. `SelectGroupLabel` and `AutocompleteGroupLabel` only label grouped options inside their popup content; they are not field labels.
|
||||
|
||||
Use `FieldsetRoot` and `FieldsetLegend` when one field is represented by a group of related controls, such as checkbox groups, radio groups, multi-thumb sliders, or a section that combines several inputs. For checkbox and radio groups, wrap each option with `FieldItem` and give each option its own label:
|
||||
|
||||
|
||||
@ -113,10 +113,6 @@
|
||||
"types": "./src/tabs/index.tsx",
|
||||
"import": "./src/tabs/index.tsx"
|
||||
},
|
||||
"./textarea": {
|
||||
"types": "./src/textarea/index.tsx",
|
||||
"import": "./src/textarea/index.tsx"
|
||||
},
|
||||
"./toggle-group": {
|
||||
"types": "./src/toggle-group/index.tsx",
|
||||
"import": "./src/toggle-group/index.tsx"
|
||||
|
||||
@ -1,130 +0,0 @@
|
||||
import { render } from 'vitest-browser-react'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../../field'
|
||||
import { Form } from '../../form'
|
||||
import { Textarea } from '../index'
|
||||
|
||||
const asHTMLElement = (element: HTMLElement | SVGElement) => element as HTMLElement
|
||||
const setTextareaValue = (element: HTMLElement | SVGElement, value: string) => {
|
||||
const textarea = asHTMLElement(element) as HTMLTextAreaElement
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
valueSetter?.call(textarea, value)
|
||||
textarea.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
}
|
||||
|
||||
describe('Textarea', () => {
|
||||
it('should render a labelled textarea through Base UI Field.Control', async () => {
|
||||
const screen = await render(
|
||||
<FieldRoot name="description">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Textarea defaultValue="A workspace for support automation." />
|
||||
<FieldDescription>Shown to workspace members.</FieldDescription>
|
||||
</FieldRoot>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Description' })
|
||||
|
||||
await expect.element(textarea).toHaveValue('A workspace for support automation.')
|
||||
await expect.element(textarea).toHaveAccessibleDescription('Shown to workspace members.')
|
||||
await expect.element(textarea).toHaveClass('min-h-20', 'overflow-auto', 'rounded-lg', 'system-sm-regular')
|
||||
expect(asHTMLElement(textarea.element()).tagName).toBe('TEXTAREA')
|
||||
})
|
||||
|
||||
it('should apply size variants and custom classes', async () => {
|
||||
const screen = await render(
|
||||
<label>
|
||||
Prompt
|
||||
<Textarea size="large" className="resize-none" />
|
||||
</label>,
|
||||
)
|
||||
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Prompt' })).toHaveClass(
|
||||
'rounded-[10px]',
|
||||
'px-4',
|
||||
'py-2',
|
||||
'system-md-regular',
|
||||
'resize-none',
|
||||
)
|
||||
})
|
||||
|
||||
it('should call onValueChange and stay controlled until value changes', async () => {
|
||||
const onValueChange = vi.fn()
|
||||
const screen = await render(
|
||||
<label>
|
||||
Notes
|
||||
<Textarea value="" onValueChange={onValueChange} />
|
||||
</label>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Notes' })
|
||||
setTextareaValue(textarea.element(), 'a')
|
||||
|
||||
expect(onValueChange).toHaveBeenCalledWith('a', expect.any(Object))
|
||||
await expect.element(textarea).toHaveValue('')
|
||||
|
||||
await screen.rerender(
|
||||
<label>
|
||||
Notes
|
||||
<Textarea value="a" onValueChange={onValueChange} />
|
||||
</label>,
|
||||
)
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Notes' })).toHaveValue('a')
|
||||
})
|
||||
|
||||
it('should submit valid values and show validation errors through Base UI Form', async () => {
|
||||
const onFormSubmit = vi.fn()
|
||||
const screen = await render(
|
||||
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="summary">
|
||||
<FieldLabel>Summary</FieldLabel>
|
||||
<Textarea required minLength={10} />
|
||||
<FieldError match="valueMissing">Summary is required.</FieldError>
|
||||
<FieldError match="tooShort">Summary is too short.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
const saveButton = asHTMLElement(screen.getByRole('button', { name: 'Save' }).element())
|
||||
saveButton.click()
|
||||
|
||||
await vi.waitFor(async () => {
|
||||
await expect.element(screen.getByText('Summary is required.')).toBeInTheDocument()
|
||||
await expect.element(screen.getByRole('textbox', { name: 'Summary' })).toHaveAttribute('aria-invalid', 'true')
|
||||
})
|
||||
expect(onFormSubmit).not.toHaveBeenCalled()
|
||||
|
||||
await screen.rerender(
|
||||
<Form aria-label="dataset form" onFormSubmit={onFormSubmit}>
|
||||
<FieldRoot name="summary">
|
||||
<FieldLabel>Summary</FieldLabel>
|
||||
<Textarea key="valid-summary" required minLength={10} defaultValue="Long enough summary" />
|
||||
<FieldError match="valueMissing">Summary is required.</FieldError>
|
||||
<FieldError match="tooShort">Summary is too short.</FieldError>
|
||||
</FieldRoot>
|
||||
<button type="submit">Save</button>
|
||||
</Form>,
|
||||
)
|
||||
|
||||
asHTMLElement(screen.getByRole('button', { name: 'Save' }).element()).click()
|
||||
expect(onFormSubmit).toHaveBeenCalledTimes(1)
|
||||
expect(onFormSubmit.mock.calls[0]?.[0]).toMatchObject({ summary: 'Long enough summary' })
|
||||
})
|
||||
|
||||
it('should pass maxLength to the textarea without rendering a counter', async () => {
|
||||
const screen = await render(
|
||||
<label>
|
||||
Release notes
|
||||
<Textarea defaultValue="Draft" maxLength={20} />
|
||||
</label>,
|
||||
)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'Release notes' })
|
||||
await expect.element(textarea).toHaveAttribute('maxLength', '20')
|
||||
expect(screen.container.textContent).not.toContain('5/20')
|
||||
})
|
||||
})
|
||||
@ -1,193 +0,0 @@
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite'
|
||||
import { useState } from 'react'
|
||||
import { Button } from '../button'
|
||||
import {
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldRoot,
|
||||
} from '../field'
|
||||
import { Form } from '../form'
|
||||
import { Textarea } from './index'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Form/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Multiline text control built on Base UI Field.Control. Use it with FieldRoot for labelled, described, and validated form fields.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
} satisfies Meta<typeof Textarea>
|
||||
|
||||
export default meta
|
||||
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Basic: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<label htmlFor="workspace-description" className="mb-1 block w-fit py-1 text-text-secondary system-sm-medium">
|
||||
Workspace description
|
||||
</label>
|
||||
<Textarea
|
||||
id="workspace-description"
|
||||
name="workspaceDescription"
|
||||
placeholder="Describe how this workspace is used..."
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const Sizes: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="small-textarea">
|
||||
Small
|
||||
<Textarea id="small-textarea" size="small" name="smallTextarea" placeholder="Short note..." rows={3} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="medium-textarea">
|
||||
Medium
|
||||
<Textarea id="medium-textarea" name="mediumTextarea" placeholder="Add context..." rows={3} />
|
||||
</label>
|
||||
<label className="grid gap-1 text-text-secondary system-sm-medium" htmlFor="large-textarea">
|
||||
Large
|
||||
<Textarea id="large-textarea" size="large" name="largeTextarea" placeholder="Write a longer instruction..." rows={3} />
|
||||
</label>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
export const States: Story = {
|
||||
render: () => (
|
||||
<div className="grid w-80 gap-3">
|
||||
<FieldRoot name="placeholderState">
|
||||
<FieldLabel>Placeholder</FieldLabel>
|
||||
<Textarea placeholder="Add a description..." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="filledState">
|
||||
<FieldLabel>Filled</FieldLabel>
|
||||
<Textarea defaultValue="Use this dataset for support articles and product FAQs." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="invalidState" invalid>
|
||||
<FieldLabel>Invalid</FieldLabel>
|
||||
<Textarea defaultValue="Too short" rows={3} />
|
||||
<FieldError match>Use at least 20 characters.</FieldError>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="disabledState">
|
||||
<FieldLabel>Disabled</FieldLabel>
|
||||
<Textarea disabled placeholder="Editing is unavailable..." rows={3} />
|
||||
</FieldRoot>
|
||||
<FieldRoot name="readonlyState">
|
||||
<FieldLabel>Read-only</FieldLabel>
|
||||
<Textarea readOnly defaultValue="Generated from the published workflow configuration." rows={3} />
|
||||
</FieldRoot>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const FormDemo = () => {
|
||||
const [savedDescription, setSavedDescription] = useState<string | null>(null)
|
||||
|
||||
return (
|
||||
<Form
|
||||
aria-label="Dataset settings"
|
||||
className="grid w-80 gap-4"
|
||||
onFormSubmit={(values) => {
|
||||
setSavedDescription(String(values.description ?? ''))
|
||||
}}
|
||||
>
|
||||
<FieldRoot name="description">
|
||||
<FieldLabel>Description</FieldLabel>
|
||||
<Textarea
|
||||
required
|
||||
minLength={20}
|
||||
maxLength={160}
|
||||
placeholder="Describe what this dataset contains..."
|
||||
rows={4}
|
||||
className="resize-y"
|
||||
/>
|
||||
<FieldDescription>Shown to teammates when they choose a knowledge source.</FieldDescription>
|
||||
<FieldError match="valueMissing">Description is required.</FieldError>
|
||||
<FieldError match="tooShort">Use at least 20 characters.</FieldError>
|
||||
</FieldRoot>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" variant="primary">Save Settings</Button>
|
||||
</div>
|
||||
{savedDescription && (
|
||||
<div className="rounded-lg bg-background-section px-3 py-2 text-text-secondary system-xs-regular">
|
||||
Saved:
|
||||
{' '}
|
||||
{savedDescription}
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithField: Story = {
|
||||
render: () => <FormDemo />,
|
||||
}
|
||||
|
||||
const ControlledDemo = () => {
|
||||
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
|
||||
|
||||
return (
|
||||
<FieldRoot name="prompt">
|
||||
<FieldLabel>Prompt</FieldLabel>
|
||||
<Textarea
|
||||
value={value}
|
||||
onValueChange={nextValue => setValue(nextValue)}
|
||||
rows={4}
|
||||
className="resize-y"
|
||||
/>
|
||||
<FieldDescription>The saved value is updated from the controlled state.</FieldDescription>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const Controlled: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<ControlledDemo />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
const CharacterCounterDemo = () => {
|
||||
const maxLength = 120
|
||||
const [value, setValue] = useState('Summarize customer feedback into actionable product themes.')
|
||||
|
||||
return (
|
||||
<FieldRoot name="limitedPrompt">
|
||||
<FieldLabel>Prompt</FieldLabel>
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
value={value}
|
||||
onValueChange={nextValue => setValue(nextValue)}
|
||||
maxLength={maxLength}
|
||||
rows={4}
|
||||
className="resize-y pb-8"
|
||||
/>
|
||||
<div className="pointer-events-none absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-text-quaternary system-xs-medium">
|
||||
<span>{value.length}</span>
|
||||
/
|
||||
<span className="text-text-tertiary">{maxLength}</span>
|
||||
</div>
|
||||
</div>
|
||||
<FieldDescription>Character counters are composed at the usage site when the workflow needs one.</FieldDescription>
|
||||
</FieldRoot>
|
||||
)
|
||||
}
|
||||
|
||||
export const WithCharacterCounter: Story = {
|
||||
render: () => (
|
||||
<div className="w-80">
|
||||
<CharacterCounterDemo />
|
||||
</div>
|
||||
),
|
||||
}
|
||||
@ -1,89 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import type { Field as BaseFieldNS } from '@base-ui/react/field'
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { ComponentPropsWithRef } from 'react'
|
||||
import { Field as BaseField } from '@base-ui/react/field'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import { cn } from '../cn'
|
||||
|
||||
const textareaVariants = cva(
|
||||
[
|
||||
'min-h-20 w-full appearance-none overflow-auto border border-transparent bg-components-input-bg-normal text-components-input-text-filled caret-primary-600 outline-hidden transition-[background-color,border-color,box-shadow]',
|
||||
'placeholder:text-components-input-text-placeholder',
|
||||
'hover:border-components-input-border-hover hover:bg-components-input-bg-hover',
|
||||
'focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
'data-invalid:border-components-input-border-destructive data-invalid:bg-components-input-bg-destructive',
|
||||
'read-only:cursor-default read-only:shadow-none read-only:hover:border-transparent read-only:hover:bg-components-input-bg-normal read-only:focus:border-transparent read-only:focus:bg-components-input-bg-normal read-only:focus:shadow-none',
|
||||
'disabled:cursor-not-allowed disabled:border-transparent disabled:bg-components-input-bg-disabled disabled:text-components-input-text-filled-disabled',
|
||||
'disabled:hover:border-transparent disabled:hover:bg-components-input-bg-disabled',
|
||||
'motion-reduce:transition-none',
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md px-2 py-1 system-xs-regular',
|
||||
medium: 'rounded-lg px-3 py-2 system-sm-regular',
|
||||
large: 'rounded-[10px] px-4 py-2 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'medium',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
type TextareaValue = string | number
|
||||
export type TextareaSize = NonNullable<VariantProps<typeof textareaVariants>['size']>
|
||||
export type TextareaChangeEventDetails = BaseFieldNS.Control.ChangeEventDetails
|
||||
type TextareaOnValueChange = (value: string, eventDetails: TextareaChangeEventDetails) => void
|
||||
|
||||
type ControlledTextareaProps = {
|
||||
value: TextareaValue
|
||||
defaultValue?: never
|
||||
onValueChange: TextareaOnValueChange
|
||||
}
|
||||
|
||||
type UncontrolledTextareaProps = {
|
||||
value?: never
|
||||
defaultValue?: TextareaValue
|
||||
onValueChange?: TextareaOnValueChange
|
||||
}
|
||||
|
||||
type NativeTextareaProps = Omit<
|
||||
ComponentPropsWithRef<'textarea'>,
|
||||
'children' | 'className' | 'defaultValue' | 'onChange' | 'size' | 'value'
|
||||
>
|
||||
|
||||
type TextareaControlProps = ControlledTextareaProps | UncontrolledTextareaProps
|
||||
type TextareaVariantProps = VariantProps<typeof textareaVariants>
|
||||
|
||||
export type TextareaProps
|
||||
= NativeTextareaProps
|
||||
& TextareaControlProps
|
||||
& TextareaVariantProps
|
||||
& {
|
||||
children?: never
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Textarea({
|
||||
className,
|
||||
defaultValue,
|
||||
onValueChange,
|
||||
ref,
|
||||
size = 'medium',
|
||||
value,
|
||||
...props
|
||||
}: TextareaProps) {
|
||||
return (
|
||||
<BaseField.Control
|
||||
className={cn(textareaVariants({ size }), className)}
|
||||
defaultValue={defaultValue}
|
||||
onValueChange={onValueChange}
|
||||
ref={ref}
|
||||
render={<textarea {...props} />}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -493,8 +493,8 @@ describe('Capacity Full Components Integration', () => {
|
||||
expect(screen.getByText(/upgradeBtn\.encourageShort/i)).toBeInTheDocument()
|
||||
// Should show usage/total fraction "5/5"
|
||||
expect(screen.getByText(/5\/5/)).toBeInTheDocument()
|
||||
// Should have an accessible meter rendered
|
||||
expect(screen.getByRole('meter', { name: /usagePage\.buildApps/i })).toBeInTheDocument()
|
||||
// Should have a meter rendered
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should display upgrade tip and upgrade button for professional plan', () => {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
'use client'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
import { useRouter } from '@/next/navigation'
|
||||
import { useLogout } from '@/service/use-common'
|
||||
@ -63,12 +63,11 @@ export default function FeedBack(props: DeleteAccountProps) {
|
||||
</DialogTitle>
|
||||
<label className="mt-3 mb-1 flex items-center system-sm-semibold text-text-secondary">{t('account.feedbackLabel', { ns: 'common' })}</label>
|
||||
<Textarea
|
||||
aria-label={t('account.feedbackLabel', { ns: 'common' }) as string}
|
||||
rows={6}
|
||||
value={userFeedback}
|
||||
placeholder={t('account.feedbackPlaceholder', { ns: 'common' }) as string}
|
||||
onValueChange={(value) => {
|
||||
setUserFeedback(value)
|
||||
onChange={(e) => {
|
||||
setUserFeedback(e.target.value)
|
||||
}}
|
||||
/>
|
||||
<div className="mt-3 flex w-full flex-col gap-2">
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
@ -33,9 +33,8 @@ const EditItem: FC<Props> = ({
|
||||
<div className="grow">
|
||||
<div className="mb-1 system-xs-semibold text-text-primary">{name}</div>
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
value={content}
|
||||
onValueChange={value => onChange(value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiDeleteBinLine, RiEditFill, RiEditLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Robot, User } from '@/app/components/base/icons/src/public/avatar'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export enum EditItemType {
|
||||
Query = 'query',
|
||||
@ -130,9 +130,8 @@ const EditItem: FC<Props> = ({
|
||||
<div className="mt-3">
|
||||
<EditTitle title={editTitle} />
|
||||
<Textarea
|
||||
aria-label={editTitle}
|
||||
value={newContent}
|
||||
onValueChange={value => setNewContent(value)}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setNewContent(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
@ -3,12 +3,12 @@ import type { VersionHistory } from '@/types/workflow'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { FieldControl, FieldLabel, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '../../base/textarea'
|
||||
|
||||
type VersionInfoModalProps = {
|
||||
isOpen: boolean
|
||||
@ -57,8 +57,8 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleDescriptionChange = useCallback((value: string) => {
|
||||
setReleaseNotes(value)
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setReleaseNotes(e.target.value)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
@ -95,16 +95,17 @@ const VersionInfoModal: FC<VersionInfoModalProps> = ({
|
||||
onValueChange={setTitle}
|
||||
/>
|
||||
</FieldRoot>
|
||||
<FieldRoot name="releaseNotes" invalid={releaseNotesError} className="gap-y-1">
|
||||
<FieldLabel className="flex h-6 items-center py-0 system-sm-semibold text-text-secondary">
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<div className="flex h-6 items-center system-sm-semibold text-text-secondary">
|
||||
{t('versionHistory.editField.releaseNotes', { ns: 'workflow' })}
|
||||
</FieldLabel>
|
||||
</div>
|
||||
<Textarea
|
||||
value={releaseNotes}
|
||||
placeholder={`${t('versionHistory.releaseNotesPlaceholder', { ns: 'workflow' })}${t('panel.optional', { ns: 'workflow' })}`}
|
||||
onValueChange={handleDescriptionChange}
|
||||
onChange={handleDescriptionChange}
|
||||
destructive={releaseNotesError}
|
||||
/>
|
||||
</FieldRoot>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end p-6 pt-5">
|
||||
<div className="flex items-center gap-x-3">
|
||||
|
||||
@ -13,12 +13,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { Trans } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import FileUploadSetting from '@/app/components/workflow/nodes/_base/components/file-upload-setting'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -121,9 +121,8 @@ const ConfigModalFormFields: FC<ConfigModalFormFieldsProps> = ({
|
||||
{type === InputVarType.paragraph && (
|
||||
<Field title={t('variableConfig.defaultValue', { ns: 'appDebug' })}>
|
||||
<Textarea
|
||||
aria-label={t('variableConfig.defaultValue', { ns: 'appDebug' })}
|
||||
value={String(tempPayload.default ?? '')}
|
||||
onValueChange={value => onPayloadChange('default')(value || undefined)}
|
||||
onChange={e => onPayloadChange('default')(e.target.value || undefined)}
|
||||
placeholder={t('variableConfig.inputPlaceholder', { ns: 'appDebug' })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@ -1,11 +1,11 @@
|
||||
'use client'
|
||||
import type { FC } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ArrowDownRoundFill } from '@/app/components/base/icons/src/vender/solid/general'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const i18nPrefix = 'generate'
|
||||
|
||||
@ -40,11 +40,10 @@ const IdeaOutput: FC<Props> = ({
|
||||
</div>
|
||||
{!isFoldIdeaOutput && (
|
||||
<Textarea
|
||||
aria-label={t(`${i18nPrefix}.idealOutput`, { ns: 'appDebug' })}
|
||||
className="h-[80px]"
|
||||
placeholder={t(`${i18nPrefix}.idealOutputPlaceholder`, { ns: 'appDebug' })}
|
||||
value={value}
|
||||
onValueChange={value => onChange(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -4,13 +4,13 @@ import type { DataSet } from '@/models/datasets'
|
||||
import type { RetrievalConfig } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { isEqual } from 'es-toolkit/predicate'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { isReRankModelSelected } from '@/app/components/datasets/common/check-rerank-model'
|
||||
import { IndexingType } from '@/app/components/datasets/create/step-two'
|
||||
import IndexMethod from '@/app/components/datasets/settings/index-method'
|
||||
@ -224,9 +224,8 @@ const SettingsModal: FC<SettingsModalProps> = ({
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Textarea
|
||||
aria-label={t('form.desc', { ns: 'datasetSettings' })}
|
||||
value={localeCurrentDataset.description || ''}
|
||||
onValueChange={value => handleValueChange('description', value)}
|
||||
onChange={e => handleValueChange('description', e.target.value)}
|
||||
className="resize-none"
|
||||
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
|
||||
/>
|
||||
|
||||
@ -84,6 +84,25 @@ vi.mock('@langgenius/dify-ui/select', async () => {
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, placeholder, readOnly, className }: {
|
||||
value: string
|
||||
onChange: (e: { target: { value: string } }) => void
|
||||
placeholder?: string
|
||||
readOnly?: boolean
|
||||
className?: string
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid={`textarea-${placeholder}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
placeholder={placeholder}
|
||||
readOnly={readOnly}
|
||||
className={className}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/workflow/nodes/_base/components/before-run-form/bool-input', () => ({
|
||||
default: ({ name, value, required, onChange, readonly }: {
|
||||
name: string
|
||||
@ -204,7 +223,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should render select input type', () => {
|
||||
@ -256,7 +275,7 @@ describe('ChatUserInput', () => {
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByTestId('input-Name')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toBeInTheDocument()
|
||||
expect(screen.getByTestId('textarea-Description')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('select-input')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
@ -315,7 +334,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{ desc: 'Long text here' }} />)
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveValue('Long text here')
|
||||
expect(screen.getByTestId('textarea-Description')).toHaveValue('Long text here')
|
||||
})
|
||||
|
||||
it('should display existing input values for number type', () => {
|
||||
@ -399,7 +418,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
fireEvent.change(screen.getByRole('textbox', { name: 'Description' }), { target: { value: 'New Description' } })
|
||||
fireEvent.change(screen.getByTestId('textarea-Description'), { target: { value: 'New Description' } })
|
||||
|
||||
expect(mockSetInputs).toHaveBeenCalledWith({ desc: 'New Description' })
|
||||
})
|
||||
@ -507,7 +526,7 @@ describe('ChatUserInput', () => {
|
||||
}))
|
||||
|
||||
render(<ChatUserInput inputs={{}} />)
|
||||
expect(screen.getByRole('textbox', { name: 'Description' })).toHaveAttribute('readonly')
|
||||
expect(screen.getByTestId('textarea-Description')).toHaveAttribute('readonly')
|
||||
})
|
||||
|
||||
it('should disable select when readonly is true', () => {
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
import type { Inputs } from '@/models/debug'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useEffect } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useContext } from 'use-context-selector'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
|
||||
@ -94,10 +94,9 @@ const ChatUserInput = ({
|
||||
{type === 'paragraph' && (
|
||||
<Textarea
|
||||
className="h-[120px] grow"
|
||||
aria-label={name || key}
|
||||
placeholder={name}
|
||||
value={inputs[key] ? `${inputs[key]}` : ''}
|
||||
onValueChange={(value) => { handleInputValueChange(key, value) }}
|
||||
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -5,7 +5,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import {
|
||||
RiArrowDownSLine,
|
||||
@ -20,6 +19,7 @@ import { useStore as useAppStore } from '@/app/components/app/store'
|
||||
import FeatureBar from '@/app/components/base/features/new-feature-panel/feature-bar'
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import ConfigContext from '@/context/debug-configuration'
|
||||
import { AppModeEnum, ModelModeType } from '@/types/app'
|
||||
@ -151,11 +151,10 @@ const PromptValuePanel: FC<IPromptValuePanelProps> = ({
|
||||
)}
|
||||
{type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
className="h-[120px] grow"
|
||||
placeholder={name}
|
||||
value={inputs[key] ? `${inputs[key]}` : ''}
|
||||
onValueChange={(value) => { handleInputValueChange(key, value) }}
|
||||
onChange={(e) => { handleInputValueChange(key, e.target.value) }}
|
||||
readOnly={readonly}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -4,7 +4,6 @@ import type { AppIconSelection } from '../../base/app-icon-picker'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiArrowRightLine, RiArrowRightSLine, RiExchange2Fill } from '@remixicon/react'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
@ -14,6 +13,7 @@ import AppIcon from '@/app/components/base/app-icon'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import { BubbleTextMod, ChatBot, ListSparkle, Logic } from '@/app/components/base/icons/src/vender/solid/communication'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { NEED_REFRESH_APP_LIST_KEY } from '@/config'
|
||||
import { useAppContext } from '@/context/app-context'
|
||||
@ -240,11 +240,10 @@ function CreateApp({ onClose, onSuccess, onCreateFromTemplate, defaultAppMode }:
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('newApp.captionDescription', { ns: 'app' })}
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onValueChange={value => setDescription(value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -8,7 +8,6 @@ import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import * as React from 'react'
|
||||
@ -19,6 +18,7 @@ import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { PremiumBadgeButton } from '@/app/components/base/premium-badge'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ACCOUNT_SETTING_TAB } from '@/app/components/header/account-setting/constants'
|
||||
import { useModalContext } from '@/context/modal-context'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
@ -289,10 +289,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className="relative">
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}</div>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.webDesc`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.desc}
|
||||
onValueChange={onDesChange}
|
||||
onChange={e => onDesChange(e.target.value)}
|
||||
placeholder={t(`${prefixSettings}.webDescPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.webDescTip`, { ns: 'appOverview' })}</p>
|
||||
@ -465,10 +464,9 @@ const SettingsModal: FC<ISettingsModalProps> = ({
|
||||
<div className={cn('py-1 system-sm-semibold text-text-secondary')}>{t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}</div>
|
||||
<p className={cn('pb-0.5 body-xs-regular text-text-tertiary')}>{t(`${prefixSettings}.more.customDisclaimerTip`, { ns: 'appOverview' })}</p>
|
||||
<Textarea
|
||||
aria-label={t(`${prefixSettings}.more.customDisclaimer`, { ns: 'appOverview' })}
|
||||
className="mt-1"
|
||||
value={inputInfo.customDisclaimer}
|
||||
onValueChange={value => setInputInfo(item => ({ ...item, customDisclaimer: value }))}
|
||||
onChange={onChange('customDisclaimer')}
|
||||
placeholder={t(`${prefixSettings}.more.customDisclaimerPlaceholder`, { ns: 'appOverview' }) as string}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -7,8 +7,8 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
@ -74,7 +74,7 @@ const WorkflowHiddenInputFields = ({
|
||||
<Textarea
|
||||
id={fieldId}
|
||||
value={typeof fieldValue === 'string' ? fieldValue : ''}
|
||||
onValueChange={value => onValueChange(variable.variable, value)}
|
||||
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => onValueChange(variable.variable, event.target.value)}
|
||||
placeholder={label}
|
||||
maxLength={variable.max_length}
|
||||
className="min-h-24"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
aria-label={form.label}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
import type { ContentItemProps } from './type'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const ContentItem = ({
|
||||
content,
|
||||
@ -42,10 +42,9 @@ const ContentItem = ({
|
||||
<div className="py-3">
|
||||
{formInputField.type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={fieldName}
|
||||
className="h-[104px] sm:text-xs"
|
||||
value={inputs[fieldName]!}
|
||||
onValueChange={(value) => { onInputChange(fieldName, value) }}
|
||||
onChange={(e) => { onInputChange(fieldName, e.target.value) }}
|
||||
data-testid="content-item-textarea"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -6,7 +6,6 @@ import type {
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogDescription, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import copy from 'copy-to-clipboard'
|
||||
@ -22,6 +21,7 @@ import ActionButton, { ActionButtonState } from '@/app/components/base/action-bu
|
||||
import Log from '@/app/components/base/chat/chat/log'
|
||||
import AnnotationCtrlButton from '@/app/components/base/features/new-feature-panel/annotation-reply/annotation-ctrl-button'
|
||||
import NewAudioButton from '@/app/components/base/new-audio-button'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useChatContext } from '../context'
|
||||
|
||||
type OperationProps = {
|
||||
@ -394,7 +394,7 @@ function Operation({
|
||||
id={feedbackTextareaId}
|
||||
name="feedback-content"
|
||||
value={feedbackContent}
|
||||
onValueChange={value => setFeedbackContent(value)}
|
||||
onChange={e => setFeedbackContent(e.target.value)}
|
||||
placeholder={t('feedback.placeholder', { ns: 'common' }) || 'Please describe what went wrong or how we can improve…'}
|
||||
rows={4}
|
||||
className="w-full"
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { memo, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -71,9 +71,8 @@ const InputsFormContent = ({ showTip }: Props) => {
|
||||
)}
|
||||
{form.type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
aria-label={form.label}
|
||||
value={inputsFormValue?.[form.variable] || ''}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
placeholder={form.label}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -12,10 +12,10 @@ import { FieldItem, FieldRoot } from '@langgenius/dify-ui/field'
|
||||
import { FieldsetLegend, FieldsetRoot } from '@langgenius/dify-ui/fieldset'
|
||||
import { RadioControl, RadioRoot } from '@langgenius/dify-ui/radio'
|
||||
import { RadioGroup } from '@langgenius/dify-ui/radio-group'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { produce } from 'immer'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelListAndDefaultModelAndCurrentProviderAndModel } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
@ -220,10 +220,9 @@ const FollowUpSettingModal = ({
|
||||
</div>
|
||||
{promptMode === PROMPT_MODE.custom && (
|
||||
<Textarea
|
||||
aria-label={t('feature.suggestedQuestionsAfterAnswer.modal.customPromptOption', { ns: 'appDebug' })}
|
||||
className="mt-3 min-h-32 resize-y border-components-input-border-active bg-components-input-bg-normal"
|
||||
value={prompt}
|
||||
onValueChange={value => setPrompt(value)}
|
||||
onChange={e => setPrompt(e.target.value)}
|
||||
maxLength={CUSTOM_FOLLOW_UP_PROMPT_MAX_LENGTH}
|
||||
placeholder={t('feature.suggestedQuestionsAfterAnswer.modal.promptPlaceholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
|
||||
@ -2,7 +2,7 @@ import type { FC } from 'react'
|
||||
import type { CodeBasedExtensionForm } from '@/models/common'
|
||||
import type { ModerationConfig } from '@/models/debug'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useLocale } from '@/context/i18n'
|
||||
|
||||
type FormGenerationProps = {
|
||||
@ -55,11 +55,10 @@ const FormGeneration: FC<FormGenerationProps> = ({
|
||||
form.type === 'paragraph' && (
|
||||
<div className="relative">
|
||||
<Textarea
|
||||
aria-label={locale === 'zh-Hans' ? form.label['zh-Hans'] : form.label['en-US']}
|
||||
className="resize-none"
|
||||
value={value?.[form.variable] || ''}
|
||||
placeholder={form.placeholder}
|
||||
onValueChange={value => handleFormChange(form.variable, value)}
|
||||
onChange={e => handleFormChange(form.variable, e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ModerationContentConfig } from '@/models/debug'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
type ModerationContentProps = {
|
||||
@ -51,14 +50,12 @@ const ModerationContent: FC<ModerationContentProps> = ({
|
||||
{t('feature.moderation.modal.content.preset', { ns: 'appDebug' })}
|
||||
<span className="text-xs font-normal text-text-tertiary">{t('feature.moderation.modal.content.supportMarkdown', { ns: 'appDebug' })}</span>
|
||||
</div>
|
||||
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
|
||||
<div className="relative h-20">
|
||||
<Textarea
|
||||
aria-label={t('feature.moderation.modal.content.preset', { ns: 'appDebug' }) as string}
|
||||
<div className="relative h-20 rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<textarea
|
||||
value={config.preset_response || ''}
|
||||
className="size-full resize-none pb-8"
|
||||
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
|
||||
placeholder={t('feature.moderation.modal.content.placeholder', { ns: 'appDebug' }) || ''}
|
||||
onValueChange={value => handleConfigChange('preset_response', value)}
|
||||
onChange={e => handleConfigChange('preset_response', e.target.value)}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
|
||||
<span>{(config.preset_response || '').length}</span>
|
||||
|
||||
@ -1,10 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ChangeEvent, FC } from 'react'
|
||||
import type { CodeBasedExtensionItem } from '@/models/common'
|
||||
import type { ModerationConfig, ModerationContentConfig } from '@/models/debug'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@ -104,7 +103,9 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
})
|
||||
}
|
||||
|
||||
const handleDataKeywordsChange = (value: string) => {
|
||||
const handleDataKeywordsChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = e.target.value
|
||||
|
||||
const arr = value.split('\n').reduce((prev: string[], next: string) => {
|
||||
if (next !== '')
|
||||
prev.push(next.slice(0, 100))
|
||||
@ -291,13 +292,11 @@ const ModerationSettingModal: FC<ModerationSettingModalProps> = ({
|
||||
<div className="py-2">
|
||||
<div className="mb-1 text-sm font-medium text-text-primary">{t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' })}</div>
|
||||
<div className="mb-2 text-xs text-text-tertiary">{t('feature.moderation.modal.keywords.tip', { ns: 'appDebug' })}</div>
|
||||
{/* Keep this counter composed locally; extract only if more textarea counter cases repeat. */}
|
||||
<div className="relative h-[88px]">
|
||||
<Textarea
|
||||
aria-label={t('feature.moderation.modal.provider.keywords', { ns: 'appDebug' }) as string}
|
||||
<div className="relative h-[88px] rounded-lg bg-components-input-bg-normal px-3 py-2">
|
||||
<textarea
|
||||
value={localeData.config?.keywords || ''}
|
||||
onValueChange={handleDataKeywordsChange}
|
||||
className="size-full resize-none pb-8"
|
||||
onChange={handleDataKeywordsChange}
|
||||
className="block size-full resize-none appearance-none bg-transparent text-sm text-text-secondary outline-hidden"
|
||||
placeholder={t('feature.moderation.modal.keywords.placeholder', { ns: 'appDebug' }) || ''}
|
||||
/>
|
||||
<div className="absolute right-2 bottom-2 flex h-5 items-center rounded-md bg-background-section px-1 text-xs font-medium text-text-quaternary">
|
||||
|
||||
@ -1,16 +1,16 @@
|
||||
import type { TextareaProps } from '@langgenius/dify-ui/textarea'
|
||||
import type { TextareaProps } from '../../../textarea'
|
||||
import type { LabelProps } from '../label'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useFieldContext } from '../..'
|
||||
import Textarea from '../../../textarea'
|
||||
import Label from '../label'
|
||||
|
||||
type TextAreaFieldProps = {
|
||||
label: string
|
||||
labelOptions?: Omit<LabelProps, 'htmlFor' | 'label'>
|
||||
className?: string
|
||||
} & Omit<TextareaProps, 'className' | 'defaultValue' | 'onBlur' | 'value' | 'id'>
|
||||
} & Omit<TextareaProps, 'className' | 'onChange' | 'onBlur' | 'value' | 'id'>
|
||||
|
||||
const TextAreaField = ({
|
||||
label,
|
||||
@ -30,7 +30,7 @@ const TextAreaField = ({
|
||||
<Textarea
|
||||
id={field.name}
|
||||
value={field.state.value}
|
||||
onValueChange={value => field.handleChange(value)}
|
||||
onChange={e => field.handleChange(e.target.value)}
|
||||
onBlur={field.handleBlur}
|
||||
{...inputProps}
|
||||
/>
|
||||
|
||||
@ -3,7 +3,6 @@ import type { Dayjs } from 'dayjs'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Checkbox } from '@langgenius/dify-ui/checkbox'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger, SelectValue } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useChatContext } from '@/app/components/base/chat/chat/context'
|
||||
@ -11,6 +10,7 @@ import DatePicker from '@/app/components/base/date-and-time-picker/date-picker'
|
||||
import TimePicker from '@/app/components/base/date-and-time-picker/time-picker'
|
||||
import { formatDateForOutput, toDayjs } from '@/app/components/base/date-and-time-picker/utils/dayjs'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
const DATA_FORMAT = {
|
||||
TEXT: 'text',
|
||||
@ -372,12 +372,11 @@ const MarkdownForm = ({ node }: { node: HastElement }) => {
|
||||
return null
|
||||
return (
|
||||
<Textarea
|
||||
aria-label={name}
|
||||
key={key}
|
||||
name={name}
|
||||
placeholder={str(child.properties.placeholder)}
|
||||
value={str(formValues[name])}
|
||||
onValueChange={value => updateValue(name, value)}
|
||||
onChange={e => updateValue(name, e.target.value)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@ -2,12 +2,12 @@
|
||||
import type { FC } from 'react'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Trans, useTranslation } from 'react-i18next'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { VarType } from '@/app/components/workflow/types'
|
||||
import Textarea from '../../../textarea'
|
||||
import TagLabel from './tag-label'
|
||||
import TypeSwitch from './type-switch'
|
||||
|
||||
@ -72,7 +72,6 @@ const PrePopulate: FC<Props> = ({
|
||||
value,
|
||||
onValueChange,
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
const [onPlaceholderClicked, setOnPlaceholderClicked] = useState(false)
|
||||
const handleTypeChange = useCallback((isVar: boolean) => {
|
||||
setOnPlaceholderClicked(true)
|
||||
@ -128,10 +127,9 @@ const PrePopulate: FC<Props> = ({
|
||||
return (
|
||||
<div className={cn('relative min-h-[80px] rounded-lg border border-transparent bg-components-input-bg-normal pb-1', isFocus && 'border-components-input-border-active bg-components-input-bg-active shadow-xs')}>
|
||||
<Textarea
|
||||
aria-label={t(`${i18nPrefix}.staticContent`, { ns: 'workflow' })}
|
||||
value={value || ''}
|
||||
className="h-[43px] min-h-[43px] rounded-none border-none bg-transparent px-3 hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
onValueChange={value => onValueChange?.(value)}
|
||||
onChange={e => onValueChange?.(e.target.value)}
|
||||
onFocus={() => {
|
||||
setOnPlaceholderClicked(true)
|
||||
setIsFocus(true)
|
||||
|
||||
77
web/app/components/base/textarea/__tests__/index.spec.tsx
Normal file
77
web/app/components/base/textarea/__tests__/index.spec.tsx
Normal file
@ -0,0 +1,77 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import TextArea from '../index'
|
||||
|
||||
describe('TextArea', () => {
|
||||
it('should render correctly with default props', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeInTheDocument()
|
||||
expect(textarea).toHaveValue('')
|
||||
})
|
||||
|
||||
it('should handle value and onChange correctly', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleChange = vi.fn()
|
||||
const { rerender } = render(<TextArea value="initial" onChange={handleChange} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveValue('initial')
|
||||
|
||||
await user.type(textarea, ' updated')
|
||||
expect(handleChange).toHaveBeenCalled()
|
||||
|
||||
rerender(<TextArea value="initial updated" onChange={handleChange} />)
|
||||
expect(textarea).toHaveValue('initial updated')
|
||||
})
|
||||
|
||||
it('should handle autoFocus correctly', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} autoFocus />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toHaveFocus()
|
||||
})
|
||||
|
||||
it('should handle disabled state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} disabled />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
expect(textarea).toBeDisabled()
|
||||
expect(textarea).toHaveClass('cursor-not-allowed')
|
||||
})
|
||||
|
||||
it('should handle placeholder', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} placeholder="Enter text here" />)
|
||||
expect(screen.getByPlaceholderText('Enter text here')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should handle className', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} className="custom-class" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('custom-class')
|
||||
})
|
||||
|
||||
it('should handle size variants', () => {
|
||||
const { rerender } = render(<TextArea value="" onChange={vi.fn()} size="small" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('py-1')
|
||||
|
||||
rerender(<TextArea value="" onChange={vi.fn()} size="large" />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('px-4')
|
||||
})
|
||||
|
||||
it('should handle destructive state', () => {
|
||||
render(<TextArea value="" onChange={vi.fn()} destructive />)
|
||||
expect(screen.getByTestId('text-area')).toHaveClass('border-components-input-border-destructive')
|
||||
})
|
||||
|
||||
it('should handle onFocus and onBlur', async () => {
|
||||
const user = userEvent.setup()
|
||||
const handleFocus = vi.fn()
|
||||
const handleBlur = vi.fn()
|
||||
render(<TextArea value="" onChange={vi.fn()} onFocus={handleFocus} onBlur={handleBlur} />)
|
||||
const textarea = screen.getByTestId('text-area')
|
||||
|
||||
await user.click(textarea)
|
||||
expect(handleFocus).toHaveBeenCalled()
|
||||
|
||||
await user.tab()
|
||||
expect(handleBlur).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
562
web/app/components/base/textarea/index.stories.tsx
Normal file
562
web/app/components/base/textarea/index.stories.tsx
Normal file
@ -0,0 +1,562 @@
|
||||
import type { Meta, StoryObj } from '@storybook/nextjs-vite'
|
||||
import { useState } from 'react'
|
||||
import Textarea from '.'
|
||||
|
||||
const meta = {
|
||||
title: 'Base/Data Entry/Textarea',
|
||||
component: Textarea,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
docs: {
|
||||
description: {
|
||||
component: 'Textarea component with multiple sizes (small, regular, large). Built with class-variance-authority for consistent styling.',
|
||||
},
|
||||
},
|
||||
},
|
||||
tags: ['autodocs'],
|
||||
argTypes: {
|
||||
size: {
|
||||
control: 'select',
|
||||
options: ['small', 'regular', 'large'],
|
||||
description: 'Textarea size',
|
||||
},
|
||||
value: {
|
||||
control: 'text',
|
||||
description: 'Textarea value',
|
||||
},
|
||||
placeholder: {
|
||||
control: 'text',
|
||||
description: 'Placeholder text',
|
||||
},
|
||||
disabled: {
|
||||
control: 'boolean',
|
||||
description: 'Disabled state',
|
||||
},
|
||||
destructive: {
|
||||
control: 'boolean',
|
||||
description: 'Error/destructive state',
|
||||
},
|
||||
rows: {
|
||||
control: 'number',
|
||||
description: 'Number of visible text rows',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Textarea>
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Interactive demo wrapper
|
||||
const TextareaDemo = (args: any) => {
|
||||
const [value, setValue] = useState(args.value || '')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }}>
|
||||
<Textarea
|
||||
{...args}
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value)
|
||||
console.log('Textarea changed:', e.target.value)
|
||||
}}
|
||||
/>
|
||||
{value && (
|
||||
<div className="mt-3 text-sm text-gray-600">
|
||||
Character count:
|
||||
{' '}
|
||||
<span className="font-semibold">{value.length}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Default state
|
||||
export const Default: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Small size
|
||||
export const SmallSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'small',
|
||||
placeholder: 'Small textarea...',
|
||||
rows: 3,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// Large size
|
||||
export const LargeSize: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'large',
|
||||
placeholder: 'Large textarea...',
|
||||
rows: 5,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
|
||||
// With initial value
|
||||
export const WithInitialValue: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This is some initial text content.\n\nIt spans multiple lines.',
|
||||
rows: 4,
|
||||
},
|
||||
}
|
||||
|
||||
// Disabled state
|
||||
export const Disabled: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This textarea is disabled and cannot be edited.',
|
||||
disabled: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Destructive/error state
|
||||
export const DestructiveState: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
value: 'This content has an error.',
|
||||
destructive: true,
|
||||
rows: 3,
|
||||
},
|
||||
}
|
||||
|
||||
// Size comparison
|
||||
const SizeComparisonDemo = () => {
|
||||
const [small, setSmall] = useState('')
|
||||
const [regular, setRegular] = useState('')
|
||||
const [large, setLarge] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Small</label>
|
||||
<Textarea
|
||||
size="small"
|
||||
value={small}
|
||||
onChange={e => setSmall(e.target.value)}
|
||||
placeholder="Small textarea..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Regular</label>
|
||||
<Textarea
|
||||
size="regular"
|
||||
value={regular}
|
||||
onChange={e => setRegular(e.target.value)}
|
||||
placeholder="Regular textarea..."
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-xs font-medium text-gray-600">Large</label>
|
||||
<Textarea
|
||||
size="large"
|
||||
value={large}
|
||||
onChange={e => setLarge(e.target.value)}
|
||||
placeholder="Large textarea..."
|
||||
rows={5}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const SizeComparison: Story = {
|
||||
render: () => <SizeComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// State comparison
|
||||
const StateComparisonDemo = () => {
|
||||
const [normal, setNormal] = useState('Normal state')
|
||||
const [error, setError] = useState('Error state')
|
||||
|
||||
return (
|
||||
<div style={{ width: '500px' }} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Normal</label>
|
||||
<Textarea
|
||||
value={normal}
|
||||
onChange={e => setNormal(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Destructive</label>
|
||||
<Textarea
|
||||
value={error}
|
||||
onChange={e => setError(e.target.value)}
|
||||
destructive
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Disabled</label>
|
||||
<Textarea
|
||||
value="Disabled state"
|
||||
onChange={() => undefined}
|
||||
disabled
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const StateComparison: Story = {
|
||||
render: () => <StateComparisonDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Comment form
|
||||
const CommentFormDemo = () => {
|
||||
const [comment, setComment] = useState('')
|
||||
const maxLength = 500
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Leave a Comment</h3>
|
||||
<Textarea
|
||||
value={comment}
|
||||
onChange={e => setComment(e.target.value)}
|
||||
placeholder="Share your thoughts..."
|
||||
rows={5}
|
||||
maxLength={maxLength}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<span className="text-xs text-gray-500">
|
||||
{comment.length}
|
||||
{' '}
|
||||
/
|
||||
{maxLength}
|
||||
{' '}
|
||||
characters
|
||||
</span>
|
||||
<button
|
||||
className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={comment.trim().length === 0}
|
||||
>
|
||||
Post Comment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CommentForm: Story = {
|
||||
render: () => <CommentFormDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Feedback form
|
||||
const FeedbackFormDemo = () => {
|
||||
const [feedback, setFeedback] = useState('')
|
||||
const [email, setEmail] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-2 text-lg font-semibold">Send Feedback</h3>
|
||||
<p className="mb-4 text-sm text-gray-600">Help us improve our product</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Email</label>
|
||||
<input
|
||||
type="email"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={email}
|
||||
onChange={e => setEmail(e.target.value)}
|
||||
placeholder="email@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Your Feedback</label>
|
||||
<Textarea
|
||||
value={feedback}
|
||||
onChange={e => setFeedback(e.target.value)}
|
||||
placeholder="Tell us what you think..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">
|
||||
Submit Feedback
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const FeedbackForm: Story = {
|
||||
render: () => <FeedbackFormDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Code snippet
|
||||
const CodeSnippetDemo = () => {
|
||||
const [code, setCode] = useState(`function hello() {
|
||||
console.log("Hello, world!");
|
||||
}`)
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Code Editor</h3>
|
||||
<Textarea
|
||||
value={code}
|
||||
onChange={e => setCode(e.target.value)}
|
||||
className="font-mono"
|
||||
rows={8}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Run Code
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const CodeSnippet: Story = {
|
||||
render: () => <CodeSnippetDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Message composer
|
||||
const MessageComposerDemo = () => {
|
||||
const [message, setMessage] = useState('')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Compose Message</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">To</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Recipient name"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Subject</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
placeholder="Message subject"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Message</label>
|
||||
<Textarea
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
placeholder="Type your message here..."
|
||||
rows={8}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Send Message
|
||||
</button>
|
||||
<button className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300">
|
||||
Save Draft
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const MessageComposer: Story = {
|
||||
render: () => <MessageComposerDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Bio editor
|
||||
const BioEditorDemo = () => {
|
||||
const [bio, setBio] = useState('Software developer passionate about building great products.')
|
||||
const maxLength = 200
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Edit Your Bio</h3>
|
||||
<Textarea
|
||||
value={bio}
|
||||
onChange={e => setBio(e.target.value.slice(0, maxLength))}
|
||||
placeholder="Tell us about yourself..."
|
||||
rows={4}
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between text-xs">
|
||||
<span className={bio.length > maxLength * 0.9 ? 'text-orange-600' : 'text-gray-500'}>
|
||||
{bio.length}
|
||||
{' '}
|
||||
/
|
||||
{maxLength}
|
||||
{' '}
|
||||
characters
|
||||
</span>
|
||||
{bio.length > maxLength * 0.9 && (
|
||||
<span className="text-orange-600">
|
||||
{maxLength - bio.length}
|
||||
{' '}
|
||||
characters remaining
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 rounded-lg bg-gray-50 p-4">
|
||||
<div className="mb-2 text-xs font-medium text-gray-600">Preview:</div>
|
||||
<p className="text-sm text-gray-800">{bio || 'Your bio will appear here...'}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const BioEditor: Story = {
|
||||
render: () => <BioEditorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - JSON editor
|
||||
const JSONEditorDemo = () => {
|
||||
const [json, setJson] = useState(`{
|
||||
"name": "John Doe",
|
||||
"age": 30,
|
||||
"email": "john@example.com"
|
||||
}`)
|
||||
const [isValid, setIsValid] = useState(true)
|
||||
|
||||
const validateJSON = (value: string) => {
|
||||
try {
|
||||
JSON.parse(value)
|
||||
setIsValid(true)
|
||||
}
|
||||
catch {
|
||||
setIsValid(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold">JSON Editor</h3>
|
||||
<span className={`rounded-sm px-2 py-1 text-xs ${isValid ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{isValid ? '✓ Valid' : '✗ Invalid'}
|
||||
</span>
|
||||
</div>
|
||||
<Textarea
|
||||
value={json}
|
||||
onChange={(e) => {
|
||||
setJson(e.target.value)
|
||||
validateJSON(e.target.value)
|
||||
}}
|
||||
className="font-mono"
|
||||
destructive={!isValid}
|
||||
rows={10}
|
||||
/>
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button className="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50" disabled={!isValid}>
|
||||
Save JSON
|
||||
</button>
|
||||
<button
|
||||
className="rounded-lg bg-gray-200 px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-300"
|
||||
onClick={() => {
|
||||
try {
|
||||
const formatted = JSON.stringify(JSON.parse(json), null, 2)
|
||||
setJson(formatted)
|
||||
}
|
||||
catch {
|
||||
// Invalid JSON, do nothing
|
||||
}
|
||||
}}
|
||||
>
|
||||
Format
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const JSONEditor: Story = {
|
||||
render: () => <JSONEditorDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Real-world example - Task description
|
||||
const TaskDescriptionDemo = () => {
|
||||
const [title, setTitle] = useState('Implement user authentication')
|
||||
const [description, setDescription] = useState('Add login and registration functionality with JWT tokens.')
|
||||
|
||||
return (
|
||||
<div style={{ width: '600px' }} className="rounded-lg border border-gray-200 bg-white p-6">
|
||||
<h3 className="mb-4 text-lg font-semibold">Create New Task</h3>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Task Title</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Description</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
placeholder="Describe the task in detail..."
|
||||
rows={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-2 block text-sm font-medium text-gray-700">Priority</label>
|
||||
<select className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm">
|
||||
<option>Low</option>
|
||||
<option>Medium</option>
|
||||
<option>High</option>
|
||||
<option>Urgent</option>
|
||||
</select>
|
||||
</div>
|
||||
<button className="w-full rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700">
|
||||
Create Task
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const TaskDescription: Story = {
|
||||
render: () => <TaskDescriptionDemo />,
|
||||
parameters: { controls: { disable: true } },
|
||||
} as unknown as Story
|
||||
|
||||
// Interactive playground
|
||||
export const Playground: Story = {
|
||||
render: args => <TextareaDemo {...args} />,
|
||||
args: {
|
||||
size: 'regular',
|
||||
placeholder: 'Enter text...',
|
||||
rows: 4,
|
||||
disabled: false,
|
||||
destructive: false,
|
||||
value: '',
|
||||
},
|
||||
}
|
||||
60
web/app/components/base/textarea/index.tsx
Normal file
60
web/app/components/base/textarea/index.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import type { VariantProps } from 'class-variance-authority'
|
||||
import type { CSSProperties } from 'react'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { cva } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
const textareaVariants = cva(
|
||||
'',
|
||||
{
|
||||
variants: {
|
||||
size: {
|
||||
small: 'rounded-md py-1 system-xs-regular',
|
||||
regular: 'rounded-md px-3 system-sm-regular',
|
||||
large: 'rounded-lg px-4 system-md-regular',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'regular',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export type TextareaProps = {
|
||||
value: string | number
|
||||
disabled?: boolean
|
||||
destructive?: boolean
|
||||
styleCss?: CSSProperties
|
||||
ref?: React.Ref<HTMLTextAreaElement>
|
||||
onFocus?: React.FocusEventHandler<HTMLTextAreaElement>
|
||||
onBlur?: React.FocusEventHandler<HTMLTextAreaElement>
|
||||
} & React.TextareaHTMLAttributes<HTMLTextAreaElement> & VariantProps<typeof textareaVariants>
|
||||
|
||||
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
|
||||
({ className, value, onChange, disabled, size, destructive, styleCss, onFocus, onBlur, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
ref={ref}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
style={styleCss}
|
||||
className={cn(
|
||||
'min-h-20 w-full appearance-none border border-transparent bg-components-input-bg-normal p-2 text-components-input-text-filled caret-primary-600 outline-hidden placeholder:text-components-input-text-placeholder hover:border-components-input-border-hover hover:bg-components-input-bg-hover focus:border-components-input-border-active focus:bg-components-input-bg-active focus:shadow-xs',
|
||||
textareaVariants({ size }),
|
||||
disabled && 'cursor-not-allowed border-transparent bg-components-input-bg-disabled text-components-input-text-filled-disabled hover:border-transparent hover:bg-components-input-bg-disabled',
|
||||
destructive && 'border-components-input-border-destructive bg-components-input-bg-destructive text-components-input-text-filled hover:border-components-input-border-destructive hover:bg-components-input-bg-destructive focus:border-components-input-border-destructive focus:bg-components-input-bg-destructive',
|
||||
className,
|
||||
)}
|
||||
value={value ?? ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
data-testid="text-area"
|
||||
{...props}
|
||||
>
|
||||
</textarea>
|
||||
)
|
||||
},
|
||||
)
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export default Textarea
|
||||
@ -25,7 +25,6 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
const total = plan.total.buildApps
|
||||
const percent = total > 0 ? (usage / total) * 100 : 0
|
||||
const tone: MeterTone = percent >= 80 ? 'error' : percent >= 50 ? 'warning' : 'neutral'
|
||||
const buildAppsLabel = t('usagePage.buildApps', { ns: 'billing' })
|
||||
return (
|
||||
<div className={cn(
|
||||
'flex flex-col gap-3 rounded-xl border-[0.5px] border-components-panel-border-subtle bg-components-panel-on-panel-item-bg p-4 shadow-xs backdrop-blur-xs',
|
||||
@ -62,14 +61,14 @@ const AppsFull: FC<{ loc: string, className?: string }> = ({
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between system-xs-medium text-text-secondary">
|
||||
<div>{buildAppsLabel}</div>
|
||||
<div>{t('usagePage.buildApps', { ns: 'billing' })}</div>
|
||||
<div>
|
||||
{usage}
|
||||
/
|
||||
{total}
|
||||
</div>
|
||||
</div>
|
||||
<MeterRoot value={Math.min(percent, 100)} max={100} aria-label={buildAppsLabel}>
|
||||
<MeterRoot value={Math.min(percent, 100)} max={100}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
|
||||
@ -229,7 +229,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
@ -270,7 +270,7 @@ describe('UsageInfo', () => {
|
||||
/>,
|
||||
)
|
||||
|
||||
expect(screen.getByRole('meter', { name: 'Storage' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('meter')).toBeInTheDocument()
|
||||
expect(container.querySelector('[aria-hidden="true"]')).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@ -144,13 +144,13 @@ const UsageInfo: FC<Props> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'h-1 rounded-md bg-progress-bar-indeterminate-stripe',
|
||||
isSandboxPlan ? 'w-full' : 'w-7.5',
|
||||
isSandboxPlan ? 'w-full' : 'w-[30px]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<MeterRoot value={effectivePercent} max={100} aria-label={name}>
|
||||
<MeterRoot value={effectivePercent} max={100}>
|
||||
<MeterTrack>
|
||||
<MeterIndicator tone={tone} />
|
||||
</MeterTrack>
|
||||
@ -162,7 +162,7 @@ const UsageInfo: FC<Props> = ({
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={<div className="cursor-default">{children}</div>} />
|
||||
<TooltipContent className="w-50 max-w-50">
|
||||
<TooltipContent className="w-[200px] max-w-[200px]">
|
||||
{storageTooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { PipelineTemplate } from '@/models/pipeline'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
@ -10,6 +9,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useInvalidCustomizedTemplateList, useUpdateTemplateInfo } from '@/service/use-pipeline'
|
||||
|
||||
type EditPipelineInfoProps = {
|
||||
@ -57,7 +57,8 @@ const EditPipelineInfo = ({
|
||||
setShowAppIconPicker(false)
|
||||
}, [])
|
||||
|
||||
const handleDescriptionChange = useCallback((value: string) => {
|
||||
const handleDescriptionChange = useCallback((event: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const value = event.target.value
|
||||
setDescription(value)
|
||||
}, [])
|
||||
|
||||
@ -132,8 +133,7 @@ const EditPipelineInfo = ({
|
||||
{t('knowledgeDescription', { ns: 'datasetPipeline' })}
|
||||
</label>
|
||||
<Textarea
|
||||
aria-label={t('knowledgeDescription', { ns: 'datasetPipeline' })}
|
||||
onValueChange={handleDescriptionChange}
|
||||
onChange={handleDescriptionChange}
|
||||
value={description}
|
||||
placeholder={t('knowledgeDescriptionPlaceholder', { ns: 'datasetPipeline' })}
|
||||
/>
|
||||
|
||||
@ -5,12 +5,12 @@ import type { DataSet } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { updateDatasetSetting } from '@/service/datasets'
|
||||
import AppIcon from '../../base/app-icon'
|
||||
import AppIconPicker from '../../base/app-icon-picker'
|
||||
@ -117,7 +117,7 @@ const RenameDatasetModal = ({ show, dataset, onSuccess, onClose }: RenameDataset
|
||||
{t('form.desc', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Textarea aria-label={t('form.desc', { ns: 'datasetSettings' })} value={description} onValueChange={value => setDescription(value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
|
||||
<Textarea value={description} onChange={e => setDescription(e.target.value)} className="resize-none" placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -3,11 +3,11 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { Member } from '@/models/common'
|
||||
import type { DataSet, DatasetPermission, IconInfo } from '@/models/datasets'
|
||||
import type { AppIconType } from '@/types/app'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import PermissionSelector from '../../permission-selector'
|
||||
|
||||
const rowClass = 'flex gap-x-1'
|
||||
@ -85,12 +85,11 @@ const BasicInfoSection = ({
|
||||
</div>
|
||||
<div className="grow">
|
||||
<Textarea
|
||||
aria-label={t('form.desc', { ns: 'datasetSettings' })}
|
||||
disabled={!currentDataset?.embedding_available}
|
||||
className="resize-none"
|
||||
placeholder={t('form.descPlaceholder', { ns: 'datasetSettings' }) || ''}
|
||||
value={description}
|
||||
onValueChange={value => setDescription(value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import type { ChangeEvent } from 'react'
|
||||
import type { DefaultModel } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { SummaryIndexSetting as SummaryIndexSettingType } from '@/models/datasets'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
@ -9,6 +9,7 @@ import {
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ModelTypeEnum } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import { useModelList } from '@/app/components/header/account-setting/model-provider-page/hooks'
|
||||
import ModelSelector from '@/app/components/header/account-setting/model-provider-page/model-selector'
|
||||
@ -52,9 +53,9 @@ const SummaryIndexSetting = ({
|
||||
})
|
||||
}, [onSummaryIndexSettingChange])
|
||||
|
||||
const handleSummaryIndexPromptChange = useCallback((value: string) => {
|
||||
const handleSummaryIndexPromptChange = useCallback((e: ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onSummaryIndexSettingChange?.({
|
||||
summary_prompt: value,
|
||||
summary_prompt: e.target.value,
|
||||
})
|
||||
}, [onSummaryIndexSettingChange])
|
||||
|
||||
@ -94,9 +95,8 @@ const SummaryIndexSetting = ({
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
@ -166,9 +166,8 @@ const SummaryIndexSetting = ({
|
||||
</div>
|
||||
<div className="grow">
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
@ -215,9 +214,8 @@ const SummaryIndexSetting = ({
|
||||
{t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('form.summaryInstructions', { ns: 'datasetSettings' })}
|
||||
value={summaryIndexSetting?.summary_prompt ?? ''}
|
||||
onValueChange={handleSummaryIndexPromptChange}
|
||||
onChange={handleSummaryIndexPromptChange}
|
||||
disabled={readonly}
|
||||
placeholder={t('form.summaryInstructionsPlaceholder', { ns: 'datasetSettings' })}
|
||||
/>
|
||||
|
||||
@ -3,7 +3,6 @@ import type { AppIconType } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogCloseButton, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useDebounceFn, useKeyPress } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@ -11,6 +10,7 @@ import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import AppsFull from '@/app/components/billing/apps-full-in-dialog'
|
||||
import { useProviderContext } from '@/context/provider-context'
|
||||
import { AppModeEnum } from '@/types/app'
|
||||
@ -145,11 +145,10 @@ const CreateAppModal = ({
|
||||
<div className="pt-2">
|
||||
<div className="py-2 text-sm leading-[20px] font-medium text-text-primary">{t('newApp.captionDescription', { ns: 'app' })}</div>
|
||||
<Textarea
|
||||
aria-label={t('newApp.captionDescription', { ns: 'app' })}
|
||||
className="resize-none"
|
||||
placeholder={t('newApp.appDescriptionPlaceholder', { ns: 'app' }) || ''}
|
||||
value={description}
|
||||
onValueChange={value => setDescription(value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* answer icon */}
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { InputVarType } from '@/app/components/workflow/types'
|
||||
|
||||
type Props = {
|
||||
@ -55,9 +55,8 @@ const AppInputsForm = ({
|
||||
if (form.type === InputVarType.paragraph) {
|
||||
return (
|
||||
<Textarea
|
||||
aria-label={label}
|
||||
value={inputs[variable] || ''}
|
||||
onValueChange={value => handleFormChange(variable, value)}
|
||||
onChange={e => handleFormChange(variable, e.target.value)}
|
||||
placeholder={label}
|
||||
/>
|
||||
)
|
||||
|
||||
@ -523,7 +523,9 @@ describe('useToolSelectorState Hook', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange('new description')
|
||||
result.current.handleDescriptionChange({
|
||||
target: { value: 'new description' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
@ -1722,7 +1724,9 @@ describe('Edge Cases', () => {
|
||||
)
|
||||
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange('')
|
||||
result.current.handleDescriptionChange({
|
||||
target: { value: '' },
|
||||
} as React.ChangeEvent<HTMLTextAreaElement>)
|
||||
})
|
||||
|
||||
expect(onSelect).toHaveBeenCalledWith(
|
||||
|
||||
@ -1,6 +1,24 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import * as React from 'react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, disabled, placeholder }: {
|
||||
value?: string
|
||||
onChange?: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) => (
|
||||
<textarea
|
||||
data-testid="description-textarea"
|
||||
value={value || ''}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('../../../../readme-panel/entrance', () => ({
|
||||
ReadmeEntrance: () => <div data-testid="readme-entrance" />,
|
||||
}))
|
||||
@ -50,28 +68,28 @@ describe('ToolBaseForm', () => {
|
||||
it('should render description textarea', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('description-textarea')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('should disable textarea when no provider_name in value', () => {
|
||||
render(<ToolBaseForm {...defaultProps} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).toBeDisabled()
|
||||
expect(screen.getByTestId('description-textarea')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('should enable textarea when value has provider_name', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
expect(screen.getByRole('textbox')).not.toBeDisabled()
|
||||
expect(screen.getByTestId('description-textarea')).not.toBeDisabled()
|
||||
})
|
||||
|
||||
it('should call onDescriptionChange when textarea content changes', () => {
|
||||
const value = { provider_name: 'test-provider', tool_name: 'test', extra: { description: 'Hello' } } as never
|
||||
render(<ToolBaseForm {...defaultProps} value={value} />)
|
||||
|
||||
fireEvent.change(screen.getByRole('textbox'), { target: { value: 'Updated' } })
|
||||
expect(mockOnDescriptionChange).toHaveBeenCalledWith('Updated', expect.any(Object))
|
||||
fireEvent.change(screen.getByTestId('description-textarea'), { target: { value: 'Updated' } })
|
||||
expect(mockOnDescriptionChange).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should show ReadmeEntrance when provider has plugin_unique_identifier', () => {
|
||||
|
||||
@ -4,8 +4,8 @@ import type { FC } from 'react'
|
||||
import type { PluginDetail } from '@/app/components/plugins/types'
|
||||
import type { ToolDefaultValue, ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import type { ToolWithProvider } from '@/app/components/workflow/types'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import ToolPicker from '@/app/components/workflow/block-selector/tool-picker'
|
||||
import { ReadmeEntrance } from '../../../readme-panel/entrance'
|
||||
import ToolTrigger from './tool-trigger'
|
||||
@ -23,7 +23,7 @@ type ToolBaseFormProps = {
|
||||
onPanelShowStateChange?: (state: boolean) => void
|
||||
onSelectTool: (tool: ToolDefaultValue) => void
|
||||
onSelectMultipleTool: (tools: ToolDefaultValue[]) => void
|
||||
onDescriptionChange: (value: string) => void
|
||||
onDescriptionChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
|
||||
}
|
||||
|
||||
const ToolBaseForm: FC<ToolBaseFormProps> = ({
|
||||
@ -85,10 +85,9 @@ const ToolBaseForm: FC<ToolBaseFormProps> = ({
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
aria-label={t('detailPanel.toolSelector.descriptionLabel', { ns: 'plugin' })}
|
||||
placeholder={t('detailPanel.toolSelector.descriptionPlaceholder', { ns: 'plugin' })}
|
||||
value={value?.extra?.description || ''}
|
||||
onValueChange={onDescriptionChange}
|
||||
onChange={onDescriptionChange}
|
||||
disabled={!value?.provider_name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
import type * as React from 'react'
|
||||
import type { ToolValue } from '@/app/components/workflow/block-selector/types'
|
||||
import { act, renderHook } from '@testing-library/react'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
@ -160,8 +161,9 @@ describe('useToolSelectorState', () => {
|
||||
useToolSelectorState({ value: toolValue, onSelect: mockOnSelect }),
|
||||
)
|
||||
|
||||
const event = { target: { value: 'New description' } } as React.ChangeEvent<HTMLTextAreaElement>
|
||||
act(() => {
|
||||
result.current.handleDescriptionChange('New description')
|
||||
result.current.handleDescriptionChange(event)
|
||||
})
|
||||
|
||||
expect(mockOnSelect).toHaveBeenCalledWith(expect.objectContaining({
|
||||
|
||||
@ -144,14 +144,14 @@ export const useToolSelectorState = ({
|
||||
onSelectMultiple?.(toolValues)
|
||||
}, [getToolValue, onSelectMultiple])
|
||||
|
||||
const handleDescriptionChange = useCallback((description: string) => {
|
||||
const handleDescriptionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
if (!value)
|
||||
return
|
||||
onSelect({
|
||||
...value,
|
||||
extra: {
|
||||
...value.extra,
|
||||
description: description || '',
|
||||
description: e.target.value || '',
|
||||
},
|
||||
})
|
||||
}, [value, onSelect])
|
||||
|
||||
@ -44,6 +44,17 @@ vi.mock('@/app/components/base/input', () => ({
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/textarea', () => ({
|
||||
default: ({ value, onChange, ...props }: Record<string, unknown>) => (
|
||||
<textarea
|
||||
data-testid="description-textarea"
|
||||
value={value as string}
|
||||
onChange={onChange as () => void}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
}))
|
||||
|
||||
vi.mock('@/app/components/base/app-icon', () => ({
|
||||
default: ({ onClick }: { onClick?: () => void }) => (
|
||||
<div data-testid="app-icon" onClick={onClick} />
|
||||
@ -104,7 +115,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
it('should initialize description as empty', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' }) as HTMLTextAreaElement
|
||||
const textarea = screen.getByTestId('description-textarea') as HTMLTextAreaElement
|
||||
expect(textarea.value).toBe('')
|
||||
})
|
||||
|
||||
@ -148,7 +159,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
it('should update description when textarea changes', () => {
|
||||
render(<PublishAsKnowledgePipelineModal {...defaultProps} />)
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
|
||||
const textarea = screen.getByTestId('description-textarea')
|
||||
fireEvent.change(textarea, { target: { value: 'My description' } })
|
||||
|
||||
expect((textarea as HTMLTextAreaElement).value).toBe('My description')
|
||||
@ -222,7 +233,7 @@ describe('PublishAsKnowledgePipelineModal', () => {
|
||||
const nameInput = screen.getByTestId('name-input')
|
||||
fireEvent.change(nameInput, { target: { value: ' Trimmed Name ' } })
|
||||
|
||||
const textarea = screen.getByRole('textbox', { name: 'pipeline.common.publishAsPipeline.description' })
|
||||
const textarea = screen.getByTestId('description-textarea')
|
||||
fireEvent.change(textarea, { target: { value: ' Some desc ' } })
|
||||
|
||||
fireEvent.click(screen.getByText('workflow.common.publish'))
|
||||
|
||||
@ -3,13 +3,13 @@ import type { AppIconSelection } from '@/app/components/base/app-icon-picker'
|
||||
import type { IconInfo } from '@/models/datasets'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import AppIconPicker from '@/app/components/base/app-icon-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useWorkflowStore } from '@/app/components/workflow/store'
|
||||
|
||||
type PublishAsKnowledgePipelineModalProps = {
|
||||
@ -118,10 +118,9 @@ const PublishAsKnowledgePipelineModal = ({
|
||||
</div>
|
||||
<Textarea
|
||||
className="resize-none"
|
||||
aria-label={t('common.publishAsPipeline.description', { ns: 'pipeline' })}
|
||||
placeholder={t('common.publishAsPipeline.descriptionPlaceholder', { ns: 'pipeline' }) || ''}
|
||||
value={description}
|
||||
onValueChange={value => setDescription(value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -7,7 +7,6 @@ import type { VisionFile, VisionSettings } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import {
|
||||
RiLoader2Line,
|
||||
RiPlayLargeLine,
|
||||
@ -19,6 +18,7 @@ import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uplo
|
||||
import { StopCircle } from '@/app/components/base/icons/src/vender/solid/mediaAndDevices'
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import BoolInput from '@/app/components/workflow/nodes/_base/components/before-run-form/bool-input'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -159,11 +159,10 @@ const RunOnce: FC<IRunOnceProps> = ({
|
||||
)}
|
||||
{item.type === 'paragraph' && (
|
||||
<Textarea
|
||||
aria-label={item.name}
|
||||
className="h-[104px] sm:text-xs"
|
||||
placeholder={item.name}
|
||||
value={inputs[item.key] as string}
|
||||
onValueChange={(value) => { handleInputsChange({ ...inputsRef.current, [item.key]: value }) }}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>) => { handleInputsChange({ ...inputsRef.current, [item.key]: e.target.value }) }}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'number' && (
|
||||
|
||||
@ -13,7 +13,6 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { RiSettings2Line } from '@remixicon/react'
|
||||
import { useDebounce, useGetState } from 'ahooks'
|
||||
@ -24,6 +23,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import AppIcon from '@/app/components/base/app-icon'
|
||||
import EmojiPicker from '@/app/components/base/emoji-picker'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import LabelSelector from '@/app/components/tools/labels/selector'
|
||||
import { parseParamsSchema } from '@/service/tools'
|
||||
import { LinkExternal02 } from '../../base/icons/src/vender/line/general'
|
||||
@ -280,10 +280,9 @@ const EditCustomCollectionModal: FC<Props> = ({
|
||||
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('createTool.schema', { ns: 'tools' })}
|
||||
className="h-[240px] resize-none"
|
||||
value={schema}
|
||||
onValueChange={value => setSchema(value)}
|
||||
onChange={e => setSchema(e.target.value)}
|
||||
placeholder={t('createTool.schemaPlaceHolder', { ns: 'tools' })!}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -4,11 +4,11 @@ import type {
|
||||
} from '@/app/components/tools/types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiCloseLine } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import MCPServerParamItem from '@/app/components/tools/mcp/mcp-server-param-item'
|
||||
import { webSocketClient } from '@/app/components/workflow/collaboration/core/websocket-manager'
|
||||
import {
|
||||
@ -154,12 +154,12 @@ const MCPServerModal = ({
|
||||
<div className="system-xs-regular text-text-destructive-secondary">*</div>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={t('mcp.server.modal.description', { ns: 'tools' })}
|
||||
className="h-[96px] resize-none"
|
||||
value={description}
|
||||
placeholder={t('mcp.server.modal.descriptionPlaceholder', { ns: 'tools' })}
|
||||
onValueChange={value => setDescription(value)}
|
||||
/>
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
>
|
||||
</Textarea>
|
||||
</div>
|
||||
|
||||
{latestParams.length > 0 && (
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
'use client'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
type Props = {
|
||||
data?: any
|
||||
@ -25,12 +25,12 @@ const MCPServerParamItem = ({
|
||||
<div className="max-w-full min-w-0 system-xs-medium wrap-break-word text-text-tertiary">{data.type}</div>
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label={data.label}
|
||||
className="h-8 resize-none"
|
||||
value={value}
|
||||
placeholder={t('mcp.server.modal.parametersPlaceholder', { ns: 'tools' })}
|
||||
onValueChange={value => onChange(value)}
|
||||
/>
|
||||
onChange={e => onChange(e.target.value)}
|
||||
>
|
||||
</Textarea>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@ -14,7 +14,6 @@ import {
|
||||
DrawerTitle,
|
||||
DrawerViewport,
|
||||
} from '@langgenius/dify-ui/drawer'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@langgenius/dify-ui/tooltip'
|
||||
import { produce } from 'immer'
|
||||
@ -26,6 +25,7 @@ import Divider from '@/app/components/base/divider'
|
||||
import EmojiPickerInner from '@/app/components/base/emoji-picker/Inner'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import LabelSelector from '@/app/components/tools/labels/selector'
|
||||
import ConfirmModal from '@/app/components/tools/workflow-tool/confirm-modal'
|
||||
import MethodSelector from '@/app/components/tools/workflow-tool/method-selector'
|
||||
@ -303,10 +303,9 @@ export function WorkflowToolDrawer({
|
||||
<div>
|
||||
<div className="py-2 system-sm-medium text-text-primary">{t('createTool.description', { ns: 'tools' })}</div>
|
||||
<Textarea
|
||||
aria-label={t('createTool.description', { ns: 'tools' })}
|
||||
placeholder={t('createTool.descriptionPlaceholder', { ns: 'tools' }) || ''}
|
||||
value={description}
|
||||
onValueChange={value => setDescription(value)}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Tool Input */}
|
||||
|
||||
@ -4,7 +4,6 @@ import type { InputVar } from '../../../../types'
|
||||
import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import {
|
||||
RiDeleteBinLine,
|
||||
} from '@remixicon/react'
|
||||
@ -19,6 +18,7 @@ import { Variable02 } from '@/app/components/base/icons/src/vender/solid/develop
|
||||
import TextGenerationImageUploader from '@/app/components/base/image-uploader/text-generation-image-uploader'
|
||||
import Input from '@/app/components/base/input'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { VarBlockIcon } from '@/app/components/workflow/block-icon'
|
||||
import { useHooksStore } from '@/app/components/workflow/hooks-store'
|
||||
import { Resolution, TransferMethod } from '@/types/app'
|
||||
@ -170,9 +170,8 @@ const FormItem: FC<Props> = ({
|
||||
{
|
||||
type === InputVarType.paragraph && (
|
||||
<Textarea
|
||||
aria-label={typeof payload.label === 'object' ? payload.label.variable : payload.label}
|
||||
value={value || ''}
|
||||
onValueChange={value => onChange(value)}
|
||||
onChange={e => onChange(e.target.value)}
|
||||
placeholder={typeof payload.label === 'object' ? payload.label.variable : payload.label}
|
||||
autoFocus={autoFocus}
|
||||
/>
|
||||
|
||||
@ -2,7 +2,6 @@
|
||||
import type { FC } from 'react'
|
||||
import type { AssignerNodeOperation } from '../../types'
|
||||
import type { ValueSelector, Var } from '@/app/components/workflow/types'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiDeleteBinLine } from '@remixicon/react'
|
||||
import { noop } from 'es-toolkit/function'
|
||||
import { produce } from 'immer'
|
||||
@ -11,6 +10,7 @@ import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import ActionButton from '@/app/components/base/action-button'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import ListNoDataPlaceholder from '@/app/components/workflow/nodes/_base/components/list-no-data-placeholder'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
@ -190,9 +190,8 @@ const VarList: FC<Props> = ({
|
||||
)}
|
||||
{assignedVarType === 'string' && (
|
||||
<Textarea
|
||||
aria-label={item.variable_selector?.join('.') || t('nodes.assigner.setParameter', { ns: 'workflow' })}
|
||||
value={item.value as string}
|
||||
onValueChange={value => handleToAssignedVarChange(index)(value)}
|
||||
onChange={e => handleToAssignedVarChange(index)(e.target.value)}
|
||||
className="w-full"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -3,11 +3,11 @@ import type { FC } from 'react'
|
||||
import type { HttpNodeType } from '../types'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { useNodesInteractions } from '@/app/components/workflow/hooks'
|
||||
import { parseCurl } from './curl-parser'
|
||||
|
||||
@ -56,10 +56,9 @@ const CurlPanel: FC<Props> = ({ nodeId, isShow, onHide, handleCurlImport }) => {
|
||||
|
||||
<div>
|
||||
<Textarea
|
||||
aria-label={t('nodes.http.curl.title', { ns: 'workflow' })}
|
||||
value={inputString}
|
||||
className="my-3 h-40 w-full grow"
|
||||
onValueChange={value => setInputString(value)}
|
||||
onChange={e => setInputString(e.target.value)}
|
||||
placeholder={t('nodes.http.curl.placeholder', { ns: 'workflow' })!}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -2,12 +2,12 @@ import type { FC } from 'react'
|
||||
import type { FormValue } from '@/app/components/header/account-setting/model-provider-page/declarations'
|
||||
import type { Model } from '@/types/app'
|
||||
import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { RiCloseLine, RiSparklingFill } from '@remixicon/react'
|
||||
import * as React from 'react'
|
||||
import { useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Infotip } from '@/app/components/base/infotip'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import ModelParameterModal from '@/app/components/header/account-setting/model-provider-page/model-parameter-modal'
|
||||
|
||||
type ModelInfo = {
|
||||
@ -38,8 +38,8 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
}) => {
|
||||
const { t } = useTranslation()
|
||||
|
||||
const handleInstructionChange = useCallback((value: string) => {
|
||||
onInstructionChange(value)
|
||||
const handleInstructionChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
onInstructionChange(e.target.value)
|
||||
}, [onInstructionChange])
|
||||
|
||||
return (
|
||||
@ -90,11 +90,10 @@ const PromptEditor: FC<PromptEditorProps> = ({
|
||||
</div>
|
||||
<div className="flex items-center">
|
||||
<Textarea
|
||||
aria-label={t('nodes.llm.jsonSchema.instruction', { ns: 'workflow' })}
|
||||
className="h-[364px] resize-none px-2 py-1"
|
||||
value={instruction}
|
||||
placeholder={t('nodes.llm.jsonSchema.promptPlaceholder', { ns: 'workflow' })}
|
||||
onValueChange={handleInstructionChange}
|
||||
onChange={handleInstructionChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
import type { FC } from 'react'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import * as React from 'react'
|
||||
import { useCallback, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Divider from '@/app/components/base/divider'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
|
||||
export type AdvancedOptionsType = {
|
||||
enum: string
|
||||
@ -22,8 +22,8 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
|
||||
// const [showAdvancedOptions, setShowAdvancedOptions] = useState(false)
|
||||
const [enumValue, setEnumValue] = useState(options.enum)
|
||||
|
||||
const handleEnumChange = useCallback((value: string) => {
|
||||
setEnumValue(value)
|
||||
const handleEnumChange = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setEnumValue(e.target.value)
|
||||
}, [])
|
||||
|
||||
const handleEnumBlur = useCallback((e: React.FocusEvent<HTMLTextAreaElement>) => {
|
||||
@ -51,11 +51,10 @@ const AdvancedOptions: FC<AdvancedOptionsProps> = ({
|
||||
Enum
|
||||
</div>
|
||||
<Textarea
|
||||
aria-label="Enum"
|
||||
size="small"
|
||||
className="min-h-6"
|
||||
value={enumValue}
|
||||
onValueChange={handleEnumChange}
|
||||
onChange={handleEnumChange}
|
||||
onBlur={handleEnumBlur}
|
||||
placeholder="abcd, 1, 1.5, etc."
|
||||
/>
|
||||
|
||||
@ -4,13 +4,13 @@ import type {
|
||||
import type {
|
||||
Var,
|
||||
} from '@/app/components/workflow/types'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import CodeEditor from '@/app/components/workflow/nodes/_base/components/editor/code-editor'
|
||||
import VarReferencePicker from '@/app/components/workflow/nodes/_base/components/variable/var-reference-picker'
|
||||
import { CodeLanguage } from '@/app/components/workflow/nodes/code/types'
|
||||
@ -49,10 +49,6 @@ const FormItem = ({
|
||||
onChange(e.target.value)
|
||||
}, [onChange])
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
onChange(value)
|
||||
}, [onChange])
|
||||
|
||||
const handleChange = useCallback((value: any) => {
|
||||
onChange(value)
|
||||
}, [onChange])
|
||||
@ -96,9 +92,8 @@ const FormItem = ({
|
||||
{
|
||||
value_type === ValueType.constant && var_type === VarType.string && (
|
||||
<Textarea
|
||||
aria-label={item.label}
|
||||
value={value}
|
||||
onValueChange={handleValueChange}
|
||||
onChange={handleInputChange}
|
||||
className="min-h-12 w-full"
|
||||
/>
|
||||
)
|
||||
|
||||
@ -6,7 +6,6 @@ import { Button } from '@langgenius/dify-ui/button'
|
||||
import { Dialog, DialogContent, DialogTitle } from '@langgenius/dify-ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectItemIndicator, SelectItemText, SelectTrigger } from '@langgenius/dify-ui/select'
|
||||
import { Switch } from '@langgenius/dify-ui/switch'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { toast } from '@langgenius/dify-ui/toast'
|
||||
import { useBoolean } from 'ahooks'
|
||||
import * as React from 'react'
|
||||
@ -15,6 +14,7 @@ import { useTranslation } from 'react-i18next'
|
||||
import Field from '@/app/components/app/configuration/config-var/config-modal/field'
|
||||
import ConfigSelect from '@/app/components/app/configuration/config-var/config-select'
|
||||
import Input from '@/app/components/base/input'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ChangeType } from '@/app/components/workflow/types'
|
||||
import { checkKeys } from '@/utils/var'
|
||||
import { ParamType } from '../../types'
|
||||
@ -175,9 +175,8 @@ const AddExtractParameter: FC<Props> = ({
|
||||
)}
|
||||
<Field title={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}>
|
||||
<Textarea
|
||||
aria-label={t(`${i18nPrefix}.addExtractParameterContent.description`, { ns: 'workflow' })}
|
||||
value={param.description}
|
||||
onValueChange={value => handleParamChange('description')(value)}
|
||||
onChange={e => handleParamChange('description')(e.target.value)}
|
||||
placeholder={t(`${i18nPrefix}.addExtractParameterContent.descriptionPlaceholder`, { ns: 'workflow' })!}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
@ -2,11 +2,11 @@ import type { VarType } from '../types'
|
||||
import type { ChunkInfo } from '@/app/components/rag-pipeline/components/chunk-card-list/types'
|
||||
import type { ParentMode } from '@/models/datasets'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { ToggleGroup, ToggleGroupItem } from '@langgenius/dify-ui/toggle-group'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Markdown } from '@/app/components/base/markdown'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import { ChunkCardList } from '@/app/components/rag-pipeline/components/chunk-card-list'
|
||||
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
|
||||
import { ChunkingMode } from '@/models/datasets'
|
||||
@ -91,12 +91,11 @@ export function DisplayContent(props: DisplayContentProps) {
|
||||
previewType === PreviewType.Markdown
|
||||
? (
|
||||
<Textarea
|
||||
aria-label="Markdown content"
|
||||
readOnly={readonly}
|
||||
disabled={readonly}
|
||||
className="h-full border-none bg-transparent p-0 text-text-secondary hover:bg-transparent focus:bg-transparent focus:shadow-none"
|
||||
value={mdString as any}
|
||||
onValueChange={value => handleTextChange?.(value)}
|
||||
onChange={e => handleTextChange?.(e.target.value)}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
/>
|
||||
|
||||
@ -2,9 +2,9 @@ import type { FileEntity } from '@/app/components/base/file-uploader/types'
|
||||
import type { FileUploadConfigResponse } from '@/models/common'
|
||||
import type { VarInInspect } from '@/types/workflow'
|
||||
import { cn } from '@langgenius/dify-ui/cn'
|
||||
import { Textarea } from '@langgenius/dify-ui/textarea'
|
||||
import { FileUploaderInAttachmentWrapper } from '@/app/components/base/file-uploader'
|
||||
import { FILE_EXTS } from '@/app/components/base/prompt-editor/constants'
|
||||
import Textarea from '@/app/components/base/textarea'
|
||||
import ErrorMessage from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/error-message'
|
||||
import SchemaEditor from '@/app/components/workflow/nodes/llm/components/json-schema-config-modal/schema-editor'
|
||||
import { SupportUploadFileTypes } from '@/app/components/workflow/types'
|
||||
@ -46,12 +46,11 @@ export const TextEditorSection = ({
|
||||
)
|
||||
: (
|
||||
<Textarea
|
||||
aria-label="Value"
|
||||
readOnly={textEditorDisabled}
|
||||
disabled={textEditorDisabled || isTruncated}
|
||||
className={cn('h-full', isTruncated && 'pt-[48px]')}
|
||||
value={typeof value === 'number' ? value : String(value ?? '')}
|
||||
onValueChange={value => onTextChange(value)}
|
||||
onChange={e => onTextChange(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user