From db4701bae791e67c436b1772a370b2d36e62b241 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 22 Aug 2026 07:37:11 +0200 Subject: [PATCH] fix: BUG-080/082 (20 unused frontend components deleted), BUG-083 (useTenant.ts deleted), BUG-011 (playwright baseURL), BUG-069 (unused python modules deleted), BUG-065 (already has eager loading), BUG-026 (already fixed 422), BUG-023 (no sync I/O found) --- app/models/outbox_delivery.py | 57 -- .../mcp_client/tool_registry_integration.py | 94 -- app/plugins/quarantine.py | 215 ----- .../custom_field_definition_service.py | 172 ---- app/services/plugin_install_service.py | 187 ---- app/services/saved_view_service.py | 162 ---- app/workflows/code/onboarding.py | 118 --- frontend/playwright.config.ts | 6 +- frontend/src/components/AddressList.tsx | 289 ------ frontend/src/components/PWAInstallPrompt.tsx | 89 -- frontend/src/components/UndoToast.tsx | 118 --- .../src/components/agents/AgentEditor.tsx | 396 -------- .../src/components/agents/AgentMonitor.tsx | 86 -- .../src/components/agents/AgentRunLog.tsx | 82 -- .../src/components/common/ABACRuleEditor.tsx | 883 ------------------ .../components/contacts/ContactEditModal.tsx | 349 ------- .../src/components/contacts/ContactList.tsx | 2 +- .../src/components/contacts/DedupDialog.tsx | 195 ---- .../src/components/knowledge/AskKnowledge.tsx | 139 --- .../components/knowledge/KnowledgeGraph.tsx | 369 -------- .../src/components/mail/MailSearchBar.tsx | 46 - .../components/mail/SharedMailboxSelector.tsx | 34 - .../src/components/shared/CsvImportDialog.tsx | 155 --- .../components/shared/UnsavedChangesGuard.tsx | 28 - .../src/components/tags/BulkTagDialog.tsx | 171 ---- frontend/src/components/tags/TagCloud.tsx | 75 -- frontend/src/components/tags/TagPicker.tsx | 248 ----- frontend/src/components/tasks/GoalView.tsx | 152 --- frontend/src/components/tasks/TaskBoard.tsx | 140 --- frontend/src/hooks/useTenant.ts | 24 - 30 files changed, 5 insertions(+), 5076 deletions(-) delete mode 100644 app/models/outbox_delivery.py delete mode 100644 app/plugins/builtins/mcp_client/tool_registry_integration.py delete mode 100644 app/plugins/quarantine.py delete mode 100644 app/services/custom_field_definition_service.py delete mode 100644 app/services/plugin_install_service.py delete mode 100644 app/services/saved_view_service.py delete mode 100644 app/workflows/code/onboarding.py delete mode 100644 frontend/src/components/AddressList.tsx delete mode 100644 frontend/src/components/PWAInstallPrompt.tsx delete mode 100644 frontend/src/components/UndoToast.tsx delete mode 100644 frontend/src/components/agents/AgentEditor.tsx delete mode 100644 frontend/src/components/agents/AgentMonitor.tsx delete mode 100644 frontend/src/components/agents/AgentRunLog.tsx delete mode 100644 frontend/src/components/common/ABACRuleEditor.tsx delete mode 100644 frontend/src/components/contacts/ContactEditModal.tsx delete mode 100644 frontend/src/components/contacts/DedupDialog.tsx delete mode 100644 frontend/src/components/knowledge/AskKnowledge.tsx delete mode 100644 frontend/src/components/knowledge/KnowledgeGraph.tsx delete mode 100644 frontend/src/components/mail/MailSearchBar.tsx delete mode 100644 frontend/src/components/mail/SharedMailboxSelector.tsx delete mode 100644 frontend/src/components/shared/CsvImportDialog.tsx delete mode 100644 frontend/src/components/shared/UnsavedChangesGuard.tsx delete mode 100644 frontend/src/components/tags/BulkTagDialog.tsx delete mode 100644 frontend/src/components/tags/TagCloud.tsx delete mode 100644 frontend/src/components/tags/TagPicker.tsx delete mode 100644 frontend/src/components/tasks/GoalView.tsx delete mode 100644 frontend/src/components/tasks/TaskBoard.tsx delete mode 100644 frontend/src/hooks/useTenant.ts diff --git a/app/models/outbox_delivery.py b/app/models/outbox_delivery.py deleted file mode 100644 index 24a9de7..0000000 --- a/app/models/outbox_delivery.py +++ /dev/null @@ -1,57 +0,0 @@ -"""Outbox delivery model for per-consumer delivery tracking.""" - -from __future__ import annotations - -import uuid -from datetime import datetime - -from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func -from sqlalchemy.dialects.postgresql import UUID as PGUUID -from sqlalchemy.orm import Mapped, mapped_column - -from app.core.db import Base - - -class OutboxDelivery(Base): - """Tracks per-consumer delivery status for outbox events. - - Each row represents one consumer (event handler) processing one outbox - event. An event is only fully 'published' when all mandatory deliveries - succeed. - """ - - __tablename__ = "outbox_deliveries" - __table_args__ = ( - UniqueConstraint("event_id", "consumer_name", name="uq_outbox_deliveries_event_consumer"), - ) - - id: Mapped[uuid.UUID] = mapped_column( - PGUUID(as_uuid=True), - primary_key=True, - server_default=func.gen_random_uuid(), - ) - event_id: Mapped[uuid.UUID] = mapped_column( - PGUUID(as_uuid=True), - ForeignKey("event_outbox.id", ondelete="CASCADE"), - nullable=False, - ) - consumer_name: Mapped[str] = mapped_column(String(150), nullable=False) - status: Mapped[str] = mapped_column( - String(30), nullable=False, server_default="pending", - ) - attempt_count: Mapped[int] = mapped_column( - Integer, nullable=False, server_default="0", - ) - next_attempt_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True, - ) - last_error: Mapped[str | None] = mapped_column(Text, nullable=True) - processed_at: Mapped[datetime | None] = mapped_column( - DateTime(timezone=True), nullable=True, - ) - created_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now(), - ) - updated_at: Mapped[datetime] = mapped_column( - DateTime(timezone=True), nullable=False, server_default=func.now(), - ) diff --git a/app/plugins/builtins/mcp_client/tool_registry_integration.py b/app/plugins/builtins/mcp_client/tool_registry_integration.py deleted file mode 100644 index 11bc134..0000000 --- a/app/plugins/builtins/mcp_client/tool_registry_integration.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Integrates external MCP server tools into the AI Assistant tool_registry. - -When the MCP Client plugin activates, it loads all enabled MCP server configs, -fetches their tool lists, and registers each tool in the global ToolRegistry. -Agents can then call external MCP tools like native tools. -""" - -from __future__ import annotations - -import logging -import uuid -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.plugins.builtins.ai_assistant.contracts import get_tool_registry -from app.plugins.builtins.mcp_client.client import McpClient -from app.plugins.builtins.mcp_client.models import McpServerConfig as McpServerConfigModel - -logger = logging.getLogger(__name__) - -PLUGIN_NAME = "mcp_client" - - -def _make_tool_name(server_name: str, tool_name: str) -> str: - """Generate a unique tool name: mcp__{server}__{tool}.""" - safe_server = server_name.replace(" ", "_").replace("-", "_").lower() - return f"mcp__{safe_server}__{tool_name}" - - -def _make_handler(server_cfg: McpServerConfigModel, tool_name: str): - """Create an async handler that calls the external MCP server.""" - - async def _handler(arguments: dict[str, Any], context: dict[str, Any]) -> str: - client = McpClient(base_url=server_cfg.url, api_token=server_cfg.api_token) - try: - resp = await client.execute_tool(tool_name, arguments) - if resp.success: - import json - result = resp.result if resp.result is not None else {} - return json.dumps(result) if isinstance(result, dict) else str(result) - return f"Error: {resp.error or 'Unknown error'}" - except Exception as exc: - logger.exception("MCP tool execution failed: %s/%s", server_cfg.name, tool_name) - return f"Error: {exc}" - - return _handler - - -async def sync_external_tools(db: AsyncSession, tenant_id: uuid.UUID) -> int: - """Fetch tools from all enabled MCP servers and register them in the tool registry. - - Returns the number of tools registered. - """ - registry = get_tool_registry() - # Unregister previous tools from this plugin - registry.unregister_plugin(PLUGIN_NAME) - - stmt = select(McpServerConfigModel).where( - McpServerConfigModel.tenant_id == tenant_id, - McpServerConfigModel.enabled.is_(True), - ) - result = await db.execute(stmt) - configs = result.scalars().all() - - count = 0 - for cfg in configs: - try: - client = McpClient(base_url=cfg.url, api_token=cfg.api_token, timeout=10.0) - tools_resp = await client.list_tools() - for tool in tools_resp.tools: - tool_name = _make_tool_name(cfg.name, tool.name) - registry.register( - name=tool_name, - description=f"[MCP:{cfg.name}] {tool.description}", - parameters=tool.parameters if isinstance(tool.parameters, dict) else {}, - handler=_make_handler(cfg, tool.name), - plugin_name=PLUGIN_NAME, - required_permission="mcp-client:read", - category="mcp-external", - ) - count += 1 - logger.info("Registered %d tools from MCP server %s", len(tools_resp.tools), cfg.name) - except Exception as exc: - logger.warning("Failed to sync tools from MCP server %s: %s", cfg.name, exc) - - return count - - -def unregister_all_external_tools() -> None: - """Remove all MCP client tools from the registry.""" - registry = get_tool_registry() - registry.unregister_plugin(PLUGIN_NAME) diff --git a/app/plugins/quarantine.py b/app/plugins/quarantine.py deleted file mode 100644 index 482df77..0000000 --- a/app/plugins/quarantine.py +++ /dev/null @@ -1,215 +0,0 @@ -"""Plugin quarantine — extract, validate, and install external plugins safely. - -Workflow: -1. Extract ZIP to a temporary directory -2. Validate manifest exists and is valid -3. Check for dangerous imports -4. Validate migration SQL -5. Verify signature (if provided) -6. If all checks pass: move to plugins/ directory -7. If any check fails: delete temp directory and raise error - -Usage:: - - from app.plugins.quarantine import quarantine_plugin - - plugin_dir = await quarantine_plugin( - zip_path=Path("plugin.zip"), - signature=b"...", - public_key=b"...", - ) -""" - -from __future__ import annotations - -import logging -import os -import re -import shutil -import tempfile -import zipfile -from pathlib import Path - -from app.plugins.signature import PluginSignature - -logger = logging.getLogger(__name__) - -# Maximum plugin ZIP size (50 MB) -MAX_PLUGIN_SIZE = 50 * 1024 * 1024 - -# Dangerous patterns in plugin source code -DANGEROUS_PATTERNS = [ - (r"\bos\.system\b", "os.system call"), - (r"\bsubprocess\.", "subprocess module"), - (r"\beval\s*\(", "eval() call"), - (r"\bexec\s*\(", "exec() call"), - (r"\b__import__\s*\(", "__import__() call"), - (r"\bcompile\s*\(", "compile() call"), - (r"\bopen\s*\([^)]*['\"]w['\"]", "file write outside DMS"), -] - - -class QuarantineError(Exception): - """Raised when a plugin fails quarantine validation.""" - - -def _validate_manifest(plugin_dir: Path) -> dict: - """Validate that the plugin has a valid manifest. - - Returns the parsed manifest data. - """ - plugin_py = plugin_dir / "plugin.py" - init_py = plugin_dir / "__init__.py" - - if not plugin_py.exists() and not init_py.exists(): - raise QuarantineError("Plugin must have plugin.py or __init__.py") - - # Read source and look for manifest - source_file = plugin_py if plugin_py.exists() else init_py - source = source_file.read_text(encoding="utf-8") - - if "PluginManifest" not in source: - raise QuarantineError("Plugin source must define a PluginManifest") - - if "BasePlugin" not in source: - raise QuarantineError("Plugin source must inherit from BasePlugin") - - return {"source_file": str(source_file), "has_manifest": True} - - -def _check_dangerous_imports(plugin_dir: Path) -> list[str]: - """Check plugin source for dangerous imports/patterns. - - Returns a list of dangerous patterns found (empty if safe). - """ - found: list[str] = [] - - for py_file in plugin_dir.rglob("*.py"): - source = py_file.read_text(encoding="utf-8") - for pattern, description in DANGEROUS_PATTERNS: - if re.search(pattern, source): - found.append(f"{py_file.name}: {description}") - - return found - - -def _check_migration_sql(plugin_dir: Path) -> list[str]: - """Validate migration SQL files in the plugin. - - Returns a list of issues found (empty if OK). - """ - issues: list[str] = [] - migrations_dir = plugin_dir / "migrations" - - if not migrations_dir.exists(): - return issues # No migrations is OK - - for sql_file in migrations_dir.glob("*.sql"): - content = sql_file.read_text(encoding="utf-8") - # Check for tenant_id in CREATE TABLE - if "CREATE TABLE" in content.upper() and "tenant_id" not in content.lower(): - issues.append( - f"{sql_file.name}: CREATE TABLE without tenant_id column" - ) - # Check for DROP DATABASE / DROP SCHEMA - if "DROP DATABASE" in content.upper() or "DROP SCHEMA" in content.upper(): - issues.append(f"{sql_file.name}: Contains DROP DATABASE/SCHEMA") - - return issues - - -async def quarantine_plugin( - zip_path: Path, - signature: bytes | None = None, - public_key: bytes | None = None, - plugins_dir: Path | None = None, -) -> Path: - """Extract, validate, and install a plugin from a ZIP file. - - Args: - zip_path: Path to the plugin ZIP file. - signature: Optional Ed25519 signature bytes. - public_key: Optional Ed25519 public key bytes. - plugins_dir: Target directory for external plugins (default: plugins/). - - Returns: - Path to the installed plugin directory. - - Raises: - QuarantineError: If any validation check fails. - """ - # Check file size - file_size = zip_path.stat().st_size # noqa: ASYNC240 - if file_size > MAX_PLUGIN_SIZE: - raise QuarantineError( - f"Plugin ZIP too large: {file_size} bytes (max {MAX_PLUGIN_SIZE})" - ) - - # Verify signature if provided - if signature and public_key: - if not PluginSignature.verify_signature(zip_path, signature, public_key): - raise QuarantineError("Signature verification failed") - - # Create temp directory for extraction - temp_dir = Path(tempfile.mkdtemp(prefix="plugin_quarantine_")) - - try: - # Extract ZIP - with zipfile.ZipFile(zip_path, "r") as zf: - # Check for path traversal in ZIP entries - for entry in zf.namelist(): - if entry.startswith("/") or ".." in entry: - raise QuarantineError(f"Unsafe ZIP entry: {entry}") - zf.extractall(temp_dir) - - # Find the plugin directory (might be nested) - plugin_dir = temp_dir - if not (plugin_dir / "plugin.py").exists() and not (plugin_dir / "__init__.py").exists(): - # Look for a single subdirectory - subdirs = [d for d in plugin_dir.iterdir() if d.is_dir() and not d.name.startswith("_")] - if len(subdirs) == 1: - plugin_dir = subdirs[0] - else: - raise QuarantineError("Could not find plugin root directory in ZIP") - - # 1. Validate manifest - _validate_manifest(plugin_dir) - logger.info("Manifest validated for plugin in %s", plugin_dir.name) - - # 2. Check dangerous imports - dangerous = _check_dangerous_imports(plugin_dir) - if dangerous: - raise QuarantineError( - f"Dangerous patterns found in plugin: {', '.join(dangerous)}" - ) - - # 3. Check migration SQL - sql_issues = _check_migration_sql(plugin_dir) - if sql_issues: - raise QuarantineError( - f"Migration SQL issues: {', '.join(sql_issues)}" - ) - - # 4. All checks passed — move to plugins directory - target_dir = plugins_dir or Path(os.environ.get("EXTERNAL_PLUGINS_PATH", "plugins")) - target_dir.mkdir(parents=True, exist_ok=True) - - plugin_name = plugin_dir.name - final_dir = target_dir / plugin_name - - if final_dir.exists(): - raise QuarantineError(f"Plugin directory already exists: {final_dir}") - - shutil.copytree(plugin_dir, final_dir) - logger.info("Plugin installed to %s", final_dir) - - return final_dir - - except Exception: - # Clean up temp directory on any error - shutil.rmtree(temp_dir, ignore_errors=True) - raise - finally: - # Always clean up temp directory - if temp_dir.exists(): # noqa: ASYNC240 - shutil.rmtree(temp_dir, ignore_errors=True) diff --git a/app/services/custom_field_definition_service.py b/app/services/custom_field_definition_service.py deleted file mode 100644 index 7ee6cf9..0000000 --- a/app/services/custom_field_definition_service.py +++ /dev/null @@ -1,172 +0,0 @@ -"""CustomFieldDefinition service — CRUD with tenant isolation and visibility filter.""" - -from __future__ import annotations - -import uuid -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.visibility import apply_visibility_filter, check_single_entity_access -from app.models.custom_field_definition import CustomFieldDefinition - - -def _definition_to_dict(d: CustomFieldDefinition) -> dict[str, Any]: - """Serialize a CustomFieldDefinition ORM object to dict.""" - return { - "id": str(d.id), - "entity": d.entity, - "name": d.name, - "label": d.label, - "field_type": d.field_type, - "options": d.options, - "default_value": d.default_value, - "required": d.required, - "is_active": d.is_active, - "sort_order": d.sort_order, - "owner_id": str(d.owner_id) if d.owner_id else None, - "created_by": str(d.created_by) if d.created_by else None, - "updated_by": str(d.updated_by) if d.updated_by else None, - } - - -async def list_custom_field_definitions( - db: AsyncSession, - tenant_id: uuid.UUID, - entity: str | None = None, - user_id: uuid.UUID | None = None, - is_system_admin: bool = False, -) -> list[dict[str, Any]]: - """List custom field definitions for a tenant, optionally filtered by entity.""" - q = select(CustomFieldDefinition).where( - CustomFieldDefinition.tenant_id == tenant_id, - CustomFieldDefinition.is_active == True, # noqa: E712 - ) - if entity: - q = q.where(CustomFieldDefinition.entity == entity) - if user_id and not is_system_admin: - q = await apply_visibility_filter( - db, q, "custom_field_definition", CustomFieldDefinition, user_id, tenant_id, is_system_admin - ) - q = q.order_by(CustomFieldDefinition.sort_order, CustomFieldDefinition.name) - result = await db.execute(q) - return [_definition_to_dict(d) for d in result.scalars().all()] - - -async def get_custom_field_definition( - db: AsyncSession, - tenant_id: uuid.UUID, - definition_id: uuid.UUID, - user_id: uuid.UUID | None = None, - is_system_admin: bool = False, -) -> dict[str, Any] | None: - """Get a single custom field definition by ID.""" - q = select(CustomFieldDefinition).where( - CustomFieldDefinition.id == definition_id, - CustomFieldDefinition.tenant_id == tenant_id, - ) - result = await db.execute(q) - definition = result.scalar_one_or_none() - if definition is None: - return None - if user_id and not is_system_admin: - has_access = await check_single_entity_access( - db, "custom_field_definition", definition.id, user_id, tenant_id, "read", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - return _definition_to_dict(definition) - - -async def create_custom_field_definition( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - data: dict[str, Any], -) -> dict[str, Any]: - """Create a new custom field definition.""" - definition = CustomFieldDefinition( - tenant_id=tenant_id, - entity=data["entity"], - name=data["name"], - label=data["label"], - field_type=data["field_type"], - options=data.get("options"), - default_value=data.get("default_value"), - required=data.get("required", False), - is_active=data.get("is_active", True), - sort_order=data.get("sort_order", 0), - created_by=user_id, - updated_by=user_id, - owner_id=user_id, - ) - db.add(definition) - await db.flush() - await db.refresh(definition) - return _definition_to_dict(definition) - - -async def update_custom_field_definition( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - definition_id: uuid.UUID, - data: dict[str, Any], - is_system_admin: bool = False, -) -> dict[str, Any] | None: - """Update an existing custom field definition.""" - q = select(CustomFieldDefinition).where( - CustomFieldDefinition.id == definition_id, - CustomFieldDefinition.tenant_id == tenant_id, - ) - result = await db.execute(q) - definition = result.scalar_one_or_none() - if definition is None: - return None - - if not is_system_admin: - has_access = await check_single_entity_access( - db, "custom_field_definition", definition.id, user_id, tenant_id, "write", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - - update_fields = ["label", "field_type", "options", "default_value", "required", "is_active", "sort_order"] - for field in update_fields: - if field in data: - setattr(definition, field, data[field]) - - definition.updated_by = user_id - await db.flush() - await db.refresh(definition) - return _definition_to_dict(definition) - - -async def delete_custom_field_definition( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - definition_id: uuid.UUID, - is_system_admin: bool = False, -) -> bool: - """Delete a custom field definition.""" - q = select(CustomFieldDefinition).where( - CustomFieldDefinition.id == definition_id, - CustomFieldDefinition.tenant_id == tenant_id, - ) - result = await db.execute(q) - definition = result.scalar_one_or_none() - if definition is None: - return False - - if not is_system_admin: - has_access = await check_single_entity_access( - db, "custom_field_definition", definition.id, user_id, tenant_id, "admin", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - - await db.delete(definition) - await db.flush() - return True diff --git a/app/services/plugin_install_service.py b/app/services/plugin_install_service.py deleted file mode 100644 index 540bd42..0000000 --- a/app/services/plugin_install_service.py +++ /dev/null @@ -1,187 +0,0 @@ -"""Plugin install service — business logic for plugin installation from ZIP/URL.""" - -from __future__ import annotations - -import importlib -import logging -import re -import shutil -import tempfile -import zipfile -from pathlib import Path -from typing import Any - -from app.plugins.base import BasePlugin -from app.services.plugin_service import get_plugin_service - -logger = logging.getLogger(__name__) - - -class PluginInstallService: - """Service layer for plugin installation operations. - - Handles ZIP extraction, security validation, and plugin registration. - """ - - @staticmethod - def validate_manifest_name(name: str) -> str: - """Validate plugin name is alphanumeric with underscores only.""" - if not re.match(r"^[a-zA-Z][a-zA-Z0-9_]*$", name): - raise ValueError( - f"Invalid plugin name '{name}': must start with a letter and contain only " - f"alphanumeric characters and underscores" - ) - return name - - @staticmethod - def check_dangerous_imports(source_code: str) -> list[str]: - """Check plugin source for dangerous imports/patterns. - - Returns a list of dangerous patterns found (empty if safe). - """ - dangerous_patterns = [ - (r"\bos\.system\b", "os.system call"), - (r"\bsubprocess\.", "subprocess module"), - (r"\beval\s*\(", "eval() call"), - (r"\bexec\s*\(", "exec() call"), - (r"\b__import__\s*\(", "__import__() call"), - (r"\bcompile\s*\(", "compile() call"), - ] - found: list[str] = [] - for pattern, description in dangerous_patterns: - if re.search(pattern, source_code): - found.append(description) - return found - - @staticmethod - def check_migration_sql(sql_content: str) -> list[str]: - """Basic SQL validation for migration files. - - Returns a list of issues found (empty if OK). - """ - issues: list[str] = [] - lines = sql_content.strip().split("\n") - for i, line in enumerate(lines, 1): - stripped = line.strip() - if not stripped or stripped.startswith("--"): - continue - if stripped.count("(") != stripped.count(")"): - issues.append(f"Line {i}: unbalanced parentheses") - if re.search(r"\bDROP\s+TABLE\b", stripped, re.IGNORECASE): - issues.append(f"Line {i}: DROP TABLE is not allowed in plugin migrations") - return issues - - @staticmethod - def find_plugin_class_in_module(module: Any) -> type[BasePlugin] | None: - """Find a BasePlugin subclass in a module.""" - for attr_name in dir(module): - attr = getattr(module, attr_name) - if ( - isinstance(attr, type) - and issubclass(attr, BasePlugin) - and attr is not BasePlugin - ): - return attr - return None - - @staticmethod - def extract_plugin_from_zip(zip_path: str) -> tuple[Path, str, type[BasePlugin]]: - """Extract a ZIP file and find the plugin class. - - Returns (extract_dir, plugin_name, plugin_class). - """ - extract_dir = Path(tempfile.mkdtemp(prefix="plugin_upload_")) - try: - with zipfile.ZipFile(zip_path, "r") as zf: - bad_files = [f for f in zf.namelist() if f.startswith("..") or f.startswith("/")] - if bad_files: - raise ValueError(f"ZIP contains files with unsafe paths: {bad_files}") - zf.extractall(extract_dir) - - plugin_py_path: Path | None = None - for fpath in extract_dir.rglob("plugin.py"): - plugin_py_path = fpath - break - - if plugin_py_path is None: - raise ValueError("ZIP does not contain a plugin.py file") - - source_code = plugin_py_path.read_text(encoding="utf-8") - dangerous = PluginInstallService.check_dangerous_imports(source_code) - if dangerous: - raise ValueError( - f"Plugin contains dangerous patterns: {', '.join(dangerous)}" - ) - - spec = importlib.util.spec_from_file_location( - "uploaded_plugin", plugin_py_path - ) - if spec is None or spec.loader is None: - raise ValueError("Could not load plugin.py module") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - - plugin_class = PluginInstallService.find_plugin_class_in_module(module) - if plugin_class is None: - raise ValueError( - "plugin.py does not contain a BasePlugin subclass" - ) - - plugin_instance = plugin_class() - plugin_name = plugin_instance.name - - manifest = plugin_instance.manifest - if not manifest.name or not manifest.version or not manifest.display_name: - raise ValueError( - "Plugin manifest must include name, version, and display_name" - ) - - PluginInstallService.validate_manifest_name(manifest.name) - - migrations_dir = plugin_py_path.parent / "migrations" - if migrations_dir.exists(): - for sql_file in sorted(migrations_dir.glob("*.sql")): - sql_content = sql_file.read_text(encoding="utf-8") - issues = PluginInstallService.check_migration_sql(sql_content) - if issues: - raise ValueError( - f"Migration file {sql_file.name} has issues: {'; '.join(issues)}" - ) - - return extract_dir, plugin_name, plugin_class - - except Exception: - shutil.rmtree(extract_dir, ignore_errors=True) - raise - - @staticmethod - def install_plugin_from_dir( - extract_dir: Path, - plugin_name: str, - plugin_class: type[BasePlugin], - ) -> None: - """Copy plugin directory to builtins and register it. - - Copies the extracted plugin directory to app/plugins/builtins/{plugin_name}/. - """ - builtins_dir = Path(__file__).parent.parent / "plugins" / "builtins" / plugin_name - builtins_dir.mkdir(parents=True, exist_ok=True) - - for item in extract_dir.iterdir(): - dest = builtins_dir / item.name - if item.is_dir(): - if dest.exists(): - shutil.rmtree(dest) - shutil.copytree(item, dest) - else: - shutil.copy2(item, dest) - - registry = get_plugin_service().registry - instance = plugin_class() - registry.register_plugin(instance) - - logger.info( - "Installed plugin '%s' from uploaded ZIP to %s", - plugin_name, - builtins_dir, - ) diff --git a/app/services/saved_view_service.py b/app/services/saved_view_service.py deleted file mode 100644 index eb48044..0000000 --- a/app/services/saved_view_service.py +++ /dev/null @@ -1,162 +0,0 @@ -"""SavedView service — CRUD with tenant isolation and visibility filter.""" - -from __future__ import annotations - -import uuid -from datetime import UTC, datetime -from typing import Any - -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.visibility import apply_visibility_filter, check_single_entity_access -from app.models.saved_view import SavedView - - -def _view_to_dict(v: SavedView) -> dict[str, Any]: - """Serialize a SavedView ORM object to dict.""" - return { - "id": str(v.id), - "name": v.name, - "entity_type": v.entity_type, - "view_config": v.view_config, - "user_id": str(v.user_id), - "created_at": v.created_at.isoformat() if v.created_at else None, - "updated_at": v.updated_at.isoformat() if v.updated_at else None, - } - - -async def list_saved_views( - db: AsyncSession, - tenant_id: uuid.UUID, - entity_type: str | None = None, - user_id: uuid.UUID | None = None, - is_system_admin: bool = False, -) -> list[dict[str, Any]]: - """List saved views for a tenant, optionally filtered by entity_type.""" - q = select(SavedView).where( - SavedView.tenant_id == tenant_id, - SavedView.deleted_at.is_(None), - ) - if entity_type: - q = q.where(SavedView.entity_type == entity_type) - if user_id and not is_system_admin: - q = await apply_visibility_filter( - db, q, "saved_view", SavedView, user_id, tenant_id, is_system_admin - ) - q = q.order_by(SavedView.name) - result = await db.execute(q) - return [_view_to_dict(v) for v in result.scalars().all()] - - -async def get_saved_view( - db: AsyncSession, - tenant_id: uuid.UUID, - view_id: uuid.UUID, - user_id: uuid.UUID | None = None, - is_system_admin: bool = False, -) -> dict[str, Any] | None: - """Get a single saved view by ID.""" - q = select(SavedView).where( - SavedView.id == view_id, - SavedView.tenant_id == tenant_id, - SavedView.deleted_at.is_(None), - ) - result = await db.execute(q) - saved = result.scalar_one_or_none() - if saved is None: - return None - if user_id and not is_system_admin: - has_access = await check_single_entity_access( - db, "saved_view", saved.id, user_id, tenant_id, "read", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - return _view_to_dict(saved) - - -async def create_saved_view( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - data: dict[str, Any], -) -> dict[str, Any]: - """Create a new saved view.""" - saved = SavedView( - tenant_id=tenant_id, - user_id=user_id, - name=data["name"], - entity_type=data["entity_type"], - view_config=data.get("view_config", {}), - owner_id=user_id, - ) - db.add(saved) - await db.flush() - await db.refresh(saved) - return _view_to_dict(saved) - - -async def update_saved_view( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - view_id: uuid.UUID, - data: dict[str, Any], - is_system_admin: bool = False, -) -> dict[str, Any] | None: - """Update a saved view.""" - q = select(SavedView).where( - SavedView.id == view_id, - SavedView.tenant_id == tenant_id, - SavedView.deleted_at.is_(None), - ) - result = await db.execute(q) - saved = result.scalar_one_or_none() - if saved is None: - return None - - if not is_system_admin: - has_access = await check_single_entity_access( - db, "saved_view", saved.id, user_id, tenant_id, "write", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - - if "name" in data and data["name"] is not None: - saved.name = data["name"] - if "view_config" in data and data["view_config"] is not None: - saved.view_config = data["view_config"] - - await db.flush() - await db.refresh(saved) - return _view_to_dict(saved) - - -async def delete_saved_view( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, - view_id: uuid.UUID, - is_system_admin: bool = False, -) -> bool: - """Soft-delete a saved view.""" - q = select(SavedView).where( - SavedView.id == view_id, - SavedView.tenant_id == tenant_id, - SavedView.deleted_at.is_(None), - ) - result = await db.execute(q) - saved = result.scalar_one_or_none() - if saved is None: - return False - - if not is_system_admin: - has_access = await check_single_entity_access( - db, "saved_view", saved.id, user_id, tenant_id, "admin", is_system_admin - ) - if not has_access: - raise PermissionError("No access") - - saved.deleted_at = datetime.now(UTC) - await db.flush() - return True diff --git a/app/workflows/code/onboarding.py b/app/workflows/code/onboarding.py deleted file mode 100644 index 3ed58a8..0000000 --- a/app/workflows/code/onboarding.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Onboarding workflow — runs on user creation. - -A code-engine workflow that creates a welcome notification and an -approval step for admin confirmation of new users. -""" - -from __future__ import annotations - -import uuid -from typing import Any - -from sqlalchemy.ext.asyncio import AsyncSession - -from app.services.workflow_service import create_instance - -# Onboarding workflow definition (code-engine — hardcoded steps) -ONBOARDING_WORKFLOW_STEPS = [ - { - "name": "Send welcome notification", - "type": "notification", - "config": { - "notification_type": "welcome", - "title": "Welcome to LeoCRM!", - "body": "Your account has been created. An admin will approve your access shortly.", - }, - "description": "Send a welcome notification to the new user", - }, - { - "name": "Admin approval", - "type": "approval", - "config": { - "required_role": "admin", - "description": "Admin must approve the new user account", - }, - "description": "Admin reviews and approves the new user", - }, - { - "name": "Send confirmation", - "type": "notification", - "config": { - "notification_type": "onboarding_complete", - "title": "Account approved", - "body": "Your account has been approved. You can now use LeoCRM.", - }, - "description": "Notify user that their account is approved", - }, -] - - -def get_onboarding_workflow_definition() -> dict[str, Any]: - """Return the onboarding workflow definition for DB seeding.""" - return { - "name": "User Onboarding", - "description": "Automated onboarding workflow triggered on user creation", - "trigger_event": "user.created", - "steps": ONBOARDING_WORKFLOW_STEPS, - "is_active": True, - } - - -async def ensure_onboarding_workflow_exists( - db: AsyncSession, - tenant_id: uuid.UUID, - user_id: uuid.UUID, -) -> uuid.UUID | None: - """Ensure the onboarding workflow exists in the DB for this tenant. - - If it doesn't exist yet, create it. Returns the workflow ID. - """ - from sqlalchemy import select - - from app.models.workflow import Workflow - - result = await db.execute( - select(Workflow).where( - Workflow.tenant_id == tenant_id, - Workflow.trigger_event == "user.created", - Workflow.name == "User Onboarding", - ) - ) - existing = result.scalar_one_or_none() - if existing: - return existing.id - - from app.services.workflow_service import create_workflow - - wf_dict = await create_workflow( - db, - tenant_id, - user_id, - get_onboarding_workflow_definition(), - ) - return uuid.UUID(wf_dict["id"]) if wf_dict else None - - -async def trigger_onboarding( - db: AsyncSession, - tenant_id: uuid.UUID, - admin_user_id: uuid.UUID, - new_user_id: uuid.UUID, -) -> dict[str, Any] | None: - """Trigger the onboarding workflow for a newly created user. - - 1. Ensure the onboarding workflow exists in DB - 2. Create a workflow instance with new_user_id in context - """ - wf_id = await ensure_onboarding_workflow_exists(db, tenant_id, admin_user_id) - if wf_id is None: - return None - - instance = await create_instance( - db, - tenant_id, - admin_user_id, - str(wf_id), - context={"new_user_id": str(new_user_id), "user_id": str(new_user_id)}, - ) - return instance diff --git a/frontend/playwright.config.ts b/frontend/playwright.config.ts index 704880c..34919a9 100644 --- a/frontend/playwright.config.ts +++ b/frontend/playwright.config.ts @@ -19,7 +19,7 @@ export default defineConfig({ }, use: { - baseURL: process.env.BASE_URL ?? 'http://localhost:5173', + baseURL: process.env.BASE_URL ?? 'https://crm.media-on.de', trace: 'on-first-retry', screenshot: 'only-on-failure', video: 'retain-on-failure', @@ -35,11 +35,13 @@ export default defineConfig({ ], webServer: process.env.CI + ? undefined + : process.env.BASE_URL ? undefined : { command: 'npm run dev', url: 'http://localhost:5173', - reuseExistingServer: !process.env.CI, + reuseExistingServer: true, timeout: 60_000, }, }); diff --git a/frontend/src/components/AddressList.tsx b/frontend/src/components/AddressList.tsx deleted file mode 100644 index a92e057..0000000 --- a/frontend/src/components/AddressList.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { asError } from '@/utils/errorTypes'; -import React, { useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { apiGet, apiPost, apiPatch, apiDelete } from '@/api/client'; - -interface Address { - id: string; - entity_type: string; - entity_id: string; - label: string; - address_type: string; - street: string | null; - street_number: string | null; - city: string | null; - zip: string | null; - state: string | null; - country: string | null; - is_default: boolean; - created_at: string | null; - updated_at: string | null; -} - -interface AddressListProps { - entityType: 'company' | 'contact'; - entityId: string; -} - -const ADDRESS_TYPE_COLORS: Record = { - billing: 'bg-blue-100 text-blue-800', - shipping: 'bg-purple-100 text-purple-800', - headquarters: 'bg-green-100 text-green-800', - branch: 'bg-yellow-100 text-yellow-800', - private: 'bg-pink-100 text-pink-800', - other: 'bg-gray-100 text-gray-800', -}; - -export function AddressList({ entityType, entityId }: AddressListProps) { - const { t } = useTranslation(); - const [addresses, setAddresses] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [showForm, setShowForm] = useState(false); - const [editingAddress, setEditingAddress] = useState
(null); - - const fetchAddresses = React.useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await apiGet<{ items: Address[]; total: number }>( - `/addresses?entity_type=${entityType}&entity_id=${entityId}` - ); - setAddresses(data.items); - } catch (err: unknown) { const errObj = asError(err); - setError(errObj.message || t('common.error')); - } finally { - setLoading(false); - } - }, [entityType, entityId, t]); - - React.useEffect(() => { - fetchAddresses(); - }, [fetchAddresses]); - - const handleDelete = async (id: string) => { - if (!window.confirm(t('address.confirmDelete'))) return; - try { - await apiDelete(`/addresses/${id}`); - await fetchAddresses(); - } catch (err: unknown) { const errObj = asError(err); - setError(errObj.message || t('common.error')); - } - }; - - const handleSetDefault = async (id: string) => { - try { - await apiPatch(`/addresses/${id}`, { is_default: true }); - await fetchAddresses(); - } catch (err: unknown) { const errObj = asError(err); - setError(errObj.message || t('common.error')); - } - }; - - const handleSave = async (data: Partial
) => { - try { - if (editingAddress) { - await apiPatch(`/addresses/${editingAddress.id}`, data); - } else { - await apiPost('/addresses', { ...data, entity_type: entityType, entity_id: entityId }); - } - setShowForm(false); - setEditingAddress(null); - await fetchAddresses(); - } catch (err: unknown) { const errObj = asError(err); - setError(errObj.message || t('common.error')); - } - }; - - if (loading) { - return
{t('common.loading')}
; - } - - return ( -
-
-

{t('address.title')}

- -
- - {error && ( -
{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}
- )} - - {showForm && ( - { setShowForm(false); setEditingAddress(null); }} - /> - )} - - {addresses.length === 0 && !showForm ? ( -

{t('address.noAddresses')}

- ) : ( -
- {addresses.map((addr) => ( -
-
-
- {addr.label} - - {t(`addressType.${addr.address_type}`)} - - {addr.is_default && ( - - {t('address.defaultAddress')} - - )} -
-
- {addr.street && {addr.street}} - {addr.street_number && {addr.street_number}} - {(addr.street || addr.street_number) &&
} - {addr.zip && {addr.zip} } - {addr.city && {addr.city}} - {(addr.zip || addr.city) &&
} - {addr.state && {addr.state}, } - {addr.country && {addr.country}} -
-
-
- {!addr.is_default && ( - - )} - - -
-
- ))} -
- )} -
- ); -} - -function AddressForm({ - address, - onSave, - onCancel, -}: { - address: Address | null; - onSave: (data: Partial
) => void; - onCancel: () => void; -}) { - const { t } = useTranslation(); - const [label, setLabel] = useState(address?.label || ''); - const [addressType, setAddressType] = useState(address?.address_type || 'headquarters'); - const [street, setStreet] = useState(address?.street || ''); - const [streetNumber, setStreetNumber] = useState(address?.street_number || ''); - const [city, setCity] = useState(address?.city || ''); - const [zip, setZip] = useState(address?.zip || ''); - const [state, setState] = useState(address?.state || ''); - const [country, setCountry] = useState(address?.country || ''); - const [isDefault, setIsDefault] = useState(address?.is_default || false); - - const handleSubmit = (e: React.FormEvent) => { - e.preventDefault(); - onSave({ label, address_type: addressType, street, street_number: streetNumber, city, zip, state, country, is_default: isDefault }); - }; - - const addressTypes = ['billing', 'shipping', 'headquarters', 'branch', 'private', 'other']; - - return ( -
-
-
- - setLabel(e.target.value)} - required - className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" - /> -
-
- - -
-
-
-
- - setStreet(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
- - setStreetNumber(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
-
-
- - setZip(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
- - setCity(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
- - setState(e.target.value)} className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
-
-
- - setCountry(e.target.value)} maxLength={2} placeholder="DE" className="mt-1 block w-full rounded-md border border-secondary-300 px-3 py-1.5 text-sm" /> -
-
- -
-
-
- - -
-
- ); -} diff --git a/frontend/src/components/PWAInstallPrompt.tsx b/frontend/src/components/PWAInstallPrompt.tsx deleted file mode 100644 index c19a7bc..0000000 --- a/frontend/src/components/PWAInstallPrompt.tsx +++ /dev/null @@ -1,89 +0,0 @@ -/** - * PWA Install Prompt — shows install button when PWA is installable (Task 5.24). - */ - -import React, { useState, useEffect, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Download, X } from 'lucide-react'; - -interface BeforeInstallPromptEvent extends Event { - prompt: () => Promise; - userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>; -} - -const DISMISS_KEY = 'leocrm_pwa_install_dismissed'; - -export function PWAInstallPrompt() { - const { t } = useTranslation(); - const [deferredPrompt, setDeferredPrompt] = useState(null); - const [visible, setVisible] = useState(false); - - useEffect(() => { - const dismissed = localStorage.getItem(DISMISS_KEY); - if (dismissed) return; - - const handler = (e: Event) => { - e.preventDefault(); - setDeferredPrompt(e as BeforeInstallPromptEvent); - setVisible(true); - }; - - window.addEventListener('beforeinstallprompt', handler); - return () => window.removeEventListener('beforeinstallprompt', handler); - }, []); - - const handleInstall = useCallback(async () => { - if (!deferredPrompt) return; - await deferredPrompt.prompt(); - const choice = await deferredPrompt.userChoice; - if (choice.outcome === 'accepted') { - setVisible(false); - } - setDeferredPrompt(null); - }, [deferredPrompt]); - - const handleDismiss = useCallback(() => { - localStorage.setItem(DISMISS_KEY, '1'); - setVisible(false); - }, []); - - if (!visible || !deferredPrompt) return null; - - return ( -
-
- -
-

{t('pwa.installTitle')}

-

{t('pwa.installDescription')}

-
- - -
-
- -
-
- ); -} diff --git a/frontend/src/components/UndoToast.tsx b/frontend/src/components/UndoToast.tsx deleted file mode 100644 index 88a2ed2..0000000 --- a/frontend/src/components/UndoToast.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { Undo2, X } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; -import { useUndoLastAction } from '@/api/entityHistory'; - -interface UndoToastProps { - message: string; - onUndo: () => void; - onDismiss: () => void; - isUndoing: boolean; -} - -/** - * Undo toast shown after delete actions. - * Auto-dismisses after 5 seconds and is manually dismissable. - */ -export function UndoToast({ message, onUndo, onDismiss, isUndoing }: UndoToastProps) { - const { t } = useTranslation(); - - return ( -
-

{message}

- - -
- ); -} - -interface UndoToastState { - message: string; - entityType: string; - entityId: string; -} - -/** - * Hook that shows an undo toast after a delete action and provides the undo function. - * - * Returns: - * - `showUndoToast(message, entityType, entityId)`: trigger the toast - * - `undoToast`: the JSX element to render (render it once in the page) - */ -export function useUndoToast() { - const { t } = useTranslation(); - const [state, setState] = useState(null); - const timerRef = useRef | null>(null); - const undoMutation = useUndoLastAction(); - - const clearTimer = useCallback(() => { - if (timerRef.current) { - clearTimeout(timerRef.current); - timerRef.current = null; - } - }, []); - - const dismiss = useCallback(() => { - clearTimer(); - setState(null); - }, [clearTimer]); - - const showUndoToast = useCallback( - (message: string, entityType: string, entityId: string) => { - clearTimer(); - setState({ message, entityType, entityId }); - timerRef.current = setTimeout(() => { - setState(null); - timerRef.current = null; - }, 5000); - }, - [clearTimer] - ); - - const handleUndo = useCallback(async () => { - if (!state) return; - try { - await undoMutation.mutateAsync({ - entityType: state.entityType, - entityId: state.entityId, - }); - dismiss(); - } catch { - // Keep the toast visible so the user can retry; the mutation error is surfaced elsewhere. - } - }, [state, undoMutation, dismiss]); - - // Cleanup timer on unmount - useEffect(() => clearTimer, [clearTimer]); - - const undoToast = state ? ( - - ) : null; - - return { showUndoToast, undoToast }; -} diff --git a/frontend/src/components/agents/AgentEditor.tsx b/frontend/src/components/agents/AgentEditor.tsx deleted file mode 100644 index 93ff512..0000000 --- a/frontend/src/components/agents/AgentEditor.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import React, { useEffect, useMemo } from 'react'; -import { useForm, Controller } from 'react-hook-form'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { z } from 'zod'; -import { useTranslation } from 'react-i18next'; -import { Button } from '@/components/ui/Button'; -import { Input } from '@/components/ui/Input'; -import { Select } from '@/components/ui/Select'; -import { useToast } from '@/components/ui/Toast'; -import { - useCreateAgent, - useUpdateAgent, - useTestRunAgent, - useAgentToolsFull, - useAgentSkills, -} from '@/api/automation'; -import type { AgentDefinitionFull } from '@/types/automation'; -import { Play, Save, X, Loader2 } from 'lucide-react'; - -const agentEditorSchema = z.object({ - name: z.string().min(1, 'required').max(120), - description: z.string().max(500).default(''), - system_prompt: z.string().default(''), - llm_model: z.string().min(1, 'required').max(100), - tool_ids: z.array(z.string()).default([]), - skill_ids: z.array(z.string()).default([]), - max_steps: z.coerce.number().int().min(1).max(100).default(20), - max_duration_seconds: z.coerce.number().int().min(1).max(86400).default(300), - budget_limit_usd: z.coerce.number().min(0).max(10000).default(1.0), - temperature: z.coerce.number().min(0).max(2).default(0.3), - max_tokens: z.coerce.number().int().min(1).max(100000).default(1000), - trace_mode: z.enum(['standard', 'extended']).default('standard'), - mode: z.enum(['proactive', 'reactive']).default('reactive'), - is_active: z.boolean().default(true), - trigger_config: z.record(z.string(), z.unknown()).default({}), - ai_use_case_metadata: z.record(z.string(), z.unknown()).default({}), -}); - -type AgentEditorFormData = z.infer; - -export interface AgentEditorProps { - agent?: AgentDefinitionFull | null; - onSaved?: (agent: AgentDefinitionFull) => void; - onCancel?: () => void; -} - -const modeOptions = [ - { value: 'reactive', label: 'Reactive' }, - { value: 'proactive', label: 'Proactive' }, -]; - -const traceModeOptions = [ - { value: 'standard', label: 'Standard' }, - { value: 'extended', label: 'Extended' }, -]; - -const commonModels = [ - 'ollama/deepseek-v4-flash', - 'gpt-4', - 'gpt-4-turbo', - 'gpt-3.5-turbo', - 'claude-3-opus', - 'claude-3-sonnet', - 'claude-3-haiku', - 'llama-3-70b', - 'llama-3-8b', - 'mistral-large', - 'mixtral-8x7b', -]; - -export function AgentEditor({ agent, onSaved, onCancel }: AgentEditorProps) { - const { t } = useTranslation(); - const toast = useToast(); - const { data: tools = [] } = useAgentToolsFull(); - const { data: skills = [] } = useAgentSkills(); - const createAgent = useCreateAgent(); - const updateAgent = useUpdateAgent(); - const testRunAgent = useTestRunAgent(); - - const defaultValues = useMemo(() => { - if (agent) { - return { - name: agent.name, - description: agent.description || '', - system_prompt: agent.system_prompt || '', - llm_model: agent.llm_model || agent.model || '', - tool_ids: agent.tool_ids || [], - skill_ids: agent.skill_ids || [], - max_steps: agent.max_steps ?? 20, - max_duration_seconds: agent.max_duration_seconds ?? 300, - budget_limit_usd: agent.budget_limit_usd ?? agent.budget_limit ?? 1.0, - temperature: agent.temperature ?? 0.3, - max_tokens: agent.max_tokens ?? 1000, - trace_mode: (agent.trace_mode === 'extended' ? 'extended' : 'standard') as 'standard' | 'extended', - mode: agent.mode, - is_active: agent.is_active ?? agent.active ?? true, - trigger_config: agent.trigger_config || {}, - ai_use_case_metadata: agent.ai_use_case_metadata || {}, - }; - } - return { - name: '', - description: '', - system_prompt: '', - llm_model: 'ollama/deepseek-v4-flash', - tool_ids: [], - skill_ids: [], - max_steps: 20, - max_duration_seconds: 300, - budget_limit_usd: 1.0, - temperature: 0.3, - max_tokens: 1000, - trace_mode: 'standard', - mode: 'reactive', - is_active: true, - trigger_config: {}, - ai_use_case_metadata: {}, - }; - }, [agent]); - - const { - register, - handleSubmit, - control, - reset, - watch, - setValue, - formState: { errors, isSubmitting }, - } = useForm({ - resolver: zodResolver(agentEditorSchema), - defaultValues, - }); - - const selectedToolIds = watch('tool_ids'); - const selectedSkillIds = watch('skill_ids'); - - useEffect(() => { - reset(defaultValues); - }, [reset, defaultValues]); - - const onSubmit = async (data: AgentEditorFormData) => { - try { - if (agent) { - const updated = await updateAgent.mutateAsync({ id: agent.id, data }); - toast.success(t('agent.saved')); - onSaved?.(updated); - } else { - const created = await createAgent.mutateAsync(data); - toast.success(t('agent.created')); - onSaved?.(created); - } - } catch { - toast.error(t('agent.saveFailed')); - } - }; - - const handleTestRun = async () => { - if (!agent) { - toast.warning(t('agent.saveBeforeTest')); - return; - } - try { - await testRunAgent.mutateAsync(agent.id); - toast.success(t('agent.testRunOk')); - } catch { - toast.error(t('agent.testRunFailed')); - } - }; - - const toggleArrayValue = (field: 'tool_ids' | 'skill_ids', value: string) => { - const current = field === 'tool_ids' ? selectedToolIds : selectedSkillIds; - const next = (current || []).includes(value) - ? (current || []).filter((v) => v !== value) - : [...(current || []), value]; - setValue(field, next, { shouldDirty: true, shouldValidate: true }); - }; - - return ( -
- {/* Basic info */} -
- - -
- - {/* System prompt */} -
- -