diff --git a/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs b/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs index 5a25ef73a..cb6c2341f 100644 --- a/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs +++ b/backend/extensions/Squidex.Extensions/Validation/CompositeUniqueValidator.cs @@ -48,12 +48,12 @@ internal sealed class CompositeUniqueValidator(string contentTag, IContentReposi var found = await contentRepository.QueryIdsAsync(context.Root.App, context.Root.Schema, filter, SearchScope.All); if (found.Any(x => x.Id != context.Root.ContentId)) { - context.AddError("A content with the same values already exist.", Enumerable.Empty()); + context.AddError("A content with the same values already exist.", []); } } } - private static ClrValue? TryGetValue(IRootField field, ContentData data) + private static ClrValue? TryGetValue(RootField field, ContentData data) { var value = JsonValue.Null; diff --git a/backend/i18n/frontend_en.json b/backend/i18n/frontend_en.json index c238f6753..641e90f08 100644 --- a/backend/i18n/frontend_en.json +++ b/backend/i18n/frontend_en.json @@ -14,7 +14,7 @@ "apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", - "apps.appNameWarning": "The app name cannot be changed later.", + "apps.appNameWarning": "The name is used to identify your app in every HTTP request. It must be unique and cannot be changed once set.", "apps.appsButtonCreate": "Create App", "apps.appsButtonCreateTeam": "Create Team", "apps.appsButtonFallbackTitle": "Apps and Teams", @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "Squidex Headless CMS", "common.project": "Project", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "contains", "common.queryOperators.empty": "is empty", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Contents Sidebar Extension", "schemas.contentsSidebarUrlHint": "URL to the plugin for the sidebar in the list view.", "schemas.create": "Create Schema", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Create new category...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Failed to create schema. Please reload.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "New Schema", "schemas.deleteConfirmText": "Do you really want to delete the schema?", "schemas.deleteConfirmTitle": "Delete schema", @@ -1007,12 +1011,14 @@ "schemas.modeMultipleDescription": "Best for multiple instances like blog posts, pages, authors, products...", "schemas.modeSingle": "Single content", "schemas.modeSingleDescription": "Best for single instances like the home page, privacy policies, settings...", - "schemas.nameWarning": "These values cannot be changed later.", + "schemas.nameWarning": "The name is used to identify your schema in every HTTP request. It must be unique within an app and cannot be changed once set.", "schemas.previewUrls.empty": "No preview urls configured.", "schemas.previewUrls.help": "Checkout the integrated help page to learn more about preview URL's.", "schemas.previewUrls.namePlaceholder": "Web or Mobile", "schemas.previewUrls.title": "Preview URLs", "schemas.previewUrls.urlPlaceholder": "URL with variables", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Published", "schemas.publishFailed": "Failed to publish schema. Please reload.", "schemas.referenceFields": "Reference Fields", diff --git a/backend/i18n/frontend_fr.json b/backend/i18n/frontend_fr.json index 5a7acab7e..3ace078c7 100644 --- a/backend/i18n/frontend_fr.json +++ b/backend/i18n/frontend_fr.json @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "CMS sans tête Squidex", "common.project": "Projet", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "contient", "common.queryOperators.empty": "est vide", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Contenu de l'extension de la barre latérale", "schemas.contentsSidebarUrlHint": "URL du plug-in pour la barre latérale dans la vue de liste.", "schemas.create": "Créer un schéma", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Créer une nouvelle catégorie...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Échec de la création du schéma. Veuillez recharger.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "Nouveau schéma", "schemas.deleteConfirmText": "Voulez-vous vraiment supprimer le schéma\u00A0?", "schemas.deleteConfirmTitle": "Supprimer le schéma", @@ -1013,6 +1017,8 @@ "schemas.previewUrls.namePlaceholder": "Web ou mobile", "schemas.previewUrls.title": "Aperçu des URL", "schemas.previewUrls.urlPlaceholder": "URL avec variables", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Publié", "schemas.publishFailed": "Échec de la publication du schéma. Veuillez recharger.", "schemas.referenceFields": "Champs de référence", diff --git a/backend/i18n/frontend_it.json b/backend/i18n/frontend_it.json index c45ccb6b0..f6e3d292d 100644 --- a/backend/i18n/frontend_it.json +++ b/backend/i18n/frontend_it.json @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "Squidex Headless CMS", "common.project": "Progetto", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "contiene", "common.queryOperators.empty": "è vuoto", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Estensione della barra di navigazione laterale (liste)", "schemas.contentsSidebarUrlHint": "URL del plug-in per la barra di navigazione laterale nella visualizzazione delle liste.", "schemas.create": "Crea uno Schema", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Crea una nuova categoria...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Non è stato possibile creare lo schema. Per favore ricarica.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "Nuovo Schema", "schemas.deleteConfirmText": "Sei sicuro di voler eliminare lo schema?", "schemas.deleteConfirmTitle": "Cancella lo schema", @@ -1013,6 +1017,8 @@ "schemas.previewUrls.namePlaceholder": "Web o Mobile", "schemas.previewUrls.title": "URL dell'anteprima", "schemas.previewUrls.urlPlaceholder": "URL con variabili", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Pubblicato", "schemas.publishFailed": "Non è stato possibile pubblicare lo schema. Per favore ricarica.", "schemas.referenceFields": "Campi per i collegamenti (riferimenti)", diff --git a/backend/i18n/frontend_nl.json b/backend/i18n/frontend_nl.json index a5d9df187..748e73fd9 100644 --- a/backend/i18n/frontend_nl.json +++ b/backend/i18n/frontend_nl.json @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "Squidex Headless CMS", "common.project": "Project", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "bevat", "common.queryOperators.empty": "is leeg", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Inhoud zijbalk uitbreiding", "schemas.contentsSidebarUrlHint": "URL naar de plug-in voor de zijbalk in de lijstweergave.", "schemas.create": "Schema maken", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Nieuwe categorie maken ...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Kan schema niet maken. Laad opnieuw.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "Nieuw schema", "schemas.deleteConfirmText": "Weet je zeker dat je het schema wilt verwijderen?", "schemas.deleteConfirmTitle": "Schema verwijderen", @@ -1013,6 +1017,8 @@ "schemas.previewUrls.namePlaceholder": "Web of mobiel", "schemas.previewUrls.title": "Voorbeeld-URL's", "schemas.previewUrls.urlPlaceholder": "URL met variabelen", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Gepubliceerd", "schemas.publishFailed": "Kan schema niet publiceren. Laad opnieuw.", "schemas.referenceFields": "Referentievelden", diff --git a/backend/i18n/frontend_pt.json b/backend/i18n/frontend_pt.json index 798147b78..644008a9a 100644 --- a/backend/i18n/frontend_pt.json +++ b/backend/i18n/frontend_pt.json @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "CMS Headless Squidex", "common.project": "Projeto", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "contém", "common.queryOperators.empty": "está vazio", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Extensão da barra lateral de conteúdo", "schemas.contentsSidebarUrlHint": "URL para o plugin para a barra lateral na vista da lista.", "schemas.create": "Criar Esquema", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Criar nova categoria...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Falhou em criar esquema. Por favor, recarregue.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "Novo esquema", "schemas.deleteConfirmText": "Quer mesmo apagar o esquema?", "schemas.deleteConfirmTitle": "Remover Esquema", @@ -1013,6 +1017,8 @@ "schemas.previewUrls.namePlaceholder": "Web ou Mobile", "schemas.previewUrls.title": "URLs de pré-visualização", "schemas.previewUrls.urlPlaceholder": "URL com variáveis", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Publicado", "schemas.publishFailed": "Falhou em publicar o esquema. Por favor, recarregue.", "schemas.referenceFields": "Campos de Referência", diff --git a/backend/i18n/frontend_zh.json b/backend/i18n/frontend_zh.json index 7b358d38b..dea090f0c 100644 --- a/backend/i18n/frontend_zh.json +++ b/backend/i18n/frontend_zh.json @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "Squidex Headless CMS", "common.project": "项目", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "包含", "common.queryOperators.empty": "为空", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "内容侧边栏扩展", "schemas.contentsSidebarUrlHint": "列表视图中侧边栏插件的 URL。", "schemas.create": "Create Schema", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "创建新类别...", + "schemas.createCustom": "Custom", "schemas.createFailed": "无法创建Schemas。请重新加载。", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "新Schemas", "schemas.deleteConfirmText": "您真的要删除Schemas吗?", "schemas.deleteConfirmTitle": "删除Schemas", @@ -1013,6 +1017,8 @@ "schemas.previewUrls.namePlaceholder": "网络或移动", "schemas.previewUrls.title": "预览网址", "schemas.previewUrls.urlPlaceholder": "带变量的 URL", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Published", "schemas.publishFailed": "无法发布Schemas。请重新加载。", "schemas.referenceFields": "参考字段", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index c238f6753..641e90f08 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -14,7 +14,7 @@ "apps.appLoadFailed": "Failed to load app. Please reload.", "apps.appNameHint": "You can only use letters, numbers and dashes and not more than 40 characters.", "apps.appNameValidationMessage": "Name can contain lower case letters (a-z), numbers and dashes between.", - "apps.appNameWarning": "The app name cannot be changed later.", + "apps.appNameWarning": "The name is used to identify your app in every HTTP request. It must be unique and cannot be changed once set.", "apps.appsButtonCreate": "Create App", "apps.appsButtonCreateTeam": "Create Team", "apps.appsButtonFallbackTitle": "Apps and Teams", @@ -339,6 +339,7 @@ "common.prevPage": "Previous Page", "common.product": "Squidex Headless CMS", "common.project": "Project", + "common.prompt": "Prompt", "common.properties": "Properties", "common.queryOperators.contains": "contains", "common.queryOperators.empty": "is empty", @@ -836,8 +837,11 @@ "schemas.contentsSidebarUrl": "Contents Sidebar Extension", "schemas.contentsSidebarUrlHint": "URL to the plugin for the sidebar in the list view.", "schemas.create": "Create Schema", + "schemas.createAI": "\uD83E\uDE84 With AI", "schemas.createCategory": "Create new category...", + "schemas.createCustom": "Custom", "schemas.createFailed": "Failed to create schema. Please reload.", + "schemas.createFromJson": "From JSON", "schemas.createSchemaTooltip": "New Schema", "schemas.deleteConfirmText": "Do you really want to delete the schema?", "schemas.deleteConfirmTitle": "Delete schema", @@ -1007,12 +1011,14 @@ "schemas.modeMultipleDescription": "Best for multiple instances like blog posts, pages, authors, products...", "schemas.modeSingle": "Single content", "schemas.modeSingleDescription": "Best for single instances like the home page, privacy policies, settings...", - "schemas.nameWarning": "These values cannot be changed later.", + "schemas.nameWarning": "The name is used to identify your schema in every HTTP request. It must be unique within an app and cannot be changed once set.", "schemas.previewUrls.empty": "No preview urls configured.", "schemas.previewUrls.help": "Checkout the integrated help page to learn more about preview URL's.", "schemas.previewUrls.namePlaceholder": "Web or Mobile", "schemas.previewUrls.title": "Preview URLs", "schemas.previewUrls.urlPlaceholder": "URL with variables", + "schemas.promptExample": "Example: \"A blog for a travel website\"", + "schemas.promptHint": "Describe your schema.", "schemas.published": "Published", "schemas.publishFailed": "Failed to publish schema. Please reload.", "schemas.referenceFields": "Reference Fields", diff --git a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj index f571b5656..2ae19039c 100644 --- a/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj +++ b/backend/src/Squidex.Data.EntityFramework/Squidex.Data.EntityFramework.csproj @@ -40,13 +40,13 @@ - - - - - - - + + + + + + + diff --git a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj index d735e1ffe..a923f3bca 100644 --- a/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj +++ b/backend/src/Squidex.Data.MongoDb/Squidex.Data.MongoDb.csproj @@ -25,12 +25,12 @@ - - - - - - + + + + + + diff --git a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj index 6041dbabf..6086d7a8c 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj @@ -20,7 +20,7 @@ - + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 631c79672..5a6330efa 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -29,8 +29,8 @@ - - + + diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs index e32e04b0a..a3d1bfd29 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/ValidateContent/ContentValidator.cs @@ -105,7 +105,7 @@ public sealed class ContentValidator return new ObjectValidator(fieldValidators, isPartial, "field"); } - private IValidator CreateFieldValidator(IRootField field, bool isPartial) + private IValidator CreateFieldValidator(RootField field, bool isPartial) { var valueValidator = CreateValueValidator(field); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/AIQueryCache.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/AIQueryCache.cs new file mode 100644 index 000000000..2eb903d42 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/AIQueryCache.cs @@ -0,0 +1,79 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using System.Text; +using IdentityModel; +using Microsoft.Extensions.Caching.Distributed; +using Newtonsoft.Json; +using Squidex.CLI.Commands.Implementation.AI; +using Squidex.Infrastructure.ObjectPool; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates; + +public sealed class AIQueryCache(IDistributedCache distributedCache) : IQueryCache +{ + private readonly DistributedCacheEntryOptions cacheOptions = new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(30), + }; + + public async Task GetAsync(string prompt, + CancellationToken ct = default) + { + var cached = await distributedCache.GetAsync(CacheKey(prompt), ct); + if (cached == null) + { + return default!; + } + + try + { + // Use newtonsoft JSON because the CLI still deals with this library and uses JTokens. + using var cacheStream = new MemoryStream(cached); + using var cacheReader = new StreamReader(cacheStream); + using var jsonReader = new JsonTextReader(cacheReader); + + var serializer = new JsonSerializer(); + return serializer.Deserialize(jsonReader); + } + catch + { + return default!; + } + } + + public async Task StoreAsync(string prompt, GeneratedContent content, + CancellationToken ct) + { + try + { + // Use newtonsoft JSON because the CLI still deals with this library and uses JTokens. + using var cacheStream = DefaultPools.MemoryStream.GetStream(); +#pragma warning disable MA0042 // Do not use blocking calls in an async method + using var cacheWriter = new StreamWriter(cacheStream, Encoding.UTF8, leaveOpen: true); + + using (var jsonWriter = new JsonTextWriter(cacheWriter)) + { + var serializer = new JsonSerializer(); + serializer.Serialize(jsonWriter, content); + jsonWriter.Flush(); + } +#pragma warning restore MA0042 // Do not use blocking calls in an async method + + await distributedCache.SetAsync(CacheKey(prompt), cacheStream.ToArray(), cacheOptions, ct); + } + catch + { + return; + } + } + + private static string CacheKey(string prompt) + { + return $"AI_{prompt.ToSha512()}"; + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIGenerator.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIGenerator.cs new file mode 100644 index 000000000..5d7d2b294 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIGenerator.cs @@ -0,0 +1,98 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using ConsoleTables; +using Microsoft.Extensions.Options; +using Squidex.AI.Implementation.OpenAI; +using Squidex.CLI.Commands.Implementation.AI; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Schemas; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Json; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates; + +public sealed class SchemaAIGenerator( + IQueryCache queryCache, + SessionFactory sessionFactory, + IOptions schemasOptions, + IOptions openAIOptions) +{ + private const int MaxContentItems = 20; + + public async Task ExecuteAsync(App app, string prompt, int numberOfContentItems, bool execute, + CancellationToken ct) + { + var apiKey = openAIOptions.Value.ApiKey; + if (string.IsNullOrWhiteSpace(apiKey)) + { + throw new NotSupportedException("OpenAI ApiKey not configured."); + } + + var request = new GenerateRequest + { + Description = prompt, + GenerateImages = false, + NumberOfAttempts = 3, + NumberOfContentItems = Math.Min(MaxContentItems, numberOfContentItems), + OpenAIApiKey = apiKey, + SystemPrompt = schemasOptions.Value.GeneratePrompt, + }; + + var generator = new AIContentGenerator(queryCache); + var generated = await generator.GenerateAsync(request, ct); + + using var cliLog = new StringLogger(); + WriteSchema(generated, cliLog); + WriteContent(generated, cliLog); + + if (execute) + { + var session = sessionFactory.CreateSession(app); + + var executor = new AIContentExecutor(session, cliLog); + await executor.ExecuteAsync(request, generated, ct); + } + + return new SchemaAIResult(cliLog.Lines.ToReadonlyList(), execute ? generated.Schema.Name : null); + } + + private static void WriteSchema(GeneratedContent generated, StringLogger log) + { + log.WriteLine($"Schema Name: {generated.Schema.Name}"); + log.WriteLine(); + log.WriteLine("Schema Fields:"); + + var schemaTable = new ConsoleTable("Name", "Type", "Required", "Localized"); + + schemaTable.Options.EnableCount = false; + foreach (var field in generated.Schema.Fields) + { + schemaTable.AddRow(field.Name, field.Type, field.IsRequired, field.IsLocalized); + } + + log.WriteLine(schemaTable.ToString()); + } + + private static void WriteContent(GeneratedContent generated, StringLogger log) + { + if (generated.Contents.Count > 0) + { + const int MaximumPreview = 3; + + log.WriteLine(); + log.WriteLine("Contents:"); + log.WriteJson(generated.Contents.Take(MaximumPreview)); + + var more = generated.Contents.Count - MaximumPreview; + if (more > 0) + { + log.WriteLine($"+ {more} content items"); + } + } + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIResult.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIResult.cs new file mode 100644 index 000000000..e498462ab --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SchemaAIResult.cs @@ -0,0 +1,14 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Collections; + +#pragma warning disable SA1313 // Parameter names should begin with lower-case letter + +namespace Squidex.Domain.Apps.Entities.Apps.Templates; + +public sealed record SchemaAIResult(ReadonlyList Log, string? SchemaName); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SessionFactory.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SessionFactory.cs new file mode 100644 index 000000000..f8d2dfd65 --- /dev/null +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/SessionFactory.cs @@ -0,0 +1,41 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.Extensions.Options; +using Squidex.CLI.Configuration; +using Squidex.ClientLibrary; +using Squidex.Domain.Apps.Core; +using Squidex.Domain.Apps.Core.Apps; + +namespace Squidex.Domain.Apps.Entities.Apps.Templates; + +public sealed class SessionFactory(IOptions templateOptions, IUrlGenerator urlGenerator) +{ + private readonly TemplatesOptions options = templateOptions.Value; + + public Session CreateSession(App app) + { + var client = app.Clients.First(); + + var url = options.LocalUrl; + if (string.IsNullOrEmpty(url)) + { + url = urlGenerator.Root(); + } + + return new Session( + new DirectoryInfo(Path.GetTempPath()), + new SquidexClient(new SquidexOptions + { + IgnoreSelfSignedCertificates = true, + AppName = app.Name, + ClientId = $"{app.Name}:{client.Key}", + ClientSecret = client.Value.Secret, + Url = url, + })); + } +} diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/StringLogger.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/StringLogger.cs index 4ca1e2452..c9670a438 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/StringLogger.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/StringLogger.cs @@ -6,14 +6,15 @@ // ========================================================================== using System.Globalization; +using Newtonsoft.Json; using Squidex.CLI.Commands.Implementation; using Squidex.Infrastructure; -using Squidex.Infrastructure.Json; using Squidex.Log; namespace Squidex.Domain.Apps.Entities.Apps.Templates; -public sealed class StringLogger(IJsonSerializer jsonSerializer) : ILogger, ILogLine +// Use newtonsoft JSON because the CLI still deals with this library and uses JTokens. +public sealed class StringLogger : ILogger, ILogLine { private const int MaxActionLength = 40; private readonly List lines = []; @@ -22,16 +23,18 @@ public sealed class StringLogger(IJsonSerializer jsonSerializer) : ILogger, ILog public bool CanWriteToSameLine => false; + public List Lines => lines; + public void Flush(ISemanticLog log, string template) { - var mesage = string.Join('\n', lines); + var mesage = string.Join('\n', Lines); log.LogInformation(w => w .WriteProperty("message", $"CLI executed or template {template}.") .WriteProperty("template", template) .WriteArray("steps", a => { - foreach (var line in lines) + foreach (var line in Lines) { a.WriteValue(line); } @@ -84,22 +87,22 @@ public sealed class StringLogger(IJsonSerializer jsonSerializer) : ILogger, ILog public void WriteLine() { - lines.Add(string.Empty); + Lines.Add(string.Empty); } public void WriteLine(string message) { - lines.Add(message); + Lines.Add(message); } public void WriteLine(string message, params object?[] args) { - lines.Add(string.Format(CultureInfo.InvariantCulture, message, args)); + Lines.Add(string.Format(CultureInfo.InvariantCulture, message, args)); } public void WriteJson(object message) { - lines.Add(jsonSerializer.Serialize(message, true)); + Lines.Add(JsonConvert.SerializeObject(message, Formatting.Indented)); } private void AddToErrors(string reason) @@ -110,7 +113,7 @@ public sealed class StringLogger(IJsonSerializer jsonSerializer) : ILogger, ILog private void AddToLine(string message) { startedLine += message; - lines.Add(startedLine); + Lines.Add(startedLine); startedLine = string.Empty; } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs index b59ed6cd0..25039e528 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplateCommandMiddleware.cs @@ -5,7 +5,6 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Microsoft.Extensions.Options; using Squidex.CLI.Commands.Implementation; using Squidex.CLI.Commands.Implementation.FileSystem; using Squidex.CLI.Commands.Implementation.Sync; @@ -16,9 +15,6 @@ using Squidex.CLI.Commands.Implementation.Sync.Contents; using Squidex.CLI.Commands.Implementation.Sync.Rules; using Squidex.CLI.Commands.Implementation.Sync.Schemas; using Squidex.CLI.Commands.Implementation.Sync.Workflows; -using Squidex.CLI.Configuration; -using Squidex.ClientLibrary; -using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; @@ -28,15 +24,11 @@ using Squidex.Log; namespace Squidex.Domain.Apps.Entities.Apps.Templates; public sealed class TemplateCommandMiddleware( - TemplatesClient templatesClient, - IOptions templateOptions, - IUrlGenerator urlGenerator, - IJsonSerializer jsonSerializer, + TemplatesClient client, + SessionFactory sessionFactory, ISemanticLog log) : ICommandMiddleware { - private readonly TemplatesOptions templateOptions = templateOptions.Value; - public async Task HandleAsync(CommandContext context, NextDelegate next, CancellationToken ct) { @@ -55,7 +47,7 @@ public sealed class TemplateCommandMiddleware( return; } - var repository = await templatesClient.GetRepositoryUrl(template); + var repository = await client.GetRepositoryUrl(template); if (string.IsNullOrEmpty(repository)) { @@ -65,17 +57,16 @@ public sealed class TemplateCommandMiddleware( return; } - using (var cliLog = new StringLogger(jsonSerializer)) + var cliLog = new StringLogger(); + try { - try - { - var session = CreateSession(app); + var session = sessionFactory.CreateSession(app); - var syncService = await CreateSyncServiceAsync(repository, session); - var syncOptions = new SyncOptions(); + var syncService = await CreateSyncServiceAsync(repository, session); + var syncOptions = new SyncOptions(); - var targets = new ISynchronizer[] - { + var targets = new ISynchronizer[] + { new AppSynchronizer(cliLog), new AssetFoldersSynchronizer(cliLog), new AssetsSynchronizer(cliLog), @@ -83,18 +74,17 @@ public sealed class TemplateCommandMiddleware( new SchemasSynchronizer(cliLog), new WorkflowsSynchronizer(cliLog), new ContentsSynchronizer(cliLog), - }; + }; - foreach (var target in targets) - { - await target.ImportAsync(syncService, syncOptions, session); - } - } - finally + foreach (var target in targets) { - cliLog.Flush(log, template); + await target.ImportAsync(syncService, syncOptions, session); } } + finally + { + cliLog.Flush(log, template); + } } private static async Task CreateSyncServiceAsync(string repository, ISession session) @@ -103,27 +93,4 @@ public sealed class TemplateCommandMiddleware( return new SyncService(fs, session); } - - private ISession CreateSession(App app) - { - var client = app.Clients.First(); - - var url = templateOptions.LocalUrl; - - if (string.IsNullOrEmpty(url)) - { - url = urlGenerator.Root(); - } - - return new Session( - new DirectoryInfo(Path.GetTempPath()), - new SquidexClient(new SquidexOptions - { - IgnoreSelfSignedCertificates = true, - AppName = app.Name, - ClientId = $"{app.Name}:{client.Key}", - ClientSecret = client.Value.Secret, - Url = url, - })); - } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs index f2d6e7907..33e4be47c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Templates/TemplatesClient.cs @@ -5,14 +5,12 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.CodeDom; using System.Text.RegularExpressions; using Markdig; using Markdig.Renderers.Normalize; using Markdig.Syntax; using Markdig.Syntax.Inlines; using Microsoft.Extensions.Options; -using Squidex.ClientLibrary; using Squidex.Infrastructure; namespace Squidex.Domain.Apps.Entities.Apps.Templates; diff --git a/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs b/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs index add08359f..6238b900f 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/ContextHeaders.cs @@ -133,6 +133,6 @@ public static class ContextHeaders return value.Split(Separators, StringSplitOptions.RemoveEmptyEntries).Select(x => x.Trim()).Distinct(); } - return Enumerable.Empty(); + return []; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasOptions.cs b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasOptions.cs index 2042979b3..0e4e5f7b1 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasOptions.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Schemas/SchemasOptions.cs @@ -10,4 +10,6 @@ namespace Squidex.Domain.Apps.Entities.Schemas; public sealed class SchemasOptions { public bool DeletePermanent { get; set; } + + public string? GeneratePrompt { get; set; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj index 772c974c7..7b781b489 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj @@ -27,6 +27,7 @@ + @@ -40,7 +41,7 @@ - + diff --git a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs index dc2f8df94..aee9a4eee 100644 --- a/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs +++ b/backend/src/Squidex.Infrastructure/Commands/DefaultDomainObjectCache.cs @@ -48,21 +48,16 @@ public sealed class DefaultDomainObjectCache : IDomainObjectCache return typed; } - var buffer = await distributedCache.GetAsync(cacheKey, ct); - - if (buffer == null) + var cached = await distributedCache.GetAsync(cacheKey, ct); + if (cached == null) { return default!; } try { - using (var stream = new MemoryStream(buffer)) - { - var result = serializer.Deserialize(stream); - - return result; - } + using var stream = new MemoryStream(cached); + return serializer.Deserialize(stream); } catch { @@ -83,12 +78,10 @@ public sealed class DefaultDomainObjectCache : IDomainObjectCache cache.Set(cacheKey, snapshot, cacheOptions.AbsoluteExpirationRelativeToNow!.Value); try { - using (var stream = DefaultPools.MemoryStream.GetStream()) - { - serializer.Serialize(snapshot, stream); + using var stream = DefaultPools.MemoryStream.GetStream(); + serializer.Serialize(snapshot, stream); - await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct); - } + await distributedCache.SetAsync(cacheKey, stream.ToArray(), cacheOptions, ct); } catch { diff --git a/backend/src/Squidex.Infrastructure/Queries/FilterSchema.cs b/backend/src/Squidex.Infrastructure/Queries/FilterSchema.cs index 813ca6b5f..1189fb5ca 100644 --- a/backend/src/Squidex.Infrastructure/Queries/FilterSchema.cs +++ b/backend/src/Squidex.Infrastructure/Queries/FilterSchema.cs @@ -98,7 +98,7 @@ public sealed record FilterSchema(FilterSchemaType Type) } else { - return Enumerable.Empty(); + return []; } }).SelectMany(x => x); diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 997117334..d58081b81 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -24,13 +24,13 @@ - - - - - - - + + + + + + + diff --git a/backend/src/Squidex.Web/Services/StringLocalizer.cs b/backend/src/Squidex.Web/Services/StringLocalizer.cs index 4f28d7d31..409953bf6 100644 --- a/backend/src/Squidex.Web/Services/StringLocalizer.cs +++ b/backend/src/Squidex.Web/Services/StringLocalizer.cs @@ -82,7 +82,7 @@ public sealed class StringLocalizer : IStringLocalizer, IStringLocalizerFactory public IEnumerable GetAllStrings(bool includeParentCultures) { - return Enumerable.Empty(); + return []; } public IStringLocalizer WithCulture(CultureInfo culture) diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs index 925b67b33..d3c750156 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ErrorDtoProcessor.cs @@ -43,7 +43,7 @@ public sealed class ErrorDtoProcessor : IOperationProcessor .OfType() .Where(x => x.Name == "response") .Where(x => x.Attribute("code") != null) - ?? Enumerable.Empty(); + ?? []; foreach (var response in responses) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs index 0748f892f..fb875ce78 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Contents/Generator/SchemasOpenApiGenerator.cs @@ -59,8 +59,8 @@ public sealed class SchemasOpenApiGenerator( var context = new DocumentProcessorContext(document, - Enumerable.Empty(), - Enumerable.Empty(), + [], + [], schemaResolver, openApiGenerator, openApiSettings); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaDto.cs new file mode 100644 index 000000000..9b2ba8883 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaDto.cs @@ -0,0 +1,29 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class GenerateSchemaDto +{ + /// + /// The prompt to generate. + /// + [LocalizedRequired] + public string Prompt { get; set; } + + /// + /// Indicates if the schema should actually be generated. + /// + public bool Execute { get; set; } + + /// + /// The number of content items to generate. + /// + public int NumberOfContentItems { get; set; } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaResponseDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaResponseDto.cs new file mode 100644 index 000000000..634ea7e5c --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/Models/GenerateSchemaResponseDto.cs @@ -0,0 +1,30 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps.Templates; +using Squidex.Infrastructure.Collections; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Schemas.Models; + +public sealed class GenerateSchemaResponseDto +{ + /// + /// The status log. + /// + public ReadonlyList Log { get; set; } = []; + + /// + /// The name of the created schema. + /// + public string? SchemaName { get; set; } + + public static GenerateSchemaResponseDto FromDomain(SchemaAIResult response) + { + return SimpleMapper.Map(response, new GenerateSchemaResponseDto()); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs index dd878f999..b82086086 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Schemas/SchemasController.cs @@ -13,6 +13,7 @@ using Squidex.Domain.Apps.Core.GenerateFilters; using Squidex.Domain.Apps.Core.Schemas; using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Entities; +using Squidex.Domain.Apps.Entities.Apps.Templates; using Squidex.Domain.Apps.Entities.Contents; using Squidex.Domain.Apps.Entities.Schemas.Commands; using Squidex.Infrastructure; @@ -31,6 +32,7 @@ public sealed class SchemasController( ICommandBus commandBus, IContentWorkflow workflow, IAppProvider appProvider, + SchemaAIGenerator schemaAIGenerator, ScriptingCompleter scriptingCompleter) : ApiController(commandBus) { @@ -105,6 +107,34 @@ public sealed class SchemasController( return CreatedAtAction(nameof(GetSchema), new { app, schema = request.Name }, response); } + /// + /// Generate a new schema. + /// + /// The name of the app. + /// The schema object that needs to be added to the app. + /// Schema created. + /// Schema request not valid. + /// Schema name already in use. + [HttpPost] + [Route("apps/{app}/schemas/generate")] + [ProducesResponseType(typeof(GenerateSchemaResponseDto), StatusCodes.Status201Created)] + [ApiPermissionOrAnonymous(PermissionIds.AppSchemasCreate)] + [ApiCosts(1)] + public async Task PostSchemaGenerate(string app, [FromBody] GenerateSchemaDto request) + { + var result = + await schemaAIGenerator.ExecuteAsync( + App, + request.Prompt, + request.NumberOfContentItems, + request.Execute, + HttpContext.RequestAborted); + + var response = GenerateSchemaResponseDto.FromDomain(result); + + return Ok(response); + } + /// /// Update a schema. /// diff --git a/backend/src/Squidex/Config/Domain/ConfigurationExtensions.cs b/backend/src/Squidex/Config/Domain/ConfigurationExtensions.cs index dee89e731..1591f8e07 100644 --- a/backend/src/Squidex/Config/Domain/ConfigurationExtensions.cs +++ b/backend/src/Squidex/Config/Domain/ConfigurationExtensions.cs @@ -9,8 +9,9 @@ namespace Squidex.Config.Domain; public static class ConfigurationExtensions { - public static void ConfigureForSquidex(this IConfigurationBuilder builder) + public static void ConfigureForSquidex(this IConfigurationBuilder builder, IHostEnvironment environment) { builder.AddJsonFile("appsettings.Custom.json", true); + builder.AddKeyPerFile(Path.Combine(environment.ContentRootPath, "Configuration"), true, true); } } diff --git a/backend/src/Squidex/Config/Domain/ContentsServices.cs b/backend/src/Squidex/Config/Domain/ContentsServices.cs index fe034aebf..4a04a1b2a 100644 --- a/backend/src/Squidex/Config/Domain/ContentsServices.cs +++ b/backend/src/Squidex/Config/Domain/ContentsServices.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.CLI.Commands.Implementation.AI; using Squidex.Domain.Apps.Core.ValidateContent; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Templates; @@ -103,5 +104,14 @@ public static class ContentsServices services.AddSingletonAs() .AsSelf(); + + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() + .AsSelf(); + + services.AddSingletonAs() + .As(); } } diff --git a/backend/src/Squidex/Config/Startup/LogConfigurationHost.cs b/backend/src/Squidex/Config/Startup/LogConfigurationHost.cs index fc224a4ad..998a5c9b4 100644 --- a/backend/src/Squidex/Config/Startup/LogConfigurationHost.cs +++ b/backend/src/Squidex/Config/Startup/LogConfigurationHost.cs @@ -5,35 +5,34 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using System.Text.RegularExpressions; using Squidex.Log; namespace Squidex.Config.Startup; public sealed class LogConfigurationHost(IConfiguration configuration, ISemanticLog log) : IHostedService { + private const int MaxValueLength = 30; private static readonly string RedactedValue = "*****"; - private static readonly Regex[] SensitivePatterns = + private static readonly string[] SensitiveValues = [ - // Authentication and API keys -#pragma warning disable MA0110 // Use the Regex source generator - new Regex(@"(?i)(secret|token|key|password|credential|auth|api[_-]?key)$"), - new Regex(@"(?i)^(aws|azure|google|microsoft|github)[_-]"), - new Regex(@"(?i)(jwt|bearer|oauth|saml)"), - new Regex(@"(?i)(client|secret|password)$"), - - // Connection strings and credentials - new Regex(@"(?i)(connectionstring|connection)$"), - new Regex(@"(?i)(username|password|credential)$"), - - // Cloud provider specific - new Regex(@"(?i)(accesskey|secretkey|privatekey|publickey)$"), - new Regex(@"(?i)(projectid|tenantid)$"), - - // Database specific - new Regex(@"(?i)(mongodb|sqlserver|postgres|mysql)://.*"), - new Regex(@"(?i)(database|db|server|host|port|user|pass)="), -#pragma warning restore MA0110 // Use the Regex source generator + "aws", + "azure", + "bearer", + "clientid", + "credential", + "database", + "db", + "github", + "google", + "jwt", + "key", + "microsoft", + "pass", + "secret", + "server", + "tenant", + "token", + "username", ]; public Task StartAsync( @@ -56,14 +55,23 @@ public sealed class LogConfigurationHost(IConfiguration configuration, ISemantic continue; } - var keyLower = key.ToLowerInvariant(); - - if (logged.Add(keyLower)) + var lowerKey = key.ToLowerInvariant(); + if (!logged.Add(lowerKey)) { - var formattedValue = IsSensitiveKey(keyLower) || IsSensitiveValue(value) ? RedactedValue : value; + continue; + } - c.WriteProperty(keyLower, value); + var formattedValue = value; + if (IsSensitiveKey(lowerKey) || IsSensitiveKey(value) || IsSensitiveValue(value)) + { + formattedValue = RedactedValue; } + else if (formattedValue.Length > MaxValueLength) + { + formattedValue = formattedValue[.. (MaxValueLength - 3)] + "..."; + } + + c.WriteProperty(lowerKey, formattedValue); } })); @@ -72,7 +80,7 @@ public sealed class LogConfigurationHost(IConfiguration configuration, ISemantic private static bool IsSensitiveKey(string key) { - return SensitivePatterns.Any(pattern => pattern.IsMatch(key)); + return SensitiveValues.Any(pattern => key.Contains(pattern, StringComparison.OrdinalIgnoreCase)); } private static bool IsSensitiveValue(string? value) diff --git a/backend/src/Squidex/Configuration/schemas__generateprompt b/backend/src/Squidex/Configuration/schemas__generateprompt new file mode 100644 index 000000000..e75e7ddbd --- /dev/null +++ b/backend/src/Squidex/Configuration/schemas__generateprompt @@ -0,0 +1,84 @@ +You are a agent to create sample content for a headless CMS. + +When asked to create a **schema** for a content type, return a JSON document as the **first markdown code block**. Use the following format: + +``` +{ + "name": string, // The schema name in kebab-case, + "hint": string | null | undefined // Optional description of the schema, + "fields": [{ + "name": string, // Field name in camelCase, + "hint": string | null | undefined // Optional description of the field, + "type": "Slug | Text | MultilineText | Markdown | Number | Boolean", + "isRequired": false, + "isLocalized": false, + "minLength": number | null, // For Slug, Text, MultilineText, Markdown + "maxLength" number | null, // For Slug, Text, MultilineText, Markdown + "minValue": number | null, // For Number + "maxValue": number | null, // For Number + }] +} +``` + +### 🚫 Absolutely Forbidden Fields + +Do **not** include any **CMS metadata fields** under any circumstances. These fields are automatically managed by the CMS system and **not authored by content creators**. This includes fields related to: + +- **Publishing state** (e.g., `publishedDate`, `isPublished`, `published`) +- **Workflow state** (e.g., `isDraft`, `status`) +- **System timestamps** (e.g., `createdAt`, `updatedAt`) +- **Visibility** (e.g., `visibility`, `ready`) + +Here are some **examples** of prohibited fields: + + - `createdAt` + - `isDraft` + - `isReadyForPublish` + - `isPublished` + - `publishDate` + - `published` + - `publishedDate` + - `ready` + - `status` + - `updatedAt` + - `visibility` + +⚠️ **Note:** This list is *not exhaustive*. Any field related to **system-level behavior, versioning, publishing workflow, or timestamps** should **never** be included. + +Only include **fields that describe the actual content** of the schema. + +### Schema Rules: + +1. Use **kebab-case** for the schema name (e.g., my-schema). +2. Use **camelCase** for field names (e.g., myField). +3. Ensure that the schema **`hint`** accurately reflects the purpose of the schema, but keep it short and precise. Do not just repeat the fields. +4. Ensure that the field **hint** accurately reflects the purpose of the schema. It can be omitted if it does not provide useful or necessary information + +When asked to create **sample content**, return a second **markdown code block** with valid JSON—an **array of objects**. + +### Sample Content Rules: + +1. The result **must be valid JSON** representing an array of objects. +2. Each object's properties must match the **field names** in the schema. +3. Keep the **size of the sample content reasonable**. +4. For **localized fields**, use the following format (only use the language codes specified in the request): + +``` +{ + "languageCode": "Hello World" +} +``` + +5. For **image fields**, use this structure: + +``` +{ + "fileName": "Example.png" + "description": "Describe the image." +} +``` + +### 🛠 Correction Instructions: + +If you are asked to correct errors, **always return the full response**, including both schema and sample content (if requested). Do not omit the schema, even if it was already correct. + diff --git a/backend/src/Squidex/Program.cs b/backend/src/Squidex/Program.cs index 86be4de23..699d37c43 100644 --- a/backend/src/Squidex/Program.cs +++ b/backend/src/Squidex/Program.cs @@ -25,7 +25,7 @@ public static class Program }) .ConfigureAppConfiguration((hostContext, builder) => { - builder.ConfigureForSquidex(); + builder.ConfigureForSquidex(hostContext.HostingEnvironment); }) .ConfigureServices((context, services) => { diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index e08af34ce..f7c3e3039 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -45,6 +45,7 @@ + @@ -59,17 +60,17 @@ - - - - - - - - - - - + + + + + + + + + + + @@ -83,11 +84,11 @@ - + - + @@ -135,6 +136,12 @@ + + + PreserveNewest + + + diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ValueConvertersTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ValueConvertersTests.cs index 61856b58e..07ea7a1c6 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ValueConvertersTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/ConvertContent/ValueConvertersTests.cs @@ -167,7 +167,7 @@ public class ValueConvertersTests var actual = new AddSchemaNames(components) - .ConvertItemAfter(field, source, Enumerable.Empty()); + .ConvertItemAfter(field, source, []); var expected = JsonValue.Object() @@ -196,7 +196,7 @@ public class ValueConvertersTests var actual = new AddSchemaNames(components) - .ConvertItemAfter(field, source, Enumerable.Empty()); + .ConvertItemAfter(field, source, []); var expected = source; @@ -221,7 +221,7 @@ public class ValueConvertersTests var actual = new AddSchemaNames(components) - .ConvertItemAfter(field, source, Enumerable.Empty()); + .ConvertItemAfter(field, source, []); var expected = source; @@ -245,7 +245,7 @@ public class ValueConvertersTests var actual = new AddSchemaNames(components) - .ConvertItemAfter(field, source, Enumerable.Empty()); + .ConvertItemAfter(field, source, []); var expected = source; @@ -265,7 +265,7 @@ public class ValueConvertersTests var actual = new AddSchemaNames(ResolvedComponents.Empty) - .ConvertItemAfter(field, source, Enumerable.Empty()); + .ConvertItemAfter(field, source, []); var expected = source; diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs index 7f6de0b37..1cc0c2019 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Subscriptions/SubscriptionPublisherTests.cs @@ -27,7 +27,7 @@ public class SubscriptionPublisherTests public SubscriptionPublisherTests() { - sut = new SubscriptionPublisher(subscriptionService, Enumerable.Empty()); + sut = new SubscriptionPublisher(subscriptionService, []); } [Fact] diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs index 96747fe9c..3362af36b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Billing/ConfigPlansProviderTests.cs @@ -58,7 +58,7 @@ public class ConfigPlansProviderTests [InlineData("my-plan")] public void Should_return_infinite_if_nothing_configured(string? planId) { - var sut = new ConfigPlansProvider(Enumerable.Empty()); + var sut = new ConfigPlansProvider([]); var actual = sut.GetActualPlan(planId); @@ -78,7 +78,7 @@ public class ConfigPlansProviderTests [Fact] public void Should_return_infinite_plan_for_free_plan_if_not_found() { - var sut = new ConfigPlansProvider(Enumerable.Empty()); + var sut = new ConfigPlansProvider([]); var plan = sut.GetFreePlan(); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs index e654a5c87..f461de80b 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Contents/Queries/ContentEnricherTests.cs @@ -101,7 +101,7 @@ public class ContentEnricherTests : GivenContext { var source = CreateContent(); - var sut = new ContentEnricher(Enumerable.Empty(), AppProvider); + var sut = new ContentEnricher([], AppProvider); var actual = await sut.EnrichAsync(source, true, ApiContext, CancellationToken); @@ -113,7 +113,7 @@ public class ContentEnricherTests : GivenContext { var source = CreateContent(); - var sut = new ContentEnricher(Enumerable.Empty(), AppProvider); + var sut = new ContentEnricher([], AppProvider); var actual = await sut.EnrichAsync(source, false, ApiContext, CancellationToken); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs index 4de5c860b..4ed3f48a9 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/HandlerTestBase.cs @@ -26,7 +26,7 @@ public abstract class HandlerTestBase : GivenContext get => persistenceFactory; } - public IEnumerable> LastEvents { get; private set; } = Enumerable.Empty>(); + public IEnumerable> LastEvents { get; private set; } = []; protected HandlerTestBase() { @@ -41,7 +41,7 @@ public abstract class HandlerTestBase : GivenContext .Invokes((IReadOnlyList> events, CancellationToken _) => LastEvents = events); A.CallTo(() => persistence.DeleteAsync(CancellationToken)) - .Invokes(() => LastEvents = Enumerable.Empty>()); + .Invokes(() => LastEvents = []); #pragma warning restore MA0056 // Do not call overridable members in constructor } diff --git a/frontend/.storybook/main.js b/frontend/.storybook/main.js index 843eef1e8..d98dabba8 100644 --- a/frontend/.storybook/main.js +++ b/frontend/.storybook/main.js @@ -8,17 +8,15 @@ const CopyPlugin = require('copy-webpack-plugin'); class FilterSassWarningsPlugin { - apply(compiler) { - compiler.hooks.done.tap('FilterSassWarningsPlugin', (stats) => { - stats.compilation.warnings = stats.compilation.warnings.filter( - (warning) => { - const message = warning.message || warning.toString(); - return !message.includes('sass-loader'); - } - ); + apply(compiler) { + compiler.hooks.done.tap('FilterSassWarningsPlugin', (stats) => { + stats.compilation.warnings = stats.compilation.warnings.filter(warning => { + const message = warning.message || warning.toString(); + return !message.includes('sass-loader'); }); - } + }); } +} module.exports = { stories: ["../src/**/*.stories.@(js|jsx|ts|tsx)"], diff --git a/frontend/src/app/features/apps/pages/apps-page.component.html b/frontend/src/app/features/apps/pages/apps-page.component.html index e54e63769..dd13bcea2 100644 --- a/frontend/src/app/features/apps/pages/apps-page.component.html +++ b/frontend/src/app/features/apps/pages/apps-page.component.html @@ -41,7 +41,7 @@ } @empty {
-

{{ "apps.empty" | sqxTranslate }}

+
{{ "apps.empty" | sqxTranslate }}
} diff --git a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html index a2791dba1..25ae1b401 100644 --- a/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html +++ b/frontend/src/app/features/schemas/pages/schema/fields/field-wizard.component.html @@ -47,7 +47,7 @@
- + - +
+ - @if (import) { + @if (source) { {{ "schemas.clone" | sqxTranslate }} } @else { {{ "schemas.create" | sqxTranslate }} } - - -
- - - - {{ "schemas.schemaNameHint" | sqxTranslate }} + + + + + + + @if (selectedTab !== 2) { +
+ + + + {{ "schemas.schemaNameHint" | sqxTranslate }} +
+ + {{ "schemas.nameWarning" | sqxTranslate }} + } -
-
-
-