diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bb887227f..301cbd89d9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1026,7 +1026,7 @@ jobs: file: deploy/cloudron/Dockerfile push: true build-args: | - FROM_ALL_IN_ONE_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/baserow:ci-tested-${{ github.sha }} + FROM_IMAGE=${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/baserow:ci-tested-${{ github.sha }} tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_REPO }}/cloudron:ci-tested-${{ github.sha }} cache-to: type=inline diff --git a/backend/src/baserow/contrib/automation/apps.py b/backend/src/baserow/contrib/automation/apps.py index 75ac9d9eba..590e980f7a 100644 --- a/backend/src/baserow/contrib/automation/apps.py +++ b/backend/src/baserow/contrib/automation/apps.py @@ -20,6 +20,7 @@ def ready(self): UpdateAutomationNodeActionType, ) from baserow.contrib.automation.nodes.node_types import ( + AIAgentActionNodeType, CoreHttpRequestNodeType, CoreHTTPTriggerNodeType, CoreIteratorNodeType, @@ -167,6 +168,7 @@ def ready(self): ) automation_node_type_registry.register(CorePeriodicTriggerNodeType()) automation_node_type_registry.register(CoreHTTPTriggerNodeType()) + automation_node_type_registry.register(AIAgentActionNodeType()) from baserow.core.trash.registries import trash_operation_type_registry diff --git a/backend/src/baserow/contrib/automation/migrations/0022_aiagentactionnode.py b/backend/src/baserow/contrib/automation/migrations/0022_aiagentactionnode.py new file mode 100644 index 0000000000..c71484966a --- /dev/null +++ b/backend/src/baserow/contrib/automation/migrations/0022_aiagentactionnode.py @@ -0,0 +1,36 @@ +# Generated by Django 5.0.14 on 2025-11-03 16:06 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ( + "automation", + "0021_coreiteratoractionnode_alter_automationnode_options_and_more", + ), + ] + + operations = [ + migrations.CreateModel( + name="AIAgentActionNode", + fields=[ + ( + "automationnode_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="automation.automationnode", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("automation.automationnode",), + ), + ] diff --git a/backend/src/baserow/contrib/automation/nodes/models.py b/backend/src/baserow/contrib/automation/nodes/models.py index 5f2a4fd73f..4841f451a2 100644 --- a/backend/src/baserow/contrib/automation/nodes/models.py +++ b/backend/src/baserow/contrib/automation/nodes/models.py @@ -121,6 +121,18 @@ def get_previous_service_outputs(self): return {node.service_id: str(out) for [node, _, out] in previous_positions} + def get_parent_nodes(self): + """ + Returns the ancestors of this node which are the container nodes that contain + the current node instance. + """ + + return [ + position[0] + for position in self.workflow.get_graph().get_previous_positions(self) + if position[1] == "child" + ] + def get_next_nodes( self, output_uid: str | None = None ) -> Iterable["AutomationNode"]: @@ -212,3 +224,7 @@ class CoreRouterActionNode(AutomationActionNode): class CoreIteratorActionNode(AutomationActionNode): ... + + +class AIAgentActionNode(AutomationActionNode): + ... diff --git a/backend/src/baserow/contrib/automation/nodes/node_types.py b/backend/src/baserow/contrib/automation/nodes/node_types.py index ed81abc650..cff59d6448 100644 --- a/backend/src/baserow/contrib/automation/nodes/node_types.py +++ b/backend/src/baserow/contrib/automation/nodes/node_types.py @@ -16,6 +16,7 @@ AutomationNodeTriggerMustBeFirstNode, ) from baserow.contrib.automation.nodes.models import ( + AIAgentActionNode, AutomationNode, AutomationTriggerNode, CoreHTTPRequestActionNode, @@ -38,6 +39,7 @@ from baserow.contrib.automation.nodes.types import NodePositionType from baserow.contrib.automation.workflows.constants import WorkflowState from baserow.contrib.automation.workflows.models import AutomationWorkflow +from baserow.contrib.integrations.ai.service_types import AIAgentServiceType from baserow.contrib.integrations.core.service_types import ( CoreHTTPRequestServiceType, CoreHTTPTriggerServiceType, @@ -103,7 +105,7 @@ def before_move( Check the container node is not moved inside it self. """ - if node in reference_node.get_previous_nodes(): + if node in reference_node.get_parent_nodes(): raise AutomationNodeNotMovable( "A container node cannot be moved inside itself" ) @@ -172,6 +174,12 @@ class CoreSMTPEmailNodeType(AutomationNodeActionNodeType): service_type = CoreSMTPEmailServiceType.type +class AIAgentActionNodeType(AutomationNodeActionNodeType): + type = "ai_agent" + model_class = AIAgentActionNode + service_type = AIAgentServiceType.type + + class CoreRouterActionNodeType(AutomationNodeActionNodeType): type = "router" model_class = CoreRouterActionNode diff --git a/backend/src/baserow/contrib/automation/workflows/graph_handler.py b/backend/src/baserow/contrib/automation/workflows/graph_handler.py index f9f51153c9..b1af973f50 100644 --- a/backend/src/baserow/contrib/automation/workflows/graph_handler.py +++ b/backend/src/baserow/contrib/automation/workflows/graph_handler.py @@ -232,9 +232,7 @@ def _get_all_next_nodes(self, node: AutomationNode): node_info = self.get_info(node) - return [ - x for sublist in node_info.get("next", {}).values() for x in sublist - ] + node_info.get("children", []) + return [x for sublist in node_info.get("next", {}).values() for x in sublist] def get_next_nodes( self, node: AutomationNode, output: str | None = None diff --git a/backend/src/baserow/contrib/automation/workflows/handler.py b/backend/src/baserow/contrib/automation/workflows/handler.py index 2bef23a43a..02df73dc0f 100644 --- a/backend/src/baserow/contrib/automation/workflows/handler.py +++ b/backend/src/baserow/contrib/automation/workflows/handler.py @@ -519,7 +519,7 @@ def import_workflows( cache=cache, ) - workflow_instance.get_graph().migrate_graph(id_mapping) + workflow_instance.get_graph().migrate_graph(id_mapping) return [i[0] for i in imported_workflows] diff --git a/backend/src/baserow/contrib/builder/apps.py b/backend/src/baserow/contrib/builder/apps.py index 85ee9f7a38..8cd53c07f6 100644 --- a/backend/src/baserow/contrib/builder/apps.py +++ b/backend/src/baserow/contrib/builder/apps.py @@ -277,6 +277,7 @@ def ready(self): from .workflow_actions.registries import builder_workflow_action_type_registry from .workflow_actions.workflow_action_types import ( + AIAgentWorkflowActionType, CoreHttpRequestActionType, CoreSMTPEmailActionType, CreateRowWorkflowActionType, @@ -299,6 +300,7 @@ def ready(self): ) builder_workflow_action_type_registry.register(CoreHttpRequestActionType()) builder_workflow_action_type_registry.register(CoreSMTPEmailActionType()) + builder_workflow_action_type_registry.register(AIAgentWorkflowActionType()) from .elements.collection_field_types import ( BooleanCollectionFieldType, diff --git a/backend/src/baserow/contrib/builder/domains/handler.py b/backend/src/baserow/contrib/builder/domains/handler.py index f24980ed46..9a1cbc9660 100644 --- a/backend/src/baserow/contrib/builder/domains/handler.py +++ b/backend/src/baserow/contrib/builder/domains/handler.py @@ -251,6 +251,7 @@ def publish(self, domain: Domain, progress: Progress | None = None): include_permission_data=True, reduce_disk_space_usage=False, exclude_sensitive_data=False, + is_publishing=True, ) default_storage = get_default_storage() diff --git a/backend/src/baserow/contrib/builder/migrations/0065_aiagentworkflowaction.py b/backend/src/baserow/contrib/builder/migrations/0065_aiagentworkflowaction.py new file mode 100644 index 0000000000..dba0fb73b0 --- /dev/null +++ b/backend/src/baserow/contrib/builder/migrations/0065_aiagentworkflowaction.py @@ -0,0 +1,42 @@ +# Generated by Django 5.0.14 on 2025-11-04 14:46 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("builder", "0064_migrate_to_formula_field_objects"), + ("core", "0106_schemaoperation"), + ] + + operations = [ + migrations.CreateModel( + name="AIAgentWorkflowAction", + fields=[ + ( + "builderworkflowaction_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="builder.builderworkflowaction", + ), + ), + ( + "service", + models.ForeignKey( + help_text="The service which this action is associated with.", + on_delete=django.db.models.deletion.CASCADE, + to="core.service", + ), + ), + ], + options={ + "abstract": False, + }, + bases=("builder.builderworkflowaction",), + ), + ] diff --git a/backend/src/baserow/contrib/builder/workflow_actions/models.py b/backend/src/baserow/contrib/builder/workflow_actions/models.py index 2fbda4964c..ab29f7f195 100644 --- a/backend/src/baserow/contrib/builder/workflow_actions/models.py +++ b/backend/src/baserow/contrib/builder/workflow_actions/models.py @@ -121,3 +121,7 @@ class CoreHTTPRequestWorkflowAction(BuilderWorkflowServiceAction): class CoreSMTPEmailWorkflowAction(BuilderWorkflowServiceAction): ... + + +class AIAgentWorkflowAction(BuilderWorkflowServiceAction): + ... diff --git a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py index bf2e58fede..4d4e1f1df7 100644 --- a/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py +++ b/backend/src/baserow/contrib/builder/workflow_actions/workflow_action_types.py @@ -16,6 +16,7 @@ from baserow.contrib.builder.elements.element_types import NavigationElementManager from baserow.contrib.builder.formula_importer import import_formula from baserow.contrib.builder.workflow_actions.models import ( + AIAgentWorkflowAction, CoreHTTPRequestWorkflowAction, CoreSMTPEmailWorkflowAction, LocalBaserowCreateRowWorkflowAction, @@ -30,6 +31,7 @@ BuilderWorkflowActionType, ) from baserow.contrib.builder.workflow_actions.types import BuilderWorkflowActionDict +from baserow.contrib.integrations.ai.service_types import AIAgentServiceType from baserow.contrib.integrations.core.service_types import ( CoreHTTPRequestServiceType, CoreSMTPEmailServiceType, @@ -477,3 +479,13 @@ class CoreSMTPEmailActionType(BuilderWorkflowServiceActionType): def get_pytest_params(self, pytest_data_fixture) -> Dict[str, int]: service = pytest_data_fixture.create_core_smtp_email_service() return {"service": service} + + +class AIAgentWorkflowActionType(BuilderWorkflowServiceActionType): + type = "ai_agent" + model_class = AIAgentWorkflowAction + service_type = AIAgentServiceType.type + + def get_pytest_params(self, pytest_data_fixture) -> Dict[str, int]: + service = pytest_data_fixture.create_ai_agent_service() + return {"service": service} diff --git a/backend/src/baserow/contrib/integrations/ai/__init__.py b/backend/src/baserow/contrib/integrations/ai/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/baserow/contrib/integrations/ai/integration_types.py b/backend/src/baserow/contrib/integrations/ai/integration_types.py new file mode 100644 index 0000000000..6ebb676423 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/ai/integration_types.py @@ -0,0 +1,141 @@ +from typing import Any, Dict + +from django.contrib.auth.models import AbstractUser + +from rest_framework import serializers + +from baserow.api.utils import validate_data +from baserow.api.workspaces.serializers import get_generative_ai_settings_serializer +from baserow.contrib.integrations.ai.models import AIIntegration +from baserow.core.integrations.registries import IntegrationType +from baserow.core.integrations.types import IntegrationDict + + +class AIIntegrationType(IntegrationType): + """ + Integration type for connecting to generative AI providers. Allows users to either + inherit workspace-level AI settings (default) or override them per integration. If + a provider key is not present in ai_settings, it inherits from workspace settings. + If present, it overrides with the specified values. + """ + + type = "ai" + model_class = AIIntegration + + class SerializedDict(IntegrationDict): + ai_settings: Dict[str, Any] + + serializer_field_names = ["ai_settings"] + allowed_fields = ["ai_settings"] + sensitive_fields = ["ai_settings"] + + serializer_field_overrides = { + "ai_settings": serializers.JSONField( + required=False, + default=dict, + help_text="Per-provider AI settings overrides. If a provider key is not " + "present, workspace settings are inherited. If present, these values " + "override workspace settings. Structure: " + '{"openai": {"api_key": "...", "models": [...], "organization": ""}, ...}', + ), + } + + request_serializer_field_names = ["ai_settings"] + request_serializer_field_overrides = { + "ai_settings": serializers.JSONField(required=False, default=dict), + } + + def prepare_values( + self, values: Dict[str, Any], user: AbstractUser + ) -> Dict[str, Any]: + """ + Prepare and validate the AI settings before saving. Uses the same validation as + workspace-level AI settings. Converts comma-separated models strings to arrays. + """ + + if "ai_settings" not in values: + values["ai_settings"] = {} + + # Validate ai_settings using the same serializer as workspace settings + # because it should allow to override the same settings. + if values["ai_settings"]: + validated_settings = validate_data( + get_generative_ai_settings_serializer(), + values["ai_settings"], + return_validated=True, + ) + values["ai_settings"] = validated_settings + + return super().prepare_values(values, user) + + def get_provider_settings( + self, integration: AIIntegration, provider_type: str + ) -> Dict[str, Any]: + """ + Get all settings for a specific provider, either from integration + settings or from workspace settings as fallback. + """ + + # Check if provider has overrides in integration settings + if provider_type in integration.ai_settings: + provider_settings = integration.ai_settings[provider_type] + if isinstance(provider_settings, dict): + return provider_settings + + # Fall back to workspace settings + workspace = integration.application.workspace + if workspace is None: + return {} + workspace_settings = workspace.generative_ai_models_settings or {} + return workspace_settings.get(provider_type, {}) + + def is_provider_overridden( + self, integration: AIIntegration, provider_type: str + ) -> bool: + """ + Check if a provider is overridden in the integration settings. + """ + + return provider_type in integration.ai_settings + + def export_serialized( + self, + instance: AIIntegration, + import_export_config=None, + files_zip=None, + storage=None, + cache=None, + ): + """ + Export the AI integration with materialized settings. When publishing, copy + workspace-level AI settings into the integration so it doesn't depend on + workspace (which will be None in published workflows). + """ + + serialized = super().export_serialized( + instance, + import_export_config=import_export_config, + files_zip=files_zip, + storage=storage, + cache=cache, + ) + + # When publishing (is_publishing=True), materialize workspace settings into the + # integration so published workflows don't lose access to settings. This is + # because the published workflow does not have access to the workspace. + if import_export_config and import_export_config.is_publishing: + workspace = instance.application.workspace + if workspace and workspace.generative_ai_models_settings: + materialized_settings = dict(serialized.get("ai_settings", {})) + for ( + provider_type, + workspace_provider_settings, + ) in workspace.generative_ai_models_settings.items(): + if provider_type not in materialized_settings: + materialized_settings[ + provider_type + ] = workspace_provider_settings + + serialized["ai_settings"] = materialized_settings + + return serialized diff --git a/backend/src/baserow/contrib/integrations/ai/models.py b/backend/src/baserow/contrib/integrations/ai/models.py new file mode 100644 index 0000000000..f503a76f27 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/ai/models.py @@ -0,0 +1,37 @@ +from django.db import models + +from baserow.core.formula.field import FormulaField +from baserow.core.integrations.models import Integration +from baserow.core.services.models import Service + + +class AIOutputType(models.TextChoices): + TEXT = "text", "Text" + CHOICE = "choice", "Choice" + + +class AIIntegration(Integration): + # JSONField to store per-provider override settings. Structure: + # `{"openai": {"api_key": "...", "models": [...]}, ...}` + ai_settings = models.JSONField(default=dict, blank=True) + + +class AIAgentService(Service): + ai_generative_ai_type = models.CharField( + max_length=32, + null=True, + help_text='The generative AI type (e.g. "openai", "anthropic", "mistral")', + ) + ai_generative_ai_model = models.CharField( + max_length=128, + null=True, + help_text='The specific model name (e.g. "gpt-4", "claude-3-opus")', + ) + ai_output_type = models.CharField( + max_length=32, + choices=AIOutputType.choices, + default=AIOutputType.TEXT, + ) + ai_temperature = models.FloatField(null=True) + ai_prompt = FormulaField(default="") + ai_choices = models.JSONField(default=list, blank=True) diff --git a/backend/src/baserow/contrib/integrations/ai/service_types.py b/backend/src/baserow/contrib/integrations/ai/service_types.py new file mode 100644 index 0000000000..b8b7b934a6 --- /dev/null +++ b/backend/src/baserow/contrib/integrations/ai/service_types.py @@ -0,0 +1,300 @@ +import enum +from typing import Any, Dict, Generator, List, Optional + +from django.contrib.auth.models import AbstractUser + +from langchain_core.exceptions import OutputParserException +from langchain_core.prompts import PromptTemplate +from rest_framework import serializers +from rest_framework.exceptions import ValidationError as DRFValidationError + +from baserow.contrib.integrations.ai.integration_types import AIIntegrationType +from baserow.contrib.integrations.ai.models import AIAgentService, AIOutputType +from baserow.core.formula.serializers import FormulaSerializerField +from baserow.core.formula.validator import ensure_string +from baserow.core.generative_ai.exceptions import ( + GenerativeAIPromptError, + GenerativeAITypeDoesNotExist, +) +from baserow.core.generative_ai.registries import generative_ai_model_type_registry +from baserow.core.integrations.handler import IntegrationHandler +from baserow.core.output_parsers import StrictEnumOutputParser +from baserow.core.services.dispatch_context import DispatchContext +from baserow.core.services.exceptions import ( + ServiceImproperlyConfiguredDispatchException, +) +from baserow.core.services.registries import DispatchTypes, ServiceType +from baserow.core.services.types import DispatchResult, FormulaToResolve, ServiceDict + + +class AIAgentServiceType(ServiceType): + type = "ai_agent" + model_class = AIAgentService + integration_type = AIIntegrationType.type + dispatch_types = [DispatchTypes.ACTION] + returns_list = False + + allowed_fields = [ + "integration_id", + "ai_generative_ai_type", + "ai_generative_ai_model", + "ai_output_type", + "ai_temperature", + "ai_prompt", + "ai_choices", + ] + + serializer_field_names = [ + "integration_id", + "ai_generative_ai_type", + "ai_generative_ai_model", + "ai_output_type", + "ai_temperature", + "ai_prompt", + "ai_choices", + ] + + serializer_field_overrides = { + "integration_id": serializers.IntegerField( + required=False, + allow_null=True, + help_text="The ID of the AI integration to use for this service.", + ), + "ai_generative_ai_type": serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="The generative AI provider type (e.g., 'openai', 'anthropic').", + ), + "ai_generative_ai_model": serializers.CharField( + required=False, + allow_null=True, + allow_blank=True, + help_text="The specific AI model to use (e.g., 'gpt-4', 'claude-3-opus').", + ), + "ai_output_type": serializers.ChoiceField( + required=False, + choices=AIOutputType.choices, + default=AIOutputType.TEXT, + help_text="The output type: 'text' for raw text, 'choice' for constrained " + "selection.", + ), + "ai_temperature": serializers.FloatField( + required=False, + allow_null=True, + min_value=0, + max_value=2, + help_text="Temperature for response randomness (0-2). Lower is more " + "focused.", + ), + "ai_prompt": FormulaSerializerField( + help_text="The prompt to send to the AI model. Can be a formula.", + ), + "ai_choices": serializers.ListField( + child=serializers.CharField(), + required=False, + default=list, + help_text="List of choice options for 'choice' output type.", + ), + } + + simple_formula_fields = ["ai_prompt"] + + class SerializedDict(ServiceDict): + integration_id: int + ai_generative_ai_type: str + ai_generative_ai_model: str + ai_output_type: str + ai_temperature: Optional[float] + ai_prompt: str + ai_choices: List[str] + + def prepare_values( + self, + values: Dict[str, Any], + user: AbstractUser, + instance: Optional[AIAgentService] = None, + ) -> Dict[str, Any]: + ai_type = values.get("ai_generative_ai_type") or ( + instance.ai_generative_ai_type if instance else None + ) + ai_model = values.get("ai_generative_ai_model") or ( + instance.ai_generative_ai_model if instance else None + ) + + if ai_type: + try: + generative_ai_model_type_registry.get(ai_type) + except GenerativeAITypeDoesNotExist as e: + raise DRFValidationError( + {"ai_generative_ai_type": f"AI type '{ai_type}' does not exist."} + ) from e + + # Get the integration to check available models + integration_id = values.get("integration_id") or ( + instance.integration_id if instance else None + ) + if integration_id and ai_model: + integration = ( + IntegrationHandler().get_integration(integration_id).specific + ) + integration_type = AIIntegrationType() + provider_settings = integration_type.get_provider_settings( + integration, ai_type + ) + available_models = provider_settings.get("models", []) + + if available_models and ai_model not in available_models: + raise DRFValidationError( + { + "ai_generative_ai_model": f"Model '{ai_model}' is not available for provider '{ai_type}'." + } + ) + + return super().prepare_values(values, user, instance) + + def formulas_to_resolve( + self, service: AIAgentService + ) -> Generator[FormulaToResolve, None, None]: + yield FormulaToResolve( + "ai_prompt", + service.ai_prompt, + ensure_string, + 'property "ai_prompt"', + ) + + def resolve_service_formulas( + self, + service: AIAgentService, + dispatch_context: DispatchContext, + ) -> Dict[str, Any]: + if not service.integration_id: + raise ServiceImproperlyConfiguredDispatchException( + "The integration property is missing." + ) + + if not service.ai_generative_ai_type: + raise ServiceImproperlyConfiguredDispatchException( + "The AI provider type is missing." + ) + + if not service.ai_generative_ai_model: + raise ServiceImproperlyConfiguredDispatchException( + "The AI model is missing." + ) + + # Check if prompt formula is set (FormulaField returns empty string when not + # set) + if not service.ai_prompt or str(service.ai_prompt).strip() == "": + raise ServiceImproperlyConfiguredDispatchException("The prompt is missing.") + + if service.ai_output_type == AIOutputType.CHOICE: + if not service.ai_choices or len(service.ai_choices) == 0: + raise ServiceImproperlyConfiguredDispatchException( + "At least one choice is required when output type is 'choice'." + ) + # At least one option must be set, otherwise we can't force the LLM to give + # one of the answers. + if not any(choice and choice.strip() for choice in service.ai_choices): + raise ServiceImproperlyConfiguredDispatchException( + "At least one non-empty choice is required when output type is 'choice'." + ) + + return super().resolve_service_formulas(service, dispatch_context) + + def dispatch_data( + self, + service: AIAgentService, + resolved_values: Dict[str, Any], + dispatch_context: DispatchContext, + ) -> Dict[str, Any]: + prompt = resolved_values.get("ai_prompt", "") + ai_model_type = generative_ai_model_type_registry.get( + service.ai_generative_ai_type + ) + workspace = service.integration.application.workspace + integration = service.integration.specific + integration_type = AIIntegrationType() + + # Always get provider settings (which handles fallback to workspace settings). + # This ensures that published workflows can access settings correctly. + provider_settings = integration_type.get_provider_settings( + integration, service.ai_generative_ai_type + ) + + output_parser = None + + # If the choice output type has been set, then a different prompt and output + # parser must be used to make sure the result matches the requirements of the + # choice type. + if service.ai_output_type == AIOutputType.CHOICE: + choices = service.ai_choices or [] + + if not choices: + raise ServiceImproperlyConfiguredDispatchException( + "No valid choices provided for 'choice' output type." + ) + + choices_enum = enum.Enum( + "Choices", {f"OPTION_{i}": choice for i, choice in enumerate(choices)} + ) + output_parser = StrictEnumOutputParser(enum=choices_enum) + format_instructions = output_parser.get_format_instructions() + prompt_template = PromptTemplate( + template=prompt + "\n\nGiven this user query:\n\n{format_instructions}", + input_variables=[], + partial_variables={"format_instructions": format_instructions}, + ) + prompt = prompt_template.format() + + try: + kwargs = {} + if service.ai_temperature is not None: + kwargs["temperature"] = service.ai_temperature + + # Always pass provider settings (which may be from integration or workspace) + if provider_settings: + kwargs["settings_override"] = provider_settings + + result = ai_model_type.prompt( + model=service.ai_generative_ai_model, + prompt=prompt, + workspace=workspace, + **kwargs, + ) + except GenerativeAIPromptError as e: + raise ServiceImproperlyConfiguredDispatchException( + f"AI prompt execution failed: {str(e)}" + ) from e + + # Parse the result for choice output type + if service.ai_output_type == AIOutputType.CHOICE and output_parser: + try: + parsed_result = output_parser.parse(result) + result = parsed_result.value + except OutputParserException: + # If parsing fails, return the raw result + pass + + return {"result": result} + + def dispatch_transform(self, data: Dict[str, Any]) -> DispatchResult: + return DispatchResult(data=data) + + def generate_schema( + self, service: AIAgentService, allowed_fields: Optional[List[str]] = None + ) -> Dict[str, Any]: + """ + Generate JSON schema for the service output. + """ + + return { + "type": "object", + "title": self.get_schema_name(service), + "properties": { + "result": { + "type": "string", + "title": "AI Response", + } + }, + } diff --git a/backend/src/baserow/contrib/integrations/apps.py b/backend/src/baserow/contrib/integrations/apps.py index 8c9bd4da88..0cfec6cc01 100644 --- a/backend/src/baserow/contrib/integrations/apps.py +++ b/backend/src/baserow/contrib/integrations/apps.py @@ -5,6 +5,7 @@ class IntegrationsConfig(AppConfig): name = "baserow.contrib.integrations" def ready(self): + from baserow.contrib.integrations.ai.integration_types import AIIntegrationType from baserow.contrib.integrations.core.integration_types import ( SMTPIntegrationType, ) @@ -16,6 +17,7 @@ def ready(self): integration_type_registry.register(LocalBaserowIntegrationType()) integration_type_registry.register(SMTPIntegrationType()) + integration_type_registry.register(AIIntegrationType()) from baserow.contrib.integrations.local_baserow.service_types import ( LocalBaserowAggregateRowsUserServiceType, @@ -53,4 +55,8 @@ def ready(self): service_type_registry.register(CoreIteratorServiceType()) service_type_registry.register(CorePeriodicServiceType()) + from baserow.contrib.integrations.ai.service_types import AIAgentServiceType + + service_type_registry.register(AIAgentServiceType()) + import baserow.contrib.integrations.signals # noqa: F403, F401 diff --git a/backend/src/baserow/contrib/integrations/core/service_types.py b/backend/src/baserow/contrib/integrations/core/service_types.py index af68312fa1..0297af4979 100644 --- a/backend/src/baserow/contrib/integrations/core/service_types.py +++ b/backend/src/baserow/contrib/integrations/core/service_types.py @@ -1407,6 +1407,31 @@ def get_api_urls(self) -> List[path]: ), ] + def serialize_property( + self, + service: CoreHTTPTriggerService, + prop_name: str, + files_zip=None, + storage=None, + cache=None, + ): + """ + Responsible for serializing the trigger's properties. + + :param service: The CoreHTTPTriggerService service. + :param prop_name: The property name we're serializing. + :param files_zip: The zip file containing the files. + :param storage: The storage to use for the files. + :param cache: The cache to use for the files. + """ + + if prop_name == "uid": + return str(service.uid) + + return super().serialize_property( + service, prop_name, files_zip=files_zip, storage=storage, cache=cache + ) + def process_webhook_request( self, webhook_uid: uuid.uuid4, request_data: Dict[str, Any], simulate: bool ) -> None: diff --git a/backend/src/baserow/contrib/integrations/migrations/0023_aiagentservice_aiintegration.py b/backend/src/baserow/contrib/integrations/migrations/0023_aiagentservice_aiintegration.py new file mode 100644 index 0000000000..831eef5b1d --- /dev/null +++ b/backend/src/baserow/contrib/integrations/migrations/0023_aiagentservice_aiintegration.py @@ -0,0 +1,89 @@ +# Generated by Django 5.0.14 on 2025-11-03 22:12 + +import django.db.models.deletion +from django.db import migrations, models + +import baserow.core.formula.field + + +class Migration(migrations.Migration): + dependencies = [ + ("core", "0106_schemaoperation"), + ("integrations", "0022_coreiteratorservice"), + ] + + operations = [ + migrations.CreateModel( + name="AIAgentService", + fields=[ + ( + "service_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.service", + ), + ), + ( + "ai_generative_ai_type", + models.CharField( + help_text='The generative AI type (e.g. "openai", "anthropic", "mistral")', + max_length=32, + null=True, + ), + ), + ( + "ai_generative_ai_model", + models.CharField( + help_text='The specific model name (e.g. "gpt-4", "claude-3-opus")', + max_length=128, + null=True, + ), + ), + ( + "ai_output_type", + models.CharField( + choices=[("text", "Text"), ("choice", "Choice")], + default="text", + max_length=32, + ), + ), + ("ai_temperature", models.FloatField(null=True)), + ( + "ai_prompt", + baserow.core.formula.field.FormulaField( + blank=True, default="", null=True + ), + ), + ("ai_choices", models.JSONField(blank=True, default=list)), + ], + options={ + "abstract": False, + }, + bases=("core.service",), + ), + migrations.CreateModel( + name="AIIntegration", + fields=[ + ( + "integration_ptr", + models.OneToOneField( + auto_created=True, + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to="core.integration", + ), + ), + ("ai_settings", models.JSONField(blank=True, default=dict)), + ], + options={ + "abstract": False, + }, + bases=("core.integration",), + ), + ] diff --git a/backend/src/baserow/core/generative_ai/generative_ai_model_types.py b/backend/src/baserow/core/generative_ai/generative_ai_model_types.py index a02b91efa5..e5ec166eae 100644 --- a/backend/src/baserow/core/generative_ai/generative_ai_model_types.py +++ b/backend/src/baserow/core/generative_ai/generative_ai_model_types.py @@ -19,29 +19,35 @@ class BaseOpenAIGenerativeAIModelType(GenerativeAIModelType): - def get_api_key(self, workspace=None): + def get_api_key(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "api_key") + self.get_workspace_setting(workspace, "api_key", settings_override) or settings.BASEROW_OPENAI_API_KEY ) - def get_enabled_models(self, workspace=None): - workspace_models = self.get_workspace_setting(workspace, "models") + def get_enabled_models(self, workspace=None, settings_override=None): + workspace_models = self.get_workspace_setting( + workspace, "models", settings_override + ) return workspace_models or settings.BASEROW_OPENAI_MODELS - def get_organization(self, workspace=None): + def get_organization(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "organization") + self.get_workspace_setting(workspace, "organization", settings_override) or settings.BASEROW_OPENAI_ORGANIZATION ) - def is_enabled(self, workspace=None): - api_key = self.get_api_key(workspace) - return bool(api_key) and bool(self.get_enabled_models(workspace=workspace)) + def is_enabled(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) + return bool(api_key) and bool( + self.get_enabled_models( + workspace=workspace, settings_override=settings_override + ) + ) - def get_client(self, workspace=None): - api_key = self.get_api_key(workspace) - organization = self.get_organization(workspace) + def get_client(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) + organization = self.get_organization(workspace, settings_override) return OpenAI(api_key=api_key, organization=organization) def get_settings_serializer(self): @@ -49,9 +55,11 @@ def get_settings_serializer(self): return OpenAISettingsSerializer - def prompt(self, model, prompt, workspace=None, temperature=None): + def prompt( + self, model, prompt, workspace=None, temperature=None, settings_override=None + ): try: - client = self.get_client(workspace) + client = self.get_client(workspace, settings_override) kwargs = {} if temperature: kwargs["temperature"] = temperature @@ -185,22 +193,28 @@ def prompt_with_files( class AnthropicGenerativeAIModelType(GenerativeAIModelType): type = "anthropic" - def get_api_key(self, workspace=None): + def get_api_key(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "api_key") + self.get_workspace_setting(workspace, "api_key", settings_override) or settings.BASEROW_ANTHROPIC_API_KEY ) - def get_enabled_models(self, workspace=None): - workspace_models = self.get_workspace_setting(workspace, "models") + def get_enabled_models(self, workspace=None, settings_override=None): + workspace_models = self.get_workspace_setting( + workspace, "models", settings_override + ) return workspace_models or settings.BASEROW_ANTHROPIC_MODELS - def is_enabled(self, workspace=None): - api_key = self.get_api_key(workspace) - return bool(api_key) and bool(self.get_enabled_models(workspace=workspace)) + def is_enabled(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) + return bool(api_key) and bool( + self.get_enabled_models( + workspace=workspace, settings_override=settings_override + ) + ) - def get_client(self, workspace=None): - api_key = self.get_api_key(workspace) + def get_client(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) return Anthropic(api_key=api_key) def get_settings_serializer(self): @@ -208,9 +222,11 @@ def get_settings_serializer(self): return AnthropicSettingsSerializer - def prompt(self, model, prompt, workspace=None, temperature=None): + def prompt( + self, model, prompt, workspace=None, temperature=None, settings_override=None + ): try: - client = self.get_client(workspace) + client = self.get_client(workspace, settings_override) kwargs = {} if temperature: # Because some LLMs can have a temperature of 2, this is the maximum by @@ -234,22 +250,28 @@ def prompt(self, model, prompt, workspace=None, temperature=None): class MistralGenerativeAIModelType(GenerativeAIModelType): type = "mistral" - def get_api_key(self, workspace=None): + def get_api_key(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "api_key") + self.get_workspace_setting(workspace, "api_key", settings_override) or settings.BASEROW_MISTRAL_API_KEY ) - def get_enabled_models(self, workspace=None): - workspace_models = self.get_workspace_setting(workspace, "models") + def get_enabled_models(self, workspace=None, settings_override=None): + workspace_models = self.get_workspace_setting( + workspace, "models", settings_override + ) return workspace_models or settings.BASEROW_MISTRAL_MODELS - def is_enabled(self, workspace=None): - api_key = self.get_api_key(workspace) - return bool(api_key) and bool(self.get_enabled_models(workspace=workspace)) + def is_enabled(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) + return bool(api_key) and bool( + self.get_enabled_models( + workspace=workspace, settings_override=settings_override + ) + ) - def get_client(self, workspace=None): - api_key = self.get_api_key(workspace) + def get_client(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) return Mistral(api_key=api_key) def get_settings_serializer(self): @@ -257,9 +279,11 @@ def get_settings_serializer(self): return MistralSettingsSerializer - def prompt(self, model, prompt, workspace=None, temperature=None): + def prompt( + self, model, prompt, workspace=None, temperature=None, settings_override=None + ): try: - client = self.get_client(workspace) + client = self.get_client(workspace, settings_override) kwargs = {} if temperature: # Because some LLMs can have a temperature of 2, this is the maximum by @@ -281,26 +305,32 @@ def prompt(self, model, prompt, workspace=None, temperature=None): class OllamaGenerativeAIModelType(GenerativeAIModelType): type = "ollama" - def get_host(self, workspace=None): + def get_host(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "host") + self.get_workspace_setting(workspace, "host", settings_override) or settings.BASEROW_OLLAMA_HOST ) - def get_enabled_models(self, workspace=None): - workspace_models = self.get_workspace_setting(workspace, "models") + def get_enabled_models(self, workspace=None, settings_override=None): + workspace_models = self.get_workspace_setting( + workspace, "models", settings_override + ) return workspace_models or settings.BASEROW_OLLAMA_MODELS - def is_enabled(self, workspace=None): - ollama_host = self.get_host(workspace) - return bool(ollama_host) and bool(self.get_enabled_models(workspace)) + def is_enabled(self, workspace=None, settings_override=None): + ollama_host = self.get_host(workspace, settings_override) + return bool(ollama_host) and bool( + self.get_enabled_models(workspace, settings_override) + ) - def get_client(self, workspace=None): - ollama_host = self.get_host(workspace) + def get_client(self, workspace=None, settings_override=None): + ollama_host = self.get_host(workspace, settings_override) return OllamaClient(host=ollama_host) - def prompt(self, model, prompt, workspace=None, temperature=None): - client = self.get_client(workspace) + def prompt( + self, model, prompt, workspace=None, temperature=None, settings_override=None + ): + client = self.get_client(workspace, settings_override) options = {} if temperature: # Because some LLMs can have a temperature of 2, this is the maximum by @@ -329,25 +359,27 @@ class OpenRouterGenerativeAIModelType(BaseOpenAIGenerativeAIModelType): type = "openrouter" - def get_api_key(self, workspace=None): + def get_api_key(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "api_key") + self.get_workspace_setting(workspace, "api_key", settings_override) or settings.BASEROW_OPENROUTER_API_KEY ) - def get_enabled_models(self, workspace=None): - workspace_models = self.get_workspace_setting(workspace, "models") + def get_enabled_models(self, workspace=None, settings_override=None): + workspace_models = self.get_workspace_setting( + workspace, "models", settings_override + ) return workspace_models or settings.BASEROW_OPENROUTER_MODELS - def get_organization(self, workspace=None): + def get_organization(self, workspace=None, settings_override=None): return ( - self.get_workspace_setting(workspace, "organization") + self.get_workspace_setting(workspace, "organization", settings_override) or settings.BASEROW_OPENROUTER_ORGANIZATION ) - def get_client(self, workspace=None): - api_key = self.get_api_key(workspace) - organization = self.get_organization(workspace) + def get_client(self, workspace=None, settings_override=None): + api_key = self.get_api_key(workspace, settings_override) + organization = self.get_organization(workspace, settings_override) return OpenAI( api_key=api_key, organization=organization, diff --git a/backend/src/baserow/core/generative_ai/registries.py b/backend/src/baserow/core/generative_ai/registries.py index 0b0862b484..09dd0bfacc 100644 --- a/backend/src/baserow/core/generative_ai/registries.py +++ b/backend/src/baserow/core/generative_ai/registries.py @@ -84,7 +84,20 @@ def prompt_with_files( class GenerativeAIModelType(Instance): - def get_workspace_setting(self, workspace, key): + def get_workspace_setting(self, workspace, key, settings_override=None): + """ + Get a setting for this AI model type. + + :param workspace: The workspace to get settings from. + :param key: The setting key to retrieve. + :param settings_override: Optional dict of settings to use instead of workspace + settings. Format: {"api_key": "...", "models": [...]} + :return: The setting value or None. + """ + + if settings_override is not None and key in settings_override: + return settings_override[key] + if not isinstance(workspace, Workspace): return None diff --git a/backend/src/baserow/core/output_parsers.py b/backend/src/baserow/core/output_parsers.py new file mode 100644 index 0000000000..04692feebf --- /dev/null +++ b/backend/src/baserow/core/output_parsers.py @@ -0,0 +1,30 @@ +import json +from difflib import get_close_matches +from typing import Any + +from langchain.output_parsers.enum import EnumOutputParser + + +class StrictEnumOutputParser(EnumOutputParser): + def get_format_instructions(self) -> str: + json_array = json.dumps(self._valid_values) + return f"""Categorize the result following these requirements: + +- Select only one option from the JSON array below. +- Don't use quotes or commas or partial values, just the option name. +- Choose the option that most closely matches the row values. + +```json +{json_array} +```""" # nosec this falsely marks as hardcoded sql expression, but it's not related + # to SQL at all. + + def parse(self, response: str) -> Any: + response = response.strip() + # Sometimes the LLM responds with a quotes value or with part of the value if + # it contains a comma. Finding the close matches helps with selecting the + # right value. + closest_matches = get_close_matches( + response, self._valid_values, n=1, cutoff=0.0 + ) + return super().parse(closest_matches[0]) diff --git a/backend/src/baserow/test_utils/fixtures/service.py b/backend/src/baserow/test_utils/fixtures/service.py index 7114086231..3b54b600bd 100644 --- a/backend/src/baserow/test_utils/fixtures/service.py +++ b/backend/src/baserow/test_utils/fixtures/service.py @@ -1,5 +1,6 @@ from uuid import uuid4 +from baserow.contrib.integrations.ai.models import AIAgentService from baserow.contrib.integrations.core.models import ( CoreHTTPRequestService, CoreHTTPTriggerService, @@ -100,6 +101,9 @@ def create_core_smtp_email_service(self, **kwargs) -> CoreSMTPEmailService: service = self.create_service(CoreSMTPEmailService, **kwargs) return service + def create_ai_agent_service(self, **kwargs): + return self.create_service(AIAgentService, **kwargs) + def create_core_iterator_service(self, **kwargs): return self.create_service(CoreIteratorService, **kwargs) diff --git a/backend/tests/baserow/contrib/automation/nodes/test_node_service.py b/backend/tests/baserow/contrib/automation/nodes/test_node_service.py index 32c2bf32a8..bb381e015b 100644 --- a/backend/tests/baserow/contrib/automation/nodes/test_node_service.py +++ b/backend/tests/baserow/contrib/automation/nodes/test_node_service.py @@ -635,6 +635,85 @@ def test_move_node_outside_of_container(data_fixture: Fixtures): assert move_result.previous_output == "" +@pytest.mark.django_db +def test_move_container_after_itself(data_fixture: Fixtures): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user) + action1 = data_fixture.create_automation_node(workflow=workflow, label="action1") + + iterator = data_fixture.create_core_iterator_action_node(workflow=workflow) + action2 = data_fixture.create_automation_node(workflow=workflow, label="action2") + action3 = data_fixture.create_automation_node(workflow=workflow, label="action3") + action4 = data_fixture.create_automation_node(workflow=workflow, label="action4") + + # move `action3` to be the first child of iterator + move_result = AutomationNodeService().move_node( + user, iterator.id, reference_node_id=action4.id, position="south", output="" + ) + + workflow.assert_reference( + { + "0": "rows_created", + "rows_created": {"next": {"": ["action1"]}}, + "action1": {"next": {"": ["action2"]}}, + "action2": {"next": {"": ["action3"]}}, + "action3": {"next": {"": ["action4"]}}, + "action4": {"next": {"": ["iterator"]}}, + "iterator": {}, + } + ) + + +@pytest.mark.django_db +def test_move_container_inside_itself_should_fail(data_fixture: Fixtures): + user = data_fixture.create_user() + workflow = data_fixture.create_automation_workflow(user) + action1 = data_fixture.create_automation_node(workflow=workflow, label="action1") + + iterator = data_fixture.create_core_iterator_action_node(workflow=workflow) + iterator2 = data_fixture.create_core_iterator_action_node( + workflow=workflow, reference_node=iterator, position="child" + ) + action2 = data_fixture.create_automation_node( + workflow=workflow, label="action2", reference_node=iterator2, position="child" + ) + action3 = data_fixture.create_automation_node( + workflow=workflow, label="action3", reference_node=action2, position="south" + ) + action4 = data_fixture.create_automation_node( + workflow=workflow, label="action4", reference_node=iterator2, position="south" + ) + + with pytest.raises(AutomationNodeNotMovable) as exc: + AutomationNodeService().move_node( + user, iterator.id, reference_node_id=action3.id, position="south", output="" + ) + + assert exc.value.args[0] == "A container node cannot be moved inside itself" + + with pytest.raises(AutomationNodeNotMovable) as exc: + AutomationNodeService().move_node( + user, + iterator.id, + reference_node_id=iterator2.id, + position="south", + output="", + ) + + assert exc.value.args[0] == "A container node cannot be moved inside itself" + + with pytest.raises(AutomationNodeNotMovable) as exc: + AutomationNodeService().move_node( + user, + iterator.id, + reference_node_id=iterator2.id, + position="child", + output="", + ) + + assert exc.value.args[0] == "A container node cannot be moved inside itself" + + @pytest.mark.django_db def test_move_node_invalid_reference_node(data_fixture: Fixtures): user = data_fixture.create_user() diff --git a/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py b/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py index 405c223d71..740fcd2817 100644 --- a/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py +++ b/backend/tests/baserow/contrib/automation/workflows/test_graph_handler.py @@ -511,7 +511,7 @@ def test_graph_handler_insert_first_node( { "0": 1, "1": {"next": {"": [2]}}, - "2": {"next": {"": [4, 7]}}, + "2": {"next": {"": [4]}}, # yes we lose the child for now "4": {"next": {"": [5], "randomUid": [9]}}, "7": {"next": {"": [8]}}, "8": {"children": []}, diff --git a/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py b/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py new file mode 100644 index 0000000000..410df2752d --- /dev/null +++ b/backend/tests/baserow/contrib/integrations/ai/test_ai_agent_service_type.py @@ -0,0 +1,801 @@ +import json +from unittest.mock import patch + +import pytest + +from baserow.contrib.automation.nodes.handler import AutomationNodeHandler +from baserow.contrib.automation.nodes.registries import automation_node_type_registry +from baserow.contrib.automation.workflows.handler import AutomationWorkflowHandler +from baserow.contrib.integrations.ai.integration_types import AIIntegrationType +from baserow.contrib.integrations.ai.service_types import AIAgentServiceType +from baserow.core.generative_ai.exceptions import GenerativeAIPromptError +from baserow.core.integrations.service import IntegrationService +from baserow.core.services.exceptions import ( + ServiceImproperlyConfiguredDispatchException, +) +from baserow.core.services.handler import ServiceHandler +from baserow.test_utils.helpers import AnyInt +from baserow.test_utils.pytest_conftest import FakeDispatchContext + + +def mock_ai_prompt(return_value="AI response", should_fail=False): + """ + Context manager to mock AI model prompt calls. + """ + + def _prompt( + model, prompt, workspace=None, temperature=None, settings_override=None + ): + if should_fail: + raise GenerativeAIPromptError("AI API error") + return return_value + + return patch( + "baserow.core.generative_ai.generative_ai_model_types.OpenAIGenerativeAIModelType.prompt", + side_effect=_prompt, + ) + + +@pytest.mark.django_db +def test_ai_agent_service_creation(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Tell me a joke'", + ) + + assert service.integration_id == integration.id + assert service.ai_generative_ai_type == "openai" + assert service.ai_generative_ai_model == "gpt-4" + assert service.ai_output_type == "text" + assert service.ai_prompt == { + "mode": "simple", + "formula": "'Tell me a joke'", + "version": "0.1", + } + assert service.ai_choices == [] + assert service.ai_temperature is None + + +@pytest.mark.django_db +def test_ai_agent_service_creation_with_temperature(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_temperature=0.7, + ai_prompt="'Be creative'", + ) + + assert service.ai_temperature == 0.7 + + +@pytest.mark.django_db +def test_ai_agent_service_creation_with_choices(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="choice", + ai_prompt="'Categorize: positive or negative'", + ai_choices=["positive", "negative", "neutral"], + ) + + assert service.ai_output_type == "choice" + assert service.ai_choices == ["positive", "negative", "neutral"] + + +@pytest.mark.django_db +def test_ai_agent_service_update(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4", "gpt-3.5-turbo"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Original prompt'", + ) + + service_type = service.get_type() + ServiceHandler().update_service( + service_type, + service, + ai_generative_ai_model="gpt-3.5-turbo", + ai_prompt="'Updated prompt'", + ai_temperature=0.5, + ) + + service.refresh_from_db() + + assert service.ai_generative_ai_model == "gpt-3.5-turbo" + assert service.ai_prompt["formula"] == "'Updated prompt'" + assert service.ai_temperature == 0.5 + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_text_output(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Tell me a joke'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with mock_ai_prompt(return_value="Why did the chicken cross the road?"): + result = service_type.dispatch(service, dispatch_context) + + assert result.data == {"result": "Why did the chicken cross the road?"} + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_with_temperature(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_temperature=0.3, + ai_prompt="'Be precise'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with patch( + "baserow.core.generative_ai.generative_ai_model_types.OpenAIGenerativeAIModelType.prompt" + ) as mock_prompt: + mock_prompt.return_value = "Precise response" + service_type.dispatch(service, dispatch_context) + + mock_prompt.assert_called_once() + call_kwargs = mock_prompt.call_args[1] + assert call_kwargs["temperature"] == 0.3 + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_choice_output(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="choice", + ai_prompt="'Is this positive or negative: I love this!'", + ai_choices=["positive", "negative", "neutral"], + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with mock_ai_prompt(return_value="positive"): + result = service_type.dispatch(service, dispatch_context) + + assert result.data == {"result": "positive"} + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_with_formula(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="concat('Summarize this text: ', get('text'))", + ) + + service_type = service.get_type() + + formula_context = {"text": "This is a long article about AI technology..."} + dispatch_context = FakeDispatchContext(context=formula_context) + + with mock_ai_prompt(return_value="Summary: AI technology article"): + result = service_type.dispatch(service, dispatch_context) + + assert result.data == {"result": "Summary: AI technology article"} + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_missing_integration(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + service.integration_id = None + service.save() + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + service_type.dispatch(service, dispatch_context) + + assert "integration property is missing" in str(exc_info.value) + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_missing_provider(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + service.ai_generative_ai_type = "" + service.save() + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + service_type.dispatch(service, dispatch_context) + + assert "AI provider type is missing" in str(exc_info.value) + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_missing_model(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + # Clear model + service.ai_generative_ai_model = "" + service.save() + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + service_type.dispatch(service, dispatch_context) + + assert "AI model is missing" in str(exc_info.value) + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_missing_choices(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="choice", + ai_prompt="'Categorize this'", + ai_choices=[], # Empty choices + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + service_type.dispatch(service, dispatch_context) + + assert "choice is required" in str(exc_info.value) + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_ai_error(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with pytest.raises(ServiceImproperlyConfiguredDispatchException) as exc_info: + with mock_ai_prompt(should_fail=True): + service_type.dispatch(service, dispatch_context) + + assert "AI prompt execution failed" in str(exc_info.value) + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_with_integration_settings(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-env-key" + settings.BASEROW_OPENAI_MODELS = ["gpt-3.5-turbo"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + + # Create integration with custom OpenAI settings + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={ + "openai": { + "api_key": "sk-integration-key", + "models": ["gpt-4"], + "organization": "org-integration", + } + }, + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + service_type = service.get_type() + dispatch_context = FakeDispatchContext() + + with patch( + "baserow.core.generative_ai.generative_ai_model_types.OpenAIGenerativeAIModelType.prompt" + ) as mock_prompt: + mock_prompt.return_value = "Response" + service_type.dispatch(service, dispatch_context) + + mock_prompt.assert_called_once() + call_kwargs = mock_prompt.call_args[1] + assert "settings_override" in call_kwargs + assert call_kwargs["settings_override"]["api_key"] == "sk-integration-key" + + +@pytest.mark.django_db +def test_ai_agent_service_generate_schema(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'Test'", + ) + + service_type = service.get_type() + schema = service_type.generate_schema(service) + + assert schema == { + "type": "object", + "title": f"Service{service.id}Schema", # Uses base ServiceType schema naming + "properties": { + "result": { + "type": "string", + "title": "AI Response", + } + }, + } + + +@pytest.mark.django_db +def test_ai_agent_service_export_import(data_fixture, settings): + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration(user, integration_type, application=application) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="choice", + ai_temperature=0.5, + ai_prompt="'Categorize sentiment'", + ai_choices=["positive", "negative", "neutral"], + ) + + service_type = service.get_type() + + serialized = json.loads(json.dumps(service_type.export_serialized(service))) + expected_serialized = { + "id": AnyInt(), + "integration_id": integration.id, + "sample_data": None, + "type": "ai_agent", + "ai_generative_ai_type": "openai", + "ai_generative_ai_model": "gpt-4", + "ai_output_type": "choice", + "ai_temperature": 0.5, + "ai_prompt": { + "formula": "'Categorize sentiment'", + "mode": "simple", + "version": "0.1", + }, + "ai_choices": ["positive", "negative", "neutral"], + } + assert serialized == expected_serialized + + new_service = service_type.import_serialized( + None, serialized, {integration.id: integration}, lambda x, d: x + ) + assert new_service.ai_generative_ai_type == "openai" + assert new_service.ai_generative_ai_model == "gpt-4" + assert new_service.ai_output_type == "choice" + assert new_service.ai_temperature == 0.5 + assert new_service.ai_prompt["formula"] == "'Categorize sentiment'" + assert new_service.ai_choices == ["positive", "negative", "neutral"] + + +@pytest.mark.django_db +def test_ai_agent_service_dispatch_in_published_workflow(data_fixture, settings): + """ + Test that AI agent services work correctly when a workflow is published. + When published, the workflow is copied and workspace relationships may be null. + This test verifies that the integration settings are properly copied. + """ + + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + workspace.generative_ai_models_settings = { + "openai": {"api_key": "sk-workspace-key", "models": ["gpt-4"]} + } + workspace.save() + + automation = data_fixture.create_automation_application( + user=user, workspace=workspace + ) + workflow = data_fixture.create_automation_workflow(automation=automation) + + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration( + user, + integration_type, + application=automation, + ai_settings={ + "openai": {"api_key": "sk-integration-key", "models": ["gpt-4"]} + }, + ) + .specific + ) + + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'What is 2+2?'", + ) + + node_type = automation_node_type_registry.get("ai_agent") + action_node = AutomationNodeHandler().create_node( + user=user, + workflow=workflow, + node_type=node_type, + service=service, + ) + + published_workflow = AutomationWorkflowHandler().publish(workflow) + published_action_node = published_workflow.automation_workflow_nodes.filter( + content_type__model="aiagentactionnode" + ).first() + published_service = published_action_node.service.specific + published_integration = published_service.integration.specific + + # Verify the integration settings were properly copied + assert published_integration.ai_settings == { + "openai": {"api_key": "sk-integration-key", "models": ["gpt-4"]} + } + + # Verify the integration type can get the provider settings + published_integration_type = AIIntegrationType() + provider_settings = published_integration_type.get_provider_settings( + published_integration, "openai" + ) + assert provider_settings["api_key"] == "sk-integration-key" + assert provider_settings["models"] == ["gpt-4"] + + # Dispatch the service in the published workflow context + service_type = published_service.get_type() + dispatch_context = FakeDispatchContext() + + with mock_ai_prompt(return_value="4"): + result = service_type.dispatch(published_service, dispatch_context) + + assert result.data == {"result": "4"} + + +@pytest.mark.django_db +def test_ai_agent_service_requires_integration_settings_not_workspace_fallback( + data_fixture, settings +): + """ + Test that demonstrates AI integrations must have explicit settings, + not rely on workspace fallback, because published workflows have workspace=None. + """ + + settings.BASEROW_OPENAI_API_KEY = "sk-test" + settings.BASEROW_OPENAI_MODELS = ["gpt-4"] + + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + + workspace.generative_ai_models_settings = { + "openai": {"api_key": "sk-workspace-key", "models": ["gpt-4"]} + } + workspace.save() + + automation = data_fixture.create_automation_application( + user=user, workspace=workspace + ) + workflow = data_fixture.create_automation_workflow(automation=automation) + + # Create AI integration WITHOUT settings (relies on workspace fallback) + integration_type = AIIntegrationType() + integration = ( + IntegrationService() + .create_integration( + user, + integration_type, + application=automation, + ai_settings={}, # Empty - relies on workspace settings + ) + .specific + ) + + # Before publishing, integration can access workspace settings + provider_settings = integration_type.get_provider_settings(integration, "openai") + assert provider_settings["api_key"] == "sk-workspace-key" + + # Create AI agent service + service = ServiceHandler().create_service( + AIAgentServiceType(), + integration_id=integration.id, + ai_generative_ai_type="openai", + ai_generative_ai_model="gpt-4", + ai_output_type="text", + ai_prompt="'What is 2+2?'", + ) + + node_type = automation_node_type_registry.get("ai_agent") + action_node = AutomationNodeHandler().create_node( + user=user, + workflow=workflow, + node_type=node_type, + service=service, + ) + + published_workflow = AutomationWorkflowHandler().publish(workflow) + + published_action_node = published_workflow.automation_workflow_nodes.filter( + content_type__model="aiagentactionnode" + ).first() + published_service = published_action_node.service.specific + published_integration = published_service.integration.specific + + # Verify workspace is None in published automation + assert published_integration.application.workspace is None + + # After publishing, verify workspace settings were materialized into integration + assert published_integration.ai_settings == { + "openai": {"api_key": "sk-workspace-key", "models": ["gpt-4"]} + } + + # After publishing, integration should still have access to settings + provider_settings = integration_type.get_provider_settings( + published_integration, "openai" + ) + # Settings should be available because they were materialized during export + assert provider_settings["api_key"] == "sk-workspace-key" + assert provider_settings["models"] == ["gpt-4"] diff --git a/backend/tests/baserow/contrib/integrations/ai/test_ai_integration_type.py b/backend/tests/baserow/contrib/integrations/ai/test_ai_integration_type.py new file mode 100644 index 0000000000..9a8f2f9942 --- /dev/null +++ b/backend/tests/baserow/contrib/integrations/ai/test_ai_integration_type.py @@ -0,0 +1,393 @@ +import json + +import pytest + +from baserow.contrib.integrations.ai.integration_types import AIIntegrationType +from baserow.contrib.integrations.ai.models import AIIntegration +from baserow.core.integrations.registries import integration_type_registry +from baserow.core.integrations.service import IntegrationService +from baserow.core.registries import ImportExportConfig +from baserow.test_utils.helpers import AnyInt + + +@pytest.mark.django_db +def test_ai_integration_creation(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ) + + assert integration.ai_settings == {} + assert integration.application_id == application.id + assert isinstance(integration, AIIntegration) + + +@pytest.mark.django_db +def test_ai_integration_creation_with_settings(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + ai_settings = { + "openai": { + "api_key": "sk-test123", + "models": ["gpt-4", "gpt-3.5-turbo"], + "organization": "org-123", + } + } + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings=ai_settings, + ) + + assert integration.ai_settings == ai_settings + assert integration.application_id == application.id + + +@pytest.mark.django_db +def test_ai_integration_update(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={"openai": {"api_key": "sk-old", "models": ["gpt-3.5-turbo"]}}, + ) + + updated_integration = IntegrationService().update_integration( + user, + integration, + ai_settings={ + "openai": {"api_key": "sk-new", "models": ["gpt-4"]}, + "anthropic": {"api_key": "sk-anthropic", "models": ["claude-3-opus"]}, + }, + ) + + assert updated_integration.ai_settings["openai"]["api_key"] == "sk-new" + assert updated_integration.ai_settings["openai"]["models"] == ["gpt-4"] + assert updated_integration.ai_settings["anthropic"]["api_key"] == "sk-anthropic" + assert updated_integration.ai_settings["anthropic"]["models"] == ["claude-3-opus"] + + +@pytest.mark.django_db +def test_ai_integration_partial_update(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + original_settings = { + "openai": {"api_key": "sk-original", "models": ["gpt-4"]}, + "anthropic": {"api_key": "sk-anthropic", "models": ["claude-3-opus"]}, + } + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings=original_settings, + ) + + updated_integration = IntegrationService().update_integration( + user, + integration, + ai_settings={"openai": {"api_key": "sk-updated", "models": ["gpt-4-turbo"]}}, + ) + + # OpenAI should be updated + assert updated_integration.ai_settings["openai"]["api_key"] == "sk-updated" + assert updated_integration.ai_settings["openai"]["models"] == ["gpt-4-turbo"] + # Anthropic should be removed (replaced, not merged) + assert "anthropic" not in updated_integration.ai_settings + + +@pytest.mark.django_db +def test_ai_integration_serializer_field_names(data_fixture): + integration_type = AIIntegrationType() + + expected_fields = ["ai_settings"] + assert integration_type.serializer_field_names == expected_fields + assert integration_type.allowed_fields == expected_fields + assert integration_type.request_serializer_field_names == expected_fields + + +@pytest.mark.django_db +def test_ai_integration_serialized_dict_type(data_fixture): + integration_type = AIIntegrationType() + + serialized_dict_class = integration_type.SerializedDict + annotations = getattr(serialized_dict_class, "__annotations__", {}) + + assert "ai_settings" in annotations + + +@pytest.mark.django_db +def test_ai_integration_export_serialized(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={ + "openai": { + "api_key": "sk-secret123", + "models": ["gpt-4"], + "organization": "org-123", + } + }, + ) + + serialized = json.loads(json.dumps(integration_type.export_serialized(integration))) + + expected_serialized = { + "id": AnyInt(), + "type": "ai", + "ai_settings": { + "openai": { + "api_key": "sk-secret123", + "models": ["gpt-4"], + "organization": "org-123", + } + }, + "name": "", + "order": "1.00000000000000000000", + } + + assert serialized == expected_serialized + + +@pytest.mark.django_db +def test_ai_integration_export_serialized_exclude_sensitive(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={ + "openai": { + "api_key": "sk-secret123", + "models": ["gpt-4"], + } + }, + ) + + serialized = json.loads( + json.dumps( + integration_type.export_serialized( + integration, + import_export_config=ImportExportConfig( + include_permission_data=False, + reduce_disk_space_usage=False, + exclude_sensitive_data=True, + ), + ) + ) + ) + + expected_serialized = { + "id": AnyInt(), + "type": "ai", + "ai_settings": None, + "name": "", + "order": "1.00000000000000000000", + } + + assert serialized == expected_serialized + + +@pytest.mark.django_db +def test_ai_integration_import_serialized(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + + serialized_data = { + "id": 1, + "type": "ai", + "ai_settings": {}, # Empty on import (will inherit from workspace) + } + + imported_integration = integration_type.import_serialized( + application, serialized_data, {}, lambda x, d: x + ) + + assert imported_integration.ai_settings == {} + assert imported_integration.application_id == application.id + + +@pytest.mark.django_db +def test_ai_integration_deletion(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = integration_type_registry.get("ai") + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={"openai": {"api_key": "sk-test", "models": ["gpt-4"]}}, + ) + + integration_id = integration.id + + IntegrationService().delete_integration(user, integration) + + # Verify integration is deleted + assert not AIIntegration.objects.filter(id=integration_id).exists() + + +@pytest.mark.django_db +def test_ai_integration_get_provider_settings_from_workspace(data_fixture, settings): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + application = data_fixture.create_builder_application( + user=user, workspace=workspace + ) + + workspace.generative_ai_models_settings = { + "openai": {"api_key": "sk-workspace-key", "models": ["gpt-3.5-turbo"]} + } + workspace.save() + + integration_type = AIIntegrationType() + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={}, + ) + + # Should get settings from workspace + provider_settings = integration_type.get_provider_settings(integration, "openai") + assert provider_settings["api_key"] == "sk-workspace-key" + + +@pytest.mark.django_db +def test_ai_integration_get_provider_settings_empty(data_fixture, settings): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + settings.BASEROW_OPENAI_API_KEY = "sk-env-key" + + integration_type = AIIntegrationType() + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={}, + ) + + # Should return empty dict when no integration or workspace settings exist + provider_settings = integration_type.get_provider_settings(integration, "openai") + assert provider_settings == {} + + +@pytest.mark.django_db +def test_ai_integration_get_provider_settings(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={ + "openai": { + "api_key": "sk-test", + "models": ["gpt-4"], + "organization": "org-123", + } + }, + ) + + settings = integration_type.get_provider_settings(integration, "openai") + assert settings == { + "api_key": "sk-test", + "models": ["gpt-4"], + "organization": "org-123", + } + + +@pytest.mark.django_db +def test_ai_integration_is_provider_overridden(data_fixture): + user = data_fixture.create_user() + application = data_fixture.create_builder_application(user=user) + + integration_type = AIIntegrationType() + + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={"openai": {"api_key": "sk-test", "models": ["gpt-4"]}}, + ) + + assert integration_type.is_provider_overridden(integration, "openai") is True + assert integration_type.is_provider_overridden(integration, "anthropic") is False + + +@pytest.mark.django_db +def test_ai_integration_settings_hierarchy(data_fixture, settings): + user = data_fixture.create_user() + workspace = data_fixture.create_workspace(user=user) + application = data_fixture.create_builder_application( + user=user, workspace=workspace + ) + + settings.BASEROW_OPENAI_API_KEY = "sk-env-key" + settings.BASEROW_OPENAI_MODELS = ["gpt-3.5-turbo"] + + workspace.generative_ai_models_settings = { + "openai": {"api_key": "sk-workspace-key", "models": ["gpt-4"]} + } + workspace.save() + + integration_type = AIIntegrationType() + integration = IntegrationService().create_integration( + user, + integration_type, + application=application, + ai_settings={"openai": {"api_key": "sk-integration-key", "models": ["gpt-4o"]}}, + ) + + # Integration settings should take precedence + provider_settings = integration_type.get_provider_settings(integration, "openai") + assert provider_settings["api_key"] == "sk-integration-key" + assert provider_settings["models"] == ["gpt-4o"] + + # Now update integration to remove OpenAI override + IntegrationService().update_integration(user, integration, ai_settings={}) + integration.refresh_from_db() + + # Should now get workspace settings + provider_settings = integration_type.get_provider_settings(integration, "openai") + assert provider_settings["api_key"] == "sk-workspace-key" + assert provider_settings["models"] == ["gpt-4"] diff --git a/changelog/entries/unreleased/feature/4116_ai_node.json b/changelog/entries/unreleased/feature/4116_ai_node.json new file mode 100644 index 0000000000..6d2165ee36 --- /dev/null +++ b/changelog/entries/unreleased/feature/4116_ai_node.json @@ -0,0 +1,9 @@ +{ + "type": "feature", + "message": "Added the AI agent workflow action.", + "domain": "builder", + "issue_number": 4116, + "issue_origin": "github", + "bullet_points": [], + "created_at": "2025-11-03" +} diff --git a/premium/backend/src/baserow_premium/fields/ai_field_output_types.py b/premium/backend/src/baserow_premium/fields/ai_field_output_types.py index 2d8e320185..98d8adccaf 100644 --- a/premium/backend/src/baserow_premium/fields/ai_field_output_types.py +++ b/premium/backend/src/baserow_premium/fields/ai_field_output_types.py @@ -1,9 +1,5 @@ import enum -import json -from difflib import get_close_matches -from typing import Any -from langchain.output_parsers.enum import EnumOutputParser from langchain_core.exceptions import OutputParserException from langchain_core.prompts import PromptTemplate @@ -11,6 +7,7 @@ LongTextFieldType, SingleSelectFieldType, ) +from baserow.core.output_parsers import StrictEnumOutputParser from .registries import AIFieldOutputType @@ -20,31 +17,6 @@ class TextAIFieldOutputType(AIFieldOutputType): baserow_field_type = LongTextFieldType -class StrictEnumOutputParser(EnumOutputParser): - def get_format_instructions(self) -> str: - json_array = json.dumps(self._valid_values) - return f"""Categorize the result following these requirements: - -- Select only one option from the JSON array below. -- Don't use quotes or commas or partial values, just the option name. -- Choose the option that most closely matches the row values. - -```json -{json_array} -```""" # nosec this falsely marks as hardcoded sql expression, but it's not related - # to SQL at all. - - def parse(self, response: str) -> Any: - response = response.strip() - # Sometimes the LLM responds with a quotes value or with part of the value if - # it contains a comma. Finding the close matches helps with selecting the - # right value. - closest_matches = get_close_matches( - response, self._valid_values, n=1, cutoff=0.0 - ) - return super().parse(closest_matches[0]) - - class ChoiceAIFieldOutputType(AIFieldOutputType): type = "choice" baserow_field_type = SingleSelectFieldType diff --git a/web-frontend/modules/automation/components/workflow/WorkflowNode.vue b/web-frontend/modules/automation/components/workflow/WorkflowNode.vue index 88ce4e5cca..2e886c0614 100644 --- a/web-frontend/modules/automation/components/workflow/WorkflowNode.vue +++ b/web-frontend/modules/automation/components/workflow/WorkflowNode.vue @@ -145,9 +145,18 @@ const coordsPerEdge = computed(() => { return nodeEdges.value.map((edge) => { const wrap = workflowNode.value - const edgeElt = refs[`edge-${edge.uid}`][0].$el - - return [edge.uid, computeEdgeCoords(wrap, edgeElt, hasMultipleEdges.value)] + if (Array.isArray(refs[`edge-${edge.uid}`])) { + const edgeElt = refs[`edge-${edge.uid}`][0].$el + + return [ + edge.uid, + computeEdgeCoords(wrap, edgeElt, hasMultipleEdges.value), + ] + } else { + // We might have a delay between the edge addition + // and the branch being visible + return [edge.uid, { startX: 0, startY: 0, endX: 0, endY: 0 }] + } }) }) diff --git a/web-frontend/modules/automation/components/workflow/sidePanels/NodeSidePanel.vue b/web-frontend/modules/automation/components/workflow/sidePanels/NodeSidePanel.vue index b572906a53..54d0fe08cc 100644 --- a/web-frontend/modules/automation/components/workflow/sidePanels/NodeSidePanel.vue +++ b/web-frontend/modules/automation/components/workflow/sidePanels/NodeSidePanel.vue @@ -30,6 +30,7 @@ :application="automation" enable-integration-picker :default-values="node.service" + :edge-in-use-fn="nodeEdgeInUseFn" class="margin-top-2" @values-changed="handleNodeChange({ service: $event })" /> @@ -59,6 +60,7 @@ import { helpers, maxLength } from '@vuelidate/validators' import { notifyIf } from '@baserow/modules/core/utils/error' const store = useStore() + const { app } = useContext() provide('formulaComponent', AutomationBuilderFormulaInput) @@ -188,4 +190,17 @@ const handleNodeChange = async ({ const nodeLoading = computed(() => { return store.getters['automationWorkflowNode/getLoading'](node.value) }) + +/** + * Responsible for informing the core router service form if an edge has an + * output. As the service form can't refer to automation nodes, we have to + * perform the check here, and pass the function as a prop into the form. + */ +const nodeEdgeInUseFn = (edge) => { + return !!store.getters['automationWorkflowNode/getNextNodes']( + workflow.value, + node.value, + edge.uid + ).length +} diff --git a/web-frontend/modules/automation/locales/en.json b/web-frontend/modules/automation/locales/en.json index e84e286988..d4cd625e36 100644 --- a/web-frontend/modules/automation/locales/en.json +++ b/web-frontend/modules/automation/locales/en.json @@ -117,7 +117,8 @@ "routerWithOutputNodesReplaceError": "Cannot be replaced until its {outputCount} output nodes are removed.", "iteratorWithChildrenNodesDeleteError": "Cannot be deleted until its child nodes are removed.", "iteratorWithChildrenNodesReplaceError": "Cannot be replaced until its child nodes are removed.", - "periodicTriggerLabel": "Periodic trigger" + "periodicTriggerLabel": "Periodic trigger", + "aiAgent": "AI Agent" }, "workflowNode": { "actionDelete": "Delete", diff --git a/web-frontend/modules/automation/nodeTypes.js b/web-frontend/modules/automation/nodeTypes.js index d3f042dd3b..a023d6b2b8 100644 --- a/web-frontend/modules/automation/nodeTypes.js +++ b/web-frontend/modules/automation/nodeTypes.js @@ -24,6 +24,7 @@ import { CoreHTTPTriggerServiceType, CoreIteratorServiceType, } from '@baserow/modules/integrations/core/serviceTypes' +import { AIAgentServiceType } from '@baserow/modules/integrations/ai/serviceTypes' import { uuid } from '@baserow/modules/core/utils/string' export class NodeType extends Registerable { @@ -371,10 +372,6 @@ export class CorePeriodicTriggerNodeType extends TriggerNodeTypeMixin( return 4 } - get iconClass() { - return 'iconoir-timer' - } - get name() { return this.app.i18n.t('nodeType.periodicTriggerLabel') } @@ -413,10 +410,6 @@ export class CoreHTTPTriggerNodeType extends TriggerNodeTypeMixin(NodeType) { return this.app.i18n.t('serviceType.coreHTTPTriggerDescription') } - get iconClass() { - return 'iconoir-globe' - } - get serviceType() { return this.app.$registry.get( 'service', @@ -584,10 +577,6 @@ export class CoreHttpRequestNodeType extends ActionNodeTypeMixin(NodeType) { return 7 } - get iconClass() { - return 'iconoir-globe' - } - get name() { return this.app.i18n.t('nodeType.httpRequestLabel') } @@ -601,7 +590,7 @@ export class CoreHttpRequestNodeType extends ActionNodeTypeMixin(NodeType) { } export class CoreIteratorNodeType extends containerNodeTypeMixin( - ActionNodeTypeMixin(NodeType) + ActionNodeTypeMixin(UtilityNodeMixin(NodeType)) ) { static getType() { return 'iterator' @@ -817,3 +806,25 @@ export class CoreRouterNodeType extends ActionNodeTypeMixin( ] } } + +export class AIAgentActionNodeType extends ActionNodeTypeMixin(NodeType) { + static getType() { + return 'ai_agent' + } + + get name() { + return this.app.i18n.t('nodeType.aiAgent') + } + + get iconClass() { + return 'iconoir-sparks' + } + + get serviceType() { + return this.app.$registry.get('service', AIAgentServiceType.getType()) + } + + getOrder() { + return 8 + } +} diff --git a/web-frontend/modules/automation/plugin.js b/web-frontend/modules/automation/plugin.js index 8bbca559f9..fcfb2d60ca 100644 --- a/web-frontend/modules/automation/plugin.js +++ b/web-frontend/modules/automation/plugin.js @@ -33,6 +33,7 @@ import { CoreSMTPEmailNodeType, CoreRouterNodeType, CorePeriodicTriggerNodeType, + AIAgentActionNodeType, } from '@baserow/modules/automation/nodeTypes' import { DuplicateAutomationWorkflowJobType, @@ -133,6 +134,7 @@ export default (context) => { new LocalBaserowAggregateRowsActionNodeType(context) ) app.$registry.register('node', new CorePeriodicTriggerNodeType(context)) + app.$registry.register('node', new AIAgentActionNodeType(context)) app.$registry.register( 'job', new DuplicateAutomationWorkflowJobType(context) diff --git a/web-frontend/modules/builder/dataProviderTypes.js b/web-frontend/modules/builder/dataProviderTypes.js index cc1dc364dd..089cc0f038 100644 --- a/web-frontend/modules/builder/dataProviderTypes.js +++ b/web-frontend/modules/builder/dataProviderTypes.js @@ -824,15 +824,19 @@ export class PreviousActionDataProviderType extends DataProviderType { const workflowAction = this.app.store.getters[ 'builderWorkflowAction/getWorkflowActionById' - ](applicationContext.page, workflowActionId) + ](applicationContext.page, parseInt(workflowActionId)) const actionType = this.app.$registry.get( 'workflowAction', workflowAction.type ) - return content - ? actionType.getValueAtPath(workflowAction, content, rest) + return content[workflowActionId] + ? actionType.getValueAtPath( + workflowAction, + content[workflowActionId], + rest + ) : null } diff --git a/web-frontend/modules/builder/elementTypes.js b/web-frontend/modules/builder/elementTypes.js index bf36de78ab..956ccc965e 100644 --- a/web-frontend/modules/builder/elementTypes.js +++ b/web-frontend/modules/builder/elementTypes.js @@ -804,7 +804,9 @@ export class ElementType extends Registerable { page, element, allowSameElement = true, - recordIndexPath, + // The default value is used when we try to resolve the name in element menu for + // instance. + recordIndexPath = [0], } = applicationContext const collectionAncestry = this.getCollectionAncestry({ diff --git a/web-frontend/modules/builder/plugin.js b/web-frontend/modules/builder/plugin.js index e4ba17dcc0..93fe0e01f5 100644 --- a/web-frontend/modules/builder/plugin.js +++ b/web-frontend/modules/builder/plugin.js @@ -120,6 +120,7 @@ import { DeleteRowWorkflowActionType, CoreHTTPRequestWorkflowActionType, CoreSMTPEmailWorkflowActionType, + AIAgentWorkflowActionType, } from '@baserow/modules/builder/workflowActionTypes' import { @@ -372,6 +373,10 @@ export default (context) => { 'workflowAction', new CoreSMTPEmailWorkflowActionType(context) ) + app.$registry.register( + 'workflowAction', + new AIAgentWorkflowActionType(context) + ) app.$registry.register( 'workflowAction', new CreateRowWorkflowActionType(context) diff --git a/web-frontend/modules/builder/workflowActionTypes.js b/web-frontend/modules/builder/workflowActionTypes.js index 9256fade8f..541f2eeea4 100644 --- a/web-frontend/modules/builder/workflowActionTypes.js +++ b/web-frontend/modules/builder/workflowActionTypes.js @@ -12,6 +12,7 @@ import { LocalBaserowUpdateRowWorkflowServiceType, LocalBaserowDeleteRowWorkflowServiceType, } from '@baserow/modules/integrations/localBaserow/serviceTypes' +import { AIAgentServiceType } from '@baserow/modules/integrations/ai/serviceTypes' import { DataProviderType } from '@baserow/modules/core/dataProviderTypes' import resolveElementUrl from '@baserow/modules/builder/utils/urlResolution' @@ -250,6 +251,10 @@ export class WorkflowActionServiceType extends WorkflowActionType { return this.serviceType.name } + get icon() { + return this.serviceType.icon + } + execute({ workflowAction: { id }, applicationContext, resolveFormula }) { const data = DataProviderType.getAllActionDispatchContext( this.app.$registry.getAll('builderDataProvider'), @@ -305,7 +310,7 @@ export class WorkflowActionServiceType extends WorkflowActionType { return this.serviceType.getValueAtPath( workflowAction.service, content, - path.join('.') + path ) } @@ -350,10 +355,6 @@ export class CreateRowWorkflowActionType extends WorkflowActionServiceType { return 'create_row' } - get icon() { - return this.serviceType.icon - } - get serviceType() { return this.app.$registry.get( 'service', @@ -387,3 +388,17 @@ export class DeleteRowWorkflowActionType extends WorkflowActionServiceType { ) } } + +export class AIAgentWorkflowActionType extends WorkflowActionServiceType { + static getType() { + return 'ai_agent' + } + + get name() { + return this.app.i18n.t('nodeType.aiAgent') + } + + get serviceType() { + return this.app.$registry.get('service', AIAgentServiceType.getType()) + } +} diff --git a/web-frontend/modules/core/formula/index.js b/web-frontend/modules/core/formula/index.js index 05b296710a..c7ef35a20b 100644 --- a/web-frontend/modules/core/formula/index.js +++ b/web-frontend/modules/core/formula/index.js @@ -28,7 +28,7 @@ export const resolveFormula = ( const tree = parseBaserowFormula(formulaCtx.formula) return new JavascriptExecutor(functions, RuntimeFormulaContext).visit(tree) } catch (err) { - console.debug('Err in formula resolution', err) + console.debug(`FORMULA DEBUG: ${err}`) return '' } } diff --git a/web-frontend/modules/core/generativeAIModelTypes.js b/web-frontend/modules/core/generativeAIModelTypes.js index 3b297310be..4aef01fdb4 100644 --- a/web-frontend/modules/core/generativeAIModelTypes.js +++ b/web-frontend/modules/core/generativeAIModelTypes.js @@ -58,6 +58,7 @@ export class OpenAIModelType extends GenerativeAIModelType { { key: 'organization', label: i18n.t('generativeAIModelType.openaiOrganization'), + optional: true, }, modelSettings( i18n.t('generativeAIModelType.openaiModelsLabel'), @@ -200,6 +201,7 @@ export class OpenRouterModelType extends GenerativeAIModelType { { key: 'organization', label: i18n.t('generativeAIModelType.openRouterOrganization'), + optional: true, }, modelSettings( i18n.t('generativeAIModelType.openRouterModelsLabel'), diff --git a/web-frontend/modules/core/runtimeFormulaContext.js b/web-frontend/modules/core/runtimeFormulaContext.js index 50c0ef146c..687c95a91c 100644 --- a/web-frontend/modules/core/runtimeFormulaContext.js +++ b/web-frontend/modules/core/runtimeFormulaContext.js @@ -49,6 +49,7 @@ export class RuntimeFormulaContext { try { return dataProviderType.getDataChunk(this.applicationContext, rest) } catch (e) { + console.debug(`DATA PROVIDER DEBUG: ${e}`) throw new UnresolvablePathError(dataProviderType.type, rest.join('.')) } } diff --git a/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue b/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue new file mode 100644 index 0000000000..084d79fb94 --- /dev/null +++ b/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue @@ -0,0 +1,244 @@ + + + diff --git a/web-frontend/modules/integrations/ai/components/services/AIAgentServiceForm.vue b/web-frontend/modules/integrations/ai/components/services/AIAgentServiceForm.vue new file mode 100644 index 0000000000..80fbad33bb --- /dev/null +++ b/web-frontend/modules/integrations/ai/components/services/AIAgentServiceForm.vue @@ -0,0 +1,388 @@ + + + diff --git a/web-frontend/modules/integrations/ai/integrationTypes.js b/web-frontend/modules/integrations/ai/integrationTypes.js new file mode 100644 index 0000000000..8b6e0a67b2 --- /dev/null +++ b/web-frontend/modules/integrations/ai/integrationTypes.js @@ -0,0 +1,39 @@ +import { IntegrationType } from '@baserow/modules/core/integrationTypes' +import AIForm from '@baserow/modules/integrations/ai/components/integrations/AIForm' + +export class AIIntegrationType extends IntegrationType { + static getType() { + return 'ai' + } + + get name() { + return this.app.i18n.t('integrationType.ai') + } + + getSummary(integration) { + const aiSettings = integration.ai_settings || {} + const overrideCount = Object.keys(aiSettings).length + + if (overrideCount === 0) { + return this.app.i18n.t('aiIntegrationType.inheritingWorkspace') + } + + return this.app.i18n.t('aiIntegrationType.overridingProviders', { + count: overrideCount, + }) + } + + get formComponent() { + return AIForm + } + + getDefaultValues() { + return { + ai_settings: {}, + } + } + + getOrder() { + return 21 + } +} diff --git a/web-frontend/modules/integrations/ai/serviceTypes.js b/web-frontend/modules/integrations/ai/serviceTypes.js new file mode 100644 index 0000000000..59689901f8 --- /dev/null +++ b/web-frontend/modules/integrations/ai/serviceTypes.js @@ -0,0 +1,89 @@ +import { + ServiceType, + WorkflowActionServiceTypeMixin, +} from '@baserow/modules/core/serviceTypes' +import { AIIntegrationType } from '@baserow/modules/integrations/ai/integrationTypes' +import AIAgentServiceForm from '@baserow/modules/integrations/ai/components/services/AIAgentServiceForm' + +export class AIAgentServiceType extends WorkflowActionServiceTypeMixin( + ServiceType +) { + static getType() { + return 'ai_agent' + } + + get name() { + return this.app.i18n.t('serviceType.aiAgent') + } + + get icon() { + return 'iconoir-sparks' + } + + get formComponent() { + return AIAgentServiceForm + } + + get integrationType() { + return this.app.$registry.get('integration', AIIntegrationType.getType()) + } + + get description() { + return this.app.i18n.t('serviceType.aiAgentDescription') + } + + getDataSchema(service) { + return service.schema + } + + getErrorMessage({ service }) { + if (service === undefined) { + return null + } + + if (service.ai_generative_ai_model === undefined) { + // we are in public mode so no properties are available let's quit. + return null + } + + if (!service.ai_generative_ai_type) { + return this.app.i18n.t('serviceType.errorNoAIProviderSelected') + } + if (!service.ai_generative_ai_model) { + return this.app.i18n.t('serviceType.errorNoAIModelSelected') + } + if (!service.ai_prompt.formula) { + return this.app.i18n.t('serviceType.errorNoPromptProvided') + } + if (service.ai_output_type === 'choice') { + // Check if choices array is empty or has no valid choices + if ( + !service.ai_choices || + !Array.isArray(service.ai_choices) || + service.ai_choices.length === 0 || + service.ai_choices.every((c) => !c || !c.trim()) + ) { + return this.app.i18n.t('serviceType.errorNoChoicesProvided') + } + } + return super.getErrorMessage({ service }) + } + + getDescription(service, application) { + let description = this.name + + if (service.ai_generative_ai_model) { + description += ` - ${service.ai_generative_ai_model}` + } + + if (this.isInError({ service })) { + description += ` - ${this.getErrorMessage({ service })}` + } + + return description + } + + getOrder() { + return 9 + } +} diff --git a/web-frontend/modules/integrations/core/serviceTypes.js b/web-frontend/modules/integrations/core/serviceTypes.js index d73c74d069..ee06ca1365 100644 --- a/web-frontend/modules/integrations/core/serviceTypes.js +++ b/web-frontend/modules/integrations/core/serviceTypes.js @@ -180,6 +180,10 @@ export class CoreHTTPTriggerServiceType extends TriggerServiceTypeMixin( return CoreHTTPTriggerServiceForm } + get icon() { + return 'iconoir-globe' + } + getErrorMessage({ service }) { if (service === undefined) { return null @@ -256,6 +260,10 @@ export class PeriodicTriggerServiceType extends TriggerServiceTypeMixin( return CorePeriodicServiceForm } + get icon() { + return 'iconoir-timer' + } + getDataSchema(service) { return service.schema } diff --git a/web-frontend/modules/integrations/localBaserow/serviceTypes.js b/web-frontend/modules/integrations/localBaserow/serviceTypes.js index 7244314aab..cffec47794 100644 --- a/web-frontend/modules/integrations/localBaserow/serviceTypes.js +++ b/web-frontend/modules/integrations/localBaserow/serviceTypes.js @@ -86,7 +86,7 @@ export class LocalBaserowTableServiceType extends ServiceType { const [field, ...rest] = path let humanName = field - if (schema) { + if (schema && field.startsWith('field_')) { if (this.returnsList) { if (schema.items?.properties?.[field]?.title) { humanName = schema.items.properties[field].title diff --git a/web-frontend/modules/integrations/locales/en.json b/web-frontend/modules/integrations/locales/en.json index ac23927197..10b00e0dc2 100644 --- a/web-frontend/modules/integrations/locales/en.json +++ b/web-frontend/modules/integrations/locales/en.json @@ -5,12 +5,17 @@ }, "integrationType": { "localBaserow": "Local Baserow", - "smtp": "SMTP Email" + "smtp": "SMTP Email", + "ai": "AI" }, "localBaserowIntegrationType": { "localBaserowSummary": "Local Baserow - {name}, {username}", "localBaserowNoUser": "Local Baserow - Not configured", - "localBaserowWarning": "Authorizing your account gives everyone who has edit permissions to the application full access to the data you have access to. It’s possible to create a second user, give the right permissions and use that one." + "localBaserowWarning": "Authorizing your account gives everyone who has edit permissions to the application full access to the data you have access to. It's possible to create a second user, give the right permissions and use that one." + }, + "aiIntegrationType": { + "inheritingWorkspace": "Inheriting workspace AI settings", + "overridingProviders": "Overriding {count} provider | Overriding {count} providers" }, "serviceType": { "localBaserowGetRow": "Get single row", @@ -57,14 +62,49 @@ "corePeriodic": "Periodic trigger", "corePeriodicDescription": "Triggers the workflow on a periodic basis at specified intervals", "corePeriodicErrorIntervalMissing": "An interval is required.", - "errorIterationSourceMissing": "Missing source property" + "errorIterationSourceMissing": "Missing source property", + "aiAgent": "AI Agent", + "aiAgentDescription": "Execute AI prompts using configured generative AI models.", + "errorNoIntegrationSelected": "No integration selected", + "errorNoAIProviderSelected": "No AI provider selected", + "errorNoAIModelSelected": "No AI model selected", + "errorNoPromptProvided": "No prompt provided", + "errorNoChoicesProvided": "No choices provided for choice output type" }, "userSourceType": { "localBaserow": "Baserow table authentication" }, "localBaserowForm": { "user": "User", - "userMessage": "By creating this connection, you’re authorizing the application to use your account to make changes in your local Baserow workspace." + "userMessage": "By creating this connection, you're authorizing the application to use your account to make changes in your local Baserow workspace." + }, + "aiForm": { + "description": "Configure AI provider settings for this integration. By default, workspace AI settings are inherited.", + "workspaceSettingsTitle": "Workspace AI Settings", + "workspaceSettingsDescription": "This integration inherits AI provider settings from your workspace by default. You can override specific providers below.", + "overrideWorkspaceSettings": "Override workspace settings for this provider", + "inherited": "Inherited", + "overridden": "Overridden" + }, + "aiAgentServiceForm": { + "integrationLabel": "AI Integration", + "providerLabel": "AI Provider", + "providerPlaceholder": "Select an AI provider...", + "modelLabel": "AI Model", + "modelPlaceholder": "Select a model...", + "outputTypeLabel": "Output Type", + "outputTypeHelp": "Choose how the AI should format its response.", + "outputTypeText": "Text", + "outputTypeChoice": "Choice", + "temperatureLabel": "Temperature", + "temperaturePlaceholder": "e.g. 0.7", + "temperatureHelp": "Controls randomness. Lower values (0-0.3) are more focused and deterministic. Higher values (0.7-2.0) are more creative and varied.", + "promptLabel": "Prompt", + "promptPlaceholder": "Enter your prompt here...", + "choicesLabel": "Choices", + "choicePlaceholder": "Enter a choice option...", + "addChoice": "Add choice", + "choicesRequired": "At least one choice is required" }, "localBaserowGetRowForm": { "rowFieldLabel": "Row ID", diff --git a/web-frontend/modules/integrations/plugin.js b/web-frontend/modules/integrations/plugin.js index 57274f52bc..038385a641 100644 --- a/web-frontend/modules/integrations/plugin.js +++ b/web-frontend/modules/integrations/plugin.js @@ -10,6 +10,7 @@ import ko from '@baserow/modules/integrations/locales/ko.json' import { FF_AUTOMATION } from '@baserow/modules/core/plugins/featureFlags' import { LocalBaserowIntegrationType } from '@baserow/modules/integrations/localBaserow/integrationTypes' import { SMTPIntegrationType } from '@baserow/modules/integrations/core/integrationTypes' +import { AIIntegrationType } from '@baserow/modules/integrations/ai/integrationTypes' import { LocalBaserowGetRowServiceType, LocalBaserowListRowsServiceType, @@ -29,6 +30,7 @@ import { CoreHTTPTriggerServiceType, CoreIteratorServiceType, } from '@baserow/modules/integrations/core/serviceTypes' +import { AIAgentServiceType } from '@baserow/modules/integrations/ai/serviceTypes' export default (context) => { const { app, isDev } = context @@ -51,6 +53,7 @@ export default (context) => { new LocalBaserowIntegrationType(context) ) app.$registry.register('integration', new SMTPIntegrationType(context)) + app.$registry.register('integration', new AIIntegrationType(context)) app.$registry.register('service', new LocalBaserowGetRowServiceType(context)) app.$registry.register( @@ -78,6 +81,7 @@ export default (context) => { app.$registry.register('service', new CoreRouterServiceType(context)) app.$registry.register('service', new CoreHTTPTriggerServiceType(context)) app.$registry.register('service', new CoreIteratorServiceType(context)) + app.$registry.register('service', new AIAgentServiceType(context)) app.$registry.register('service', new PeriodicTriggerServiceType(context))