diff --git a/changelog/entries/unreleased/bug/3090_ai_file_field_import_validation.json b/changelog/entries/unreleased/bug/3090_ai_file_field_import_validation.json new file mode 100644 index 0000000000..cc7cc593cd --- /dev/null +++ b/changelog/entries/unreleased/bug/3090_ai_file_field_import_validation.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "AI file field is now validated during import", + "issue_origin": "github", + "issue_number": 3090, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-14" +} diff --git a/changelog/entries/unreleased/bug/5163_ai_field_import_baserow_type_fail.json b/changelog/entries/unreleased/bug/5163_ai_field_import_baserow_type_fail.json new file mode 100644 index 0000000000..f934c0d0a4 --- /dev/null +++ b/changelog/entries/unreleased/bug/5163_ai_field_import_baserow_type_fail.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Fix import of unknown ai generative types", + "issue_origin": "github", + "issue_number": 5163, + "domain": "database", + "bullet_points": [], + "created_at": "2026-04-13" +} diff --git a/changelog/entries/unreleased/bug/improved_error_handling_for_the_ai_form.json b/changelog/entries/unreleased/bug/improved_error_handling_for_the_ai_form.json new file mode 100644 index 0000000000..f2fd20f109 --- /dev/null +++ b/changelog/entries/unreleased/bug/improved_error_handling_for_the_ai_form.json @@ -0,0 +1,9 @@ +{ + "type": "bug", + "message": "Improved error handling for the AI Form.", + "issue_origin": "github", + "issue_number": null, + "domain": "integration", + "bullet_points": [], + "created_at": "2026-03-31" +} diff --git a/premium/backend/src/baserow_premium/fields/field_types.py b/premium/backend/src/baserow_premium/fields/field_types.py index 4bcf5054d7..b991196e9a 100644 --- a/premium/backend/src/baserow_premium/fields/field_types.py +++ b/premium/backend/src/baserow_premium/fields/field_types.py @@ -335,10 +335,11 @@ def _validate_field_kwargs( self, ai_output_type, ai_type, model_type, ai_file_field_id, workspace=None ): ai_field_output_registry.get(ai_output_type) - ai_type = generative_ai_model_type_registry.get(ai_type) - models = ai_type.get_enabled_models(workspace=workspace) - if model_type not in models: - raise ModelDoesNotBelongToType(model_name=model_type) + if ai_type is not None: + ai_type = generative_ai_model_type_registry.get(ai_type) + models = ai_type.get_enabled_models(workspace=workspace) + if model_type not in models: + raise ModelDoesNotBelongToType(model_name=model_type) if ai_file_field_id is not None and not ai_type.supports_files: raise GenerativeAITypeDoesNotSupportFileField() @@ -508,6 +509,20 @@ def import_serialized( serialized_values = serialized_values.copy() serialized_values.pop("ai_auto_update_user_id", None) serialized_values["ai_auto_update"] = False + + ai_type = serialized_values.get("ai_generative_ai_type") + generative_ai_type = None + if ai_type is not None: + try: + generative_ai_type = generative_ai_model_type_registry.get(ai_type) + except GenerativeAITypeDoesNotExist: + serialized_values["ai_generative_ai_type"] = None + + ai_file_field_id = serialized_values.get("ai_file_field_id") + if generative_ai_type is not None and ai_file_field_id is not None: + if not generative_ai_type.supports_files: + serialized_values["ai_file_field_id"] = None + return super().import_serialized( table, serialized_values, @@ -524,9 +539,25 @@ def after_import_serialized( ): save = False if field.ai_file_field_id: - field.ai_file_field_id = id_mapping["database_fields"][ + mapped_ai_file_field_id = id_mapping["database_fields"][ field.ai_file_field_id ] + table_model = field_cache.get_model(field.table) + + try: + file_field_object = table_model.get_field_object_by_id( + mapped_ai_file_field_id + ) + file_field = file_field_object.get("field") + field_type = file_field_object.get("type") + except ValueError: + file_field = None + field_type = None + + if field_type and field_type.can_represent_files(file_field): + field.ai_file_field_id = mapped_ai_file_field_id + else: + field.ai_file_field_id = None save = True if field.ai_prompt: diff --git a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py index 854797c930..0aca241cfb 100644 --- a/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py +++ b/premium/backend/tests/baserow_premium_tests/fields/test_ai_field_type.py @@ -1,3 +1,4 @@ +import json from unittest.mock import patch from django.shortcuts import reverse @@ -7,14 +8,17 @@ from pytest_unordered import unordered from rest_framework.status import HTTP_200_OK, HTTP_400_BAD_REQUEST, HTTP_404_NOT_FOUND +from baserow.contrib.database.application_types import DatabaseApplicationType from baserow.contrib.database.fields.dependencies.models import FieldDependency from baserow.contrib.database.fields.handler import FieldHandler +from baserow.contrib.database.fields.models import FileField from baserow.contrib.database.fields.registries import field_type_registry from baserow.contrib.database.fields.utils.deferred_foreign_key_updater import ( DeferredForeignKeyUpdater, ) from baserow.contrib.database.rows.handler import RowHandler from baserow.contrib.database.table.handler import TableHandler +from baserow.contrib.database.table.models import Table from baserow.core.cache import local_cache from baserow.core.db import specific_iterator from baserow.core.registries import ImportExportConfig @@ -743,7 +747,7 @@ def test_duplicate_table_with_ai_field(patched_job_creation, premium_data_fixtur table=table, order=2, name="ai", - ai_generative_ai_type="test_generative_ai", + ai_generative_ai_type="test_generative_ai_with_files", ai_generative_ai_model="test_1", ai_file_field=file_field, ai_prompt=f"concat('test:',get('fields.field_{text_field.id}'))", @@ -761,7 +765,7 @@ def test_duplicate_table_with_ai_field(patched_job_creation, premium_data_fixtur duplicated_ai_field = duplicated_fields[2] assert duplicated_ai_field.name == "ai" - assert duplicated_ai_field.ai_generative_ai_type == "test_generative_ai" + assert duplicated_ai_field.ai_generative_ai_type == "test_generative_ai_with_files" assert duplicated_ai_field.ai_generative_ai_model == "test_1" assert duplicated_ai_field.ai_file_field_id == duplicated_file_field.id assert ( @@ -1382,6 +1386,216 @@ def test_create_ai_field_auto_doesnt_update_user_if_set(premium_data_fixture): assert ai_field.ai_auto_update_user_id == user.id # not changed +@pytest.mark.django_db +@pytest.mark.field_ai +def test_import_serialized_ai_field_missing_ai_generative_ai_type(premium_data_fixture): + user = premium_data_fixture.create_user() + table = premium_data_fixture.create_database_table(user=user) + premium_data_fixture.register_fake_generate_ai_type() + premium_data_fixture.create_text_field( + table=table, order=0, name="text", primary=True + ) + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="ai", + ai_generative_ai_type="missing", + ai_generative_ai_model="test_1", + ai_prompt="Tell me a joke", + ) + field_type = field_type_registry.get_by_model(ai_field) + serialized = field_type.export_serialized(ai_field) + + imported_field = field_type.import_serialized( + table, + serialized, + ImportExportConfig(include_permission_data=False), + id_mapping={}, + deferred_fk_update_collector=DeferredForeignKeyUpdater(), + ) + + imported_field = AIField.objects.get(id=imported_field.id) + assert imported_field.ai_generative_ai_type is None + assert imported_field.ai_generative_ai_model == "test_1" + + +@pytest.mark.django_db +@pytest.mark.field_ai +def test_import_serialized_ai_field_file_field_mapped_correctly( + premium_data_fixture, +): + user = premium_data_fixture.create_user() + database = premium_data_fixture.create_database_application(user=user) + table = premium_data_fixture.create_database_table(database=database) + premium_data_fixture.register_fake_generate_ai_type() + premium_data_fixture.create_text_field( + table=table, order=0, name="text", primary=True + ) + file_field = premium_data_fixture.create_file_field( + table=table, order=0, name="file", primary=True + ) + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="ai", + ai_generative_ai_type="test_generative_ai_with_files", + ai_generative_ai_model="test_1", + ai_prompt="'What is in the file'", + ai_file_field=file_field, + ) + serialized = DatabaseApplicationType().export_serialized( + database, ImportExportConfig(include_permission_data=False) + ) + serialized = json.loads(json.dumps(serialized)) + new_workspace = premium_data_fixture.create_workspace(user=user) + + imported_database = DatabaseApplicationType().import_serialized( + new_workspace, + serialized, + ImportExportConfig(include_permission_data=True), + id_mapping={}, + ) + + imported_table = Table.objects.get(database=imported_database) + new_ai_field = AIField.objects.get(table=imported_table) + + assert new_ai_field.ai_file_field is not None + assert new_ai_field.ai_file_field.id != ai_field.id + + FileField.objects.get(table=imported_table, id=new_ai_field.ai_file_field.id) + + +@pytest.mark.django_db +@pytest.mark.field_ai +def test_import_serialized_ai_field_file_field_not_correct_field_type( + premium_data_fixture, +): + user = premium_data_fixture.create_user() + database = premium_data_fixture.create_database_application(user=user) + table = premium_data_fixture.create_database_table(database=database) + premium_data_fixture.register_fake_generate_ai_type() + premium_data_fixture.create_text_field( + table=table, order=0, name="text", primary=True + ) + fake_file_field = premium_data_fixture.create_text_field( + table=table, order=0, name="file", primary=True + ) + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="ai", + ai_generative_ai_type="test_generative_ai_with_files", + ai_generative_ai_model="test_1", + ai_prompt="'What is in the file'", + ai_file_field=fake_file_field, + ) + serialized = DatabaseApplicationType().export_serialized( + database, ImportExportConfig(include_permission_data=False) + ) + serialized = json.loads(json.dumps(serialized)) + new_workspace = premium_data_fixture.create_workspace(user=user) + + imported_database = DatabaseApplicationType().import_serialized( + new_workspace, + serialized, + ImportExportConfig(include_permission_data=True), + id_mapping={}, + ) + + imported_table = Table.objects.get(database=imported_database) + new_ai_field = AIField.objects.get(table=imported_table) + + assert new_ai_field.ai_file_field is None + + +@pytest.mark.django_db +@pytest.mark.field_ai +def test_import_serialized_ai_field_file_field_not_in_correct_table( + premium_data_fixture, +): + user = premium_data_fixture.create_user() + database = premium_data_fixture.create_database_application(user=user) + table = premium_data_fixture.create_database_table(database=database, name="table1") + table_2 = premium_data_fixture.create_database_table(database=database) + premium_data_fixture.register_fake_generate_ai_type() + premium_data_fixture.create_text_field( + table=table, order=0, name="text", primary=True + ) + file_field_wrong_table = premium_data_fixture.create_file_field( + table=table_2, order=0, name="file", primary=True + ) + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="ai", + ai_generative_ai_type="test_generative_ai_with_files", + ai_generative_ai_model="test_1", + ai_prompt="'What is in the file'", + ai_file_field=file_field_wrong_table, + ) + serialized = DatabaseApplicationType().export_serialized( + database, ImportExportConfig(include_permission_data=False) + ) + serialized = json.loads(json.dumps(serialized)) + new_workspace = premium_data_fixture.create_workspace(user=user) + + imported_database = DatabaseApplicationType().import_serialized( + new_workspace, + serialized, + ImportExportConfig(include_permission_data=True), + id_mapping={}, + ) + + imported_table = Table.objects.get(database=imported_database, name="table1") + + new_ai_field = AIField.objects.get(table=imported_table) + + assert new_ai_field.ai_file_field is None + + +@pytest.mark.django_db +@pytest.mark.field_ai +def test_import_serialized_ai_field_file_field_not_supported_by_ai_provider( + premium_data_fixture, +): + user = premium_data_fixture.create_user() + database = premium_data_fixture.create_database_application(user=user) + table = premium_data_fixture.create_database_table(database=database) + premium_data_fixture.register_fake_generate_ai_type() + premium_data_fixture.create_text_field( + table=table, order=0, name="text", primary=True + ) + file_field = premium_data_fixture.create_file_field( + table=table, order=0, name="file", primary=True + ) + ai_field = premium_data_fixture.create_ai_field( + table=table, + order=1, + name="ai", + ai_generative_ai_type="test_generative_ai", + ai_generative_ai_model="test_1", + ai_prompt="'What is in the file'", + ai_file_field=file_field, + ) + serialized = DatabaseApplicationType().export_serialized( + database, ImportExportConfig(include_permission_data=False) + ) + serialized = json.loads(json.dumps(serialized)) + new_workspace = premium_data_fixture.create_workspace(user=user) + + imported_database = DatabaseApplicationType().import_serialized( + new_workspace, + serialized, + ImportExportConfig(include_permission_data=True), + id_mapping={}, + ) + + imported_table = Table.objects.get(database=imported_database) + new_ai_field = AIField.objects.get(table=imported_table) + + assert new_ai_field.ai_file_field is None + + @pytest.mark.django_db @pytest.mark.field_ai def test_import_serialized_ai_field_with_auto_update_user(premium_data_fixture): diff --git a/web-frontend/README.md b/web-frontend/README.md deleted file mode 100644 index 25b58212c9..0000000000 --- a/web-frontend/README.md +++ /dev/null @@ -1,75 +0,0 @@ -# Nuxt Minimal Starter - -Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more. - -## Setup - -Make sure to install dependencies: - -```bash -# npm -npm install - -# pnpm -pnpm install - -# yarn -yarn install - -# bun -bun install -``` - -## Development Server - -Start the development server on `http://localhost:3000`: - -```bash -# npm -npm run dev - -# pnpm -pnpm dev - -# yarn -yarn dev - -# bun -bun run dev -``` - -## Production - -Build the application for production: - -```bash -# npm -npm run build - -# pnpm -pnpm build - -# yarn -yarn build - -# bun -bun run build -``` - -Locally preview production build: - -```bash -# npm -npm run preview - -# pnpm -pnpm preview - -# yarn -yarn preview - -# bun -bun run preview -``` - -Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information. diff --git a/web-frontend/modules/core/components/workspace/GenerativeAIWorkspaceSettings.vue b/web-frontend/modules/core/components/workspace/GenerativeAIWorkspaceSettings.vue index 2897ecf391..0bafffd7d0 100644 --- a/web-frontend/modules/core/components/workspace/GenerativeAIWorkspaceSettings.vue +++ b/web-frontend/modules/core/components/workspace/GenerativeAIWorkspaceSettings.vue @@ -117,7 +117,7 @@ export default { modelTypes() { return this.$registry .getOrderedList('generativeAIModel') - .filter((modelType) => modelType.getSettings() !== null) + .filter((modelType) => modelType.getSettings().length > 0) .map((modelType) => [modelType.getType(), modelType]) }, }, diff --git a/web-frontend/modules/core/generativeAIModelTypes.js b/web-frontend/modules/core/generativeAIModelTypes.js index 4e1c788738..a02254d252 100644 --- a/web-frontend/modules/core/generativeAIModelTypes.js +++ b/web-frontend/modules/core/generativeAIModelTypes.js @@ -21,6 +21,21 @@ export class GenerativeAIModelType extends Registerable { getMaxTemperature() { return 2 } + + /** + * Returns an array of objects that define the settings for workspace + * Generative AI and integration overrides. The array can be empty if + * the model type is not configurable. + * + * Each setting object in the array describes a form field. See + * `modelSettings` for a full example. A setting may only define + * a subset of the properties. + * + * @returns {Array} An array of setting objects. Can be empty. + */ + getSettings() { + return [] + } } const modelSettings = (label, description) => ({ diff --git a/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue b/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue index 369637dba5..e7f28f7308 100644 --- a/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue +++ b/web-frontend/modules/integrations/ai/components/integrations/AIForm.vue @@ -20,7 +20,7 @@
{{ getProviderName(providerType.type) }}
-
+