Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/automation/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ def ready(self):
UpdateAutomationNodeActionType,
)
from baserow.contrib.automation.nodes.node_types import (
AIAgentActionNodeType,
CoreHttpRequestNodeType,
CoreHTTPTriggerNodeType,
CoreIteratorNodeType,
Expand Down Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
@@ -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",),
),
]
16 changes: 16 additions & 0 deletions backend/src/baserow/contrib/automation/nodes/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down Expand Up @@ -212,3 +224,7 @@ class CoreRouterActionNode(AutomationActionNode):

class CoreIteratorActionNode(AutomationActionNode):
...


class AIAgentActionNode(AutomationActionNode):
...
10 changes: 9 additions & 1 deletion backend/src/baserow/contrib/automation/nodes/node_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
AutomationNodeTriggerMustBeFirstNode,
)
from baserow.contrib.automation.nodes.models import (
AIAgentActionNode,
AutomationNode,
AutomationTriggerNode,
CoreHTTPRequestActionNode,
Expand All @@ -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,
Expand Down Expand Up @@ -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"
)
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down
2 changes: 2 additions & 0 deletions backend/src/baserow/contrib/builder/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down
1 change: 1 addition & 0 deletions backend/src/baserow/contrib/builder/domains/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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",),
),
]
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,7 @@ class CoreHTTPRequestWorkflowAction(BuilderWorkflowServiceAction):

class CoreSMTPEmailWorkflowAction(BuilderWorkflowServiceAction):
...


class AIAgentWorkflowAction(BuilderWorkflowServiceAction):
...
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
Expand Down Expand Up @@ -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}
Empty file.
141 changes: 141 additions & 0 deletions backend/src/baserow/contrib/integrations/ai/integration_types.py
Original file line number Diff line number Diff line change
@@ -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
Loading
Loading