diff --git a/api/app/schemas/app_schema.py b/api/app/schemas/app_schema.py index 9e7bc876..b63c73f5 100644 --- a/api/app/schemas/app_schema.py +++ b/api/app/schemas/app_schema.py @@ -368,6 +368,10 @@ class App(BaseModel): source_workspace_icon: Optional[str] = None # 共享来源工作空间图标 source_app_version: Optional[str] = None # 应用版本号 source_app_is_active: Optional[bool] = None # 应用是否生效 + share_id: Optional[uuid.UUID] = None # 分享记录ID(取消共享时使用) + shared_by: Optional[uuid.UUID] = None # 分享者用户ID + shared_by_name: Optional[str] = None # 分享者名称 + shared_at: Optional[datetime.datetime] = None # 分享时间 created_at: datetime.datetime updated_at: datetime.datetime @@ -379,6 +383,10 @@ class App(BaseModel): def _serialize_updated_at(self, dt: datetime.datetime): return int(dt.timestamp() * 1000) if dt else None + @field_serializer("shared_at", when_used="json") + def _serialize_shared_at(self, dt: Optional[datetime.datetime]): + return int(dt.timestamp() * 1000) if dt else None + class AgentConfig(BaseModel): """Agent 配置输出 Schema""" diff --git a/api/app/services/app_dsl_service.py b/api/app/services/app_dsl_service.py index 0c778b81..a10aa70a 100644 --- a/api/app/services/app_dsl_service.py +++ b/api/app/services/app_dsl_service.py @@ -11,6 +11,7 @@ from app.core.error_codes import BizCode from app.core.exceptions import BusinessException, ResourceNotFoundException from app.models import AgentConfig, MultiAgentConfig from app.models.app_model import App, AppType +from app.models.appshare_model import AppShare from app.models.app_release_model import AppRelease from app.models.knowledge_model import Knowledge from app.models.models_model import ModelConfig @@ -298,11 +299,22 @@ class AppDslService: return new_app, warnings def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: + """生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用""" + # 本空间自有应用名 existing = {r[0] for r in self.db.query(App.name).filter( App.workspace_id == workspace_id, App.type == app_type, App.is_active.is_(True) ).all()} + # 共享到本空间的应用名 + shared_names = {r[0] for r in self.db.query(App.name).join( + AppShare, AppShare.source_app_id == App.id + ).filter( + AppShare.target_workspace_id == workspace_id, + App.type == app_type, + App.is_active.is_(True) + ).all()} + existing |= shared_names if name not in existing: return name counter = 1 diff --git a/api/app/services/app_service.py b/api/app/services/app_service.py index d0d44fd3..1b0613e8 100644 --- a/api/app/services/app_service.py +++ b/api/app/services/app_service.py @@ -7,6 +7,7 @@ - 应用发布和版本管理 - 应用回滚 """ +import copy import datetime import uuid from typing import Annotated, Any, Dict, List, Optional, Tuple @@ -80,6 +81,8 @@ class AppService: ) raise BusinessException("应用不在指定工作空间中", BizCode.WORKSPACE_NO_ACCESS) + + def _check_app_accessible(self, app: App, workspace_id: Optional[uuid.UUID]) -> bool: """检查应用是否可访问(包括共享应用) @@ -126,6 +129,28 @@ class AppService: ) raise BusinessException("应用不可访问", BizCode.WORKSPACE_NO_ACCESS) + def _unique_app_name(self, name: str, workspace_id: uuid.UUID, app_type: AppType) -> str: + """生成唯一应用名称,同时检查本空间自有应用和共享到本空间的应用""" + existing = {r[0] for r in self.db.query(App.name).filter( + App.workspace_id == workspace_id, + App.type == app_type, + App.is_active.is_(True) + ).all()} + shared_names = {r[0] for r in self.db.query(App.name).join( + AppShare, AppShare.source_app_id == App.id + ).filter( + AppShare.target_workspace_id == workspace_id, + App.type == app_type, + App.is_active.is_(True) + ).all()} + existing |= shared_names + if name not in existing: + return name + counter = 1 + while f"{name}({counter})" in existing: + counter += 1 + return f"{name}({counter})" + def _get_share_permission(self, app: App, workspace_id: Optional[uuid.UUID]) -> Optional[str]: """获取共享应用的权限 @@ -148,11 +173,11 @@ class AppService: return share.permission if share else None def _validate_app_writable(self, app: App, workspace_id: Optional[uuid.UUID]) -> None: - """Validate that the app config is writable (owner only). + """Validate that the app config is writable. - Shared apps (both readonly and editable) cannot modify config. - Own workspace app: allowed - - Any shared app: denied + - Shared app with editable permission: allowed + - Shared app with readonly permission: denied Raises: BusinessException: when app is not writable @@ -164,6 +189,11 @@ class AppService: if app.workspace_id == workspace_id: return + # Check share permission + permission = self._get_share_permission(app, workspace_id) + if permission == "editable": + return + logger.warning( "应用写操作被拒", extra={"app_id": str(app.id), "workspace_id": str(workspace_id)} @@ -506,6 +536,10 @@ class AppService: source_workspace_icon = None source_app_version = None source_app_is_active = None + share_id = None + shared_by = None + shared_by_name = None + shared_at = None if is_shared: # 查询共享权限和来源工作空间名称 @@ -517,7 +551,12 @@ class AppService: ) share = self.db.scalars(stmt).first() if share: + share_id = share.id share_permission = share.permission + shared_by = share.shared_by + shared_at = share.created_at + if share.shared_user: + shared_by_name = share.shared_user.username if share.source_workspace: source_workspace_name = share.source_workspace.name source_workspace_icon = share.source_workspace.icon @@ -547,6 +586,10 @@ class AppService: "source_workspace_icon": source_workspace_icon, "source_app_version": source_app_version, "source_app_is_active": source_app_is_active, + "share_id": share_id, + "shared_by": shared_by, + "shared_by_name": shared_by_name, + "shared_at": shared_at, "created_at": app.created_at, "updated_at": app.updated_at } @@ -761,6 +804,7 @@ class AppService: # 确定新应用名称 if not new_name: new_name = f"{source_app.name} - 副本" + new_name = self._unique_app_name(new_name, target_workspace_id, source_app.type) now = datetime.datetime.now() @@ -784,6 +828,19 @@ class AppService: self.db.add(new_app) self.db.flush() + # 判断是否跨工作空间复制(共享应用复制到自己的工作空间) + is_cross_workspace = target_workspace_id != source_app.workspace_id + + # 跨工作空间时,获取目标工作空间的 tenant_id 用于判断模型配置是否可用 + target_tenant_id = None + available_model_ids: set = set() + available_kb_ids: set = set() + if is_cross_workspace: + target_ws = self.db.get(Workspace, target_workspace_id) + if not target_ws: + raise ResourceNotFoundException("工作空间", str(target_workspace_id)) + target_tenant_id = target_ws.tenant_id + # 如果是 agent 类型,复制 AgentConfig if source_app.type == AppType.AGENT: source_config = self.db.query(AgentConfig).filter( @@ -791,16 +848,40 @@ class AppService: ).first() if source_config: + if is_cross_workspace: + # Batch-collect and preload all referenced resources + model_ids, kb_ids = self._collect_resource_ids_from_config( + source_config.default_model_config_id, + source_config.knowledge_retrieval, + source_config.tools + ) + available_model_ids, available_kb_ids = self._preload_cross_workspace_resources( + target_tenant_id, target_workspace_id, model_ids, kb_ids + ) + new_model_config_id = self._is_model_available( + source_config.default_model_config_id, available_model_ids + ) + new_knowledge_retrieval = self._clean_knowledge_retrieval( + source_config.knowledge_retrieval, available_kb_ids + ) + new_tools = self._clean_tools( + source_config.tools, available_kb_ids + ) + else: + new_model_config_id = source_config.default_model_config_id + new_knowledge_retrieval = copy.deepcopy(source_config.knowledge_retrieval) if source_config.knowledge_retrieval else None + new_tools = copy.deepcopy(source_config.tools) if source_config.tools else [] + new_config = AgentConfig( id=uuid.uuid4(), app_id=new_app.id, system_prompt=source_config.system_prompt, - default_model_config_id=source_config.default_model_config_id, - model_parameters=source_config.model_parameters.copy() if source_config.model_parameters else None, - knowledge_retrieval=source_config.knowledge_retrieval.copy() if source_config.knowledge_retrieval else None, - memory=source_config.memory.copy() if source_config.memory else None, - variables=source_config.variables.copy() if source_config.variables else [], - tools=source_config.tools.copy() if source_config.tools else [], + default_model_config_id=new_model_config_id, + model_parameters=copy.deepcopy(source_config.model_parameters) if source_config.model_parameters else None, + knowledge_retrieval=new_knowledge_retrieval, + memory=copy.deepcopy(source_config.memory) if source_config.memory else None, + variables=copy.deepcopy(source_config.variables) if source_config.variables else [], + tools=new_tools, is_active=True, created_at=now, updated_at=now, @@ -813,14 +894,29 @@ class AppService: ).first() if source_config: + if is_cross_workspace: + model_ids, kb_ids = self._collect_resource_ids_from_workflow_nodes( + source_config.nodes + ) + available_model_ids, available_kb_ids = self._preload_cross_workspace_resources( + target_tenant_id, target_workspace_id, model_ids, kb_ids + ) + new_nodes = self._clean_workflow_nodes_for_cross_workspace( + source_config.nodes or [], + available_model_ids, + available_kb_ids + ) + else: + new_nodes = copy.deepcopy(source_config.nodes) if source_config.nodes else [] + new_config = WorkflowConfig( id=uuid.uuid4(), app_id=new_app.id, - nodes=source_config.nodes.copy() if source_config.nodes else [], - edges=source_config.edges.copy() if source_config.edges else [], - variables=source_config.variables.copy() if source_config.variables else [], - execution_config=source_config.execution_config.copy() if source_config.execution_config else {}, - triggers=source_config.triggers.copy() if source_config.triggers else [], + nodes=new_nodes, + edges=copy.deepcopy(source_config.edges) if source_config.edges else [], + variables=copy.deepcopy(source_config.variables) if source_config.variables else [], + execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {}, + triggers=copy.deepcopy(source_config.triggers) if source_config.triggers else [], is_active=True, created_at=now, updated_at=now, @@ -833,17 +929,28 @@ class AppService: ).first() if source_config: + if is_cross_workspace: + model_ids = {source_config.default_model_config_id} if source_config.default_model_config_id else set() + available_model_ids, _ = self._preload_cross_workspace_resources( + target_tenant_id, target_workspace_id, model_ids, set() + ) + new_model_config_id = self._is_model_available( + source_config.default_model_config_id, available_model_ids + ) + else: + new_model_config_id = source_config.default_model_config_id + new_config = MultiAgentConfig( id=uuid.uuid4(), app_id=new_app.id, - master_agent_id=source_config.master_agent_id, + master_agent_id=source_config.master_agent_id if not is_cross_workspace else None, master_agent_name=source_config.master_agent_name, - default_model_config_id=source_config.default_model_config_id, + default_model_config_id=new_model_config_id, model_parameters=source_config.model_parameters, orchestration_mode=source_config.orchestration_mode, - sub_agents=source_config.sub_agents.copy() if source_config.sub_agents else [], - routing_rules=source_config.routing_rules.copy() if source_config.routing_rules else None, - execution_config=source_config.execution_config.copy() if source_config.execution_config else {}, + sub_agents=copy.deepcopy(source_config.sub_agents) if source_config.sub_agents else [], + routing_rules=copy.deepcopy(source_config.routing_rules) if source_config.routing_rules else None, + execution_config=copy.deepcopy(source_config.execution_config) if source_config.execution_config else {}, aggregation_strategy=source_config.aggregation_strategy, is_active=True, created_at=now, @@ -873,6 +980,241 @@ class AppService: ) raise BusinessException(f"应用复制失败: {str(e)}", BizCode.INTERNAL_ERROR, cause=e) + def _preload_cross_workspace_resources( + self, + target_tenant_id: Optional[uuid.UUID], + target_workspace_id: uuid.UUID, + model_config_ids: set, + kb_ids: set + ) -> tuple: + """Batch-load model configs and knowledge bases to avoid N+1 queries. + + Returns: + (available_model_ids, available_kb_ids): sets of IDs available in target workspace + """ + from app.models.models_model import ModelConfig as MC + from app.models.knowledge_model import Knowledge + from app.models.knowledgeshare_model import KnowledgeShare + + # Batch check model configs by tenant + available_model_ids: set = set() + if model_config_ids and target_tenant_id: + stmt = select(MC.id).where( + MC.id.in_(model_config_ids), + MC.tenant_id == target_tenant_id + ) + available_model_ids = set(self.db.scalars(stmt).all()) + + # Batch check knowledge bases + available_kb_ids: set = set() + if kb_ids: + kb_uuids = set() + for kid in kb_ids: + try: + kb_uuids.add(uuid.UUID(str(kid))) + except (ValueError, AttributeError): + pass + + if kb_uuids: + # KBs in target workspace + stmt = select(Knowledge.id).where( + Knowledge.id.in_(kb_uuids), + Knowledge.workspace_id == target_workspace_id + ) + available_kb_ids.update(self.db.scalars(stmt).all()) + + # KBs shared to target workspace + remaining = kb_uuids - available_kb_ids + if remaining: + stmt = select(KnowledgeShare.source_kb_id).where( + KnowledgeShare.source_kb_id.in_(remaining), + KnowledgeShare.target_workspace_id == target_workspace_id + ) + available_kb_ids.update(self.db.scalars(stmt).all()) + + return available_model_ids, available_kb_ids + + @staticmethod + def _collect_resource_ids_from_config( + model_config_id: Optional[uuid.UUID], + knowledge_retrieval: Optional[dict], + tools: Optional[list] + ) -> tuple: + """Extract all model config IDs and knowledge base IDs from an app config.""" + model_ids: set = set() + kb_ids: set = set() + + if model_config_id: + model_ids.add(model_config_id) + + if knowledge_retrieval and isinstance(knowledge_retrieval, dict): + if "kb_ids" in knowledge_retrieval: + for kid in knowledge_retrieval.get("kb_ids", []): + if kid: + kb_ids.add(str(kid)) + if knowledge_retrieval.get("knowledge_id"): + kb_ids.add(str(knowledge_retrieval["knowledge_id"])) + + if tools: + for tool in tools: + if isinstance(tool, dict): + kid = tool.get("knowledge_id") or tool.get("kb_id") + if kid: + kb_ids.add(str(kid)) + + return model_ids, kb_ids + + @staticmethod + def _collect_resource_ids_from_workflow_nodes(nodes: list) -> tuple: + """Extract all model config IDs and knowledge base IDs from workflow nodes.""" + model_ids: set = set() + kb_ids: set = set() + + for node in (nodes or []): + if not isinstance(node, dict): + continue + data = node.get("data", {}) + if not isinstance(data, dict): + continue + for key in ("model_config_id", "default_model_config_id"): + val = data.get(key) + if val: + try: + model_ids.add(uuid.UUID(str(val))) + except (ValueError, AttributeError): + pass + kr = data.get("knowledge_retrieval") + if isinstance(kr, dict): + for kid in kr.get("kb_ids", []): + if kid: + kb_ids.add(str(kid)) + if kr.get("knowledge_id"): + kb_ids.add(str(kr["knowledge_id"])) + if data.get("knowledge_id"): + kb_ids.add(str(data["knowledge_id"])) + for kid in data.get("kb_ids", []): + if kid: + kb_ids.add(str(kid)) + + return model_ids, kb_ids + + @staticmethod + def _is_model_available(model_config_id: Optional[uuid.UUID], available_model_ids: set) -> Optional[uuid.UUID]: + if not model_config_id: + return None + return model_config_id if model_config_id in available_model_ids else None + + @staticmethod + def _is_kb_available(kb_id: Optional[str], available_kb_ids: set) -> Optional[str]: + if not kb_id: + return None + try: + return kb_id if uuid.UUID(str(kb_id)) in available_kb_ids else None + except (ValueError, AttributeError): + return None + + def _clean_knowledge_retrieval( + self, + knowledge_retrieval: Optional[dict], + available_kb_ids: set + ) -> Optional[dict]: + """Clean knowledge retrieval config, keeping only available KBs.""" + if not knowledge_retrieval: + return None + + cleaned = copy.deepcopy(knowledge_retrieval) + + if "kb_ids" in cleaned and isinstance(cleaned["kb_ids"], list): + cleaned["kb_ids"] = [ + kid for kid in cleaned["kb_ids"] + if self._is_kb_available(kid, available_kb_ids) + ] + + if "knowledge_id" in cleaned: + cleaned["knowledge_id"] = self._is_kb_available( + cleaned.get("knowledge_id"), available_kb_ids + ) + + return cleaned + + def _clean_tools( + self, + tools: Optional[list], + available_kb_ids: set + ) -> list: + """Clean tools config, keeping built-in tools and tools with available KBs.""" + if not tools: + return [] + + cleaned = [] + for tool in tools: + if not isinstance(tool, dict): + cleaned.append(tool) + continue + + tool_type = tool.get("type", "") + if tool_type in ("builtin", "built_in", "system"): + cleaned.append(copy.deepcopy(tool)) + continue + + kb_id = tool.get("knowledge_id") or tool.get("kb_id") + if kb_id: + if self._is_kb_available(kb_id, available_kb_ids): + cleaned.append(copy.deepcopy(tool)) + continue + + cleaned.append(copy.deepcopy(tool)) + + return cleaned + + def _clean_workflow_nodes_for_cross_workspace( + self, + nodes: list, + available_model_ids: set, + available_kb_ids: set + ) -> list: + """Clean workflow nodes, using pre-loaded resource sets. Uses deepcopy to avoid mutating source.""" + if not nodes: + return [] + + cleaned = [] + for node in nodes: + if not isinstance(node, dict): + cleaned.append(node) + continue + + node_copy = copy.deepcopy(node) + data = node_copy.get("data") + if not isinstance(data, dict): + cleaned.append(node_copy) + continue + + for key in ("model_config_id", "default_model_config_id"): + if key in data and data[key]: + try: + mid = uuid.UUID(str(data[key])) + except (ValueError, AttributeError): + data[key] = None + continue + data[key] = str(mid) if mid in available_model_ids else None + + if "knowledge_retrieval" in data and data["knowledge_retrieval"]: + data["knowledge_retrieval"] = self._clean_knowledge_retrieval( + data["knowledge_retrieval"], available_kb_ids + ) + if "knowledge_id" in data: + data["knowledge_id"] = self._is_kb_available( + data.get("knowledge_id"), available_kb_ids + ) + if "kb_ids" in data and isinstance(data["kb_ids"], list): + data["kb_ids"] = [ + kid for kid in data["kb_ids"] + if self._is_kb_available(kid, available_kb_ids) + ] + + cleaned.append(node_copy) + return cleaned + def list_apps( self, *,