From 00653e4bdec087ec01b056d595ca479f78c97c6b Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Sat, 25 May 2024 21:32:57 +0200 Subject: [PATCH] Progress --- .../Squidex.Extensions.csproj | 6 +- backend/i18n/source/backend_en.json | 2 + backend/i18n/source/frontend_en.json | 1 + backend/src/Migrations/Migrations.csproj | 2 +- .../Squidex.Domain.Apps.Core.Model.csproj | 2 +- .../Extensions/StringAsyncJintExtension.cs | 9 +- ...Squidex.Domain.Apps.Core.Operations.csproj | 10 +- ...quidex.Domain.Apps.Entities.MongoDb.csproj | 2 +- .../Apps/Commands/UploadAppImage.cs | 2 +- .../Assets/Commands/UploadAssetCommand.cs | 2 +- .../DomainObject/AssetCommandMiddleware.cs | 2 +- .../Assets/FileTagAssetMetadataSource.cs | 4 +- .../Squidex.Domain.Apps.Entities.csproj | 4 +- .../Squidex.Domain.Apps.Events.csproj | 2 +- .../Squidex.Domain.Users.MongoDb.csproj | 2 +- .../Squidex.Domain.Users.csproj | 6 +- ...quidex.Infrastructure.GetEventStore.csproj | 10 +- .../Squidex.Infrastructure.MongoDb.csproj | 2 +- .../Squidex.Infrastructure.csproj | 22 +-- .../src/Squidex.Shared/Squidex.Shared.csproj | 2 +- .../src/Squidex.Web/AssetFileModelBinder.cs | 147 +++++++++++++++++ .../AssetFileModelBinderProvider.cs | 25 +++ backend/src/Squidex.Web/FileExtensions.cs | 31 ---- .../src/Squidex.Web/Services/UrlGenerator.cs | 8 +- backend/src/Squidex.Web/Squidex.Web.csproj | 2 +- .../Areas/Api/Config/AssetFileResolver.cs | 12 ++ .../Api/Config/OpenApi/OpenApiServices.cs | 15 +- .../Api/Config/OpenApi/ReflectionServices.cs | 43 +++++ .../Api/Controllers/Apps/AppsController.cs | 17 +- .../Controllers/Assets/AssetsController.cs | 36 +--- .../Assets/Models/CreateAssetDto.cs | 14 +- .../Assets/Models/UpsertAssetDto.cs | 6 +- .../Statistics/UsagesController.cs | 6 +- .../Controllers/Translations/Models/AskDto.cs | 15 +- .../Translations/TranslationsController.cs | 71 +++++++- .../Areas/Api/Controllers/UploadModel.cs | 155 ++++++++++++++++++ .../Controllers/Profile/ProfileController.cs | 19 +-- .../Config/Domain/InfrastructureServices.cs | 25 ++- .../Squidex/Config/Domain/QueryServices.cs | 3 +- .../Squidex/Config/Domain/StoreServices.cs | 3 +- .../Config/Messaging/MessagingServices.cs | 3 + backend/src/Squidex/Config/Web/WebServices.cs | 5 + backend/src/Squidex/Squidex.csproj | 44 ++--- backend/src/Squidex/appsettings.json | 17 ++ .../Scripting/JintScriptEngineHelperTests.cs | 9 +- .../Squidex.Domain.Apps.Core.Tests.csproj | 8 +- .../AssetCommandMiddlewareTests.cs | 4 +- .../DomainObject/AssetDomainObjectTests.cs | 2 +- .../Assets/ImageAssetMetadataSourceTests.cs | 2 +- .../Squidex.Domain.Apps.Entities.Tests.csproj | 12 +- .../TestHelpers/NoopAssetFile.cs | 9 +- .../Squidex.Domain.Users.Tests.csproj | 8 +- .../Squidex.Infrastructure.Tests.csproj | 8 +- .../Squidex.Web.Tests.csproj | 8 +- .../administration/services/users.service.ts | 4 +- .../shared/forms/assets-editor.component.html | 31 ++-- .../shared/forms/assets-editor.component.ts | 52 ++++-- .../shared/forms/field-editor.component.html | 13 +- .../shared/forms/field-editor.component.ts | 16 +- .../src/app/framework/angular/drag-helper.ts | 5 +- .../framework/angular/http/http-extensions.ts | 13 +- .../angular/image-source.directive.ts | 16 +- .../src/app/framework/utils/string-helper.ts | 30 ++-- .../assets/asset-dialog.component.ts | 4 +- .../assets/asset-text-editor.component.ts | 10 +- .../assets/asset-uploader.component.ts | 4 +- .../components/assets/asset.component.ts | 6 +- .../assets/assets-list.component.ts | 10 +- .../assets/image-cropper.component.ts | 14 +- .../src/app/shared/components/assets/pipes.ts | 23 ++- .../components/chat-dialog.component.html | 99 ++--------- .../components/chat-dialog.component.scss | 61 ------- .../components/chat-dialog.component.ts | 105 ++++++------ .../components/chat-item.component.html | 66 ++++++++ .../components/chat-item.component.scss | 82 +++++++++ .../shared/components/chat-item.component.ts | 141 ++++++++++++++++ .../forms/geolocation-editor.component.ts | 6 +- .../forms/rich-editor.component.html | 4 +- .../components/forms/rich-editor.component.ts | 8 +- .../src/app/shared/services/apps.service.ts | 2 +- .../src/app/shared/services/assets.service.ts | 25 +-- .../app/shared/services/contents.service.ts | 10 +- .../app/shared/services/history.service.ts | 4 +- .../src/app/shared/services/news.service.ts | 4 +- .../src/app/shared/services/rules.service.ts | 4 +- .../src/app/shared/services/search.service.ts | 4 +- .../shared/services/stock-photo.service.ts | 3 +- .../shared/services/translations.service.ts | 79 ++++++++- .../src/app/shared/services/users.service.ts | 4 +- .../app/shared/state/asset-uploader.state.ts | 6 +- 90 files changed, 1273 insertions(+), 581 deletions(-) create mode 100644 backend/src/Squidex.Web/AssetFileModelBinder.cs create mode 100644 backend/src/Squidex.Web/AssetFileModelBinderProvider.cs delete mode 100644 backend/src/Squidex.Web/FileExtensions.cs create mode 100644 backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs create mode 100644 frontend/src/app/shared/components/chat-item.component.html create mode 100644 frontend/src/app/shared/components/chat-item.component.scss create mode 100644 frontend/src/app/shared/components/chat-item.component.ts diff --git a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj index 3a23f71743..e33ca1927d 100644 --- a/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj +++ b/backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj @@ -17,9 +17,9 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -27,7 +27,7 @@ - + diff --git a/backend/i18n/source/backend_en.json b/backend/i18n/source/backend_en.json index 90963abf12..af9c83cd22 100644 --- a/backend/i18n/source/backend_en.json +++ b/backend/i18n/source/backend_en.json @@ -57,6 +57,8 @@ "common.fullTextNotSupported": "Query search clause not supported.", "common.httpContentTypeNotDefined": "File content-type is not defined.", "common.httpFileNameNotDefined": "File name is not defined.", + "common.httpDownloadFailed": "Failed to download file.", + "common.httpDownloadRequestSize": "File exceeded maximum request size.", "common.httpInvalidRequest": "The model is not valid.", "common.httpInvalidRequestFormat": "Request body has an invalid format.", "common.httpOnlyAsUser": "Not allowed for clients.", diff --git a/backend/i18n/source/frontend_en.json b/backend/i18n/source/frontend_en.json index 61f66ef891..b409ed61fd 100644 --- a/backend/i18n/source/frontend_en.json +++ b/backend/i18n/source/frontend_en.json @@ -142,6 +142,7 @@ "chat.describeFormat": "Also add the desired format (for example Markdown or HTML) to your prompt, dependending on the editor that you use.", "chat.description": "Use the ChatBot (usually OpenAI) to generate content. Just write a prompt and describe the content you want to generate.", "chat.prompt": "Describe the content you want to generate", + "chat.failed": "Failed to answer your request.", "chat.title": "Chat Bot", "chat.use": "Use", "chatBot.questionFailed": "", diff --git a/backend/src/Migrations/Migrations.csproj b/backend/src/Migrations/Migrations.csproj index 438ecb81c3..7934d73f26 100644 --- a/backend/src/Migrations/Migrations.csproj +++ b/backend/src/Migrations/Migrations.csproj @@ -6,7 +6,7 @@ enable - + all runtime; build; native; contentfiles; analyzers; buildtransitive 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 cd12705db6..9cb29d38f8 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 @@ -12,7 +12,7 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs index 86dfe41f0f..27b9b8dc9b 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs @@ -61,9 +61,14 @@ private void Generate(ScriptExecutionContext context, string prompt, Action - + - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj index d2778507a5..0c2757236c 100644 --- a/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs index d5b6e195ed..e654d92af4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs @@ -11,5 +11,5 @@ namespace Squidex.Domain.Apps.Entities.Apps.Commands; public sealed class UploadAppImage : AppCommand { - public AssetFile File { get; set; } + public IAssetFile File { get; set; } } diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs index 59d7e6497e..bd367c82e9 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs @@ -14,7 +14,7 @@ public abstract class UploadAssetCommand : AssetCommand { public HashSet Tags { get; set; } = []; - public AssetFile File { get; set; } + public IAssetFile File { get; set; } public AssetMetadata Metadata { get; } = []; diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs index 75cffd5023..dc79efec59 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs @@ -167,7 +167,7 @@ private async Task EnrichWithHashAndUploadAsync(UploadAssetCommand command, stri } } - private static string ComputeHash(AssetFile file, HasherStream hashStream) + private static string ComputeHash(IAssetFile file, HasherStream hashStream) { var steamHash = hashStream.GetHashStringAndReset(); diff --git a/backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs b/backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs index dccbbaeeff..c51da6b5f4 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs @@ -19,7 +19,7 @@ public sealed class FileTagAssetMetadataSource : IAssetMetadataSource { private sealed class FileAbstraction : IFileAbstraction { - private readonly AssetFile file; + private readonly IAssetFile file; public string Name { @@ -36,7 +36,7 @@ public Stream WriteStream get => throw new NotSupportedException(); } - public FileAbstraction(AssetFile file) + public FileAbstraction(IAssetFile file) { this.file = file; } 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 01861f2639..d05c6a32c0 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 @@ -24,10 +24,10 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj index 64a82b338d..c7f203cb6c 100644 --- a/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj +++ b/backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj index b7afd68d52..92882e2296 100644 --- a/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj +++ b/backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj @@ -19,7 +19,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj index 74a3ad059e..2131633201 100644 --- a/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj +++ b/backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj @@ -18,13 +18,13 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + diff --git a/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj b/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj index 12ed781315..643309d891 100644 --- a/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj +++ b/backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj @@ -11,11 +11,11 @@ True - - - - - + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj index 078d67028d..d450c763b7 100644 --- a/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj +++ b/backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj @@ -14,7 +14,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj index 05c02f98ad..abf5aba1b9 100644 --- a/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj +++ b/backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj @@ -11,30 +11,30 @@ True - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - - - - - + + + + + + - + diff --git a/backend/src/Squidex.Shared/Squidex.Shared.csproj b/backend/src/Squidex.Shared/Squidex.Shared.csproj index dc90447b20..6a048ea3c5 100644 --- a/backend/src/Squidex.Shared/Squidex.Shared.csproj +++ b/backend/src/Squidex.Shared/Squidex.Shared.csproj @@ -10,7 +10,7 @@ True - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex.Web/AssetFileModelBinder.cs b/backend/src/Squidex.Web/AssetFileModelBinder.cs new file mode 100644 index 0000000000..d6313cb86c --- /dev/null +++ b/backend/src/Squidex.Web/AssetFileModelBinder.cs @@ -0,0 +1,147 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Billing; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Web; + +public sealed class AssetFileModelBinder : IModelBinder +{ + private readonly IUsageGate usageGate; + private readonly IAssetUsageTracker assetUsage; + private readonly IHttpClientFactory httpClientFactory; + + public AssetFileModelBinder(IUsageGate usageGate, IAssetUsageTracker assetUsage, IHttpClientFactory httpClientFactory) + { + this.usageGate = usageGate; + this.assetUsage = assetUsage; + this.httpClientFactory = httpClientFactory; + } + + public async Task BindModelAsync(ModelBindingContext bindingContext) + { + var httpContext = bindingContext.ActionContext.HttpContext; + + var file = await DownloadFileAsync(httpContext, httpContext.RequestAborted) ?? GetFile(httpContext); + + if (!await IsSizeAllowedAsync(httpContext, file.FileSize, httpContext.RequestAborted)) + { + await file.DisposeAsync(); + throw new ValidationException(T.Get("assets.maxSizeReached")); + } + + bindingContext.Result = ModelBindingResult.Success(file); + } + + private static IAssetFile GetFile(HttpContext httpContext) + { + var requestFiles = httpContext.Request.Form.Files; + + if (requestFiles.Count != 1) + { + throw new ValidationException(T.Get("validation.onlyOneFile")); + } + + var formFile = requestFiles[0]; + + if (string.IsNullOrWhiteSpace(formFile.ContentType)) + { + throw new ValidationException(T.Get("common.httpContentTypeNotDefined")); + } + + if (string.IsNullOrWhiteSpace(formFile.FileName)) + { + throw new ValidationException(T.Get("common.httpFileNameNotDefined")); + } + + return new DelegateAssetFile( + formFile.FileName, + formFile.ContentType, + formFile.Length, + formFile.OpenReadStream); + } + + private async Task DownloadFileAsync(HttpContext httpContext, + CancellationToken ct) + { + if (httpContext.Request.Form.Files.Count > 0) + { + return null; + } + + var fileUrl = httpContext.Request.Form["url"].ToString(); + var fileName = httpContext.Request.Form["name"].ToString(); + + if (string.IsNullOrEmpty(fileUrl) || + string.IsNullOrEmpty(fileName)) + { + return null; + } + + var requestSize = httpContext.Features.Get()?.MaxRequestBodySize ?? int.MaxValue; + + try + { + using var httpClient = httpClientFactory.CreateClient(); + using var httpResponse = await httpClient.GetAsync(fileUrl, ct); + + var length = httpResponse.Content.Headers.ContentLength; + if (length == null || length > requestSize) + { + throw new ValidationException(T.Get("common.httpDownloadRequestSize")); + } + + if (!httpResponse.IsSuccessStatusCode) + { + throw new ValidationException(T.Get("common.httpDownloadFailed")); + } + + await using var httpStream = await httpResponse.Content.ReadAsStreamAsync(ct); + + var tempFile = new TempAssetFile(fileName, httpResponse.Content.Headers.ContentType?.ToString()!); + + await using (var tempStream = tempFile.OpenWrite()) + { + await httpStream.CopyToAsync(tempStream, ct); + } + + return tempFile; + } + catch + { + throw new ValidationException(T.Get("common.httpDownloadFailed")); + } + } + + private async Task IsSizeAllowedAsync(HttpContext httpContext, long size, + CancellationToken ct) + { + if (httpContext.Features.Get() is not App app) + { + return true; + } + + var (plan, _, _) = await usageGate.GetPlanForAppAsync(app, true, ct); + + if (plan.MaxAssetSize <= 0) + { + return true; + } + + var (_, currentSize) = await assetUsage.GetTotalByAppAsync(app.Id, ct); + + return plan.MaxAssetSize < currentSize + size; + } +} diff --git a/backend/src/Squidex.Web/AssetFileModelBinderProvider.cs b/backend/src/Squidex.Web/AssetFileModelBinderProvider.cs new file mode 100644 index 0000000000..83014c7d1e --- /dev/null +++ b/backend/src/Squidex.Web/AssetFileModelBinderProvider.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.Extensions.DependencyInjection; +using Squidex.Assets; + +namespace Squidex.Web; + +public sealed class AssetFileModelBinderProvider : IModelBinderProvider +{ + public IModelBinder? GetBinder(ModelBinderProviderContext context) + { + if (context.Metadata.ModelType == typeof(IAssetFile)) + { + return context.Services.GetRequiredService(); + } + + return null; + } +} diff --git a/backend/src/Squidex.Web/FileExtensions.cs b/backend/src/Squidex.Web/FileExtensions.cs deleted file mode 100644 index 2d65ba67bd..0000000000 --- a/backend/src/Squidex.Web/FileExtensions.cs +++ /dev/null @@ -1,31 +0,0 @@ -// ========================================================================== -// Squidex Headless CMS -// ========================================================================== -// Copyright (c) Squidex UG (haftungsbeschraenkt) -// All rights reserved. Licensed under the MIT license. -// ========================================================================== - -using Microsoft.AspNetCore.Http; -using Squidex.Assets; -using Squidex.Infrastructure.Translations; -using Squidex.Infrastructure.Validation; - -namespace Squidex.Web; - -public static class FileExtensions -{ - public static AssetFile ToAssetFile(this IFormFile formFile) - { - if (string.IsNullOrWhiteSpace(formFile.ContentType)) - { - throw new ValidationException(T.Get("common.httpContentTypeNotDefined")); - } - - if (string.IsNullOrWhiteSpace(formFile.FileName)) - { - throw new ValidationException(T.Get("common.httpFileNameNotDefined")); - } - - return new DelegateAssetFile(formFile.FileName, formFile.ContentType, formFile.Length, formFile.OpenReadStream); - } -} diff --git a/backend/src/Squidex.Web/Services/UrlGenerator.cs b/backend/src/Squidex.Web/Services/UrlGenerator.cs index 3656c5a368..d3419ea809 100644 --- a/backend/src/Squidex.Web/Services/UrlGenerator.cs +++ b/backend/src/Squidex.Web/Services/UrlGenerator.cs @@ -6,6 +6,7 @@ // ========================================================================== using Microsoft.Extensions.Options; +using Squidex.AI.Implementation.OpenAI; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Assets; using Squidex.Domain.Apps.Entities.Assets; @@ -15,7 +16,7 @@ namespace Squidex.Web.Services; -public sealed class UrlGenerator : IUrlGenerator +public sealed class UrlGenerator : IUrlGenerator, IHttpImageEndpoint { private readonly IAssetFileStore assetFileStore; private readonly IGenericUrlGenerator urlGenerator; @@ -171,4 +172,9 @@ public string UI() { return urlGenerator.BuildUrl("app", false); } + + string IHttpImageEndpoint.GetUrl(string relativePath) + { + return urlGenerator.BuildUrl($"ai-images/{relativePath}", false); + } } diff --git a/backend/src/Squidex.Web/Squidex.Web.csproj b/backend/src/Squidex.Web/Squidex.Web.csproj index 51f2bcc3c0..dc94a13c58 100644 --- a/backend/src/Squidex.Web/Squidex.Web.csproj +++ b/backend/src/Squidex.Web/Squidex.Web.csproj @@ -16,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs b/backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs new file mode 100644 index 0000000000..0fe75cc8e8 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs @@ -0,0 +1,12 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +namespace Squidex.Areas.Api.Config; + +public class AssetFileResolver +{ +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index 85774131dd..19fce68b4e 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -27,9 +27,18 @@ public static class OpenApiServices { public static void AddSquidexOpenApiSettings(this IServiceCollection services) { + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -39,12 +48,6 @@ public static void AddSquidexOpenApiSettings(this IServiceCollection services) services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As(); - - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .As(); diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/ReflectionServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/ReflectionServices.cs index 56e88db269..048248cb45 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/ReflectionServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/ReflectionServices.cs @@ -7,12 +7,55 @@ using Namotion.Reflection; using NJsonSchema.Generation; +using Squidex.Assets; using Squidex.Infrastructure.Collections; +using System.Diagnostics; namespace Squidex.Areas.Api.Config.OpenApi; public class ReflectionServices : SystemTextJsonReflectionService { + private static HashSet written = []; + + protected override JsonTypeDescription GetDescription(ContextualType contextualType, SystemTextJsonSchemaGeneratorSettings settings, Type originalType, bool isNullable, ReferenceTypeNullHandling defaultReferenceTypeNullHandling) + { + if (contextualType.Type == typeof(IAssetFile)) + { + Debugger.Break(); + } + + return base.GetDescription(contextualType, settings, originalType, isNullable, defaultReferenceTypeNullHandling); + } + + protected override bool IsBinary(ContextualType contextualType) + { + var parameterTypeName = contextualType.Name; + if (parameterTypeName.Contains("Asset") && written.Add(parameterTypeName)) + { + + Console.WriteLine(parameterTypeName); + } + + if (parameterTypeName.Contains("CreateAssetDto")) + { + + } + + if (contextualType.Type == typeof(IAssetFile)) + { + return true; + } + + var x = base.IsBinary(contextualType); + + if (x) + { + Debugger.Break(); + } + + return x; + } + protected override bool IsArrayType(ContextualType contextualType) { if (contextualType.Type.IsGenericType && diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 20c7567c0d..c868690b1c 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Net.Http.Headers; using Squidex.Areas.Api.Controllers.Apps.Models; +using Squidex.Assets; using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Commands; @@ -193,9 +194,9 @@ public async Task PutAppTeam(string app, [FromBody] TransferToTea [ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppImageUpload)] [ApiCosts(0)] - public async Task UploadImage(string app, IFormFile file) + public async Task UploadImage(string app, [FromForm(Name = "file")] IAssetFile file) { - var response = await InvokeCommandAsync(CreateCommand(file)); + var response = await InvokeCommandAsync(new UploadAppImage { File = file }); return Ok(response); } @@ -255,16 +256,4 @@ private async Task InvokeCommandAsync(ICommand command, Func conve return response; } - - private UploadAppImage CreateCommand(IFormFile? file) - { - if (file == null || Request.Form.Files.Count != 1) - { - var error = T.Get("validation.onlyOneFile"); - - throw new ValidationException(error); - } - - return new UploadAppImage { File = file.ToAssetFile() }; - } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 56596f8377..f8ed900b2c 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -19,8 +19,6 @@ using Squidex.Domain.Apps.Entities.Billing; using Squidex.Infrastructure; using Squidex.Infrastructure.Commands; -using Squidex.Infrastructure.Translations; -using Squidex.Infrastructure.Validation; using Squidex.Shared; using Squidex.Web; @@ -32,7 +30,6 @@ namespace Squidex.Areas.Api.Controllers.Assets; [ApiExplorerSettings(GroupName = nameof(Assets))] public sealed class AssetsController : ApiController { - private readonly IUsageGate usageGate; private readonly IAssetQueryService assetQuery; private readonly IAssetUsageTracker assetUsageTracker; private readonly ITagService tagService; @@ -40,14 +37,12 @@ public sealed class AssetsController : ApiController public AssetsController( ICommandBus commandBus, - IUsageGate usageGate, IAssetQueryService assetQuery, IAssetUsageTracker assetUsageTracker, ITagService tagService, AssetTusRunner assetTusRunner) : base(commandBus) { - this.usageGate = usageGate; this.assetQuery = assetQuery; this.assetUsageTracker = assetUsageTracker; this.assetTusRunner = assetTusRunner; @@ -207,7 +202,7 @@ public async Task GetAsset(string app, DomainId id) [ApiCosts(1)] public async Task PostAsset(string app, CreateAssetDto request) { - var command = request.ToCommand(await CheckAssetFileAsync(request.File)); + var command = request.ToCommand(); var response = await InvokeCommandAsync(command); @@ -295,7 +290,7 @@ public async Task BulkUpdateAssets(string app, [FromBody] BulkUpd [ApiCosts(1)] public async Task PostUpsertAsset(string app, DomainId id, UpsertAssetDto request) { - var command = request.ToCommand(id, await CheckAssetFileAsync(request.File)); + var command = request.ToCommand(id); var response = await InvokeCommandAsync(command); @@ -321,9 +316,9 @@ public async Task PostUpsertAsset(string app, DomainId id, Upsert [AssetRequestSizeLimit] [ApiPermissionOrAnonymous(PermissionIds.AppAssetsUpload)] [ApiCosts(1)] - public async Task PutAssetContent(string app, DomainId id, IFormFile file) + public async Task PutAssetContent(string app, DomainId id, IAssetFile file) { - var command = new UpdateAsset { File = await CheckAssetFileAsync(file), AssetId = id }; + var command = new UpdateAsset { File = file }; var response = await InvokeCommandAsync(command); @@ -440,29 +435,6 @@ private async Task InvokeCommandAsync(ICommand command) } } - private async Task CheckAssetFileAsync(IFormFile? file) - { - if (file == null || Request.Form.Files.Count != 1) - { - var error = T.Get("validation.onlyOneFile"); - - throw new ValidationException(error); - } - - var (plan, _, _) = await usageGate.GetPlanForAppAsync(App, true, HttpContext.RequestAborted); - - var (_, currentSize) = await assetUsageTracker.GetTotalByAppAsync(AppId, HttpContext.RequestAborted); - - if (plan.MaxAssetSize > 0 && plan.MaxAssetSize < currentSize + file.Length) - { - var error = new ValidationError(T.Get("assets.maxSizeReached")); - - throw new ValidationException(error); - } - - return file.ToAssetFile(); - } - private Q CreateQuery(string? ids, string? q) { return Q.Empty diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs index a7900b59a3..61c9b7de9b 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs @@ -17,16 +17,22 @@ namespace Squidex.Areas.Api.Controllers.Assets.Models; [OpenApiRequest] public sealed class CreateAssetDto { + /// + /// The file to upload. + /// + [FromForm(Name = "file2")] + public IFormFile File2 { get; set; } + /// /// The file to upload. /// [FromForm(Name = "file")] - public IFormFile File { get; set; } + public IAssetFile File { get; set; } /// /// The optional parent folder id. /// - [FromQuery(Name = "parentId")] + // [FromQuery(Name = "parentId")] public DomainId ParentId { get; set; } /// @@ -41,9 +47,9 @@ public sealed class CreateAssetDto [FromQuery(Name = "duplicate")] public bool Duplicate { get; set; } - public CreateAsset ToCommand(AssetFile file) + public CreateAsset ToCommand() { - var command = SimpleMapper.Map(this, new CreateAsset { File = file }); + var command = SimpleMapper.Map(this, new CreateAsset()); if (Id != null && Id.Value != default && !string.IsNullOrWhiteSpace(Id.Value.ToString())) { diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs index 57957a982e..67ad3b3959 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs @@ -21,7 +21,7 @@ public sealed class UpsertAssetDto /// The file to upload. /// [FromForm(Name = "file")] - public IFormFile File { get; set; } + public IAssetFile File { get; set; } /// /// The optional parent folder id. @@ -72,8 +72,8 @@ bool TryGetString(string key, out string result) return command; } - public UpsertAsset ToCommand(DomainId id, AssetFile file) + public UpsertAsset ToCommand(DomainId id) { - return SimpleMapper.Map(this, new UpsertAsset { File = file, AssetId = id }); + return SimpleMapper.Map(this, new UpsertAsset { AssetId = id }); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index 88bd4145ed..3a570fc410 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs @@ -86,12 +86,10 @@ public IActionResult GetLogFile(string token) var fileDate = DateTime.UtcNow; var fileName = $"Usage-{fileDate:yyy-MM-dd}.csv"; - var callback = new FileCallback((body, range, ct) => + return new FileCallbackResult("text/csv", (body, range, ct) => { return usageLog.ReadLogAsync(appId, fileDate.AddDays(-30), fileDate, body, ct); - }); - - return new FileCallbackResult("text/csv", callback) + }) { FileDownloadName = fileName }; diff --git a/backend/src/Squidex/Areas/Api/Controllers/Translations/Models/AskDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Translations/Models/AskDto.cs index ca2cd7df1c..91cc34993f 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Translations/Models/AskDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Translations/Models/AskDto.cs @@ -5,22 +5,27 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== -using Squidex.Infrastructure.Validation; -using Squidex.Web; +using Microsoft.AspNetCore.Mvc; namespace Squidex.Areas.Api.Controllers.Translations.Models; -[OpenApiRequest] public sealed class AskDto { /// /// Optional conversation ID. /// + [FromQuery(Name = "conversationId")] public string? ConversationId { get; set; } + /// + /// Optional configuration. + /// + [FromQuery(Name = "configuration")] + public string? Configuration { get; set; } + /// /// The text to ask. /// - [LocalizedRequired] - public string Prompt { get; set; } + [FromQuery(Name = "prompt")] + public string? Prompt { get; set; } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs index 979f96b12c..36cd5de6d6 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs @@ -5,9 +5,13 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using System.Text; +using System.Text.Json; using Microsoft.AspNetCore.Mvc; +using NSwag.Annotations; using Squidex.AI; using Squidex.Areas.Api.Controllers.Translations.Models; +using Squidex.Assets; using Squidex.Infrastructure.Commands; using Squidex.Shared; using Squidex.Text.Translations; @@ -22,16 +26,33 @@ namespace Squidex.Areas.Api.Controllers.Translations; [ApiExplorerSettings(GroupName = nameof(Translations))] public sealed class TranslationsController : ApiController { + private static readonly byte[] LineStart = Encoding.UTF8.GetBytes("data: "); + private static readonly byte[] LineEnd = Encoding.UTF8.GetBytes("\r\r"); + private readonly IAssetStore assetStore; private readonly ITranslator translator; private readonly IChatAgent chatAgent; - public TranslationsController(ICommandBus commandBus, ITranslator translator, IChatAgent chatAgent) + public TranslationsController(ICommandBus commandBus, IAssetStore assetStore, ITranslator translator, IChatAgent chatAgent) : base(commandBus) { + this.assetStore = assetStore; this.translator = translator; this.chatAgent = chatAgent; } + [OpenApiIgnore] + [HttpGet("/ai-images/{*path}")] + public IActionResult GetImage(string path) + { + return new FileCallbackResult("image/webp", async (body, range, ct) => + { + await assetStore.DownloadAsync(path, body, range, ct); + }) + { + ErrorAs404 = true + }; + } + /// /// Translate a text. /// @@ -57,16 +78,52 @@ public async Task PostTranslation(string app, [FromBody] Translat /// The name of the app. /// The question request. /// Question asked. - [HttpPost] + [HttpGet] [Route("apps/{app}/ask/")] - [ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppTranslate)] [ApiCosts(10)] - public async Task PostQuestion(string app, [FromBody] AskDto request) + [OpenApiIgnore] + public IActionResult GetQuestion(string app, AskDto request) { - var result = await chatAgent.PromptAsync(request.Prompt, request.ConversationId, HttpContext.RequestAborted); - var response = new string[] { result.Text }; + var chatRequest = new ChatRequest + { + Configuration = request.Configuration, + ConversationId = request.ConversationId, + Prompt = request.Prompt + }; - return Ok(response); + var context = new ChatContext + { + User = User + }; + + return new FileCallbackResult("text/event-stream", async (body, range, ct) => + { + await foreach (var @event in chatAgent.StreamAsync(chatRequest, context, HttpContext.RequestAborted)) + { + object? json = null; + switch (@event) + { + case ChunkEvent chunk: + json = new { type = "Chunk", content = chunk.Content }; + break; + case ToolStartEvent toolStart: + json = new { type = "ToolStart", tool = toolStart.Tool.Spec.DisplayName }; + break; + case ToolEndEvent toolEnd: + json = new { type = "ToolEnd", tool = toolEnd.Tool.Spec.DisplayName }; + break; + } + + if (json != null) + { + await body.WriteAsync(LineStart, ct); + await JsonSerializer.SerializeAsync(body, json, cancellationToken: ct); + await body.WriteAsync(LineEnd, ct); + + await body.FlushAsync(ct); + } + } + }); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs b/backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs new file mode 100644 index 0000000000..ca25065fdc --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs @@ -0,0 +1,155 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http.Metadata; +using Microsoft.AspNetCore.Mvc; +using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Billing; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Controllers; + +public class UploadModel +{ + /// + /// The file to upload. + /// + [FromForm(Name = "file")] + public IFormFile File { get; set; } + + /// + /// The alternative URL to download from. + /// + [FromForm(Name = "fileUrl")] + public string? FileUrl { get; set; } + + /// + /// The file name if the URL is specified. + /// + [FromForm(Name = "fileName")] + public string? FileName { get; set; } + + public async Task ToFileAsync(HttpContext httpContext) + { + var file = await DownloadFileAsync(httpContext, httpContext.RequestAborted) ?? GetFile(httpContext); + + if (!await IsSizeAllowedAsync(httpContext, file.FileSize, httpContext.RequestAborted)) + { + await file.DisposeAsync(); + throw new ValidationException(T.Get("assets.maxSizeReached")); + } + + return file; + } + + private static IAssetFile GetFile(HttpContext httpContext) + { + var requestFiles = httpContext.Request.Form.Files; + + if (requestFiles.Count != 1) + { + throw new ValidationException(T.Get("validation.onlyOneFile")); + } + + var formFile = requestFiles[0]; + + if (string.IsNullOrWhiteSpace(formFile.ContentType)) + { + throw new ValidationException(T.Get("common.httpContentTypeNotDefined")); + } + + if (string.IsNullOrWhiteSpace(formFile.FileName)) + { + throw new ValidationException(T.Get("common.httpFileNameNotDefined")); + } + + return new DelegateAssetFile( + formFile.FileName, + formFile.ContentType, + formFile.Length, + formFile.OpenReadStream); + } + + private static async Task DownloadFileAsync(HttpContext httpContext, + CancellationToken ct) + { + if (httpContext.Request.Form.Files.Count > 0) + { + return null; + } + + var fileUrl = httpContext.Request.Form["url"].ToString(); + var fileName = httpContext.Request.Form["name"].ToString(); + + if (string.IsNullOrEmpty(fileUrl) || + string.IsNullOrEmpty(fileName)) + { + return null; + } + + var requestSize = httpContext.Features.Get()?.MaxRequestBodySize ?? int.MaxValue; + + try + { + var httpClientFactory = httpContext.RequestServices.GetRequiredService(); + + using var httpClient = httpClientFactory.CreateClient(); + using var httpResponse = await httpClient.GetAsync(fileUrl, ct); + + var length = httpResponse.Content.Headers.ContentLength; + if (length == null || length > requestSize) + { + throw new ValidationException(T.Get("common.httpDownloadRequestSize")); + } + + if (!httpResponse.IsSuccessStatusCode) + { + throw new ValidationException(T.Get("common.httpDownloadFailed")); + } + + await using var httpStream = await httpResponse.Content.ReadAsStreamAsync(ct); + + var tempFile = new TempAssetFile(fileName, httpResponse.Content.Headers.ContentType?.ToString()!); + + await using (var tempStream = tempFile.OpenWrite()) + { + await httpStream.CopyToAsync(tempStream, ct); + } + + return tempFile; + } + catch + { + throw new ValidationException(T.Get("common.httpDownloadFailed")); + } + } + + private static async Task IsSizeAllowedAsync(HttpContext httpContext, long size, + CancellationToken ct) + { + if (httpContext.Features.Get() is not App app) + { + return true; + } + + var usageGate = httpContext.RequestServices.GetRequiredService(); + var (plan, _, _) = await usageGate.GetPlanForAppAsync(app, true, ct); + + if (plan.MaxAssetSize <= 0) + { + return true; + } + + var assetUsage = httpContext.RequestServices.GetRequiredService(); + var (_, currentSize) = await assetUsage.GetTotalByAppAsync(app.Id, ct); + + return plan.MaxAssetSize < currentSize + size; + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 1b11fc1107..7441df1e5f 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -134,7 +134,7 @@ public Task GenerateClientSecret() [HttpPost] [Route("account/profile/upload-picture/")] - public Task UploadPicture(List file) + public Task UploadPicture(IAssetFile file) { return MakeChangeAsync((id, ct) => UpdatePictureAsync(file, id, ct), T.Get("users.profile.uploadPictureDone"), None.Value); @@ -156,15 +156,10 @@ private async Task AddLoginAsync(string id, await userService.AddLoginAsync(id, login, ct); } - private async Task UpdatePictureAsync(List files, string id, + private async Task UpdatePictureAsync(IAssetFile file, string id, CancellationToken ct) { - if (files.Count != 1) - { - throw new ValidationException(T.Get("validation.onlyOneFile")); - } - - await UploadResizedAsync(files[0], id, ct); + await UploadResizedAsync(file, id, ct); var update = new UserValues { @@ -174,10 +169,10 @@ private async Task UpdatePictureAsync(List files, string id, await userService.UpdateAsync(id, update, ct: ct); } - private async Task UploadResizedAsync(IFormFile file, string id, + private async Task UploadResizedAsync(IAssetFile file, string id, CancellationToken ct) { - await using var assetResized = TempAssetFile.Create(file.ToAssetFile()); + await using var assetResized = TempAssetFile.Create(file); var resizeOptions = new ResizeOptions { @@ -187,11 +182,11 @@ private async Task UploadResizedAsync(IFormFile file, string id, try { - await using (var originalStream = file.OpenReadStream()) + await using (var originalStream = file.OpenRead()) { await using (var resizeStream = assetResized.OpenWrite()) { - await assetGenerator.CreateThumbnailAsync(originalStream, file.ContentType, resizeStream, resizeOptions, ct); + await assetGenerator.CreateThumbnailAsync(originalStream, file.MimeType, resizeStream, resizeOptions, ct); } } } diff --git a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs index 72593d7c98..0eab3d6356 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -6,8 +6,9 @@ // ========================================================================== using Microsoft.Extensions.Caching.Memory; -using Microsoft.SemanticKernel; using NodaTime; +using Squidex.AI; +using Squidex.AI.Implementation.OpenAI; using Squidex.Areas.Api.Controllers.Contents.Generator; using Squidex.Areas.Api.Controllers.News; using Squidex.Areas.Api.Controllers.News.Service; @@ -127,22 +128,32 @@ public static void AddSquidexTranslation(this IServiceCollection services, IConf services.Configure(config, "languages"); + services.Configure(config, + "chatbot"); + services.AddSingletonAs() .AsSelf(); - var kernel = services.AddKernel(); + services.AddAI(); - var openAiKey = config["chatBot:openAi:apiKey"]; - var openAiModel = config["chatBot:openAi:model"] ?? "gpt-3.5-turbo-0125"; + var apiKey = config["chatBot:openAi:apiKey"]; - if (!string.IsNullOrWhiteSpace(openAiKey)) + if (!string.IsNullOrWhiteSpace(apiKey)) { - kernel.AddOpenAIChatCompletion(openAiModel, openAiKey); + services.AddOpenAIChat(config); + services.AddDallE(config, options => + { + options.DownloadImage = true; + + if (string.IsNullOrEmpty(options.ApiKey)) + { + options.ApiKey = apiKey; + } + }); } services.AddDeepLTranslations(config); services.AddGoogleCloudTranslations(config); - services.AddOpenAIChatAgent(config); } public static void AddSquidexLocalization(this IServiceCollection services) diff --git a/backend/src/Squidex/Config/Domain/QueryServices.cs b/backend/src/Squidex/Config/Domain/QueryServices.cs index 7fe728f451..686ed21422 100644 --- a/backend/src/Squidex/Config/Domain/QueryServices.cs +++ b/backend/src/Squidex/Config/Domain/QueryServices.cs @@ -5,6 +5,7 @@ // All rights reserved. Licensed under the MIT license. // ========================================================================== +using Squidex.AI.Implementation.OpenAI; using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.ExtractReferenceIds; using Squidex.Domain.Apps.Entities.Contents.GraphQL; @@ -24,7 +25,7 @@ public static void AddSquidexQueries(this IServiceCollection services, IConfigur .AsSelf(); services.AddSingletonAs() - .As(); + .As().As(); services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Config/Domain/StoreServices.cs b/backend/src/Squidex/Config/Domain/StoreServices.cs index 3ea946e3f0..e48f3fc5dc 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -75,8 +75,7 @@ public static void AddSquidexStoreServices(this IServiceCollection services, ICo options.DatabaseName = mongoDatabaseName; }); - services.AddKernel() - .AddMongoChatStore(config, options => + services.AddMongoChatStore(config, options => { options.CollectionName = "Chat"; }); diff --git a/backend/src/Squidex/Config/Messaging/MessagingServices.cs b/backend/src/Squidex/Config/Messaging/MessagingServices.cs index f0a9a7e595..9e8e2cf5d9 100644 --- a/backend/src/Squidex/Config/Messaging/MessagingServices.cs +++ b/backend/src/Squidex/Config/Messaging/MessagingServices.cs @@ -6,6 +6,7 @@ // ========================================================================== using System.Text.Json; +using Squidex.AI; using Squidex.Domain.Apps.Core.Subscriptions; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Assets; @@ -41,6 +42,8 @@ public static void AddSquidexMessaging(this IServiceCollection services, IConfig if (isWorker) { + services.AddAICleaner(); + services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Config/Web/WebServices.cs b/backend/src/Squidex/Config/Web/WebServices.cs index 529683a0b7..b6e8ca66bd 100644 --- a/backend/src/Squidex/Config/Web/WebServices.cs +++ b/backend/src/Squidex/Config/Web/WebServices.cs @@ -52,6 +52,9 @@ public static void AddSquidexMvcWithPlugins(this IServiceCollection services, IC services.AddSingletonAs() .AsSelf(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .As().As(); @@ -90,6 +93,8 @@ public static void AddSquidexMvcWithPlugins(this IServiceCollection services, IC options.Filters.Add(); options.Filters.Add(); + options.ModelBinderProviders.Insert(0, new AssetFileModelBinderProvider()); + // Ingore all values that could have JsonValue somewhere. options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(ContentData))); options.ModelMetadataDetailsProviders.Add(new SuppressChildValidationMetadataProvider(typeof(ContentFieldData))); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 7077259e02..52c11bd70c 100644 --- a/backend/src/Squidex/Squidex.csproj +++ b/backend/src/Squidex/Squidex.csproj @@ -34,26 +34,26 @@ - + - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + - - + + @@ -64,19 +64,19 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + diff --git a/backend/src/Squidex/appsettings.json b/backend/src/Squidex/appsettings.json index da1954a8b3..611f88e9d0 100644 --- a/backend/src/Squidex/appsettings.json +++ b/backend/src/Squidex/appsettings.json @@ -686,6 +686,23 @@ // The chat model. "model": "gpt-3.5-turbo-0125" + }, + + "defaults": { + "systemMessages": [ + "You are a bot to generate text content.", + "Say hello to the user and explain him about your capabilities in a single, short sentence." + ], + "tools": [] + }, + + "configurations": { + "image": { + "systemMessages": [ + "You are a bot to generate images.", + "Say hello to the user and explain him the user about your capabilities in a single, short sentence." + ] + } } }, diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs index f38551149a..c3b41da069 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs @@ -619,8 +619,11 @@ public async Task Should_make_putJson_request() [Fact] public async Task Should_generate_content() { - A.CallTo(() => chatAgent.PromptAsync("prompt", A._, A._)) - .Returns(ChatBotResponse.Success("Generated")); + A.CallTo(() => chatAgent.PromptAsync( + A.That.Matches(x => x.Prompt == "prompt"), + A._, + A._)) + .Returns(new ChatResult { Content = "Generated", Metadata = new ChatMetadata() }); var vars = new ScriptVars { @@ -660,7 +663,7 @@ public async Task Should_return_null_string_on_generate_if_prompt_is_invalid(str Assert.Equal(JsonValue.Null, actual); - A.CallTo(() => chatAgent.PromptAsync(A._, A._, A._)) + A.CallTo(() => chatAgent.PromptAsync(A._, A._, A._)) .MustNotHaveHappened(); A.CallTo(() => chatAgent.StopConversationAsync(A._, A._)) diff --git a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj index 484c8801bf..6a77f405e3 100644 --- a/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj @@ -16,19 +16,19 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs index f6c92fbe09..3015bb94fe 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs @@ -20,11 +20,11 @@ public class AssetCommandMiddlewareTests : HandlerTestBase private readonly IDomainObjectCache domainObjectCache = A.Fake(); private readonly IDomainObjectFactory domainObjectFactory = A.Fake(); private readonly IAssetEnricher assetEnricher = A.Fake(); + private readonly IAssetFile file = new NoopAssetFile(); private readonly IAssetFileStore assetFileStore = A.Fake(); private readonly IAssetMetadataSource assetMetadataSource = A.Fake(); private readonly IAssetQueryService assetQuery = A.Fake(); private readonly DomainId assetId = DomainId.NewGuid(); - private readonly AssetFile file = new NoopAssetFile(); private readonly AssetCommandMiddleware sut; public sealed class MyCommand : SquidexCommand @@ -38,8 +38,6 @@ protected override DomainId Id public AssetCommandMiddlewareTests() { - file = new NoopAssetFile(); - A.CallTo(() => assetQuery.FindByHashAsync(A._, A._, A._, A._, CancellationToken)) .Returns(Task.FromResult(null)); diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs index 31ee196edf..fcade70c4d 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs @@ -22,13 +22,13 @@ namespace Squidex.Domain.Apps.Entities.Assets.DomainObject; public class AssetDomainObjectTests : HandlerTestBase { + private readonly IAssetFile file = new NoopAssetFile(); private readonly IAssetQueryService assetQuery = A.Fake(); private readonly IContentRepository contentRepository = A.Fake(); private readonly IScriptEngine scriptEngine = A.Fake(); private readonly ITagService tagService = A.Fake(); private readonly DomainId parentId = DomainId.NewGuid(); private readonly DomainId assetId = DomainId.NewGuid(); - private readonly AssetFile file = new NoopAssetFile(); private readonly AssetDomainObject sut; protected override DomainId Id diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs index 91ba6e42f4..8ce3607023 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs @@ -15,8 +15,8 @@ namespace Squidex.Domain.Apps.Entities.Assets; public class ImageAssetMetadataSourceTests : GivenContext { private readonly IAssetThumbnailGenerator assetGenerator = A.Fake(); + private readonly IAssetFile file; private readonly MemoryStream stream = new MemoryStream(); - private readonly AssetFile file; private readonly ImageAssetMetadataSource sut; public ImageAssetMetadataSourceTests() diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj index 0920d5f46e..58362d2306 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj @@ -27,21 +27,21 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - + - - - + + + all runtime; build; native; contentfiles; analyzers diff --git a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs index 0b25331b16..d3e56d5022 100644 --- a/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs +++ b/backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs @@ -9,15 +9,10 @@ namespace Squidex.Domain.Apps.Entities.TestHelpers; -public sealed class NoopAssetFile : AssetFile +public sealed class NoopAssetFile : DelegateAssetFile { public NoopAssetFile(string fileName = "image.png", string mimeType = "image/png", long fileSize = 1024) - : base(fileName, mimeType, fileSize) + : base(fileName, mimeType, fileSize, () => new MemoryStream()) { } - - public override Stream OpenRead() - { - return new MemoryStream(); - } } diff --git a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj index e99008a03a..b1c8015d08 100644 --- a/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj +++ b/backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj @@ -16,15 +16,15 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj index 3dceddd05a..ad18c519de 100644 --- a/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj +++ b/backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj @@ -16,7 +16,7 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -24,12 +24,12 @@ - + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj index ff0206d720..1192247575 100644 --- a/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj +++ b/backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj @@ -16,14 +16,14 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - + + all runtime; build; native; contentfiles; analyzers diff --git a/frontend/src/app/features/administration/services/users.service.ts b/frontend/src/app/features/administration/services/users.service.ts index d565f5b103..24596bb0fb 100644 --- a/frontend/src/app/features/administration/services/users.service.ts +++ b/frontend/src/app/features/administration/services/users.service.ts @@ -9,7 +9,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks } from '@app/shared'; +import { ApiUrlConfig, hasAnyLink, pretifyError, Resource, ResourceLinks, StringHelper } from '@app/shared'; export class UserDto implements Resource { public readonly _links: ResourceLinks; @@ -69,7 +69,7 @@ export class UsersService { } public getUsers(take: number, skip: number, query?: string): Observable { - const url = this.apiUrl.buildUrl(`api/user-management?take=${take}&skip=${skip}&query=${query || ''}`); + const url = this.apiUrl.buildUrl(`api/user-management${StringHelper.buildQuery({ take, skip, query })}`); return this.http.get(url).pipe( map(body => { diff --git a/frontend/src/app/features/content/shared/forms/assets-editor.component.html b/frontend/src/app/features/content/shared/forms/assets-editor.component.html index ccbe4320d8..4c812a0632 100644 --- a/frontend/src/app/features/content/shared/forms/assets-editor.component.html +++ b/frontend/src/app/features/content/shared/forms/assets-editor.component.html @@ -10,6 +10,11 @@ {{ 'contents.assetsUpload' | sqxTranslate }} +
+ +
@@ -60,7 +60,12 @@ - + + @@ -287,6 +292,6 @@

{{field.displayName}}

- + \ No newline at end of file diff --git a/frontend/src/app/features/content/shared/forms/field-editor.component.ts b/frontend/src/app/features/content/shared/forms/field-editor.component.ts index 7df95c5a0f..590c9a642f 100644 --- a/frontend/src/app/features/content/shared/forms/field-editor.component.ts +++ b/frontend/src/app/features/content/shared/forms/field-editor.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe, NgFor, NgIf, NgSwitch, NgSwitchCase } from '@angular/common' import { booleanAttribute, Component, ElementRef, EventEmitter, Input, numberAttribute, Output, ViewChild } from '@angular/core'; import { AbstractControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { Observable } from 'rxjs'; -import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FieldDto, FormHintComponent, GeolocationEditorComponent, hasNoValue$, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; +import { AbstractContentForm, AnnotationCreate, AnnotationsSelect, AppLanguageDto, ChatDialogComponent, CheckboxGroupComponent, CodeEditorComponent, ColorPickerComponent, CommentsState, ConfirmClickDirective, ControlErrorsComponent, DateTimeEditorComponent, DialogModel, disabled$, EditContentForm, FieldDto, FormHintComponent, GeolocationEditorComponent, hasNoValue$, HTTP, IndeterminateValueDirective, MarkdownDirective, MathHelper, MessageBus, ModalDirective, RadioGroupComponent, ReferenceInputComponent, RichEditorComponent, StarsComponent, TagEditorComponent, ToggleComponent, TooltipDirective, TransformInputDirective, TypedSimpleChanges, Types } from '@app/shared'; import { ReferenceDropdownComponent } from '../references/reference-dropdown.component'; import { ReferencesCheckboxesComponent } from '../references/references-checkboxes.component'; import { ReferencesEditorComponent } from '../references/references-editor.component'; @@ -122,6 +122,10 @@ export class FieldEditorComponent { return this.formModel.form; } + public get isString() { + return this.field?.properties.fieldType === 'String'; + } + constructor( private readonly messageBus: MessageBus, ) { @@ -174,11 +178,11 @@ export class FieldEditorComponent { this.formModel.unset(); } - public setValue(value: any) { - if (value) { - this.formModel.setValue(value); - } - + public setValue(content: string | HTTP.UploadFile | null | undefined) { this.chatDialog.hide(); + + if (Types.isString(content)) { + this.formModel.setValue(content); + } } } diff --git a/frontend/src/app/framework/angular/drag-helper.ts b/frontend/src/app/framework/angular/drag-helper.ts index f294ede49c..6c1cc9417c 100644 --- a/frontend/src/app/framework/angular/drag-helper.ts +++ b/frontend/src/app/framework/angular/drag-helper.ts @@ -7,6 +7,7 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'; import { Types } from '../utils/types'; +import { HTTP } from './http/http-extensions'; export function sorted(event: CdkDragDrop>): T[] { const items = event.container.data; @@ -16,12 +17,12 @@ export function sorted(event: CdkDragDrop>): T[] { return items; } -export function getFiles(files: FileList | ReadonlyArray) { +export function getFiles(files: FileList | ReadonlyArray) { if (Types.isArray(files)) { return files; } - const result: File[] = []; + const result: HTTP.UploadFile[] = []; for (let i = 0; i < files.length; i++) { result.push(files[i]); diff --git a/frontend/src/app/framework/angular/http/http-extensions.ts b/frontend/src/app/framework/angular/http/http-extensions.ts index c91fa3f97f..c9dcb8e7e3 100644 --- a/frontend/src/app/framework/angular/http/http-extensions.ts +++ b/frontend/src/app/framework/angular/http/http-extensions.ts @@ -12,7 +12,9 @@ import { catchError, map, Observable, throwError } from 'rxjs'; import { ErrorDto, Types, Version, Versioned } from '@app/framework/internal'; export module HTTP { - export function upload(http: HttpClient, method: string, url: string, file: Blob, version?: Version): Observable> { + export type UploadFile = File | { url: string; name: string }; + + export function upload(http: HttpClient, method: string, url: string, file: UploadFile, version?: Version): Observable> { const req = new HttpRequest(method, url, getFormData(file), { headers: createHeaders(version, undefined), reportProgress: true }); return http.request(req); @@ -60,10 +62,15 @@ export module HTTP { return handleVersion(http.request(method, url, { observe: 'response', headers, body })); } - function getFormData(file: Blob) { + function getFormData(file: UploadFile) { const formData = new FormData(); - formData.append('file', file); + if (file instanceof File) { + formData.append('file', file); + } else { + formData.append('url', file.url); + formData.append('name', file.name); + } return formData; } diff --git a/frontend/src/app/framework/angular/image-source.directive.ts b/frontend/src/app/framework/angular/image-source.directive.ts index 2622891240..6e91eb67af 100644 --- a/frontend/src/app/framework/angular/image-source.directive.ts +++ b/frontend/src/app/framework/angular/image-source.directive.ts @@ -121,15 +121,17 @@ export class ImageSourceDirective implements OnDestroy, OnInit, AfterViewInit { const h = Math.round(this.size.height); if (w > 0 && h > 0) { - let source = this.imageSource; + const query = StringHelper.buildQuery({ + width: w, + height: h, + mode: 'Pad', + nofocus: 'nofocus', + q: this.loadQuery, + }); - source = StringHelper.appendToUrl(source, `width=${w}&height=${h}&mode=Pad&nofocus`); + const url = this.imageSource + query; - if (this.loadQuery) { - source = StringHelper.appendToUrl(source, 'q', this.loadQuery); - } - - this.renderer.setProperty(this.element.nativeElement, 'src', source); + this.renderer.setProperty(this.element.nativeElement, 'src', url); } } diff --git a/frontend/src/app/framework/utils/string-helper.ts b/frontend/src/app/framework/utils/string-helper.ts index f96c36ba1d..8a0354f465 100644 --- a/frontend/src/app/framework/utils/string-helper.ts +++ b/frontend/src/app/framework/utils/string-helper.ts @@ -20,20 +20,28 @@ export module StringHelper { return ''; } - export function appendToUrl(url: string, key: string, value?: any, ambersand = false) { - if (url.includes('?') || ambersand) { - url += '&'; - } else { - url += '?'; - } + export function buildQuery(values: Record) { + let query = ''; - if (value !== undefined) { - url += `${key}=${value}`; - } else { - url += key; + for (const [key, value] of Object.entries(values)) { + if (value === null || value === undefined) { + continue; + } + + if (query.includes('?')) { + query += '&'; + } else { + query += '?'; + } + + if (value === key) { + query += key; + } else { + query += `${key}=${encodeURIComponent(value)}`; + } } - return url; + return query; } export function appendLast(row: string, char: string) { diff --git a/frontend/src/app/shared/components/assets/asset-dialog.component.ts b/frontend/src/app/shared/components/assets/asset-dialog.component.ts index f3cfa59653..a63e9cb1de 100644 --- a/frontend/src/app/shared/components/assets/asset-dialog.component.ts +++ b/frontend/src/app/shared/components/assets/asset-dialog.component.ts @@ -12,7 +12,7 @@ import { RouterLink } from '@angular/router'; import { NgxDocViewerModule } from 'ngx-doc-viewer'; import { BehaviorSubject, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ConfirmClickDirective, ControlErrorsComponent, CopyDirective, DialogService, FormErrorComponent, FormHintComponent, ModalDialogComponent, ProgressBarComponent, switchMapCached, TagEditorComponent, TooltipDirective, TransformInputDirective, TranslatePipe, Types, VideoPlayerComponent } from '@app/framework'; +import { ConfirmClickDirective, ControlErrorsComponent, CopyDirective, DialogService, FormErrorComponent, FormHintComponent, HTTP, ModalDialogComponent, ProgressBarComponent, switchMapCached, TagEditorComponent, TooltipDirective, TransformInputDirective, TranslatePipe, Types, VideoPlayerComponent } from '@app/framework'; import { AnnotateAssetDto, AnnotateAssetForm, AppsState, AssetDto, AssetPathItem, AssetsService, AssetsState, AssetUploaderState, AuthService, MoveAssetForm, MoveAssetItemDto, ROOT_ITEM, UploadCanceled } from '@app/shared/internal'; import { AssetFolderDropdownComponent } from './asset-folder-dropdown.component'; import { AssetHistoryComponent } from './asset-history.component'; @@ -184,7 +184,7 @@ export class AssetDialogComponent implements OnInit { this.uploadEdited(this.textEditor.first.toFile()); } - public uploadEdited(fileChange: Promise) { + public uploadEdited(fileChange: Promise) { fileChange.then(file => { if (file) { this.setProgress(0); diff --git a/frontend/src/app/shared/components/assets/asset-text-editor.component.ts b/frontend/src/app/shared/components/assets/asset-text-editor.component.ts index 89a766c50c..9fd6ad7d8d 100644 --- a/frontend/src/app/shared/components/assets/asset-text-editor.component.ts +++ b/frontend/src/app/shared/components/assets/asset-text-editor.component.ts @@ -48,13 +48,17 @@ export class AssetTextEditorComponent implements OnInit { }); } - public toFile(): Promise { - return new Promise(resolve => { + public toFile(): Promise { + return new Promise(resolve => { const blob = new Blob([this.text || ''], { type: this.mimeType, }); - resolve(blob); + const file = new File([blob], 'content.txt', { + type: this.mimeType, + }); + + resolve(file); }); } } diff --git a/frontend/src/app/shared/components/assets/asset-uploader.component.ts b/frontend/src/app/shared/components/assets/asset-uploader.component.ts index 76d75c29a6..0b8d6360af 100644 --- a/frontend/src/app/shared/components/assets/asset-uploader.component.ts +++ b/frontend/src/app/shared/components/assets/asset-uploader.component.ts @@ -7,7 +7,7 @@ import { AsyncPipe, NgFor, NgIf, NgSwitch, NgSwitchCase, NgSwitchDefault } from '@angular/common'; import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { DropdownMenuComponent, FileDropDirective, ModalDirective, ModalPlacementDirective, ProgressBarComponent, TranslatePipe } from '@app/framework'; +import { DropdownMenuComponent, FileDropDirective, HTTP, ModalDirective, ModalPlacementDirective, ProgressBarComponent, TranslatePipe } from '@app/framework'; import { AppsState, AssetsState, AssetUploaderState, ModalModel, Types, Upload } from '@app/shared/internal'; @Component({ @@ -41,7 +41,7 @@ export class AssetUploaderComponent { ) { } - public addFiles(files: ReadonlyArray) { + public addFiles(files: ReadonlyArray) { for (const file of files) { this.assetUploader.uploadFile(file) .subscribe({ diff --git a/frontend/src/app/shared/components/assets/asset.component.ts b/frontend/src/app/shared/components/assets/asset.component.ts index c350d58aaf..00d31f10a7 100644 --- a/frontend/src/app/shared/components/assets/asset.component.ts +++ b/frontend/src/app/shared/components/assets/asset.component.ts @@ -7,7 +7,7 @@ import { NgFor, NgIf } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, HostBinding, Input, OnInit, Output } from '@angular/core'; -import { ConfirmClickDirective, ExternalLinkDirective, FileDropDirective, FromNowPipe, ImageSourceDirective, ProgressBarComponent, StopClickDirective, TooltipDirective, TranslatePipe } from '@app/framework'; +import { ConfirmClickDirective, ExternalLinkDirective, FileDropDirective, FromNowPipe, HTTP, ImageSourceDirective, ProgressBarComponent, StopClickDirective, TooltipDirective, TranslatePipe } from '@app/framework'; import { AssetDto, AssetUploaderState, DialogService, StatefulComponent, Types, UploadCanceled } from '@app/shared/internal'; import { UserNameRefPipe, UserPictureRefPipe } from '../pipes'; import { AssetPreviewUrlPipe, AssetUrlPipe, FileIconPipe } from './pipes'; @@ -65,7 +65,7 @@ export class AssetComponent extends StatefulComponent implements OnInit { public selectFolder = new EventEmitter(); @Input() - public assetFile?: File; + public assetFile?: HTTP.UploadFile; @Input() public asset?: AssetDto; @@ -127,7 +127,7 @@ export class AssetComponent extends StatefulComponent implements OnInit { } } - public updateFile(files: ReadonlyArray) { + public updateFile(files: ReadonlyArray) { const asset = this.asset; if (files.length === 1 && asset?.canUpload) { diff --git a/frontend/src/app/shared/components/assets/assets-list.component.ts b/frontend/src/app/shared/components/assets/assets-list.component.ts index 6e0f12e78c..eb5207be00 100644 --- a/frontend/src/app/shared/components/assets/assets-list.component.ts +++ b/frontend/src/app/shared/components/assets/assets-list.component.ts @@ -8,14 +8,14 @@ import { CdkDrag, CdkDragDrop, CdkDropList, CdkDropListGroup } from '@angular/cdk/drag-drop'; import { AsyncPipe, NgFor, NgIf } from '@angular/common'; import { booleanAttribute, ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { FileDropDirective, TourStepDirective, TranslatePipe } from '@app/framework'; +import { FileDropDirective, HTTP, TourStepDirective, TranslatePipe } from '@app/framework'; import { AssetDto, AssetFolderDto, AssetsState, getFiles, StatefulComponent, Types } from '@app/shared/internal'; import { AssetFolderComponent } from './asset-folder.component'; import { AssetComponent } from './asset.component'; interface State { // The new files. - newFiles: ReadonlyArray; + newFiles: ReadonlyArray; } @Component({ @@ -64,7 +64,7 @@ export class AssetsListComponent extends StatefulComponent { super({ newFiles: [] }); } - public add(file: File, asset: AssetDto) { + public add(file: HTTP.UploadFile, asset: AssetDto) { if (asset.isDuplicate) { setTimeout(() => { this.remove(file); @@ -108,7 +108,7 @@ export class AssetsListComponent extends StatefulComponent { return this.selectedIds && this.selectedIds[asset.id]; } - public remove(file: File) { + public remove(file: HTTP.UploadFile) { this.next(s => ({ ...s, newFiles: s.newFiles.removed(file), @@ -117,7 +117,7 @@ export class AssetsListComponent extends StatefulComponent { return true; } - public addFiles(files: ReadonlyArray) { + public addFiles(files: ReadonlyArray) { this.next(s => ({ ...s, newFiles: [...getFiles(files), ...s.newFiles], diff --git a/frontend/src/app/shared/components/assets/image-cropper.component.ts b/frontend/src/app/shared/components/assets/image-cropper.component.ts index 900bb543d3..bb8ef1c818 100644 --- a/frontend/src/app/shared/components/assets/image-cropper.component.ts +++ b/frontend/src/app/shared/components/assets/image-cropper.component.ts @@ -115,8 +115,8 @@ export class ImageCropperComponent implements AfterViewInit, OnDestroy { } } - public toFile(): Promise { - return new Promise(resolve => { + public async toFile(): Promise { + return new Promise(resolve => { if (!this.cropper) { return resolve(null); } else { @@ -128,8 +128,14 @@ export class ImageCropperComponent implements AfterViewInit, OnDestroy { this.data = data; this.cropper.getCroppedCanvas().toBlob(blob => { - resolve(blob); - }); + if (blob) { + const file = new File([blob], 'image.png', { type: 'image.png' }); + + resolve(file); + } else { + resolve(null); + } + }, 'image.png'); } } diff --git a/frontend/src/app/shared/components/assets/pipes.ts b/frontend/src/app/shared/components/assets/pipes.ts index dc1b1fc11b..ba67c8ec37 100644 --- a/frontend/src/app/shared/components/assets/pipes.ts +++ b/frontend/src/app/shared/components/assets/pipes.ts @@ -21,19 +21,20 @@ export class AssetUrlPipe implements PipeTransform { } public transform(asset: AssetDto, version?: number | Version, withQuery = false): string { - let url = asset.fullUrl(this.apiUrl); + const url = asset.fullUrl(this.apiUrl); + const query: Record = {}; if (withQuery) { - url = StringHelper.appendToUrl(url, 'sq', MathHelper.guid()); + query['sq'] = MathHelper.guid(); } if (Types.isNumber(version)) { - url = StringHelper.appendToUrl(url, 'version', version); + query['version'] = version; } else if (Types.is(version, Version)) { - url = StringHelper.appendToUrl(url, 'version', version.value); + query['version'] = version.value; } - return url; + return url + StringHelper.buildQuery(query); } } @@ -50,11 +51,17 @@ export class AssetPreviewUrlPipe implements PipeTransform { } public transform(asset: AssetDto): string { - let url = asset.fullUrl(this.apiUrl, this.authService); + const url = asset.fullUrl(this.apiUrl); - url = StringHelper.appendToUrl(url, 'version', asset.version); + const query: Record = { + version: asset.version, + }; - return url; + if (this.authService.user) { + query['access_token'] = this.authService.user.accessToken; + } + + return url + StringHelper.buildQuery(query); } } diff --git a/frontend/src/app/shared/components/chat-dialog.component.html b/frontend/src/app/shared/components/chat-dialog.component.html index 8b43ff0130..fd732c8528 100644 --- a/frontend/src/app/shared/components/chat-dialog.component.html +++ b/frontend/src/app/shared/components/chat-dialog.component.html @@ -1,97 +1,20 @@ - + {{ 'chat.title' | sqxTranslate }}
-
-
-
-
-
- -
-
-
-
-

{{ 'chat.description' | sqxTranslate }}

-

{{ 'chat.describeFormat' | sqxTranslate }}

-
-
-
-
- -
-
-
-
- {{item.text}} -
-
-
- -
-
- -
-
-
- -
-
-
-
- {{ item.text | sqxTranslate}} -
-
-
- -
-
-
- -
-
-
-
-
- {{ 'chat.answer' | sqxTranslate}} -
- - - - -
-
-
-
- -
-
-
- -
-
-
-
- - - - - -
-
-
-
+ +
diff --git a/frontend/src/app/shared/components/chat-dialog.component.scss b/frontend/src/app/shared/components/chat-dialog.component.scss index e63b13b111..2e9aca5537 100644 --- a/frontend/src/app/shared/components/chat-dialog.component.scss +++ b/frontend/src/app/shared/components/chat-dialog.component.scss @@ -28,65 +28,4 @@ textarea { overflow-x: hidden; overflow-y: auto; padding: 1.5rem; -} - -.bubble { - background-color: $color-white; - border: 0; - border-radius: $border-radius; - padding: 1rem; - position: relative; - - &-right { - &::before { - @include caret-left($color-white, 10px); - @include absolute(.5rem, null, null, -18px); - } - } - - &-left { - &::before { - @include caret-right($color-white, 10px); - @include absolute(.5rem, -18px); - } - } -} - -.use-container { - position: relative; - - .btn { - @include absolute(1rem, 1rem); - visibility: hidden; - } - - &:hover { - .btn { - visibility: visible; - } - } -} - -@keyframes blink { - 50% { - fill: transparent - } -} - -.dot { - animation: 1s blink infinite; -} - -svg { - .dot { - fill: $color-border; - } -} - -.dot:nth-child(2) { - animation-delay: 250ms; -} - -.dot:nth-child(3) { - animation-delay: 500ms; } \ No newline at end of file diff --git a/frontend/src/app/shared/components/chat-dialog.component.ts b/frontend/src/app/shared/components/chat-dialog.component.ts index f39e15bc8d..f52bfbf5c7 100644 --- a/frontend/src/app/shared/components/chat-dialog.component.ts +++ b/frontend/src/app/shared/components/chat-dialog.component.ts @@ -6,22 +6,22 @@ */ import { NgFor, NgIf } from '@angular/common'; -import { booleanAttribute, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { delay } from 'rxjs/operators'; -import { FocusOnInitDirective, MarkdownDirective, MathHelper, ModalDialogComponent, ResizedDirective, ScrollActiveDirective, TooltipDirective, TranslatePipe } from '@app/framework'; -import { AppsState, AuthService, StatefulComponent, TranslationsService } from '@app/shared/internal'; -import { UserIdPicturePipe } from './pipes'; +import { Observable } from 'rxjs'; +import { HTTP, MathHelper, ModalDialogComponent, ResizedDirective, TooltipDirective, TranslatePipe } from '@app/framework'; +import { AppsState, AuthService, ChatEventDto, StatefulComponent, TranslationsService } from '@app/shared/internal'; +import { ChatItemComponent } from './chat-item.component'; interface State { - // True, when running - isRunning: boolean; - // The questions. chatQuestion: string; + // Indicates if an item is running. + isRunning: boolean; + // The answers. - chatTalk: ReadonlyArray<{ text: string; type: 'user' | 'bot' | 'system' }>; + chatItems: ReadonlyArray<{ content: string | Observable; type: 'User' | 'Bot' | 'System' }>; } @Component({ @@ -30,27 +30,27 @@ interface State { styleUrls: ['./chat-dialog.component.scss'], templateUrl: './chat-dialog.component.html', imports: [ - FocusOnInitDirective, + ChatItemComponent, FormsModule, - MarkdownDirective, ModalDialogComponent, NgFor, NgIf, ResizedDirective, - ScrollActiveDirective, TooltipDirective, TranslatePipe, - UserIdPicturePipe, ], }) export class ChatDialogComponent extends StatefulComponent { private readonly conversationId = MathHelper.guid(); @Output() - public textSelect = new EventEmitter(); + public contentSelect = new EventEmitter(); + + @Input() + public configuration?: string; - @Input({ required: true, transform: booleanAttribute }) - public showFormatHint = false; + @Input() + public copyMode?: 'Text' | 'Image'; @ViewChild('input', { static: false }) public input!: ElementRef; @@ -63,16 +63,39 @@ export class ChatDialogComponent extends StatefulComponent { private readonly translator: TranslationsService, ) { super({ - isRunning: false, chatQuestion: '', - chatTalk: [], + chatItems: [{ + type: 'Bot', + content: 'HELLO', + }, + { + type: 'Bot', + content: 'IMAGE: ![image](https://localhost:5001/ai-images/dall-e/4043c8c4-c05e-4212-b4c0-4653059c06d3)', + }], + isRunning: false, }); } + public ngOnInit() { + const { configuration, conversationId } = this; + const stream = this.translator.ask(this.appsState.appName, { conversationId, configuration }); + + this.next(s => ({ + ...s, + chatQuestion: '', + chatItems: [...s.chatItems, { content: stream, type: 'Bot' }], + isRunning: true, + })); + } + public setQuestion(chatQuestion: string) { this.next({ chatQuestion }); } + public setCompleted() { + this.next({ isRunning: false }); + } + public ask() { const prompt = this.snapshot.chatQuestion; @@ -80,50 +103,18 @@ export class ChatDialogComponent extends StatefulComponent { return; } + const { configuration, conversationId } = this; + const stream = this.translator.ask(this.appsState.appName, { prompt, conversationId, configuration }); + this.next(s => ({ ...s, chatQuestion: '', - chatTalk: [ - ...s.chatTalk, - { text: prompt, type: 'user' }, + chatItems: [ + ...s.chatItems, + { content: prompt, type: 'User' }, + { content: stream, type: 'Bot' }, ], isRunning: true, })); - - this.translator.ask(this.appsState.appName, { prompt, conversationId: this.conversationId }).pipe(delay(500)) - .subscribe({ - next: chatAnswers => { - if (chatAnswers.length === 0) { - this.next(s => ({ - ...s, - chatQuestion: '', - chatTalk: [ - ...s.chatTalk, - { text: 'i18n:chat.answersEmpty', type: 'system' }, - ], - isRunning: true, - })); - } else { - this.next(s => ({ - ...s, - chatTalk: [ - ...s.chatTalk, - ...chatAnswers.map(text => ({ text, type: 'bot' } as any)), - ], - isRunning: false, - })); - } - - setTimeout(() => { - this.input.nativeElement.focus(); - }, 100); - }, - error: () => { - this.next({ isRunning: false }); - }, - complete: () => { - this.next({ isRunning: false }); - }, - }); } } diff --git a/frontend/src/app/shared/components/chat-item.component.html b/frontend/src/app/shared/components/chat-item.component.html new file mode 100644 index 0000000000..d8984e21ab --- /dev/null +++ b/frontend/src/app/shared/components/chat-item.component.html @@ -0,0 +1,66 @@ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ {{ content | sqxTranslate}} +
+
+
+ +
+
+
+ +
+
+
+
+
+
+
+ {{tool}} +
+
+ + + + + {{ 'chat.failed' | sqxTranslate }} + + + + + + + +
+ + + + + + +
+
+
+ +
\ No newline at end of file diff --git a/frontend/src/app/shared/components/chat-item.component.scss b/frontend/src/app/shared/components/chat-item.component.scss new file mode 100644 index 0000000000..8b632e1d72 --- /dev/null +++ b/frontend/src/app/shared/components/chat-item.component.scss @@ -0,0 +1,82 @@ +@import 'mixins'; +@import 'vars'; + +:host ::ng-deep { + img { + width: 100%; + } +} + +.bubble { + background-color: $color-white; + border: 0; + border-radius: $border-radius; + padding: 1rem; + position: relative; + + &-right { + &::before { + @include caret-left($color-white, 10px); + @include absolute(.5rem, null, null, -18px); + } + } + + &-left { + &::before { + @include caret-right($color-white, 10px); + @include absolute(.5rem, -18px); + } + } +} + +.content { + .btn-image { + display: none; + } + + &:has(img) { + .btn-image { + display: block; + } + } +} + +.use-container { + position: relative; + + .btn { + @include absolute(.75rem, 1rem); + visibility: hidden; + } + + &:hover { + .btn { + visibility: visible; + } + } +} + +@keyframes blink { + 50% { + fill: transparent + } +} + +.dot { + animation: 1s blink infinite; +} + +svg { + + .dot { + fill: $color-border; + } +} + +.dot:nth-child(2) { + animation-delay: 250ms; +} + +.dot:nth-child(3) { + animation-delay: 500ms; +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/chat-item.component.ts b/frontend/src/app/shared/components/chat-item.component.ts new file mode 100644 index 0000000000..9194c04682 --- /dev/null +++ b/frontend/src/app/shared/components/chat-item.component.ts @@ -0,0 +1,141 @@ +/* + * Squidex Headless CMS + * + * @license + * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved. + */ + +import { NgFor, NgIf } from '@angular/common'; +import { ChangeDetectionStrategy, Component, ElementRef, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { Observable } from 'rxjs'; +import { HTTP, MarkdownDirective, StatefulComponent, TranslatePipe, Types } from '@app/framework'; +import { ChatEventDto, Profile } from '../internal'; +import { UserIdPicturePipe } from './pipes'; + +interface State { + // True, when running + isRunning: boolean; + + // True, when failed + isFailed: boolean; + + // The content. + content: string; + + // The running tools. + runningTools: string[]; +} + +@Component({ + standalone: true, + selector: 'sqx-chat-item', + styleUrls: ['./chat-item.component.scss'], + templateUrl: './chat-item.component.html', + imports: [ + MarkdownDirective, + NgFor, + NgIf, + TranslatePipe, + UserIdPicturePipe, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ChatItemComponent extends StatefulComponent { + @ViewChild('focusElement', { static: false }) + public focusElement!: ElementRef; + + @ViewChild('contentElement', { static: false }) + public contentElement!: ElementRef; + + @Input({ required: true }) + public type: 'Bot' | 'User' | 'System' = 'Bot'; + + @Input({ required: true }) + public user!: Profile; + + @Input({ required: true }) + public isLast: boolean = false; + + @Input({ required: true }) + public isFirst: boolean = false; + + @Input({ required: true }) + public copyMode?: 'Text' | 'Image'; + + @Input({ required: true }) + public set content(value: string | Observable) { + if (Types.isString(value)) { + this.next({ content: value }); + } else { + this.next({ isRunning: true }); + + value.subscribe({ + next: event => { + if (event.type === 'Chunk') { + this.next(s => ({ + ...s, + content: s.content + event.content, + })); + } else if (event.type === 'ToolStart') { + this.next(s => ({ + ...s, + runningTools: [...s.runningTools, event.tool], + })); + } + }, + error: () => { + this.next({ isRunning: false, isFailed: true }); + this.done.emit(); + }, + complete: () => { + this.next(s => ({ + ...s, + isRunning: false, + isFailed: !s.content, + })); + + this.done.emit(); + }, + }); + } + } + + @Output() + public done = new EventEmitter(); + + @Output() + public contentSelect = new EventEmitter(); + + constructor() { + super({ + content: '', + isFailed: false, + isRunning: false, + runningTools: [], + }); + + this.changes.subscribe(() => { + this.focusElement.nativeElement?.scrollIntoView(); + }); + } + + public scrollIntoView() { + this.focusElement.nativeElement?.scrollIntoView(); + } + + public selectContent() { + this.contentSelect.emit(this.snapshot.content); + } + + public selectImage() { + const image = this.contentElement.nativeElement?.querySelector('img'); + + if (!image) { + return; + } + + const name = image.alt || 'image.webp'; + + this.contentSelect.emit({ url: image.src, name }); + } +} \ No newline at end of file diff --git a/frontend/src/app/shared/components/forms/geolocation-editor.component.ts b/frontend/src/app/shared/components/forms/geolocation-editor.component.ts index 46ecd20852..30fd81457e 100644 --- a/frontend/src/app/shared/components/forms/geolocation-editor.component.ts +++ b/frontend/src/app/shared/components/forms/geolocation-editor.component.ts @@ -8,7 +8,7 @@ import { NgIf } from '@angular/common'; import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, forwardRef, inject, Input, ViewChild } from '@angular/core'; import { FormsModule, NG_VALUE_ACCESSOR, ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; -import { ControlErrorsComponent, ResizedDirective, TooltipDirective, TranslatePipe } from '@app/framework'; +import { ControlErrorsComponent, ResizedDirective, StringHelper, TooltipDirective, TranslatePipe } from '@app/framework'; import { ExtendedFormGroup, LocalStoreService, ResourceLoaderService, Settings, StatefulControlComponent, Types, UIOptions, ValidatorsEx } from '@app/shared/internal'; declare const L: any; @@ -228,8 +228,8 @@ export class GeolocationEditorComponent extends StatefulControlComponent - + \ No newline at end of file diff --git a/frontend/src/app/shared/components/forms/rich-editor.component.ts b/frontend/src/app/shared/components/forms/rich-editor.component.ts index a5cf8f0268..dbaf4e8abe 100644 --- a/frontend/src/app/shared/components/forms/rich-editor.component.ts +++ b/frontend/src/app/shared/components/forms/rich-editor.component.ts @@ -9,7 +9,7 @@ import { AsyncPipe } from '@angular/common'; import { AfterViewInit, booleanAttribute, ChangeDetectionStrategy, Component, ElementRef, EventEmitter, forwardRef, Input, OnDestroy, Output, ViewChild } from '@angular/core'; import { NG_VALUE_ACCESSOR } from '@angular/forms'; import { BehaviorSubject, catchError, of, switchMap } from 'rxjs'; -import { ModalDirective, TypedSimpleChanges } from '@app/framework'; +import { HTTP, ModalDirective, TypedSimpleChanges } from '@app/framework'; import { ApiUrlConfig, AppsState, AssetDto, AssetsService, AssetUploaderState, ContentDto, DialogModel, getContentValue, LanguageDto, ResourceLoaderService, StatefulControlComponent, Types } from '@app/shared/internal'; import { AssetDialogComponent } from '../assets/asset-dialog.component'; import { AssetSelectorComponent } from '../assets/asset-selector.component'; @@ -227,14 +227,14 @@ export class RichEditorComponent extends StatefulControlComponent<{}, EditorValu } } - public insertText(text: string | undefined | null) { + public insertText(content: string | HTTP.UploadFile | undefined | null) { this.chatDialog.hide(); - if (!this.currentChat) { + if (!this.currentChat || !Types.isString(content)) { return; } - this.currentChat.resolve(text); + this.currentChat.resolve(content); this.currentChat = undefined; } diff --git a/frontend/src/app/shared/services/apps.service.ts b/frontend/src/app/shared/services/apps.service.ts index 45ce1e667f..878d5741ab 100644 --- a/frontend/src/app/shared/services/apps.service.ts +++ b/frontend/src/app/shared/services/apps.service.ts @@ -281,7 +281,7 @@ export class AppsService { pretifyError('i18n:apps.updateAssetScriptsFailed')); } - public postAppImage(appName: string, resource: Resource, file: File, version: Version): Observable { + public postAppImage(appName: string, resource: Resource, file: HTTP.UploadFile, version: Version): Observable { const link = resource._links['image/upload']; const url = this.apiUrl.buildUrl(link.href); diff --git a/frontend/src/app/shared/services/assets.service.ts b/frontend/src/app/shared/services/assets.service.ts index 474bbe8670..8ecf6eedfa 100644 --- a/frontend/src/app/shared/services/assets.service.ts +++ b/frontend/src/app/shared/services/assets.service.ts @@ -10,7 +10,6 @@ import { Injectable } from '@angular/core'; import { Observable, throwError } from 'rxjs'; import { catchError, filter, map } from 'rxjs/operators'; import { ApiUrlConfig, DateTime, ErrorDto, getLinkUrl, hasAnyLink, HTTP, Metadata, pretifyError, Resource, ResourceLinks, ScriptCompletions, StringHelper, Types, Version, Versioned } from '@app/framework'; -import { AuthService } from './auth.service'; import { Query, sanitize } from './query'; const SVG_PREVIEW_LIMIT = 10 * 1024; @@ -84,14 +83,8 @@ export class AssetDto { this._meta = meta; } - public fullUrl(apiUrl: ApiUrlConfig, authService?: AuthService) { - let url = apiUrl.buildUrl(this.contentUrl); - - if (authService && authService.user) { - url = StringHelper.appendToUrl(url, 'access_token', authService.user.accessToken); - } - - return url; + public fullUrl(apiUrl: ApiUrlConfig) { + return apiUrl.buildUrl(this.contentUrl); } } @@ -250,7 +243,7 @@ export class AssetsService { } public getAssetFolders(appName: string, parentId: string, scope: AssetFolderScope): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/folders?parentId=${parentId}&scope=${scope}`); + const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets/folders${StringHelper.buildQuery({ parentId, scope })}`); return this.http.get(url).pipe( map(body => { @@ -269,12 +262,8 @@ export class AssetsService { pretifyError('i18n:assets.loadFailed')); } - public postAssetFile(appName: string, file: Blob, parentId?: string): Observable { - let url = this.apiUrl.buildUrl(`api/apps/${appName}/assets`); - - if (parentId) { - url += `?parentId=${parentId}`; - } + public postAssetFile(appName: string, file: HTTP.UploadFile, parentId?: string): Observable { + const url = this.apiUrl.buildUrl(`api/apps/${appName}/assets${StringHelper.buildQuery({ parentId })}`); return HTTP.upload(this.http, 'POST', url, file).pipe( filter(event => @@ -301,7 +290,7 @@ export class AssetsService { pretifyError('i18n:assets.uploadFailed')); } - public putAssetFile(appName: string, resource: Resource, file: Blob, version: Version): Observable { + public putAssetFile(appName: string, resource: Resource, file: HTTP.UploadFile, version: Version): Observable { const link = resource._links['upload']; const url = this.apiUrl.buildUrl(link.href); @@ -392,7 +381,7 @@ export class AssetsService { public deleteAssetItem(appName: string, asset: Resource, checkReferrers: boolean, version: Version): Observable> { const link = asset._links['delete']; - const url = `${this.apiUrl.buildUrl(link.href)}?checkReferrers=${checkReferrers}`; + const url = `${this.apiUrl.buildUrl(link.href)}${StringHelper.buildQuery({ checkReferrers })}`; return HTTP.requestVersioned(this.http, link.method, url, version).pipe( pretifyError('i18n:assets.deleteFailed')); diff --git a/frontend/src/app/shared/services/contents.service.ts b/frontend/src/app/shared/services/contents.service.ts index 47e7bf88ef..f26651a3f0 100644 --- a/frontend/src/app/shared/services/contents.service.ts +++ b/frontend/src/app/shared/services/contents.service.ts @@ -9,7 +9,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, Version, Versioned } from '@app/framework'; +import { ApiUrlConfig, DateTime, ErrorDto, hasAnyLink, HTTP, mapVersioned, pretifyError, Resource, ResourceLinks, StringHelper, Version, Versioned } from '@app/framework'; import { StatusInfo } from '../state/contents.state'; import { Query, sanitize } from './query'; import { parseField, RootFieldDto } from './schemas.service'; @@ -241,7 +241,7 @@ export class ContentsService { public getContentReferences(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable { const query = buildQuery(q); - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references?${buildQueryString(query)}`); + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/references${buildQueryString(query)}`); return this.http.get(url, buildHeaders(q)).pipe( map(body => { @@ -253,7 +253,7 @@ export class ContentsService { public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable { const query = buildQuery(q); - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/referencing?${buildQueryString(query)}`); + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}/${id}/referencing${buildQueryString(query)}`); return this.http.get(url, buildHeaders(q)).pipe( map(body => { @@ -300,7 +300,7 @@ export class ContentsService { } public postContent(appName: string, schemaName: string, data: any, publish: boolean, id = ''): Observable { - const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}?publish=${publish}&id=${id ?? ''}`); + const url = this.apiUrl.buildUrl(`/api/content/${appName}/${schemaName}${StringHelper.buildQuery({ publish, id })}`); return HTTP.postVersioned(this.http, url, data).pipe( map(({ payload }) => { @@ -409,7 +409,7 @@ function buildFullQuery(primary: FullQuery, q?: ContentsByQuery) { function buildQueryString(input: { q?: object; odata?: string }) { const { odata, q } = input; - return q ? `q=${JSON.stringify(q)}` : odata; + return q ? `?q=${JSON.stringify(q)}` : `?${odata}`; } function buildQuery(q?: ContentsByQuery): { q?: object; odata?: string } { diff --git a/frontend/src/app/shared/services/history.service.ts b/frontend/src/app/shared/services/history.service.ts index dfcbb7f3e1..8522e6a57d 100644 --- a/frontend/src/app/shared/services/history.service.ts +++ b/frontend/src/app/shared/services/history.service.ts @@ -9,7 +9,7 @@ import { HttpClient, HttpHeaders } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { firstValueFrom, from, Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, DateTime, escapeHTML, pretifyError, Version } from '@app/framework'; +import { ApiUrlConfig, DateTime, escapeHTML, pretifyError, StringHelper, Version } from '@app/framework'; import { UsersProviderService } from './users-provider.service'; export class HistoryEventDto { @@ -81,7 +81,7 @@ export class HistoryService { } public getHistory(appName: string, channel: string): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/history?channel=${channel}`); + const url = this.apiUrl.buildUrl(`api/apps/${appName}/history${StringHelper.buildQuery({ channel })}`); const options = { headers: new HttpHeaders({ diff --git a/frontend/src/app/shared/services/news.service.ts b/frontend/src/app/shared/services/news.service.ts index fa8706c1e7..6d7a2c233f 100644 --- a/frontend/src/app/shared/services/news.service.ts +++ b/frontend/src/app/shared/services/news.service.ts @@ -8,7 +8,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; -import { ApiUrlConfig, pretifyError } from '@app/framework'; +import { ApiUrlConfig, pretifyError, StringHelper } from '@app/framework'; export type FeatureDto = Readonly<{ // The name of the feature. @@ -37,7 +37,7 @@ export class NewsService { } public getFeatures(version: number): Observable { - const url = this.apiUrl.buildUrl(`api/news/features?version=${version}`); + const url = this.apiUrl.buildUrl(`api/news/features${StringHelper.buildQuery({ version })}`); return this.http.get(url).pipe( pretifyError('i18n:features.loadFailed')); diff --git a/frontend/src/app/shared/services/rules.service.ts b/frontend/src/app/shared/services/rules.service.ts index 3929e9c1c2..ecc6d2d135 100644 --- a/frontend/src/app/shared/services/rules.service.ts +++ b/frontend/src/app/shared/services/rules.service.ts @@ -9,7 +9,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, DateTime, hasAnyLink, HTTP, Model, pretifyError, Resource, ResourceLinks, ScriptCompletions, Version } from '@app/framework'; +import { ApiUrlConfig, DateTime, hasAnyLink, HTTP, Model, pretifyError, Resource, ResourceLinks, ScriptCompletions, StringHelper, Version } from '@app/framework'; export type RuleElementMetadataDto = Readonly<{ description: string; @@ -355,7 +355,7 @@ export class RulesService { } public getEvents(appName: string, take: number, skip: number, ruleId?: string): Observable { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events?take=${take}&skip=${skip}&ruleId=${ruleId || ''}`); + const url = this.apiUrl.buildUrl(`api/apps/${appName}/rules/events${StringHelper.buildQuery({ take, skip, ruleId })}`); return this.http.get(url).pipe( map(body => { diff --git a/frontend/src/app/shared/services/search.service.ts b/frontend/src/app/shared/services/search.service.ts index 18abae193e..e92f02e8a2 100644 --- a/frontend/src/app/shared/services/search.service.ts +++ b/frontend/src/app/shared/services/search.service.ts @@ -9,7 +9,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, pretifyError, ResourceLinks } from '@app/framework'; +import { ApiUrlConfig, pretifyError, ResourceLinks, StringHelper } from '@app/framework'; export class SearchResultDto { public readonly _links: ResourceLinks; @@ -38,7 +38,7 @@ export class SearchService { } public getResults(appName: string, query: string): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/search/?query=${encodeURIComponent(query)}`); + const url = this.apiUrl.buildUrl(`api/apps/${appName}/search${StringHelper.buildQuery({ query })}`); return this.http.get(url).pipe( map(body => { diff --git a/frontend/src/app/shared/services/stock-photo.service.ts b/frontend/src/app/shared/services/stock-photo.service.ts index 9fc01d6a4d..fd2765c523 100644 --- a/frontend/src/app/shared/services/stock-photo.service.ts +++ b/frontend/src/app/shared/services/stock-photo.service.ts @@ -9,6 +9,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable, of } from 'rxjs'; import { catchError, map } from 'rxjs/operators'; +import { StringHelper } from '@app/framework'; export class StockPhotoDto { constructor( @@ -30,7 +31,7 @@ export class StockPhotoService { } public getImages(query: string, page = 1): Observable> { - const url = `https://stockphoto.squidex.io/?query=${query}&page=${page}`; + const url = `https://stockphoto.squidex.io${StringHelper.buildQuery({ query, page })}`; return this.http.get(url).pipe( map(body => { diff --git a/frontend/src/app/shared/services/translations.service.ts b/frontend/src/app/shared/services/translations.service.ts index 734784cc5d..6b2aa68cb6 100644 --- a/frontend/src/app/shared/services/translations.service.ts +++ b/frontend/src/app/shared/services/translations.service.ts @@ -9,7 +9,8 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, pretifyError } from '@app/framework'; +import { ApiUrlConfig, StringHelper, pretifyError } from '@app/framework'; +import { AuthService } from './auth.service'; export class TranslationDto { constructor( @@ -30,13 +31,40 @@ export type TranslateDto = Readonly<{ targetLanguage: string; }>; - export type AskDto = Readonly<{ +export type AskDto = Readonly<{ // Optional conversation ID. conversationId?: string; + // The configuration. + configuration?: string; + // The question to ask. - prompt: string; - }>; + prompt?: string; +}>; + +export interface ChatChunkDto { + type: 'Chunk'; + + // The content of the chunk. + content: string; +} + +export interface ChatToolStartDto { + type: 'ToolStart'; + + // The tool that has been started. + tool: string; +} + +export interface ChatToolEndDto { + type: 'ToolEnd'; + + // The tool that has been finished. + tool: string; +} + +export type ChatEventDto = ChatChunkDto | ChatToolStartDto | ChatToolEndDto; + @Injectable({ providedIn: 'root', @@ -44,6 +72,7 @@ export type TranslateDto = Readonly<{ export class TranslationsService { constructor( private readonly http: HttpClient, + private readonly authService: AuthService, private readonly apiUrl: ApiUrlConfig, ) { } @@ -58,11 +87,45 @@ export class TranslationsService { pretifyError('i18n:translate.translateFailed')); } - public ask(appName: string, request: AskDto): Observable> { - const url = this.apiUrl.buildUrl(`api/apps/${appName}/ask`); + public ask(appName: string, request: AskDto): Observable { + const token = this.authService.user!.accessToken; - return this.http.post(url, request).pipe( - pretifyError('i18n:chatBot.questionFailed')); + const url = this.apiUrl.buildUrl(`api/apps/${appName}/ask${StringHelper.buildQuery({ ...request, access_token: token })}`); + + return new Observable((subscriber) => { + const source = new EventSource(url); + + source.addEventListener('message', (event) => { + if (!event) { + source.close(); + + subscriber.complete(); + } else { + subscriber.next(JSON.parse(event.data)); + } + }); + + source.addEventListener('error', (event) => { + + const data = (event as any)['data']; + try { + if (data) { + try { + subscriber.error(JSON.parse(data).message); + } finally { + subscriber.error(data); + } + } + } finally { + subscriber.complete(); + source.close(); + } + }); + + return () => { + source.close(); + }; + }); } } diff --git a/frontend/src/app/shared/services/users.service.ts b/frontend/src/app/shared/services/users.service.ts index d91f819f1d..574f4ec08f 100644 --- a/frontend/src/app/shared/services/users.service.ts +++ b/frontend/src/app/shared/services/users.service.ts @@ -9,7 +9,7 @@ import { HttpClient } from '@angular/common/http'; import { Injectable } from '@angular/core'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; -import { ApiUrlConfig, pretifyError, Resource } from '@app/framework'; +import { ApiUrlConfig, pretifyError, Resource, StringHelper } from '@app/framework'; export class UserDto { constructor( @@ -41,7 +41,7 @@ export class UsersService { } public getUsers(query?: string): Observable> { - const url = this.apiUrl.buildUrl(`api/users?query=${query || ''}`); + const url = this.apiUrl.buildUrl(`api/users${StringHelper.buildQuery({ query })}`); return this.http.get(url).pipe( map(body => { diff --git a/frontend/src/app/shared/state/asset-uploader.state.ts b/frontend/src/app/shared/state/asset-uploader.state.ts index 580ca9c561..5e85f3d8b2 100644 --- a/frontend/src/app/shared/state/asset-uploader.state.ts +++ b/frontend/src/app/shared/state/asset-uploader.state.ts @@ -7,7 +7,7 @@ import { Injectable } from '@angular/core'; import { Observable, shareReplay, Subject, takeUntil } from 'rxjs'; -import { debug, DialogService, MathHelper, State, Types } from '@app/framework'; +import { debug, DialogService, HTTP, MathHelper, State, Types } from '@app/framework'; import { AssetDto, AssetsService } from '../services/assets.service'; import { AppsState } from './apps.state'; @@ -70,13 +70,13 @@ export class AssetUploaderState extends State { }, 'Stopped'); } - public uploadFile(file: File, parentId?: string): Observable { + public uploadFile(file: HTTP.UploadFile, parentId?: string): Observable { const stream = this.assetsService.postAssetFile(this.appName, file, parentId); return this.upload(stream, MathHelper.guid(), file.name); } - public uploadAsset(asset: AssetDto, file: Blob): Observable { + public uploadAsset(asset: AssetDto, file: HTTP.UploadFile): Observable { const stream = this.assetsService.putAssetFile(this.appName, asset, file, asset.version); return this.upload(stream, asset.id, (file as any)['name'] || asset.fileName);