mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
refactor(api): replace legacy recommended app retrieval stack (#40395)
This commit is contained in:
41 files changed
+1704
-1941
No files matched your search
Whitespace-only changes.
Whitespace-only changes.
@@ -1,76 +0,0 @@
|
||||
import json
|
||||
from os import path
|
||||
from pathlib import Path
|
||||
from typing import Any, override
|
||||
|
||||
from flask import current_app
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
|
||||
class BuildInRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from buildin, the location is constants/recommended_apps.json
|
||||
"""
|
||||
|
||||
builtin_data: dict[str, Any] | None = None
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.BUILDIN
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
|
||||
del session
|
||||
result = self.fetch_recommended_apps_from_builtin(language)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session):
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session):
|
||||
del session
|
||||
result = self.fetch_recommended_app_detail_from_builtin(app_id)
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def _get_builtin_data(cls):
|
||||
"""
|
||||
Get builtin data.
|
||||
:return:
|
||||
"""
|
||||
if cls.builtin_data:
|
||||
return cls.builtin_data
|
||||
|
||||
root_path = current_app.root_path
|
||||
cls.builtin_data = json.loads(
|
||||
Path(path.join(root_path, "constants", "recommended_apps.json")).read_text(encoding="utf-8")
|
||||
)
|
||||
|
||||
return cls.builtin_data or {}
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_builtin(cls, language: str):
|
||||
"""
|
||||
Fetch recommended apps from builtin.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
|
||||
return builtin_data.get("recommended_apps", {}).get(language, {})
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_builtin(cls, app_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Fetch recommended app detail from builtin.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
builtin_data: dict[str, dict[str, dict]] = cls._get_builtin_data()
|
||||
return builtin_data.get("app_details", {}).get(app_id)
|
||||
@@ -1,49 +0,0 @@
|
||||
"""Apply Redis-backed category ordering for DB-backed Explore apps."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Collection
|
||||
from typing import Any
|
||||
|
||||
from extensions.ext_redis import redis_client
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX = "explore:apps:category_order"
|
||||
|
||||
|
||||
def _category_order_key(language: str) -> str:
|
||||
return f"{EXPLORE_APP_CATEGORY_ORDER_KEY_PREFIX}:{language}"
|
||||
|
||||
|
||||
def get_explore_app_category_order(language: str) -> list[str]:
|
||||
try:
|
||||
raw_categories = redis_client.get(_category_order_key(language))
|
||||
except Exception:
|
||||
logger.exception("Failed to read explore app category order from Redis.")
|
||||
return []
|
||||
|
||||
if not raw_categories:
|
||||
return []
|
||||
|
||||
if isinstance(raw_categories, bytes):
|
||||
raw_categories = raw_categories.decode("utf-8")
|
||||
|
||||
try:
|
||||
categories: Any = json.loads(raw_categories)
|
||||
except (TypeError, json.JSONDecodeError):
|
||||
logger.warning("Invalid explore app category order payload for language %s.", language)
|
||||
return []
|
||||
|
||||
if not isinstance(categories, list):
|
||||
return []
|
||||
|
||||
return [category for category in categories if isinstance(category, str)]
|
||||
|
||||
|
||||
def order_categories(categories: Collection[str], language: str) -> list[str]:
|
||||
configured_order = get_explore_app_category_order(language)
|
||||
if configured_order:
|
||||
return configured_order
|
||||
|
||||
return sorted(categories)
|
||||
Whitespace-only changes.
@@ -1,179 +0,0 @@
|
||||
from typing import Any, NotRequired, TypedDict, override
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from constants.languages import languages
|
||||
from models.model import App, RecommendedApp
|
||||
from services.app_dsl_service import AppDslService
|
||||
from services.recommend_app.category_order import order_categories
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
|
||||
class RecommendedAppItemDict(TypedDict):
|
||||
id: str
|
||||
app: App | None
|
||||
app_id: str
|
||||
description: Any
|
||||
copyright: Any
|
||||
privacy_policy: Any
|
||||
custom_disclaimer: str
|
||||
categories: list[str]
|
||||
position: int
|
||||
is_listed: bool
|
||||
can_trial: NotRequired[bool]
|
||||
|
||||
|
||||
class RecommendedAppsResultDict(TypedDict):
|
||||
recommended_apps: list[RecommendedAppItemDict]
|
||||
categories: list[str]
|
||||
|
||||
|
||||
class RecommendedAppDetailDict(TypedDict):
|
||||
id: str
|
||||
name: str
|
||||
icon: Any
|
||||
icon_background: str | None
|
||||
mode: str
|
||||
export_data: str
|
||||
|
||||
|
||||
class DatabaseRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from database
|
||||
"""
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
result = self.fetch_recommended_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
result = self.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
|
||||
result = self.fetch_recommended_app_detail_from_db(app_id, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.DATABASE
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Fetch recommended apps from db.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session)
|
||||
|
||||
if len(recommended_apps) == 0:
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session)
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language, session=session)
|
||||
|
||||
@classmethod
|
||||
def fetch_learn_dify_apps_from_db(cls, language: str, *, session: Session) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Fetch listed recommended apps explicitly marked for the Learn Dify section.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(language, session=session, is_learn_dify=True)
|
||||
|
||||
if len(recommended_apps) == 0 and language != languages[0]:
|
||||
recommended_apps = cls._fetch_listed_recommended_apps(languages[0], session=session, is_learn_dify=True)
|
||||
|
||||
return cls._format_recommended_apps(recommended_apps, language, session=session)
|
||||
|
||||
@classmethod
|
||||
def _fetch_listed_recommended_apps(
|
||||
cls, language: str, *, session: Session, is_learn_dify: bool | None = None
|
||||
) -> list[RecommendedApp]:
|
||||
filters = [RecommendedApp.is_listed.is_(True), RecommendedApp.language == language]
|
||||
if is_learn_dify is not None:
|
||||
filters.append(RecommendedApp.is_learn_dify.is_(is_learn_dify))
|
||||
|
||||
return list(session.scalars(select(RecommendedApp).where(*filters)).all())
|
||||
|
||||
@classmethod
|
||||
def _format_recommended_apps(
|
||||
cls,
|
||||
recommended_apps: list[RecommendedApp],
|
||||
language: str,
|
||||
*,
|
||||
session: Session,
|
||||
) -> RecommendedAppsResultDict:
|
||||
"""
|
||||
Serialize DB recommended app rows into the Explore list response shape.
|
||||
:param recommended_apps: recommended app rows
|
||||
:param language: language used for category ordering
|
||||
:return:
|
||||
"""
|
||||
|
||||
categories = set()
|
||||
recommended_apps_result: list[RecommendedAppItemDict] = []
|
||||
for recommended_app in recommended_apps:
|
||||
app = session.get(App, recommended_app.app_id)
|
||||
if not app or not app.is_public:
|
||||
continue
|
||||
|
||||
site = app.site_with_session(session=session)
|
||||
if not site:
|
||||
continue
|
||||
|
||||
app_categories = recommended_app.categories or []
|
||||
recommended_app_result: RecommendedAppItemDict = {
|
||||
"id": recommended_app.id,
|
||||
"app": app,
|
||||
"app_id": recommended_app.app_id,
|
||||
"description": site.description,
|
||||
"copyright": site.copyright,
|
||||
"privacy_policy": site.privacy_policy,
|
||||
"custom_disclaimer": site.custom_disclaimer,
|
||||
"categories": app_categories,
|
||||
"position": recommended_app.position,
|
||||
"is_listed": recommended_app.is_listed,
|
||||
}
|
||||
recommended_apps_result.append(recommended_app_result)
|
||||
|
||||
categories.update(app_categories)
|
||||
|
||||
return RecommendedAppsResultDict(
|
||||
recommended_apps=recommended_apps_result,
|
||||
categories=order_categories(categories, language),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_db(cls, app_id: str, *, session: Session) -> RecommendedAppDetailDict | None:
|
||||
"""
|
||||
Fetch recommended app detail from db.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
# is in public recommended list
|
||||
recommended_app = session.scalar(
|
||||
select(RecommendedApp).where(RecommendedApp.is_listed == True, RecommendedApp.app_id == app_id).limit(1)
|
||||
)
|
||||
|
||||
if not recommended_app:
|
||||
return None
|
||||
|
||||
# get app detail
|
||||
app_model = session.get(App, app_id)
|
||||
if not app_model or not app_model.is_public:
|
||||
return None
|
||||
|
||||
return RecommendedAppDetailDict(
|
||||
id=app_model.id,
|
||||
name=app_model.name,
|
||||
icon=app_model.icon,
|
||||
icon_background=app_model.icon_background,
|
||||
mode=app_model.mode,
|
||||
export_data=AppDslService.export_dsl(app_model=app_model, session=session),
|
||||
)
|
||||
@@ -1,15 +0,0 @@
|
||||
from typing import Any, Protocol
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
class RecommendAppRetrievalBase(Protocol):
|
||||
"""Interface for recommend app retrieval."""
|
||||
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session) -> Any: ...
|
||||
|
||||
def get_type(self) -> str: ...
|
||||
@@ -1,23 +0,0 @@
|
||||
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
from services.recommend_app.remote.remote_retrieval import RemoteRecommendAppRetrieval
|
||||
|
||||
|
||||
class RecommendAppRetrievalFactory:
|
||||
@staticmethod
|
||||
def get_recommend_app_factory(mode: str) -> type[RecommendAppRetrievalBase]:
|
||||
match mode:
|
||||
case RecommendAppType.REMOTE:
|
||||
return RemoteRecommendAppRetrieval
|
||||
case RecommendAppType.DATABASE:
|
||||
return DatabaseRecommendAppRetrieval
|
||||
case RecommendAppType.BUILDIN:
|
||||
return BuildInRecommendAppRetrieval
|
||||
case _:
|
||||
raise ValueError(f"invalid fetch recommended apps mode: {mode}")
|
||||
|
||||
@staticmethod
|
||||
def get_buildin_recommend_app_retrieval():
|
||||
return BuildInRecommendAppRetrieval
|
||||
@@ -1,7 +0,0 @@
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class RecommendAppType(StrEnum):
|
||||
REMOTE = "remote"
|
||||
BUILDIN = "builtin"
|
||||
DATABASE = "db"
|
||||
Whitespace-only changes.
@@ -1,173 +0,0 @@
|
||||
import logging
|
||||
import threading
|
||||
from typing import Any, override
|
||||
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
from flask import has_request_context, request
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from configs import dify_config
|
||||
from services.recommend_app.buildin.buildin_retrieval import BuildInRecommendAppRetrieval
|
||||
from services.recommend_app.database.database_retrieval import DatabaseRecommendAppRetrieval
|
||||
from services.recommend_app.recommend_app_base import RecommendAppRetrievalBase
|
||||
from services.recommend_app.recommend_app_type import RecommendAppType
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_REMOTE_FETCH_CACHE_MAXSIZE = 64
|
||||
_remote_fetch_cache: TTLCache[tuple[str, str], dict[str, Any]] | None = None
|
||||
_remote_fetch_cache_ttl: int | None = None
|
||||
_remote_fetch_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _current_origin_headers() -> dict[str, str]:
|
||||
origin = request.headers.get("Origin") if has_request_context() else None
|
||||
if origin:
|
||||
return {"Origin": origin}
|
||||
|
||||
console_web_url = getattr(dify_config, "CONSOLE_WEB_URL", "")
|
||||
if not isinstance(console_web_url, str) or not console_web_url:
|
||||
return {}
|
||||
return {"Origin": console_web_url}
|
||||
|
||||
|
||||
def _remote_fetch_cache_key(url: str, headers: dict[str, str]) -> tuple[str, str]:
|
||||
return url, headers.get("Origin", "")
|
||||
|
||||
|
||||
def _hosted_fetch_cache_ttl() -> int:
|
||||
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
|
||||
if isinstance(ttl, int) and not isinstance(ttl, bool):
|
||||
return ttl
|
||||
return 600
|
||||
|
||||
|
||||
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], dict[str, Any]] | None:
|
||||
ttl = _hosted_fetch_cache_ttl()
|
||||
if ttl <= 0:
|
||||
return None
|
||||
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
with _remote_fetch_cache_lock:
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
|
||||
_remote_fetch_cache_ttl = ttl
|
||||
return _remote_fetch_cache
|
||||
|
||||
|
||||
def clear_remote_fetch_cache() -> None:
|
||||
"""Reset the in-memory remote fetch cache (used by tests)."""
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
with _remote_fetch_cache_lock:
|
||||
_remote_fetch_cache = None
|
||||
_remote_fetch_cache_ttl = None
|
||||
|
||||
|
||||
def _fetch_remote_payload(url: str) -> tuple[int, dict[str, Any] | None]:
|
||||
headers = _current_origin_headers()
|
||||
cache_key = _remote_fetch_cache_key(url, headers)
|
||||
cache = _get_remote_fetch_cache()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return 200, cached
|
||||
|
||||
response = httpx.get(url, headers=headers, timeout=httpx.Timeout(10.0, connect=3.0))
|
||||
status_code = response.status_code
|
||||
if status_code != 200:
|
||||
return status_code, None
|
||||
|
||||
result: dict[str, Any] = response.json()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cache[cache_key] = result
|
||||
return status_code, result
|
||||
|
||||
|
||||
class RemoteRecommendAppRetrieval(RecommendAppRetrievalBase):
|
||||
"""
|
||||
Retrieval recommended app from dify official.
|
||||
|
||||
The remote `/apps` payload is already curated for display, including category order.
|
||||
Keep the response order intact so Explore matches the template service.
|
||||
"""
|
||||
|
||||
@override
|
||||
def get_recommend_app_detail(self, app_id: str, *, session: Session):
|
||||
del session
|
||||
try:
|
||||
result = self.fetch_recommended_app_detail_from_dify_official(app_id)
|
||||
except Exception as e:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", e)
|
||||
result = BuildInRecommendAppRetrieval.fetch_recommended_app_detail_from_builtin(app_id)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_recommended_apps_and_categories(self, language: str, *, session: Session):
|
||||
del session
|
||||
try:
|
||||
result = self.fetch_recommended_apps_from_dify_official(language)
|
||||
except Exception as e:
|
||||
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", e)
|
||||
result = BuildInRecommendAppRetrieval.fetch_recommended_apps_from_builtin(language)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_learn_dify_apps(self, language: str, *, session: Session):
|
||||
try:
|
||||
result = self.fetch_learn_dify_apps_from_dify_official(language)
|
||||
except Exception as e:
|
||||
logger.warning("fetch learn dify apps from dify official failed: %s, switch to database.", e)
|
||||
result = DatabaseRecommendAppRetrieval.fetch_learn_dify_apps_from_db(language, session=session)
|
||||
return result
|
||||
|
||||
@override
|
||||
def get_type(self) -> str:
|
||||
return RecommendAppType.REMOTE
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_app_detail_from_dify_official(cls, app_id: str) -> dict[str, Any] | None:
|
||||
"""
|
||||
Fetch recommended app detail from dify official.
|
||||
:param app_id: App ID
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps/{app_id}"
|
||||
status_code, data = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
return None
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def fetch_recommended_apps_from_dify_official(cls, language: str):
|
||||
"""
|
||||
Fetch recommended apps from dify official.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps?language={language}"
|
||||
status_code, result = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
|
||||
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def fetch_learn_dify_apps_from_dify_official(cls, language: str):
|
||||
"""
|
||||
Fetch Learn Dify apps from dify official.
|
||||
:param language: language
|
||||
:return:
|
||||
"""
|
||||
domain = dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN
|
||||
url = f"{domain}/apps/learn-dify?language={language}"
|
||||
status_code, result = _fetch_remote_payload(url)
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,376 @@
|
||||
"""Typed remote and built-in adapters for the recommended app catalog."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from collections.abc import Callable, Mapping, Sequence
|
||||
from pathlib import Path
|
||||
from typing import cast, override
|
||||
|
||||
import httpx
|
||||
from cachetools import TTLCache
|
||||
|
||||
from configs import dify_config
|
||||
from services.recommended_app_query_service import (
|
||||
RecommendedAppCatalogPage,
|
||||
RecommendedAppCatalogQuery,
|
||||
RecommendedAppDetailRecord,
|
||||
RecommendedAppInfoRecord,
|
||||
RecommendedAppRecord,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
|
||||
_BUILTIN_CATALOG_PATH = Path(__file__).resolve().parents[1] / "constants" / "recommended_apps.json"
|
||||
_REMOTE_FETCH_CACHE_MAXSIZE = 64
|
||||
_remote_fetch_cache: TTLCache[tuple[str, str], object] | None = None
|
||||
_remote_fetch_cache_ttl: int | None = None
|
||||
_remote_fetch_cache_lock = threading.Lock()
|
||||
|
||||
|
||||
def _hosted_fetch_cache_ttl() -> int:
|
||||
ttl = dify_config.HOSTED_FETCH_APP_TEMPLATES_CACHE_TTL
|
||||
if isinstance(ttl, int) and not isinstance(ttl, bool):
|
||||
return ttl
|
||||
return 600
|
||||
|
||||
|
||||
def _get_remote_fetch_cache() -> TTLCache[tuple[str, str], object] | None:
|
||||
ttl = _hosted_fetch_cache_ttl()
|
||||
if ttl <= 0:
|
||||
return None
|
||||
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
with _remote_fetch_cache_lock:
|
||||
if _remote_fetch_cache is None or _remote_fetch_cache_ttl != ttl:
|
||||
_remote_fetch_cache = TTLCache(maxsize=_REMOTE_FETCH_CACHE_MAXSIZE, ttl=ttl)
|
||||
_remote_fetch_cache_ttl = ttl
|
||||
return _remote_fetch_cache
|
||||
|
||||
|
||||
def clear_remote_fetch_cache() -> None:
|
||||
"""Reset the in-memory remote fetch cache (used by tests)."""
|
||||
global _remote_fetch_cache, _remote_fetch_cache_ttl
|
||||
with _remote_fetch_cache_lock:
|
||||
_remote_fetch_cache = None
|
||||
_remote_fetch_cache_ttl = None
|
||||
|
||||
|
||||
class _RecommendedAppSourceUnavailableError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class BuiltinRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
|
||||
def __init__(self) -> None:
|
||||
self._data: Mapping[str, object] | None = None
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
return _map_recommended_page(self._raw_page(language))
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
return _map_learn_dify_page(self._raw_learn_dify_page(language))
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
detail = self._raw_detail(app_id)
|
||||
if detail is None:
|
||||
return None
|
||||
return _map_detail(_as_mapping(detail, field="recommended app detail"))
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
return self._raw_detail(app_id) is not None
|
||||
|
||||
def _raw_page(self, language: str) -> Mapping[str, object]:
|
||||
pages = _as_mapping(self._get_data().get("recommended_apps", {}), field="recommended_apps")
|
||||
return _as_mapping(pages.get(language, {}), field="recommended app page")
|
||||
|
||||
def _raw_learn_dify_page(self, language: str) -> Mapping[str, object]:
|
||||
apps = self._raw_learn_dify_apps(language)
|
||||
if not apps and language != _BUILTIN_FALLBACK_LANGUAGE:
|
||||
apps = self._raw_learn_dify_apps(_BUILTIN_FALLBACK_LANGUAGE)
|
||||
return {"recommended_apps": apps}
|
||||
|
||||
def _raw_learn_dify_apps(self, language: str) -> tuple[object, ...]:
|
||||
page = self._raw_page(language)
|
||||
return tuple(
|
||||
app
|
||||
for app in _as_sequence(page.get("recommended_apps", ()), field="apps")
|
||||
if _as_mapping(app, field="recommended app").get("is_learn_dify") is True
|
||||
)
|
||||
|
||||
def _raw_detail(self, app_id: str) -> object | None:
|
||||
details = _as_mapping(self._get_data().get("app_details", {}), field="app_details")
|
||||
return details.get(app_id)
|
||||
|
||||
def _get_data(self) -> Mapping[str, object]:
|
||||
if self._data is None:
|
||||
loaded = json.loads(_BUILTIN_CATALOG_PATH.read_text(encoding="utf-8"))
|
||||
self._data = _as_mapping(loaded, field="built-in recommended app catalog")
|
||||
return self._data
|
||||
|
||||
|
||||
class RemoteRecommendedAppCatalogGateway(RecommendedAppCatalogQuery):
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
result = self._fetch(lambda: self._fetch_page(language))
|
||||
return _map_recommended_page(_as_mapping(result, field="recommended app page"))
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
result = self._fetch(lambda: self._fetch_learn_dify_page(language))
|
||||
return _map_learn_dify_page(_as_mapping(result, field="Learn Dify app page"))
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
detail = self._fetch(lambda: self._fetch_detail(app_id))
|
||||
if detail is None:
|
||||
return None
|
||||
return _map_detail(_as_mapping(detail, field="recommended app detail"))
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
detail = self._fetch(lambda: self._fetch_detail(app_id))
|
||||
return detail is not None
|
||||
|
||||
def _fetch_detail(self, app_id: str) -> object | None:
|
||||
status_code, detail = self._get_payload(f"/apps/{app_id}")
|
||||
if status_code != 200:
|
||||
# Preserve the legacy detail contract: only request or decoding
|
||||
# failures use the bundled fallback; HTTP responses are authoritative.
|
||||
return None
|
||||
return detail
|
||||
|
||||
def _fetch_page(self, language: str) -> object:
|
||||
status_code, page = self._get_payload(f"/apps?language={language}")
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch recommended apps failed, status code: {status_code}")
|
||||
return page
|
||||
|
||||
def _fetch_learn_dify_page(self, language: str) -> object:
|
||||
status_code, page = self._get_payload(f"/apps/learn-dify?language={language}")
|
||||
if status_code != 200:
|
||||
raise ValueError(f"fetch learn dify apps failed, status code: {status_code}")
|
||||
return page
|
||||
|
||||
@staticmethod
|
||||
def _get_payload(path: str) -> tuple[int, object]:
|
||||
origin = dify_config.CONSOLE_WEB_URL
|
||||
|
||||
url = f"{dify_config.HOSTED_FETCH_APP_TEMPLATES_REMOTE_DOMAIN}{path}"
|
||||
headers = {"Origin": origin} if origin else {}
|
||||
cache_key = (url, origin)
|
||||
cache = _get_remote_fetch_cache()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cached = cache.get(cache_key)
|
||||
if cached is not None:
|
||||
return 200, cached
|
||||
|
||||
response = httpx.get(
|
||||
url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(10.0, connect=3.0),
|
||||
)
|
||||
if response.status_code != 200:
|
||||
return response.status_code, None
|
||||
|
||||
result = response.json()
|
||||
if cache is not None:
|
||||
with _remote_fetch_cache_lock:
|
||||
cache[cache_key] = result
|
||||
return response.status_code, result
|
||||
|
||||
@staticmethod
|
||||
def _fetch[T](fetch: Callable[[], T]) -> T:
|
||||
try:
|
||||
return fetch()
|
||||
except Exception as error:
|
||||
raise _RecommendedAppSourceUnavailableError(str(error)) from error
|
||||
|
||||
|
||||
class RecommendedAppCatalogRouter(RecommendedAppCatalogQuery):
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
remote: RecommendedAppCatalogQuery,
|
||||
database: RecommendedAppCatalogQuery,
|
||||
builtin: RecommendedAppCatalogQuery,
|
||||
) -> None:
|
||||
self._remote = remote
|
||||
self._builtin = builtin
|
||||
self._sources: dict[str, RecommendedAppCatalogQuery] = {
|
||||
"remote": remote,
|
||||
"db": database,
|
||||
"builtin": builtin,
|
||||
}
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
page = source.list_recommended(language)
|
||||
else:
|
||||
try:
|
||||
page = self._remote.list_recommended(language)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended apps from dify official failed: %s, switch to built-in.", error)
|
||||
page = self._builtin.list_recommended(language)
|
||||
|
||||
if not page.recommended_apps:
|
||||
return self._builtin.list_recommended(_BUILTIN_FALLBACK_LANGUAGE)
|
||||
return page
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.list_learn_dify(language)
|
||||
try:
|
||||
return self._remote.list_learn_dify(language)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch learn dify apps from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.list_learn_dify(language)
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.get_detail(app_id)
|
||||
try:
|
||||
return self._remote.get_detail(app_id)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.get_detail(app_id)
|
||||
|
||||
@override
|
||||
def contains(self, app_id: str) -> bool:
|
||||
source = self._source()
|
||||
if source is not self._remote:
|
||||
return source.contains(app_id)
|
||||
try:
|
||||
return self._remote.contains(app_id)
|
||||
except _RecommendedAppSourceUnavailableError as error:
|
||||
logger.warning("fetch recommended app detail from dify official failed: %s, switch to built-in.", error)
|
||||
return self._builtin.contains(app_id)
|
||||
|
||||
def _source(self) -> RecommendedAppCatalogQuery:
|
||||
mode = dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
try:
|
||||
return self._sources[mode]
|
||||
except KeyError:
|
||||
raise ValueError(f"invalid fetch recommended apps mode: {mode}") from None
|
||||
|
||||
|
||||
def _map_recommended_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
if not source.get("recommended_apps"):
|
||||
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
|
||||
return _map_page(source)
|
||||
|
||||
|
||||
def _map_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
|
||||
categories=_as_string_tuple(source["categories"], field="categories"),
|
||||
)
|
||||
|
||||
|
||||
def _map_learn_dify_page(source: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(_map_app(app) for app in _as_sequence(source["recommended_apps"], field="apps")),
|
||||
categories=(),
|
||||
)
|
||||
|
||||
|
||||
def _map_app(source: object) -> RecommendedAppRecord:
|
||||
source = _as_mapping(source, field="recommended app")
|
||||
app_id = source["app_id"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app_id must be a string")
|
||||
|
||||
app_source = source.get("app")
|
||||
if app_source is not None:
|
||||
app_source = _as_mapping(app_source, field="app")
|
||||
|
||||
return RecommendedAppRecord(
|
||||
app=_map_app_info(app_source),
|
||||
app_id=app_id,
|
||||
description=cast(str | None, source.get("description")),
|
||||
copyright=cast(str | None, source.get("copyright")),
|
||||
privacy_policy=cast(str | None, source.get("privacy_policy")),
|
||||
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
|
||||
categories=_as_string_tuple(source.get("categories", ()), field="categories"),
|
||||
position=cast(int | None, source.get("position")),
|
||||
is_listed=cast(bool | None, source.get("is_listed")),
|
||||
)
|
||||
|
||||
|
||||
def _map_app_info(source: Mapping[str, object] | None) -> RecommendedAppInfoRecord | None:
|
||||
if source is None:
|
||||
return None
|
||||
app_id = source["id"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app.id must be a string")
|
||||
return RecommendedAppInfoRecord(
|
||||
id=app_id,
|
||||
name=cast(str | None, source.get("name")),
|
||||
mode=_enum_string(source.get("mode"), field="app.mode"),
|
||||
icon=cast(str | None, source.get("icon")),
|
||||
icon_type=_enum_string(source.get("icon_type"), field="app.icon_type"),
|
||||
icon_background=cast(str | None, source.get("icon_background")),
|
||||
)
|
||||
|
||||
|
||||
def _map_detail(source: Mapping[str, object]) -> RecommendedAppDetailRecord:
|
||||
app_id = source["id"]
|
||||
name = source["name"]
|
||||
export_data = source["export_data"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("id must be a string")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("name must be a string")
|
||||
if not isinstance(export_data, str):
|
||||
raise TypeError("export_data must be a string")
|
||||
|
||||
mode = _enum_string(source["mode"], field="mode")
|
||||
if mode is None:
|
||||
raise TypeError("mode must be a string or string enum")
|
||||
return RecommendedAppDetailRecord(
|
||||
id=app_id,
|
||||
name=name,
|
||||
icon=cast(str | None, source.get("icon")),
|
||||
icon_background=cast(str | None, source.get("icon_background")),
|
||||
mode=mode,
|
||||
export_data=export_data,
|
||||
)
|
||||
|
||||
|
||||
def _as_mapping(value: object, *, field: str) -> Mapping[str, object]:
|
||||
if not isinstance(value, Mapping):
|
||||
raise TypeError(f"{field} must be a mapping")
|
||||
return cast(Mapping[str, object], value)
|
||||
|
||||
|
||||
def _as_sequence(value: object, *, field: str) -> Sequence[object]:
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||
raise TypeError(f"{field} must be a sequence")
|
||||
return cast(Sequence[object], value)
|
||||
|
||||
|
||||
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
|
||||
values = _as_sequence(value, field=field)
|
||||
if not all(isinstance(item, str) for item in values):
|
||||
raise TypeError(f"{field} must contain only strings")
|
||||
return cast(tuple[str, ...], tuple(values))
|
||||
|
||||
|
||||
def _enum_string(value: object, *, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return str(value)
|
||||
raise TypeError(f"{field} must be a string or string enum")
|
||||
@@ -1,192 +0,0 @@
|
||||
"""Compatibility adapter for the existing recommended app retrieval stack."""
|
||||
|
||||
from collections.abc import Mapping, Sequence
|
||||
from typing import cast, override
|
||||
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from configs import dify_config
|
||||
from models.model import App
|
||||
from services.recommend_app.recommend_app_factory import RecommendAppRetrievalFactory
|
||||
from services.recommended_app_query_service import (
|
||||
RecommendedAppCatalogGateway,
|
||||
RecommendedAppCatalogPage,
|
||||
RecommendedAppDetailRecord,
|
||||
RecommendedAppInfoRecord,
|
||||
RecommendedAppRecord,
|
||||
)
|
||||
|
||||
|
||||
class LegacyRecommendedAppCatalogGateway(RecommendedAppCatalogGateway):
|
||||
"""Map the mixed dict/ORM retrieval results to persistence-neutral records."""
|
||||
|
||||
def __init__(self, session_factory: sessionmaker[Session]) -> None:
|
||||
self._session_factory = session_factory
|
||||
|
||||
@override
|
||||
def is_recommended(self, app_id: str) -> bool:
|
||||
retrieval = self._configured_retrieval()
|
||||
with self._session_factory() as session:
|
||||
return retrieval.get_recommend_app_detail(app_id, session=session) is not None
|
||||
|
||||
@override
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage:
|
||||
retrieval = self._configured_retrieval()
|
||||
with self._session_factory() as session:
|
||||
result = retrieval.get_recommended_apps_and_categories(language, session=session)
|
||||
return self._map_recommended_page(result)
|
||||
|
||||
@override
|
||||
def list_builtin(self, language: str) -> RecommendedAppCatalogPage:
|
||||
retrieval = RecommendAppRetrievalFactory.get_buildin_recommend_app_retrieval()
|
||||
return self._map_page(retrieval.fetch_recommended_apps_from_builtin(language))
|
||||
|
||||
@override
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage:
|
||||
retrieval = self._configured_retrieval()
|
||||
with self._session_factory() as session:
|
||||
result = retrieval.get_learn_dify_apps(language, session=session)
|
||||
raw_apps = cast(Sequence[object], result["recommended_apps"])
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(self._map_app(app) for app in raw_apps),
|
||||
categories=(),
|
||||
)
|
||||
|
||||
@override
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None:
|
||||
retrieval = self._configured_retrieval()
|
||||
with self._session_factory() as session:
|
||||
result = retrieval.get_recommend_app_detail(app_id, session=session)
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, Mapping):
|
||||
raise TypeError("recommended app detail must be a mapping")
|
||||
return self._map_detail(cast(Mapping[str, object], result))
|
||||
|
||||
@staticmethod
|
||||
def _configured_retrieval():
|
||||
retrieval_type = RecommendAppRetrievalFactory.get_recommend_app_factory(
|
||||
dify_config.HOSTED_FETCH_APP_TEMPLATES_MODE
|
||||
)
|
||||
return retrieval_type()
|
||||
|
||||
@classmethod
|
||||
def _map_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
raw_apps = cast(Sequence[object], result["recommended_apps"])
|
||||
return RecommendedAppCatalogPage(
|
||||
recommended_apps=tuple(cls._map_app(app) for app in raw_apps),
|
||||
categories=cls._as_string_tuple(result["categories"], field="categories"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _map_recommended_page(cls, result: Mapping[str, object]) -> RecommendedAppCatalogPage:
|
||||
if not result.get("recommended_apps"):
|
||||
return RecommendedAppCatalogPage(recommended_apps=(), categories=())
|
||||
return cls._map_page(result)
|
||||
|
||||
@classmethod
|
||||
def _map_app(cls, source: object) -> RecommendedAppRecord:
|
||||
if not isinstance(source, Mapping):
|
||||
raise TypeError("recommended app must be a mapping")
|
||||
|
||||
source = cast(Mapping[str, object], source)
|
||||
app_id = source["app_id"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app_id must be a string")
|
||||
app_source = source.get("app")
|
||||
if app_source is not None and not isinstance(app_source, (Mapping, App)):
|
||||
raise TypeError("app must be a mapping or App")
|
||||
|
||||
return RecommendedAppRecord(
|
||||
app=cls._map_app_info(cast(Mapping[str, object] | App | None, app_source)),
|
||||
app_id=app_id,
|
||||
description=cast(str | None, source.get("description")),
|
||||
copyright=cast(str | None, source.get("copyright")),
|
||||
privacy_policy=cast(str | None, source.get("privacy_policy")),
|
||||
custom_disclaimer=cast(str | None, source.get("custom_disclaimer")),
|
||||
categories=cls._as_string_tuple(source.get("categories", ()), field="categories"),
|
||||
position=cast(int | None, source.get("position")),
|
||||
is_listed=cast(bool | None, source.get("is_listed")),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _map_app_info(cls, source: Mapping[str, object] | App | None) -> RecommendedAppInfoRecord | None:
|
||||
if source is None:
|
||||
return None
|
||||
|
||||
app_id: object
|
||||
name: object
|
||||
mode: object
|
||||
icon: object
|
||||
icon_type: object
|
||||
icon_background: object
|
||||
if isinstance(source, App):
|
||||
app_id = source.id
|
||||
name = source.name
|
||||
mode = source.mode
|
||||
icon = source.icon
|
||||
icon_type = source.icon_type
|
||||
icon_background = source.icon_background
|
||||
else:
|
||||
app_id = source["id"]
|
||||
name = source.get("name")
|
||||
mode = source.get("mode")
|
||||
icon = source.get("icon")
|
||||
icon_type = source.get("icon_type")
|
||||
icon_background = source.get("icon_background")
|
||||
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("app.id must be a string")
|
||||
|
||||
return RecommendedAppInfoRecord(
|
||||
id=app_id,
|
||||
name=cast(str | None, name),
|
||||
mode=cls._enum_string(mode, field="app.mode"),
|
||||
icon=cast(str | None, icon),
|
||||
icon_type=cls._enum_string(icon_type, field="app.icon_type"),
|
||||
icon_background=cast(str | None, icon_background),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _map_detail(cls, source: Mapping[str, object]) -> RecommendedAppDetailRecord:
|
||||
app_id = source["id"]
|
||||
name = source["name"]
|
||||
export_data = source["export_data"]
|
||||
if not isinstance(app_id, str):
|
||||
raise TypeError("id must be a string")
|
||||
if not isinstance(name, str):
|
||||
raise TypeError("name must be a string")
|
||||
if not isinstance(export_data, str):
|
||||
raise TypeError("export_data must be a string")
|
||||
|
||||
mode = cls._enum_string(source["mode"], field="mode")
|
||||
if mode is None:
|
||||
raise TypeError("mode must be a string or string enum")
|
||||
|
||||
return RecommendedAppDetailRecord(
|
||||
id=app_id,
|
||||
name=name,
|
||||
icon=cast(str | None, source.get("icon")),
|
||||
icon_background=cast(str | None, source.get("icon_background")),
|
||||
mode=mode,
|
||||
export_data=export_data,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _as_string_tuple(value: object, *, field: str) -> tuple[str, ...]:
|
||||
if isinstance(value, (str, bytes)) or not isinstance(value, Sequence):
|
||||
raise TypeError(f"{field} must be a sequence of strings")
|
||||
items: list[str] = []
|
||||
for item in value:
|
||||
if not isinstance(item, str):
|
||||
raise TypeError(f"{field} must contain only strings")
|
||||
items.append(item)
|
||||
return tuple(items)
|
||||
|
||||
@staticmethod
|
||||
def _enum_string(value: object, *, field: str) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
return str(value)
|
||||
raise TypeError(f"{field} must be a string or string enum")
|
||||
@@ -5,8 +5,6 @@ from typing import NamedTuple, Protocol
|
||||
|
||||
from constants.languages import languages
|
||||
|
||||
_BUILTIN_FALLBACK_LANGUAGE = "en-US"
|
||||
|
||||
|
||||
class RecommendedAppInfoRecord(NamedTuple):
|
||||
id: str
|
||||
@@ -43,17 +41,17 @@ class RecommendedAppDetailRecord(NamedTuple):
|
||||
export_data: str
|
||||
|
||||
|
||||
class RecommendedAppCatalogGateway(Protocol):
|
||||
def is_recommended(self, app_id: str) -> bool: ...
|
||||
class RecommendedAppCatalogQuery(Protocol):
|
||||
"""Read from the recommended-app catalog."""
|
||||
|
||||
def list_recommended(self, language: str) -> RecommendedAppCatalogPage: ...
|
||||
|
||||
def list_builtin(self, language: str) -> RecommendedAppCatalogPage: ...
|
||||
|
||||
def list_learn_dify(self, language: str) -> RecommendedAppCatalogPage: ...
|
||||
|
||||
def get_detail(self, app_id: str) -> RecommendedAppDetailRecord | None: ...
|
||||
|
||||
def contains(self, app_id: str) -> bool: ...
|
||||
|
||||
|
||||
class TrialAppQuery(Protocol):
|
||||
def existing_ids(self, app_ids: Sequence[str]) -> Set[str]: ...
|
||||
@@ -99,7 +97,7 @@ class RecommendedAppQueryService:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
catalog: RecommendedAppCatalogGateway,
|
||||
catalog: RecommendedAppCatalogQuery,
|
||||
trial_apps: TrialAppQuery,
|
||||
trial_enabled: bool,
|
||||
) -> None:
|
||||
@@ -113,7 +111,7 @@ class RecommendedAppQueryService:
|
||||
def is_previewable(self, app_id: str) -> bool:
|
||||
if app_id in self._trial_apps.existing_ids((app_id,)):
|
||||
return True
|
||||
return self._catalog.is_recommended(app_id)
|
||||
return self._catalog.contains(app_id)
|
||||
|
||||
def list_recommended(
|
||||
self,
|
||||
@@ -123,8 +121,6 @@ class RecommendedAppQueryService:
|
||||
) -> RecommendedAppListResult:
|
||||
language = self._resolve_language(requested_language, interface_language)
|
||||
page = self._catalog.list_recommended(language)
|
||||
if not page.recommended_apps:
|
||||
page = self._catalog.list_builtin(_BUILTIN_FALLBACK_LANGUAGE)
|
||||
|
||||
return RecommendedAppListResult(
|
||||
recommended_apps=self._with_trial_status(page.recommended_apps),
|
||||
|
||||
Reference in new issue
Block a user