mirror of
https://github.com/langgenius/dify.git
synced 2026-09-28 06:13:22 +08:00
1022 lines
40 KiB
Python
1022 lines
40 KiB
Python
import json
|
|
import logging
|
|
from collections.abc import Callable, Mapping, Sequence
|
|
from copy import deepcopy
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
from typing import Any, NotRequired, TypedDict, cast
|
|
|
|
import sqlalchemy as sa
|
|
from pydantic import JsonValue
|
|
from sqlalchemy import ColumnElement, delete, select
|
|
from sqlalchemy.exc import IntegrityError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from configs import dify_config
|
|
from constants.model_template import default_app_templates
|
|
from core.agent.publish_visibility import agent_has_workflow_callable_active_snapshot
|
|
from core.agent.tool_configuration import mask_agent_tool_parameters
|
|
from core.errors.error import LLMBadRequestError, ProviderTokenNotInitError
|
|
from core.model_manager import ModelManager
|
|
from enums import DeploymentEdition
|
|
from events.app_event import app_was_created, app_was_deleted, app_was_updated
|
|
from extensions.ext_database import db # noqa: F401
|
|
from graphon.model_runtime.entities.model_entities import ModelPropertyKey, ModelType
|
|
from graphon.model_runtime.model_providers.base.large_language_model import LargeLanguageModel
|
|
from libs.datetime_utils import naive_utc_now
|
|
from libs.login import current_user
|
|
from libs.pagination import PaginatedResult, paginate_query
|
|
from models import Account, AppStar
|
|
from models.agent import (
|
|
APP_BACKED_AGENT_SOURCES,
|
|
Agent,
|
|
AgentIconType,
|
|
AgentScope,
|
|
AgentStatus,
|
|
AgentWorkingResourceStatus,
|
|
AgentWorkspaceBinding,
|
|
WorkflowAgentBindingType,
|
|
WorkflowAgentNodeBinding,
|
|
)
|
|
from models.agent_config_entities import AgentSoulConfig, AgentSoulModelConfig
|
|
from models.model import App, AppMode, AppModelConfig, IconType, Site, load_annotation_reply_config
|
|
from models.provider_ids import ModelProviderID
|
|
from models.skill import AgentSkillBinding
|
|
from models.workflow import Workflow
|
|
from services.agent.errors import AgentAccessNotReadyError, AgentNameConflictError
|
|
from services.agent.home_snapshot_service import AgentHomeSnapshotService
|
|
from services.agent.retirement_service import WorkflowAgentRetirementService
|
|
from services.agent.workspace_service import AgentWorkspaceService
|
|
from services.billing_service import BillingService
|
|
from services.enterprise import rbac_service as enterprise_rbac_service
|
|
from services.enterprise.enterprise_service import EnterpriseService
|
|
from services.entities.app_entities import (
|
|
AgentAppPublicationCounts,
|
|
AppCreationSettings,
|
|
AppDeletion,
|
|
AppEvent,
|
|
AppListBaseParams,
|
|
AppListParams,
|
|
AppListSortBy,
|
|
CreateAppParams,
|
|
)
|
|
from services.model_provider_service import ModelProviderService
|
|
from services.openapi.visibility import apply_openapi_gate, is_openapi_visible
|
|
from services.rbac_agent_access_service import initialize_agent_rbac_access
|
|
from services.system_feature_service import SystemFeatureService
|
|
from services.tag_service import TagService
|
|
from tasks.collect_agent_resources_task import enqueue_agent_resource_collection
|
|
from tasks.remove_app_and_related_data_task import remove_app_and_related_data_task
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class _CreatedApp:
|
|
tenant_id: str
|
|
creator_account_id: str
|
|
app_id: str
|
|
backing_agent_id: str | None
|
|
|
|
|
|
def _initialize_created_app_access(created: _CreatedApp) -> None:
|
|
enterprise_rbac_service.try_sync_creator_access_policy_member_bindings(
|
|
created.tenant_id,
|
|
created.creator_account_id,
|
|
enterprise_rbac_service.RBACResourceType.APP,
|
|
created.app_id,
|
|
)
|
|
|
|
|
|
def _initialize_created_agent_access(created: _CreatedApp) -> None:
|
|
if created.backing_agent_id is None:
|
|
raise ValueError(f"agent app {created.app_id} was created without a backing agent")
|
|
initialize_agent_rbac_access(
|
|
tenant_id=created.tenant_id,
|
|
agent_id=created.backing_agent_id,
|
|
creator_account_id=created.creator_account_id,
|
|
)
|
|
|
|
|
|
_CREATED_APP_ACCESS_INITIALIZERS: dict[AppMode, Callable[[_CreatedApp], None]] = {
|
|
AppMode.AGENT: _initialize_created_agent_access,
|
|
}
|
|
|
|
|
|
class AppModelConfigResponseView:
|
|
"""Expose AppModelConfig response properties through the request session."""
|
|
|
|
def __init__(self, app_model_config: AppModelConfig, *, session: Session) -> None:
|
|
self._app_model_config = app_model_config
|
|
self._session = session
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(self._app_model_config, name) # guard-ignore: no-new-getattr -- delegates model fields
|
|
|
|
@property
|
|
def annotation_reply_dict(self) -> Any:
|
|
return load_annotation_reply_config(self._session, self._app_model_config.app_id)
|
|
|
|
|
|
class AppResponseView:
|
|
"""Expose App response properties through one caller-owned database session."""
|
|
|
|
def __init__(
|
|
self, app: App, *, session: Session, account: Account | None = None, access_mode: str | None = None
|
|
) -> None:
|
|
self._app = app
|
|
self._session = session
|
|
self._account = account
|
|
self._access_mode = access_mode
|
|
|
|
def __getattr__(self, name: str) -> Any:
|
|
return getattr(self._app, name) # guard-ignore: no-new-getattr -- delegates model fields
|
|
|
|
@property
|
|
def desc_or_prompt(self) -> str:
|
|
return self._app.desc_or_prompt_with_session(session=self._session)
|
|
|
|
@property
|
|
def site(self) -> Site | None:
|
|
return self._app.site_with_session(session=self._session)
|
|
|
|
@property
|
|
def app_model_config(self) -> AppModelConfigResponseView | None:
|
|
app_model_config = self._app.app_model_config_with_session(session=self._session)
|
|
if app_model_config is None:
|
|
return None
|
|
if self._account is not None and (
|
|
self._app.mode == AppMode.AGENT_CHAT or self._app.is_agent_with_session(session=self._session)
|
|
):
|
|
tenant_id = self._account.current_tenant_id
|
|
assert tenant_id is not None
|
|
masked_agent_mode = mask_agent_tool_parameters(
|
|
agent_mode=cast(Mapping[str, JsonValue], app_model_config.agent_mode_dict),
|
|
app_id=self._app.id,
|
|
tenant_id=tenant_id,
|
|
user_id=self._account.id,
|
|
)
|
|
app_model_config = deepcopy(app_model_config)
|
|
app_model_config.agent_mode = json.dumps(masked_agent_mode)
|
|
return AppModelConfigResponseView(app_model_config, session=self._session)
|
|
|
|
@property
|
|
def workflow(self) -> Workflow | None:
|
|
return self._app.workflow_with_session(session=self._session)
|
|
|
|
@property
|
|
def bound_agent_id(self) -> str | None:
|
|
return self._app.bound_agent_id_with_session(session=self._session)
|
|
|
|
@property
|
|
def mode_compatible_with_agent(self) -> str:
|
|
return self._app.mode_compatible_with_agent_with_session(session=self._session)
|
|
|
|
@property
|
|
def access_mode(self) -> str | None:
|
|
return self._access_mode
|
|
|
|
@property
|
|
def permission_keys(self) -> list[str]:
|
|
return []
|
|
|
|
@property
|
|
def app_id(self) -> str | None:
|
|
return None
|
|
|
|
@property
|
|
def deleted_tools(self) -> list[Any]:
|
|
return self._app.deleted_tools_with_session(session=self._session)
|
|
|
|
@property
|
|
def tags(self) -> Sequence[Any]:
|
|
return self._app.tags_with_session(session=self._session)
|
|
|
|
@property
|
|
def author_name(self) -> str | None:
|
|
return self._app.author_name_with_session(session=self._session)
|
|
|
|
|
|
class AppService:
|
|
@staticmethod
|
|
def _agent_app_exists_filter(tenant_id: str, *, is_published: bool | None = None) -> sa.Exists:
|
|
agent_filters = [
|
|
Agent.tenant_id == tenant_id,
|
|
Agent.app_id == App.id,
|
|
Agent.scope == AgentScope.ROSTER,
|
|
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
|
Agent.status == AgentStatus.ACTIVE,
|
|
]
|
|
if is_published is not None:
|
|
has_published_config = sa.and_(
|
|
Agent.active_config_snapshot_id.is_not(None),
|
|
Agent.active_config_is_published.is_(True),
|
|
)
|
|
agent_filters.append(has_published_config if is_published else sa.not_(has_published_config))
|
|
|
|
return sa.exists().where(*agent_filters).correlate(App)
|
|
|
|
@staticmethod
|
|
def _build_app_list_filters(
|
|
user_id: str, tenant_id: str, params: AppListBaseParams, session: Session
|
|
) -> list[sa.ColumnElement[bool]]:
|
|
filters = [App.tenant_id == tenant_id, App.is_universal == False]
|
|
|
|
if params.mode == "workflow":
|
|
filters.append(App.mode == AppMode.WORKFLOW)
|
|
elif params.mode == "completion":
|
|
filters.append(App.mode == AppMode.COMPLETION)
|
|
elif params.mode == "chat":
|
|
filters.append(App.mode == AppMode.CHAT)
|
|
elif params.mode == "advanced-chat":
|
|
filters.append(App.mode == AppMode.ADVANCED_CHAT)
|
|
elif params.mode == "agent-chat":
|
|
filters.append(App.mode == AppMode.AGENT_CHAT)
|
|
elif params.mode == "agent":
|
|
filters.append(App.mode == AppMode.AGENT)
|
|
publication_filter = params.agent_is_published if isinstance(params, AppListParams) else None
|
|
filters.append(AppService._agent_app_exists_filter(tenant_id, is_published=publication_filter))
|
|
elif params.mode == "all":
|
|
filters.append(App.mode != AppMode.AGENT)
|
|
|
|
if isinstance(params, AppListParams):
|
|
if params.status:
|
|
filters.append(App.status == params.status)
|
|
# OpenAPI surface visibility gate. Pushed into the query so
|
|
# `pagination.total` reflects only apps the openapi caller can
|
|
# actually reach; post-filtering by enable_api after the page
|
|
# arrives would make `total` page-dependent.
|
|
if params.openapi_visible:
|
|
filters.append(App.enable_api.is_(True))
|
|
|
|
if params.is_created_by_me:
|
|
filters.append(App.created_by == user_id)
|
|
elif params.accessible_app_ids is not None:
|
|
accessible_filter: ColumnElement[bool] = App.id.in_(params.accessible_app_ids)
|
|
if params.include_own_apps:
|
|
accessible_filter = sa.or_(App.maintainer == user_id, accessible_filter)
|
|
filters.append(accessible_filter)
|
|
if params.creator_ids:
|
|
filters.append(App.created_by.in_(params.creator_ids))
|
|
if params.name:
|
|
from libs.helper import escape_like_pattern
|
|
|
|
name = params.name[:30]
|
|
escaped_name = escape_like_pattern(name)
|
|
filters.append(App.name.ilike(f"%{escaped_name}%", escape="\\"))
|
|
if params.tag_ids and len(params.tag_ids) > 0:
|
|
target_ids = TagService.get_target_ids_by_tag_ids("app", tenant_id, params.tag_ids, session, match_all=True)
|
|
if target_ids and len(target_ids) > 0:
|
|
filters.append(App.id.in_(target_ids))
|
|
else:
|
|
return []
|
|
|
|
return filters
|
|
|
|
@staticmethod
|
|
def _build_app_list_order_by(sort_by: AppListSortBy) -> sa.ColumnElement[Any]:
|
|
return {
|
|
"last_modified": App.updated_at.desc(),
|
|
"recently_created": App.created_at.desc(),
|
|
"earliest_created": App.created_at.asc(),
|
|
}[sort_by]
|
|
|
|
@staticmethod
|
|
def get_starred_app_ids(
|
|
session: Session,
|
|
*,
|
|
tenant_id: str,
|
|
account_id: str,
|
|
app_ids: Sequence[str],
|
|
) -> set[str]:
|
|
"""Return app IDs starred by this account within the tenant."""
|
|
if not app_ids:
|
|
return set()
|
|
|
|
starred_app_ids = session.scalars(
|
|
select(AppStar.app_id).where(
|
|
AppStar.tenant_id == tenant_id,
|
|
AppStar.account_id == account_id,
|
|
AppStar.app_id.in_(list(app_ids)),
|
|
)
|
|
).all()
|
|
return set(starred_app_ids)
|
|
|
|
@staticmethod
|
|
def get_app_by_id(
|
|
app_id: str,
|
|
session: Session,
|
|
) -> App | None:
|
|
return session.get(App, app_id)
|
|
|
|
@staticmethod
|
|
def get_visible_app_by_id(
|
|
app_id: str,
|
|
session: Session,
|
|
) -> App | None:
|
|
app = session.get(App, app_id)
|
|
if not app or app.status != "normal" or not is_openapi_visible(app):
|
|
return None
|
|
return app
|
|
|
|
@staticmethod
|
|
def find_visible_apps_by_ids(
|
|
app_ids: Sequence[str],
|
|
session: Session,
|
|
) -> list[App]:
|
|
if not app_ids:
|
|
return []
|
|
return list(session.execute(apply_openapi_gate(select(App).where(App.id.in_(list(app_ids))))).scalars().all())
|
|
|
|
def get_paginate_apps(
|
|
self,
|
|
user_id: str,
|
|
tenant_id: str,
|
|
params: AppListParams,
|
|
session: Session,
|
|
) -> PaginatedResult | None:
|
|
"""
|
|
Get app list with pagination, filters, and explicit sort order.
|
|
:param user_id: user id
|
|
:param tenant_id: tenant id
|
|
:param params: query parameters
|
|
:return:
|
|
"""
|
|
filters = self._build_app_list_filters(user_id, tenant_id, params, session)
|
|
if not filters:
|
|
return None
|
|
|
|
order_by = self._build_app_list_order_by(params.sort_by)
|
|
|
|
app_models = paginate_query(
|
|
sa.select(App).where(*filters).order_by(order_by),
|
|
page=params.page,
|
|
per_page=params.limit,
|
|
session=session,
|
|
)
|
|
|
|
app_ids = [str(app.id) for app in app_models.items]
|
|
starred_app_ids = self.get_starred_app_ids(
|
|
session=session,
|
|
tenant_id=tenant_id,
|
|
account_id=user_id,
|
|
app_ids=app_ids,
|
|
)
|
|
for app in app_models.items:
|
|
app.is_starred = str(app.id) in starred_app_ids
|
|
|
|
return app_models
|
|
|
|
def get_agent_publication_counts(
|
|
self,
|
|
user_id: str,
|
|
tenant_id: str,
|
|
params: AppListParams,
|
|
session: Session,
|
|
) -> AgentAppPublicationCounts:
|
|
unfiltered_params = params.model_copy(update={"agent_is_published": None})
|
|
filters = self._build_app_list_filters(user_id, tenant_id, unfiltered_params, session)
|
|
if not filters:
|
|
return AgentAppPublicationCounts(published=0, drafts=0)
|
|
|
|
published_filter = self._agent_app_exists_filter(tenant_id, is_published=True)
|
|
draft_filter = self._agent_app_exists_filter(tenant_id, is_published=False)
|
|
published_count, draft_count = session.execute(
|
|
sa.select(
|
|
sa.func.coalesce(sa.func.sum(sa.case((published_filter, 1), else_=0)), 0),
|
|
sa.func.coalesce(sa.func.sum(sa.case((draft_filter, 1), else_=0)), 0),
|
|
)
|
|
.select_from(App)
|
|
.where(*filters)
|
|
).one()
|
|
|
|
return AgentAppPublicationCounts(published=int(published_count), drafts=int(draft_count))
|
|
|
|
@staticmethod
|
|
def prepare_agent_soul(tenant_id: str, *, session: Session) -> AgentSoulConfig | None:
|
|
"""Build the initial Agent Soul from the workspace default model, when available."""
|
|
default_model = ModelProviderService().get_default_model_selection(tenant_id, ModelType.LLM, session=session)
|
|
if default_model is None:
|
|
return None
|
|
agent_provider, agent_model = default_model
|
|
try:
|
|
provider_id = ModelProviderID(agent_provider)
|
|
except ValueError:
|
|
logger.warning("Invalid Agent default model, tenant_id: %s", tenant_id, exc_info=True)
|
|
return None
|
|
return AgentSoulConfig(
|
|
model=AgentSoulModelConfig(
|
|
plugin_id=provider_id.plugin_id,
|
|
model_provider=str(provider_id),
|
|
model=agent_model,
|
|
)
|
|
)
|
|
|
|
def create_app(
|
|
self,
|
|
tenant_id: str,
|
|
params: CreateAppParams,
|
|
account: Account,
|
|
*,
|
|
session: Session,
|
|
) -> App:
|
|
"""
|
|
Create app
|
|
:param tenant_id: tenant id
|
|
:param params: app creation parameters
|
|
:param account: Account instance
|
|
"""
|
|
app_mode = AppMode.value_of(params.mode)
|
|
app_template = default_app_templates[app_mode]
|
|
initial_agent_soul = self.prepare_agent_soul(tenant_id, session=session) if app_mode == AppMode.AGENT else None
|
|
|
|
# get model config
|
|
default_model_config = app_template.get("model_config")
|
|
default_model_config = default_model_config.copy() if default_model_config else None
|
|
if default_model_config and "model" in default_model_config:
|
|
default_model_dict = default_model_config["model"]
|
|
# get model provider
|
|
model_manager = ModelManager.for_tenant(tenant_id=account.current_tenant_id or "")
|
|
|
|
# get default model instance
|
|
try:
|
|
model_instance = model_manager.get_default_model_instance(
|
|
tenant_id=account.current_tenant_id or "", model_type=ModelType.LLM
|
|
)
|
|
except (ProviderTokenNotInitError, LLMBadRequestError):
|
|
model_instance = None
|
|
except Exception:
|
|
logger.exception("Get default model instance failed, tenant_id: %s", tenant_id)
|
|
model_instance = None
|
|
|
|
if model_instance is not None:
|
|
if (
|
|
model_instance.model_name == default_model_config["model"]["name"]
|
|
and model_instance.provider == default_model_config["model"]["provider"]
|
|
):
|
|
default_model_dict = default_model_config["model"]
|
|
else:
|
|
llm_model = cast(LargeLanguageModel, model_instance.model_type_instance)
|
|
try:
|
|
model_schema = llm_model.get_model_schema(model_instance.model_name, model_instance.credentials)
|
|
if model_schema is None:
|
|
raise ValueError(f"model schema not found for model {model_instance.model_name}")
|
|
except Exception:
|
|
# A removed provider model must not prevent creating an app.
|
|
logger.warning(
|
|
"Default model schema is unavailable, tenant_id: %s, provider: %s, model: %s",
|
|
tenant_id,
|
|
model_instance.provider,
|
|
model_instance.model_name,
|
|
exc_info=True,
|
|
)
|
|
model_instance = None
|
|
else:
|
|
default_model_dict = {
|
|
"provider": model_instance.provider,
|
|
"name": model_instance.model_name,
|
|
"mode": model_schema.model_properties.get(ModelPropertyKey.MODE),
|
|
"completion_params": {},
|
|
}
|
|
if model_instance is None:
|
|
try:
|
|
provider, model = model_manager.get_default_provider_model_name(
|
|
tenant_id=account.current_tenant_id or "", model_type=ModelType.LLM
|
|
)
|
|
except Exception:
|
|
logger.exception("Get default provider model failed, tenant_id: %s", tenant_id)
|
|
provider = default_model_config["model"].get("provider")
|
|
model = default_model_config["model"].get("name")
|
|
|
|
if provider:
|
|
default_model_config["model"]["provider"] = provider
|
|
if model:
|
|
default_model_config["model"]["name"] = model
|
|
default_model_dict = default_model_config["model"]
|
|
|
|
default_model_config["model"] = json.dumps(default_model_dict)
|
|
|
|
app = App(**app_template["app"])
|
|
app.name = params.name
|
|
app.description = params.description or ""
|
|
app.mode = app_mode
|
|
app.icon_type = IconType(params.icon_type) if params.icon_type else IconType.EMOJI
|
|
app.icon = params.icon
|
|
app.icon_background = params.icon_background
|
|
app.tenant_id = tenant_id
|
|
app.api_rph = params.api_rph
|
|
app.api_rpm = params.api_rpm
|
|
app.max_active_requests = params.max_active_requests
|
|
app.created_by = account.id
|
|
app.maintainer = account.id
|
|
app.updated_by = account.id
|
|
|
|
session.add(app)
|
|
session.flush()
|
|
|
|
if default_model_config:
|
|
app_model_config = AppModelConfig(
|
|
**default_model_config, app_id=app.id, created_by=account.id, updated_by=account.id
|
|
)
|
|
session.add(app_model_config)
|
|
session.flush()
|
|
|
|
app.app_model_config_id = app_model_config.id
|
|
elif app_mode == AppMode.AGENT:
|
|
# An Agent App keeps its model / prompt / tools in the bound Agent
|
|
# Soul, so the app_model_config row carries no model — only the
|
|
# app-level presentation features the PRD requires (conversation
|
|
# opener, follow-up suggestions, citations, moderation, annotation).
|
|
# They default to disabled/empty here and are read by both the
|
|
# webapp /parameters endpoint and the chat pipeline. agent_mode is
|
|
# left unset so App.is_agent stays False (this is the new Agent App
|
|
# type, not a legacy function-call/react agent).
|
|
agent_app_model_config = AppModelConfig(app_id=app.id, created_by=account.id, updated_by=account.id)
|
|
session.add(agent_app_model_config)
|
|
session.flush()
|
|
|
|
app.app_model_config_id = agent_app_model_config.id
|
|
|
|
# Agent App type is backed 1:1 by a roster Agent (linked via Agent.app_id).
|
|
# Created in the same transaction so the App and its backing Agent persist
|
|
# atomically; the Agent Soul starts with the workspace model selection.
|
|
backing_agent: Agent | None = None
|
|
if app_mode == AppMode.AGENT:
|
|
from services.agent.roster_service import AgentRosterService
|
|
|
|
icon_type = AgentIconType(params.icon_type) if params.icon_type else None
|
|
try:
|
|
backing_agent = AgentRosterService(session).create_backing_agent_for_app(
|
|
tenant_id=tenant_id,
|
|
account_id=account.id,
|
|
app_id=app.id,
|
|
name=params.name,
|
|
description=params.description or "",
|
|
role=params.agent_role,
|
|
icon_type=icon_type,
|
|
icon=params.icon,
|
|
icon_background=params.icon_background,
|
|
initial_soul=initial_agent_soul,
|
|
)
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
raise AgentNameConflictError() from exc
|
|
|
|
session.flush()
|
|
|
|
# Preserve the original commit-before-signal ordering for telemetry.
|
|
session.commit()
|
|
self.finalize_created_app(
|
|
app=app,
|
|
backing_agent_id=backing_agent.id if backing_agent else None,
|
|
account=account,
|
|
session=session,
|
|
)
|
|
return app
|
|
|
|
def finalize_created_app(
|
|
self,
|
|
*,
|
|
app: App,
|
|
backing_agent_id: str | None,
|
|
account: Account,
|
|
session: Session,
|
|
created_records_initialized: bool = False,
|
|
) -> None:
|
|
"""Run post-commit App creation hooks and external access initialization."""
|
|
|
|
app_was_created.send(
|
|
app,
|
|
account=account,
|
|
session=session,
|
|
created_records_initialized=created_records_initialized,
|
|
)
|
|
session.commit()
|
|
app_mode = app.mode
|
|
initialize_access = _CREATED_APP_ACCESS_INITIALIZERS.get(app_mode, _initialize_created_app_access)
|
|
initialize_access(
|
|
_CreatedApp(
|
|
tenant_id=app.tenant_id,
|
|
creator_account_id=account.id,
|
|
app_id=app.id,
|
|
backing_agent_id=backing_agent_id,
|
|
)
|
|
)
|
|
|
|
if SystemFeatureService.is_webapp_auth_enabled():
|
|
# update web app setting as private
|
|
EnterpriseService.WebAppAuth.update_app_access_mode(app.id, "private")
|
|
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
|
BillingService.clean_billing_info_cache(app.tenant_id)
|
|
|
|
class ArgsDict(TypedDict):
|
|
name: str
|
|
description: str
|
|
icon_type: IconType | str | None
|
|
icon: str
|
|
icon_background: str
|
|
use_icon_as_answer_icon: bool
|
|
max_active_requests: int
|
|
role: NotRequired[str | None]
|
|
|
|
@staticmethod
|
|
def _get_backing_agent(app: App, *, session: Session) -> Agent | None:
|
|
if app.mode != AppMode.AGENT:
|
|
return None
|
|
return session.scalar(
|
|
select(Agent).where(
|
|
Agent.tenant_id == app.tenant_id,
|
|
Agent.app_id == app.id,
|
|
Agent.scope == AgentScope.ROSTER,
|
|
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
|
Agent.status == AgentStatus.ACTIVE,
|
|
)
|
|
)
|
|
|
|
@staticmethod
|
|
def _to_agent_icon_type(icon_type: IconType | str | None) -> AgentIconType | None:
|
|
if icon_type is None:
|
|
return None
|
|
value = icon_type.value if isinstance(icon_type, IconType) else icon_type
|
|
return AgentIconType(value)
|
|
|
|
def _sync_backing_agent_identity(
|
|
self,
|
|
app: App,
|
|
*,
|
|
name: str | None = None,
|
|
description: str | None = None,
|
|
role: str | None = None,
|
|
icon_type: IconType | str | None = None,
|
|
icon: str | None = None,
|
|
icon_background: str | None = None,
|
|
account_id: str | None = None,
|
|
updated_at: datetime | None = None,
|
|
session: Session,
|
|
) -> None:
|
|
"""Keep the Roster identity aligned with its Agent App shell.
|
|
|
|
Agent Soul remains versioned through Composer. This helper only mirrors
|
|
user-facing identity fields, including the roster role/persona label,
|
|
so Roster and Agent Console do not drift.
|
|
|
|
Role omission is intentional: ``role=None`` preserves the backing
|
|
Agent's current role, while ``role=""`` explicitly clears it.
|
|
"""
|
|
agent = self._get_backing_agent(app, session=session)
|
|
if agent is None:
|
|
return
|
|
|
|
if name is not None:
|
|
agent.name = name
|
|
if description is not None:
|
|
agent.description = description
|
|
if role is not None:
|
|
agent.role = role
|
|
if icon_type is not None:
|
|
agent.icon_type = self._to_agent_icon_type(icon_type)
|
|
if icon is not None:
|
|
agent.icon = icon
|
|
if icon_background is not None:
|
|
agent.icon_background = icon_background
|
|
agent.updated_by = account_id
|
|
if updated_at is not None:
|
|
agent.updated_at = updated_at
|
|
|
|
@staticmethod
|
|
def _commit_app_identity_update(app: App, *, session: Session) -> None:
|
|
try:
|
|
session.commit()
|
|
except IntegrityError as exc:
|
|
session.rollback()
|
|
if app.mode == AppMode.AGENT:
|
|
raise AgentNameConflictError() from exc
|
|
raise
|
|
|
|
def update_app(self, app: App, args: ArgsDict, *, session: Session) -> App:
|
|
"""
|
|
Update app
|
|
:param app: App instance
|
|
:param args: request args
|
|
:return: App instance
|
|
"""
|
|
assert current_user is not None
|
|
app.name = args["name"]
|
|
app.description = args["description"]
|
|
icon_type = args.get("icon_type")
|
|
if icon_type is None:
|
|
resolved_icon_type = app.icon_type
|
|
else:
|
|
resolved_icon_type = IconType(icon_type)
|
|
|
|
app.icon_type = resolved_icon_type
|
|
app.icon = args["icon"]
|
|
app.icon_background = args["icon_background"]
|
|
app.use_icon_as_answer_icon = args.get("use_icon_as_answer_icon", False)
|
|
app.max_active_requests = args.get("max_active_requests")
|
|
app.updated_by = current_user.id
|
|
app.updated_at = naive_utc_now()
|
|
self._sync_backing_agent_identity(
|
|
app,
|
|
name=app.name,
|
|
description=app.description,
|
|
# Omitted role must stay omitted here: None means "preserve current
|
|
# backing-agent role", while an empty string is an explicit clear.
|
|
role=args.get("role"),
|
|
icon_type=app.icon_type,
|
|
icon=app.icon,
|
|
icon_background=app.icon_background,
|
|
account_id=current_user.id,
|
|
updated_at=app.updated_at,
|
|
session=session,
|
|
)
|
|
self._commit_app_identity_update(app, session=session)
|
|
|
|
app_was_updated.send(app)
|
|
|
|
return app
|
|
|
|
@staticmethod
|
|
def is_agent_app_access_ready(app: App, *, session: Session) -> bool:
|
|
"""Return whether an Agent App has a publish-visible active snapshot."""
|
|
|
|
if app.mode != AppMode.AGENT:
|
|
return True
|
|
agent = session.scalar(
|
|
select(Agent)
|
|
.where(
|
|
Agent.tenant_id == app.tenant_id,
|
|
Agent.app_id == app.id,
|
|
Agent.scope == AgentScope.ROSTER,
|
|
Agent.source.in_(APP_BACKED_AGENT_SOURCES),
|
|
Agent.status == AgentStatus.ACTIVE,
|
|
)
|
|
.limit(1)
|
|
)
|
|
return bool(agent and agent_has_workflow_callable_active_snapshot(session=session, agent=agent))
|
|
|
|
@classmethod
|
|
def ensure_agent_app_access_ready(cls, app: App, *, session: Session) -> None:
|
|
if not cls.is_agent_app_access_ready(app, session=session):
|
|
raise AgentAccessNotReadyError()
|
|
|
|
def update_app_api_status(self, app: App, enable_api: bool, *, session: Session) -> App:
|
|
"""
|
|
Update app api status
|
|
:param app: App instance
|
|
:param enable_api: enable api status
|
|
:return: App instance
|
|
"""
|
|
if enable_api:
|
|
self.ensure_agent_app_access_ready(app, session=session)
|
|
if enable_api == app.enable_api:
|
|
return app
|
|
assert current_user is not None
|
|
|
|
app.enable_api = enable_api
|
|
app.updated_by = current_user.id
|
|
app.updated_at = naive_utc_now()
|
|
session.commit()
|
|
|
|
app_was_updated.send(app)
|
|
|
|
return app
|
|
|
|
def delete_app(self, app: App, *, session: Session) -> None:
|
|
"""Delete an App and commit the passed session.
|
|
|
|
The transaction releases all of a Workflow App's binding owners across
|
|
draft and published versions, archives a backing Roster Agent, retires
|
|
its resources, and deletes the App. Deleting a Roster Agent's backing
|
|
App does not remove bindings owned by external Workflows.
|
|
|
|
After commit, the main App cleanup is published first, followed by
|
|
workflow-only Agent retirement and the Roster resource collector. Any
|
|
publication failure propagates.
|
|
"""
|
|
app_was_deleted.send(app)
|
|
|
|
backing_agent = self._get_backing_agent(app, session=session)
|
|
workflow_agent_ids = set(
|
|
session.scalars(
|
|
select(Agent.id).where(
|
|
Agent.tenant_id == app.tenant_id,
|
|
Agent.app_id == app.id,
|
|
Agent.scope == AgentScope.WORKFLOW_ONLY,
|
|
Agent.status == AgentStatus.ACTIVE,
|
|
)
|
|
).all()
|
|
)
|
|
if app.mode in (AppMode.WORKFLOW, AppMode.ADVANCED_CHAT):
|
|
workflow_agent_ids.update(
|
|
agent_id
|
|
for agent_id in session.scalars(
|
|
select(WorkflowAgentNodeBinding.agent_id).where(
|
|
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
|
|
WorkflowAgentNodeBinding.app_id == app.id,
|
|
WorkflowAgentNodeBinding.binding_type == WorkflowAgentBindingType.INLINE_AGENT,
|
|
WorkflowAgentNodeBinding.agent_id.is_not(None),
|
|
)
|
|
).all()
|
|
if agent_id
|
|
)
|
|
session.execute(
|
|
delete(WorkflowAgentNodeBinding).where(
|
|
WorkflowAgentNodeBinding.tenant_id == app.tenant_id,
|
|
WorkflowAgentNodeBinding.app_id == app.id,
|
|
)
|
|
)
|
|
agent_ids_to_unbind = set(workflow_agent_ids)
|
|
if backing_agent is not None:
|
|
agent_ids_to_unbind.add(backing_agent.id)
|
|
if agent_ids_to_unbind:
|
|
session.execute(
|
|
delete(AgentSkillBinding).where(
|
|
AgentSkillBinding.tenant_id == app.tenant_id,
|
|
AgentSkillBinding.agent_id.in_(agent_ids_to_unbind),
|
|
)
|
|
)
|
|
account_id = current_user.id if current_user else None
|
|
if backing_agent is not None:
|
|
now = naive_utc_now()
|
|
backing_agent.status = AgentStatus.ARCHIVED
|
|
backing_agent.archived_by = account_id
|
|
backing_agent.archived_at = now
|
|
backing_agent.updated_by = account_id
|
|
backing_agent.updated_at = now
|
|
|
|
retired_binding_ids: list[str] = []
|
|
retired_snapshot_ids: list[str] = []
|
|
if backing_agent is not None:
|
|
bindings = session.scalars(
|
|
select(AgentWorkspaceBinding).where(
|
|
AgentWorkspaceBinding.tenant_id == app.tenant_id,
|
|
AgentWorkspaceBinding.agent_id == backing_agent.id,
|
|
)
|
|
).all()
|
|
for binding in bindings:
|
|
if binding.status == AgentWorkingResourceStatus.ACTIVE:
|
|
AgentWorkspaceService.retire_binding(
|
|
session=session,
|
|
tenant_id=app.tenant_id,
|
|
binding_id=binding.id,
|
|
)
|
|
retired_binding_ids.append(binding.id)
|
|
retired_snapshot_ids = AgentHomeSnapshotService.retire_all_for_agent(
|
|
session=session,
|
|
tenant_id=app.tenant_id,
|
|
agent_id=backing_agent.id,
|
|
)
|
|
|
|
retired_workspace_ids = AgentWorkspaceService.retire_all_for_app(
|
|
session=session,
|
|
tenant_id=app.tenant_id,
|
|
app_id=app.id,
|
|
)
|
|
session.delete(app)
|
|
session.commit()
|
|
|
|
try:
|
|
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to enqueue App cleanup",
|
|
extra={"tenant_id": app.tenant_id, "app_id": app.id},
|
|
)
|
|
raise
|
|
|
|
WorkflowAgentRetirementService.retire_unowned(
|
|
tenant_id=app.tenant_id,
|
|
agent_ids=workflow_agent_ids,
|
|
account_id=account_id,
|
|
)
|
|
enqueue_agent_resource_collection(
|
|
tenant_id=app.tenant_id,
|
|
workspace_ids=retired_workspace_ids,
|
|
binding_ids=retired_binding_ids,
|
|
home_snapshot_ids=retired_snapshot_ids,
|
|
purge_agent_ids=[backing_agent.id] if backing_agent is not None else [],
|
|
)
|
|
|
|
# clean up web app settings
|
|
if SystemFeatureService.is_webapp_auth_enabled():
|
|
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
|
|
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
|
BillingService.clean_billing_info_cache(app.tenant_id)
|
|
|
|
@staticmethod
|
|
def prepare_creation(tenant_id: str, params: CreateAppParams) -> AppCreationSettings:
|
|
app_mode = AppMode.value_of(params.mode)
|
|
app_template = default_app_templates[app_mode]
|
|
|
|
# get model config
|
|
default_model_config = app_template.get("model_config")
|
|
default_model_config = default_model_config.copy() if default_model_config else None
|
|
if default_model_config and "model" in default_model_config:
|
|
default_model_dict = default_model_config["model"]
|
|
# get model provider
|
|
model_manager = ModelManager.for_tenant(tenant_id=tenant_id)
|
|
|
|
# get default model instance
|
|
try:
|
|
model_instance = model_manager.get_default_model_instance(tenant_id=tenant_id, model_type=ModelType.LLM)
|
|
except (ProviderTokenNotInitError, LLMBadRequestError):
|
|
model_instance = None
|
|
except Exception:
|
|
logger.exception("Get default model instance failed, tenant_id: %s", tenant_id)
|
|
model_instance = None
|
|
|
|
if model_instance is not None:
|
|
if (
|
|
model_instance.model_name == default_model_config["model"]["name"]
|
|
and model_instance.provider == default_model_config["model"]["provider"]
|
|
):
|
|
default_model_dict = default_model_config["model"]
|
|
else:
|
|
llm_model = cast(LargeLanguageModel, model_instance.model_type_instance)
|
|
try:
|
|
model_schema = llm_model.get_model_schema(model_instance.model_name, model_instance.credentials)
|
|
if model_schema is None:
|
|
raise ValueError(f"model schema not found for model {model_instance.model_name}")
|
|
except Exception:
|
|
# A removed provider model must not prevent creating an app.
|
|
logger.warning(
|
|
"Default model schema is unavailable, tenant_id: %s, provider: %s, model: %s",
|
|
tenant_id,
|
|
model_instance.provider,
|
|
model_instance.model_name,
|
|
exc_info=True,
|
|
)
|
|
model_instance = None
|
|
else:
|
|
default_model_dict = {
|
|
"provider": model_instance.provider,
|
|
"name": model_instance.model_name,
|
|
"mode": model_schema.model_properties.get(ModelPropertyKey.MODE),
|
|
"completion_params": {},
|
|
}
|
|
if model_instance is None:
|
|
try:
|
|
provider, model = model_manager.get_default_provider_model_name(
|
|
tenant_id=tenant_id, model_type=ModelType.LLM
|
|
)
|
|
except Exception:
|
|
logger.exception("Get default provider model failed, tenant_id: %s", tenant_id)
|
|
provider = default_model_config["model"].get("provider")
|
|
model = default_model_config["model"].get("name")
|
|
|
|
if provider:
|
|
default_model_config["model"]["provider"] = provider
|
|
if model:
|
|
default_model_config["model"]["name"] = model
|
|
default_model_dict = default_model_config["model"]
|
|
|
|
default_model_config["model"] = json.dumps(default_model_dict)
|
|
|
|
return AppCreationSettings(dict(app_template["app"]), default_model_config)
|
|
|
|
@staticmethod
|
|
def notify_created_app(*, event: AppEvent, account_id: str, backing_agent_id: str | None) -> None:
|
|
app_was_created.send(event, created_records_initialized=True)
|
|
initialize_access = _CREATED_APP_ACCESS_INITIALIZERS.get(AppMode(event.mode), _initialize_created_app_access)
|
|
initialize_access(_CreatedApp(event.tenant_id, account_id, event.id, backing_agent_id))
|
|
if SystemFeatureService.is_webapp_auth_enabled():
|
|
EnterpriseService.WebAppAuth.update_app_access_mode(event.id, "private")
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
|
BillingService.clean_billing_info_cache(event.tenant_id)
|
|
|
|
@staticmethod
|
|
def notify_deleted_app(deleted: AppDeletion, *, account_id: str | None) -> None:
|
|
app = deleted.app
|
|
app_was_deleted.send(app)
|
|
try:
|
|
remove_app_and_related_data_task.delay(tenant_id=app.tenant_id, app_id=app.id)
|
|
except Exception:
|
|
logger.exception(
|
|
"Failed to enqueue App cleanup",
|
|
extra={"tenant_id": app.tenant_id, "app_id": app.id},
|
|
)
|
|
raise
|
|
|
|
WorkflowAgentRetirementService.retire_unowned(
|
|
tenant_id=app.tenant_id,
|
|
agent_ids=deleted.workflow_agent_ids,
|
|
account_id=account_id,
|
|
)
|
|
enqueue_agent_resource_collection(
|
|
tenant_id=app.tenant_id,
|
|
workspace_ids=deleted.workspace_ids,
|
|
binding_ids=deleted.binding_ids,
|
|
home_snapshot_ids=deleted.home_snapshot_ids,
|
|
purge_agent_ids=[deleted.backing_agent_id] if deleted.backing_agent_id is not None else [],
|
|
)
|
|
|
|
# clean up web app settings
|
|
if SystemFeatureService.is_webapp_auth_enabled():
|
|
EnterpriseService.WebAppAuth.cleanup_webapp(app.id)
|
|
|
|
if dify_config.DEPLOYMENT_EDITION == DeploymentEdition.CLOUD:
|
|
BillingService.clean_billing_info_cache(app.tenant_id)
|