From 48cd104fba34874aee87ed8839b08c1dc755d208 Mon Sep 17 00:00:00 2001 From: Sebastian Stehle Date: Mon, 27 May 2024 16:37:29 +0200 Subject: [PATCH] AI improvements (#1095) * Progress * Add missing files. * Fix tests --- .../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 +- .../DomainObject/TeamDomainObject.State.cs | 1 - .../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 +- 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 | 144 ++++++++++++++++++ .../Api/Config/OpenApi/OpenApiServices.cs | 16 +- .../Controllers/Apps/AppAssetsController.cs | 4 +- .../Controllers/Apps/AppSettingsController.cs | 4 +- .../Api/Controllers/Apps/AppsController.cs | 34 ++--- .../Apps/Models/UploadAppImageDto.cs | 21 +++ .../Controllers/Assets/AssetsController.cs | 40 +---- .../Assets/Models/CreateAssetDto.cs | 16 +- .../Assets/Models/UpdateAssetDto.cs | 25 +++ .../Assets/Models/UpsertAssetDto.cs | 15 +- .../Statistics/UsagesController.cs | 6 +- .../Api/Controllers/Teams/TeamsController.cs | 8 +- .../Controllers/Translations/Models/AskDto.cs | 15 +- .../Translations/TranslationsController.cs | 71 ++++++++- .../Areas/Api/Controllers/UploadModel.cs | 42 +++++ .../Controllers/Profile/ProfileController.cs | 15 +- .../Config/Domain/InfrastructureServices.cs | 24 ++- .../Squidex/Config/Domain/QueryServices.cs | 3 +- .../Squidex/Config/Domain/StoreServices.cs | 3 +- .../Config/Messaging/MessagingServices.cs | 3 + 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 +- .../services/users.service.spec.ts | 2 +- .../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 | 14 +- .../angular/image-source.directive.ts | 16 +- .../app/framework/utils/string-helper.spec.ts | 30 +++- .../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 | 98 +++++------- .../components/chat-item.component.html | 62 ++++++++ .../components/chat-item.component.scss | 82 ++++++++++ .../shared/components/chat-item.component.ts | 142 +++++++++++++++++ .../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 +- .../shared/services/search.service.spec.ts | 2 +- .../src/app/shared/services/search.service.ts | 4 +- .../services/stock-photo.service.spec.ts | 4 +- .../shared/services/stock-photo.service.ts | 3 +- .../services/translations.service.spec.ts | 26 ---- .../shared/services/translations.service.ts | 79 +++++++++- .../app/shared/services/users.service.spec.ts | 2 +- .../src/app/shared/services/users.service.ts | 4 +- .../app/shared/state/asset-uploader.state.ts | 6 +- 98 files changed, 1159 insertions(+), 642 deletions(-) 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/Apps/Models/UploadAppImageDto.cs create mode 100644 backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.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 3a23f7174..e33ca1927 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 90963abf1..af9c83cd2 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 61f66ef89..b409ed61f 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 438ecb81c..7934d73f2 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 cd12705db..9cb29d38f 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 86dfe41f0..27b9b8dc9 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 @@ public sealed class StringAsyncJintExtension : IJintExtension, IScriptDescriptor return; } - var result = await chatAgent.PromptAsync(prompt, ct: ct); + var request = new ChatRequest + { + Prompt = prompt + }; + + var result = await chatAgent.PromptAsync(request, ct: ct); - scheduler.Run(callback, JsValue.FromObject(context.Engine, result.Text)); + scheduler.Run(callback, JsValue.FromObject(context.Engine, result.Content)); } catch (Exception ex) { diff --git a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj index 498f8694e..c62d0a01d 100644 --- a/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj +++ b/backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj @@ -18,18 +18,18 @@ - + - - + + 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 d2778507a..0c2757236 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 d5b6e195e..e654d92af 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 59d7e6497..bd367c82e 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 75cffd502..dc79efec5 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 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware 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 01861f263..d05c6a32c 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.Entities/Teams/DomainObject/TeamDomainObject.State.cs b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs index 77b935ddb..fc7a3c8d7 100644 --- a/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs +++ b/backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs @@ -8,7 +8,6 @@ using Squidex.Domain.Apps.Core; using Squidex.Domain.Apps.Core.Teams; using Squidex.Domain.Apps.Events; -using Squidex.Domain.Apps.Events.Apps; using Squidex.Domain.Apps.Events.Teams; using Squidex.Infrastructure.EventSourcing; 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 64a82b338..c7f203cb6 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 b7afd68d5..92882e229 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 74a3ad059..213163320 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 12ed78131..643309d89 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 078d67028..d450c763b 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 05c02f98a..abf5aba1b 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 dc90447b2..6a048ea3c 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/FileExtensions.cs b/backend/src/Squidex.Web/FileExtensions.cs deleted file mode 100644 index 2d65ba67b..000000000 --- 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 3656c5a36..d3419ea80 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 @@ using IGenericUrlGenerator = Squidex.Hosting.IUrlGenerator; 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 sealed class UrlGenerator : IUrlGenerator { 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 51f2bcc3c..dc94a13c5 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 000000000..ac293fad5 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs @@ -0,0 +1,144 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Http.Metadata; +using Squidex.Areas.Api.Controllers; +using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Assets; +using Squidex.Domain.Apps.Entities.Billing; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Translations; +using Squidex.Infrastructure.Validation; + +namespace Squidex.Areas.Api.Config; + +public class AssetFileResolver +{ + private readonly IAssetUsageTracker assetUsage; + private readonly IUsageGate usageGate; + private readonly IHttpClientFactory httpClientFactory; + + public AssetFileResolver(IAssetUsageTracker assetUsage, IUsageGate usageGate, IHttpClientFactory httpClientFactory) + { + this.assetUsage = assetUsage; + this.usageGate = usageGate; + this.httpClientFactory = httpClientFactory; + } + + public async Task ToFileAsync(UploadModel model, HttpContext httpContext, App? app, + CancellationToken ct) + { + Guard.NotNull(model); + Guard.NotNull(httpContext); + + var file = await DownloadFileAsync(httpContext, ct) ?? GetFile(httpContext); + + if (app != null && !await IsSizeAllowedAsync(httpContext, app, file, ct)) + { + 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 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, App app, IAssetFile file, + CancellationToken ct) + { + 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 + file.FileSize; + } +} diff --git a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs index 85774131d..8956bc315 100644 --- a/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs +++ b/backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs @@ -6,7 +6,6 @@ // ========================================================================== using System.Text.Json; -using Microsoft.Extensions.DependencyInjection; using NJsonSchema; using NJsonSchema.Generation; using NJsonSchema.Generation.TypeMappers; @@ -30,6 +29,12 @@ public static class OpenApiServices services.AddSingletonAs() .As(); + services.AddSingletonAs() + .As(); + + services.AddSingletonAs() + .As(); + services.AddSingletonAs() .As(); @@ -39,15 +44,12 @@ public static class OpenApiServices services.AddSingletonAs() .As(); - services.AddSingletonAs() - .As(); - - services.AddSingletonAs() - .As(); - services.AddSingletonAs() .As(); + services.AddSingletonAs() + .AsSelf(); + services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs index a23c4f8f8..4da109227 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs @@ -61,7 +61,9 @@ public sealed class AppAssetsController : ApiController [ApiCosts(0)] public async Task PutAssetScripts(string app, [FromBody] UpdateAssetScriptsDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return Ok(response); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs index f4227939f..65a94b377 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs @@ -61,7 +61,9 @@ public sealed class AppSettingsController : ApiController [ApiCosts(0)] public async Task PutSettings(string app, [FromBody] UpdateAppSettingsDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return Ok(response); } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs index 20c7567c0..2c8fdfb37 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs @@ -13,8 +13,6 @@ using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Apps.Commands; using Squidex.Infrastructure.Commands; using Squidex.Infrastructure.Security; -using Squidex.Infrastructure.Translations; -using Squidex.Infrastructure.Validation; using Squidex.Shared; using Squidex.Web; @@ -135,7 +133,9 @@ public sealed class AppsController : ApiController [ApiCosts(0)] public async Task PostApp([FromBody] CreateAppDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetApps), response); } @@ -155,7 +155,9 @@ public sealed class AppsController : ApiController [ApiCosts(0)] public async Task PutApp(string app, [FromBody] UpdateAppDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return Ok(response); } @@ -175,7 +177,9 @@ public sealed class AppsController : ApiController [ApiCosts(0)] public async Task PutAppTeam(string app, [FromBody] TransferToTeamDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return Ok(response); } @@ -184,7 +188,7 @@ public sealed class AppsController : ApiController /// Upload the app image. /// /// The name of the app to update. - /// The file to upload. + /// The request parameters. /// App image uploaded. /// App request not valid. /// App not found. @@ -193,9 +197,11 @@ public sealed class AppsController : ApiController [ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)] [ApiPermissionOrAnonymous(PermissionIds.AppImageUpload)] [ApiCosts(0)] - public async Task UploadImage(string app, IFormFile file) + public async Task UploadImage(string app, UploadAppImageDto request) { - var response = await InvokeCommandAsync(CreateCommand(file)); + var command = await request.ToCommandAsync(HttpContext); + + var response = await InvokeCommandAsync(command); return Ok(response); } @@ -255,16 +261,4 @@ public sealed class AppsController : ApiController 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/Apps/Models/UploadAppImageDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UploadAppImageDto.cs new file mode 100644 index 000000000..1bef304c5 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UploadAppImageDto.cs @@ -0,0 +1,21 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Entities.Apps.Commands; +using Squidex.Infrastructure.Reflection; + +namespace Squidex.Areas.Api.Controllers.Apps.Models; + +public sealed class UploadAppImageDto : UploadModel +{ + public async Task ToCommandAsync(HttpContext httpContext) + { + var file = await ToFileAsync(httpContext, null); + + return SimpleMapper.Map(this, new UploadAppImage { File = file }); + } +} diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs index 56596f837..6bb83b0cb 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs @@ -15,12 +15,8 @@ using Squidex.Domain.Apps.Core.Scripting; using Squidex.Domain.Apps.Core.Tags; using Squidex.Domain.Apps.Entities; using Squidex.Domain.Apps.Entities.Assets; -using Squidex.Domain.Apps.Entities.Assets.Commands; -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 +28,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 +35,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 +200,7 @@ public sealed class AssetsController : ApiController [ApiCosts(1)] public async Task PostAsset(string app, CreateAssetDto request) { - var command = request.ToCommand(await CheckAssetFileAsync(request.File)); + var command = await request.ToCommandAsync(HttpContext, App); var response = await InvokeCommandAsync(command); @@ -295,7 +288,7 @@ public sealed class AssetsController : ApiController [ApiCosts(1)] public async Task PostUpsertAsset(string app, DomainId id, UpsertAssetDto request) { - var command = request.ToCommand(id, await CheckAssetFileAsync(request.File)); + var command = await request.ToCommandAsync(id, HttpContext, App); var response = await InvokeCommandAsync(command); @@ -307,7 +300,7 @@ public sealed class AssetsController : ApiController /// /// The name of the app. /// The ID of the asset. - /// The file to upload. + /// The request parameters. /// Asset updated. /// Asset request not valid. /// Asset exceeds the maximum upload size. @@ -321,9 +314,9 @@ public sealed class AssetsController : ApiController [AssetRequestSizeLimit] [ApiPermissionOrAnonymous(PermissionIds.AppAssetsUpload)] [ApiCosts(1)] - public async Task PutAssetContent(string app, DomainId id, IFormFile file) + public async Task PutAssetContent(string app, DomainId id, UpdateAssetDto request) { - var command = new UpdateAsset { File = await CheckAssetFileAsync(file), AssetId = id }; + var command = await request.ToCommandAsync(id, HttpContext, App); var response = await InvokeCommandAsync(command); @@ -440,29 +433,6 @@ public sealed class AssetsController : ApiController } } - 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 a7900b59a..a31de03dc 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs @@ -6,7 +6,7 @@ // ========================================================================== using Microsoft.AspNetCore.Mvc; -using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -15,18 +15,12 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets.Models; [OpenApiRequest] -public sealed class CreateAssetDto +public sealed class CreateAssetDto : UploadModel { - /// - /// The file to upload. - /// - [FromForm(Name = "file")] - public IFormFile File { get; set; } - /// /// The optional parent folder id. /// - [FromQuery(Name = "parentId")] + // [FromQuery(Name = "parentId")] public DomainId ParentId { get; set; } /// @@ -41,8 +35,10 @@ public sealed class CreateAssetDto [FromQuery(Name = "duplicate")] public bool Duplicate { get; set; } - public CreateAsset ToCommand(AssetFile file) + public async Task ToCommandAsync(HttpContext httpContext, App app) { + var file = await ToFileAsync(httpContext, app); + var command = SimpleMapper.Map(this, new CreateAsset { File = file }); if (Id != null && Id.Value != default && !string.IsNullOrWhiteSpace(Id.Value.ToString())) diff --git a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs new file mode 100644 index 000000000..143764857 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs @@ -0,0 +1,25 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Squidex.Domain.Apps.Core.Apps; +using Squidex.Domain.Apps.Entities.Assets.Commands; +using Squidex.Infrastructure; +using Squidex.Infrastructure.Reflection; +using Squidex.Web; + +namespace Squidex.Areas.Api.Controllers.Assets.Models; + +[OpenApiRequest] +public sealed class UpdateAssetDto : UploadModel +{ + public async Task ToCommandAsync(DomainId id, HttpContext httpContext, App app) + { + var file = await ToFileAsync(httpContext, app); + + return SimpleMapper.Map(this, new UpdateAsset { AssetId = id, File = file }); + } +} 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 57957a982..0bc4355d5 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs @@ -7,6 +7,7 @@ using Microsoft.AspNetCore.Mvc; using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; using Squidex.Domain.Apps.Entities.Assets.Commands; using Squidex.Infrastructure; using Squidex.Infrastructure.Reflection; @@ -15,14 +16,8 @@ using Squidex.Web; namespace Squidex.Areas.Api.Controllers.Assets.Models; [OpenApiRequest] -public sealed class UpsertAssetDto +public sealed class UpsertAssetDto : UploadModel { - /// - /// The file to upload. - /// - [FromForm(Name = "file")] - public IFormFile File { get; set; } - /// /// The optional parent folder id. /// @@ -72,8 +67,10 @@ public sealed class UpsertAssetDto return command; } - public UpsertAsset ToCommand(DomainId id, AssetFile file) + public async Task ToCommandAsync(DomainId id, HttpContext httpContext, App app) { - return SimpleMapper.Map(this, new UpsertAsset { File = file, AssetId = id }); + var file = await ToFileAsync(httpContext, app); + + return SimpleMapper.Map(this, new UpsertAsset { AssetId = id, File = file }); } } diff --git a/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs b/backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs index 88bd4145e..3a570fc41 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 sealed class UsagesController : ApiController 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/Teams/TeamsController.cs b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs index 17e60fc04..5d2426cc8 100644 --- a/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs +++ b/backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs @@ -98,7 +98,9 @@ public sealed class TeamsController : ApiController [ApiCosts(0)] public async Task PostTeam([FromBody] CreateTeamDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return CreatedAtAction(nameof(GetTeams), response); } @@ -118,7 +120,9 @@ public sealed class TeamsController : ApiController [ApiCosts(0)] public async Task PutTeam(string team, [FromBody] UpdateTeamDto request) { - var response = await InvokeCommandAsync(request.ToCommand()); + var command = request.ToCommand(); + + var response = await InvokeCommandAsync(command); return Ok(response); } 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 ca2cd7df1..91cc34993 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 979f96b12..36cd5de6d 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 sealed class TranslationsController : ApiController /// 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 000000000..fab26cb13 --- /dev/null +++ b/backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs @@ -0,0 +1,42 @@ +// ========================================================================== +// Squidex Headless CMS +// ========================================================================== +// Copyright (c) Squidex UG (haftungsbeschraenkt) +// All rights reserved. Licensed under the MIT license. +// ========================================================================== + +using Microsoft.AspNetCore.Mvc; +using Squidex.Areas.Api.Config; +using Squidex.Assets; +using Squidex.Domain.Apps.Core.Apps; + +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 Task ToFileAsync(HttpContext httpContext, App? app) + { + var resolver = httpContext.RequestServices.GetRequiredService(); + + return resolver.ToFileAsync(this, httpContext, app, + httpContext.RequestAborted); + } +} diff --git a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs index 1b11fc110..c927f7f47 100644 --- a/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs +++ b/backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs @@ -20,7 +20,6 @@ using Squidex.Infrastructure.Translations; using Squidex.Infrastructure.Validation; using Squidex.Shared.Identity; using Squidex.Shared.Users; -using Squidex.Web; namespace Squidex.Areas.IdentityServer.Controllers.Profile; @@ -134,9 +133,9 @@ public sealed class ProfileController : IdentityServerController [HttpPost] [Route("account/profile/upload-picture/")] - public Task UploadPicture(List file) + public Task UploadPicture(List files) { - return MakeChangeAsync((id, ct) => UpdatePictureAsync(file, id, ct), + return MakeChangeAsync((id, ct) => UpdatePictureAsync(files, id, ct), T.Get("users.profile.uploadPictureDone"), None.Value); } @@ -164,8 +163,6 @@ public sealed class ProfileController : IdentityServerController throw new ValidationException(T.Get("validation.onlyOneFile")); } - await UploadResizedAsync(files[0], id, ct); - var update = new UserValues { PictureUrl = SquidexClaimTypes.PictureUrlStore @@ -174,10 +171,10 @@ public sealed class ProfileController : IdentityServerController 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 +184,11 @@ public sealed class ProfileController : IdentityServerController 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 72593d7c9..e7e0be95e 100644 --- a/backend/src/Squidex/Config/Domain/InfrastructureServices.cs +++ b/backend/src/Squidex/Config/Domain/InfrastructureServices.cs @@ -6,8 +6,8 @@ // ========================================================================== using Microsoft.Extensions.Caching.Memory; -using Microsoft.SemanticKernel; using NodaTime; +using Squidex.AI; using Squidex.Areas.Api.Controllers.Contents.Generator; using Squidex.Areas.Api.Controllers.News; using Squidex.Areas.Api.Controllers.News.Service; @@ -127,22 +127,32 @@ public static class InfrastructureServices 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 7fe728f45..686ed2142 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 class QueryServices .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 3ea946e3f..e48f3fc5d 100644 --- a/backend/src/Squidex/Config/Domain/StoreServices.cs +++ b/backend/src/Squidex/Config/Domain/StoreServices.cs @@ -75,8 +75,7 @@ public static class StoreServices 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 f0a9a7e59..9e8e2cf5d 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 class MessagingServices if (isWorker) { + services.AddAICleaner(); + services.AddSingletonAs() .AsSelf(); diff --git a/backend/src/Squidex/Squidex.csproj b/backend/src/Squidex/Squidex.csproj index 7077259e0..52c11bd70 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 da1954a8b..611f88e9d 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 f38551149..c3b41da06 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 class JintScriptEngineHelperTests : IClassFixture [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 class JintScriptEngineHelperTests : IClassFixture 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 484c8801b..6a77f405e 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 f6c92fbe0..3015bb94f 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 @@ public class AssetCommandMiddlewareTests : HandlerTestBase 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 31ee196ed..fcade70c4 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 91ba6e42f..8ce360702 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 0920d5f46..58362d230 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 0b25331b1..d3e56d502 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 @@ using Squidex.Assets; 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 e99008a03..b1c8015d0 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 3dceddd05..ad18c519d 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 ff0206d72..119224757 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.spec.ts b/frontend/src/app/features/administration/services/users.service.spec.ts index 1f920eeb8..40282e640 100644 --- a/frontend/src/app/features/administration/services/users.service.spec.ts +++ b/frontend/src/app/features/administration/services/users.service.spec.ts @@ -35,7 +35,7 @@ describe('UsersService', () => { users = result; }); - const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30&query='); + const req = httpMock.expectOne('http://service/p/api/user-management?take=20&skip=30'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); diff --git a/frontend/src/app/features/administration/services/users.service.ts b/frontend/src/app/features/administration/services/users.service.ts index d565f5b10..24596bb0f 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 ccbe4320d..4c812a063 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 @@
- + \ 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 7df95c5a0..590c9a642 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 f294ede49..6c1cc9417 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 c91fa3f97..dd62637b0 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,16 @@ 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); + const untyped = file as any; + if (Types.isObject(untyped) && Types.isString(untyped['name']) && Types.isString(untyped['url'])) { + formData.append('url', untyped.url); + formData.append('name', untyped.name); + } else { + formData.append('file', untyped); + } 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 262289124..6e91eb67a 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.spec.ts b/frontend/src/app/framework/utils/string-helper.spec.ts index d32fcc8a1..d9e1fc6c5 100644 --- a/frontend/src/app/framework/utils/string-helper.spec.ts +++ b/frontend/src/app/framework/utils/string-helper.spec.ts @@ -58,15 +58,33 @@ describe('StringHelper', () => { expect(StringHelper.hashCode('ABC')).not.toBe(StringHelper.hashCode('XYZ')); }); - it('should append query string to url if url already contains query', () => { - const url = StringHelper.appendToUrl('http://squidex.io?query=value', 'other', 1); + it('should build query for empty object', () => { + const url = StringHelper.buildQuery({}); - expect(url).toEqual('http://squidex.io?query=value&other=1'); + expect(url).toEqual(''); }); - it('should append query string to url if url already contains no query', () => { - const url = StringHelper.appendToUrl('http://squidex.io', 'other', 1); + it('should build query for single value', () => { + const url = StringHelper.buildQuery({ key1: '42' }); - expect(url).toEqual('http://squidex.io?other=1'); + expect(url).toEqual('?key1=42'); + }); + + it('should build query for multiple values', () => { + const url = StringHelper.buildQuery({ key1: '42', key2: 21 }); + + expect(url).toEqual('?key1=42&key2=21'); + }); + + it('should build query and ignore null and undefined', () => { + const url = StringHelper.buildQuery({ key1: '42', key2: 21, key: undefined, key4: null }); + + expect(url).toEqual('?key1=42&key2=21'); + }); + + it('should build query with encoded values', () => { + const url = StringHelper.buildQuery({ key1: 'Hello World' }); + + expect(url).toEqual('?key1=Hello%20World'); }); }); diff --git a/frontend/src/app/framework/utils/string-helper.ts b/frontend/src/app/framework/utils/string-helper.ts index f96c36ba1..8a0354f46 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 f3cfa5965..a63e9cb1d 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 89a766c50..9fd6ad7d8 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 76d75c29a..0b8d6360a 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 c350d58aa..00d31f10a 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 6e0f12e78..eb5207be0 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 900bb543d..bb8ef1c81 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 dc1b1fc11..ba67c8ec3 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 8b43ff013..fd732c852 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 e63b13b11..2e9aca553 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 f39e15bc8..fc6a2fdeb 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,32 @@ export class ChatDialogComponent extends StatefulComponent { private readonly translator: TranslationsService, ) { super({ - isRunning: false, + chatItems: [], chatQuestion: '', - chatTalk: [], + 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 +96,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 000000000..21e083946 --- /dev/null +++ b/frontend/src/app/shared/components/chat-item.component.html @@ -0,0 +1,62 @@ +
+
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+
+
+ {{ 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 000000000..8b632e1d7 --- /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 000000000..015447da0 --- /dev/null +++ b/frontend/src/app/shared/components/chat-item.component.ts @@ -0,0 +1,142 @@ +/* + * 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, ResizedDirective, 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, + ResizedDirective, + 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 46ecd2085..30fd81457 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 a5cf8f026..dbaf4e8ab 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 45ce1e667..878d5741a 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 474bbe867..8ecf6eedf 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 47e7bf88e..f26651a3f 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 dfcbb7f3e..8522e6a57 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 fa8706c1e..6d7a2c233 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 3929e9c1c..ecc6d2d13 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.spec.ts b/frontend/src/app/shared/services/search.service.spec.ts index 9548616d5..2a97e58ff 100644 --- a/frontend/src/app/shared/services/search.service.spec.ts +++ b/frontend/src/app/shared/services/search.service.spec.ts @@ -34,7 +34,7 @@ describe('SearchService', () => { results = result; }); - const req = httpMock.expectOne('http://service/p/api/apps/my-app/search/?query=my-query'); + const req = httpMock.expectOne('http://service/p/api/apps/my-app/search?query=my-query'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); diff --git a/frontend/src/app/shared/services/search.service.ts b/frontend/src/app/shared/services/search.service.ts index 18abae193..e92f02e8a 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.spec.ts b/frontend/src/app/shared/services/stock-photo.service.spec.ts index 6e791368c..6ace63258 100644 --- a/frontend/src/app/shared/services/stock-photo.service.spec.ts +++ b/frontend/src/app/shared/services/stock-photo.service.spec.ts @@ -35,7 +35,7 @@ describe('StockPhotoService', () => { images = result; }); - const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&page=4'); + const req = httpMock.expectOne('https://stockphoto.squidex.io?query=my-query&page=4'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); @@ -66,7 +66,7 @@ describe('StockPhotoService', () => { images = result; }); - const req = httpMock.expectOne('https://stockphoto.squidex.io/?query=my-query&page=1'); + const req = httpMock.expectOne('https://stockphoto.squidex.io?query=my-query&page=1'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); diff --git a/frontend/src/app/shared/services/stock-photo.service.ts b/frontend/src/app/shared/services/stock-photo.service.ts index 9fc01d6a4..fd2765c52 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.spec.ts b/frontend/src/app/shared/services/translations.service.spec.ts index 5956830a4..6736b22fc 100644 --- a/frontend/src/app/shared/services/translations.service.spec.ts +++ b/frontend/src/app/shared/services/translations.service.spec.ts @@ -47,30 +47,4 @@ describe('TranslationsService', () => { expect(translation!).toEqual(new TranslationDto('Translated', 'Hallo')); })); - - it('should make post request to ask question', - inject([TranslationsService, HttpTestingController], (translationsService: TranslationsService, httpMock: HttpTestingController) => { - const dto = { prompt: 'My Question' }; - - let answers: ReadonlyArray; - - translationsService.ask('my-app', dto).subscribe(result => { - answers = result; - }); - - const req = httpMock.expectOne('http://service/p/api/apps/my-app/ask'); - - expect(req.request.method).toEqual('POST'); - expect(req.request.headers.get('If-Match')).toBeNull(); - - req.flush([ - 'Answer1', - 'Answer2', - ]); - - expect(answers!).toEqual([ - 'Answer1', - 'Answer2', - ]); - })); }); diff --git a/frontend/src/app/shared/services/translations.service.ts b/frontend/src/app/shared/services/translations.service.ts index 734784cc5..6b2aa68cb 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.spec.ts b/frontend/src/app/shared/services/users.service.spec.ts index b9ce4330d..521499c99 100644 --- a/frontend/src/app/shared/services/users.service.spec.ts +++ b/frontend/src/app/shared/services/users.service.spec.ts @@ -44,7 +44,7 @@ describe('UsersService', () => { users = result; }); - const req = httpMock.expectOne('http://service/p/api/users?query='); + const req = httpMock.expectOne('http://service/p/api/users'); expect(req.request.method).toEqual('GET'); expect(req.request.headers.get('If-Match')).toBeNull(); diff --git a/frontend/src/app/shared/services/users.service.ts b/frontend/src/app/shared/services/users.service.ts index d91f819f1..574f4ec08 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 580ca9c56..5e85f3d8b 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);