Browse Source

AI improvements (#1095)

* Progress

* Add missing files.

* Fix tests
pull/1098/head
Sebastian Stehle 2 years ago
committed by GitHub
parent
commit
48cd104fba
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 6
      backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj
  2. 2
      backend/i18n/source/backend_en.json
  3. 1
      backend/i18n/source/frontend_en.json
  4. 2
      backend/src/Migrations/Migrations.csproj
  5. 2
      backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj
  6. 9
      backend/src/Squidex.Domain.Apps.Core.Operations/Scripting/Extensions/StringAsyncJintExtension.cs
  7. 10
      backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj
  8. 2
      backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj
  9. 2
      backend/src/Squidex.Domain.Apps.Entities/Apps/Commands/UploadAppImage.cs
  10. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs
  11. 2
      backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs
  12. 4
      backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs
  13. 4
      backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj
  14. 1
      backend/src/Squidex.Domain.Apps.Entities/Teams/DomainObject/TeamDomainObject.State.cs
  15. 2
      backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj
  16. 2
      backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj
  17. 6
      backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj
  18. 10
      backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj
  19. 2
      backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj
  20. 22
      backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj
  21. 2
      backend/src/Squidex.Shared/Squidex.Shared.csproj
  22. 31
      backend/src/Squidex.Web/FileExtensions.cs
  23. 8
      backend/src/Squidex.Web/Services/UrlGenerator.cs
  24. 2
      backend/src/Squidex.Web/Squidex.Web.csproj
  25. 144
      backend/src/Squidex/Areas/Api/Config/AssetFileResolver.cs
  26. 16
      backend/src/Squidex/Areas/Api/Config/OpenApi/OpenApiServices.cs
  27. 4
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs
  28. 4
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs
  29. 34
      backend/src/Squidex/Areas/Api/Controllers/Apps/AppsController.cs
  30. 21
      backend/src/Squidex/Areas/Api/Controllers/Apps/Models/UploadAppImageDto.cs
  31. 40
      backend/src/Squidex/Areas/Api/Controllers/Assets/AssetsController.cs
  32. 16
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/CreateAssetDto.cs
  33. 25
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpdateAssetDto.cs
  34. 15
      backend/src/Squidex/Areas/Api/Controllers/Assets/Models/UpsertAssetDto.cs
  35. 6
      backend/src/Squidex/Areas/Api/Controllers/Statistics/UsagesController.cs
  36. 8
      backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs
  37. 15
      backend/src/Squidex/Areas/Api/Controllers/Translations/Models/AskDto.cs
  38. 71
      backend/src/Squidex/Areas/Api/Controllers/Translations/TranslationsController.cs
  39. 42
      backend/src/Squidex/Areas/Api/Controllers/UploadModel.cs
  40. 15
      backend/src/Squidex/Areas/IdentityServer/Controllers/Profile/ProfileController.cs
  41. 24
      backend/src/Squidex/Config/Domain/InfrastructureServices.cs
  42. 3
      backend/src/Squidex/Config/Domain/QueryServices.cs
  43. 3
      backend/src/Squidex/Config/Domain/StoreServices.cs
  44. 3
      backend/src/Squidex/Config/Messaging/MessagingServices.cs
  45. 44
      backend/src/Squidex/Squidex.csproj
  46. 17
      backend/src/Squidex/appsettings.json
  47. 9
      backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs
  48. 8
      backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj
  49. 4
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs
  50. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetDomainObjectTests.cs
  51. 2
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/ImageAssetMetadataSourceTests.cs
  52. 12
      backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj
  53. 9
      backend/tests/Squidex.Domain.Apps.Entities.Tests/TestHelpers/NoopAssetFile.cs
  54. 8
      backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj
  55. 8
      backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj
  56. 8
      backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj
  57. 2
      frontend/src/app/features/administration/services/users.service.spec.ts
  58. 4
      frontend/src/app/features/administration/services/users.service.ts
  59. 31
      frontend/src/app/features/content/shared/forms/assets-editor.component.html
  60. 52
      frontend/src/app/features/content/shared/forms/assets-editor.component.ts
  61. 13
      frontend/src/app/features/content/shared/forms/field-editor.component.html
  62. 16
      frontend/src/app/features/content/shared/forms/field-editor.component.ts
  63. 5
      frontend/src/app/framework/angular/drag-helper.ts
  64. 14
      frontend/src/app/framework/angular/http/http-extensions.ts
  65. 16
      frontend/src/app/framework/angular/image-source.directive.ts
  66. 30
      frontend/src/app/framework/utils/string-helper.spec.ts
  67. 30
      frontend/src/app/framework/utils/string-helper.ts
  68. 4
      frontend/src/app/shared/components/assets/asset-dialog.component.ts
  69. 10
      frontend/src/app/shared/components/assets/asset-text-editor.component.ts
  70. 4
      frontend/src/app/shared/components/assets/asset-uploader.component.ts
  71. 6
      frontend/src/app/shared/components/assets/asset.component.ts
  72. 10
      frontend/src/app/shared/components/assets/assets-list.component.ts
  73. 14
      frontend/src/app/shared/components/assets/image-cropper.component.ts
  74. 23
      frontend/src/app/shared/components/assets/pipes.ts
  75. 99
      frontend/src/app/shared/components/chat-dialog.component.html
  76. 61
      frontend/src/app/shared/components/chat-dialog.component.scss
  77. 98
      frontend/src/app/shared/components/chat-dialog.component.ts
  78. 62
      frontend/src/app/shared/components/chat-item.component.html
  79. 82
      frontend/src/app/shared/components/chat-item.component.scss
  80. 142
      frontend/src/app/shared/components/chat-item.component.ts
  81. 6
      frontend/src/app/shared/components/forms/geolocation-editor.component.ts
  82. 4
      frontend/src/app/shared/components/forms/rich-editor.component.html
  83. 8
      frontend/src/app/shared/components/forms/rich-editor.component.ts
  84. 2
      frontend/src/app/shared/services/apps.service.ts
  85. 25
      frontend/src/app/shared/services/assets.service.ts
  86. 10
      frontend/src/app/shared/services/contents.service.ts
  87. 4
      frontend/src/app/shared/services/history.service.ts
  88. 4
      frontend/src/app/shared/services/news.service.ts
  89. 4
      frontend/src/app/shared/services/rules.service.ts
  90. 2
      frontend/src/app/shared/services/search.service.spec.ts
  91. 4
      frontend/src/app/shared/services/search.service.ts
  92. 4
      frontend/src/app/shared/services/stock-photo.service.spec.ts
  93. 3
      frontend/src/app/shared/services/stock-photo.service.ts
  94. 26
      frontend/src/app/shared/services/translations.service.spec.ts
  95. 79
      frontend/src/app/shared/services/translations.service.ts
  96. 2
      frontend/src/app/shared/services/users.service.spec.ts
  97. 4
      frontend/src/app/shared/services/users.service.ts
  98. 6
      frontend/src/app/shared/state/asset-uploader.state.ts

6
backend/extensions/Squidex.Extensions/Squidex.Extensions.csproj

@ -17,9 +17,9 @@
<PackageReference Include="CoreTweet" Version="1.0.0.483" />
<PackageReference Include="Elasticsearch.Net" Version="7.17.5" />
<PackageReference Include="Google.Cloud.Diagnostics.Common" Version="5.2.0" />
<PackageReference Include="Google.Cloud.Logging.V2" Version="4.3.0" />
<PackageReference Include="Google.Cloud.Logging.V2" Version="4.4.0" />
<PackageReference Include="Google.Cloud.Monitoring.V3" Version="3.9.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -27,7 +27,7 @@
<PackageReference Include="Microsoft.Azure.SignalR.Management" Version="1.25.2" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.2" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="OpenSearch.Net" Version="1.7.1" />
<PackageReference Include="OpenTelemetry.Exporter.Console" Version="1.8.1" />

2
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.",

1
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": "",

2
backend/src/Migrations/Migrations.csproj

@ -6,7 +6,7 @@
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
backend/src/Squidex.Domain.Apps.Core.Model/Squidex.Domain.Apps.Core.Model.csproj

@ -12,7 +12,7 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

9
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)
{

10
backend/src/Squidex.Domain.Apps.Core.Operations/Squidex.Domain.Apps.Core.Operations.csproj

@ -18,18 +18,18 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Fluid.Core" Version="2.9.0" />
<PackageReference Include="Fluid.Core" Version="2.10.0" />
<PackageReference Include="GeoJSON.Net" Version="1.2.19" />
<PackageReference Include="Jint" Version="3.1.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Jint" Version="3.1.2" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="NJsonSchema" Version="11.0.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.AI" Version="6.6.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.6.4" />
<PackageReference Include="Squidex.AI" Version="6.8.8" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.8.8" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />

2
backend/src/Squidex.Domain.Apps.Entities.MongoDb/Squidex.Domain.Apps.Entities.MongoDb.csproj

@ -19,7 +19,7 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Lucene.Net.QueryParser" Version="4.8.0-beta00016" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
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; }
}

2
backend/src/Squidex.Domain.Apps.Entities/Assets/Commands/UploadAssetCommand.cs

@ -14,7 +14,7 @@ public abstract class UploadAssetCommand : AssetCommand
{
public HashSet<string> Tags { get; set; } = [];
public AssetFile File { get; set; }
public IAssetFile File { get; set; }
public AssetMetadata Metadata { get; } = [];

2
backend/src/Squidex.Domain.Apps.Entities/Assets/DomainObject/AssetCommandMiddleware.cs

@ -167,7 +167,7 @@ public sealed class AssetCommandMiddleware : CachingDomainObjectMiddleware<Asset
}
}
private static string ComputeHash(AssetFile file, HasherStream hashStream)
private static string ComputeHash(IAssetFile file, HasherStream hashStream)
{
var steamHash = hashStream.GetHashStringAndReset();

4
backend/src/Squidex.Domain.Apps.Entities/Assets/FileTagAssetMetadataSource.cs

@ -19,7 +19,7 @@ public sealed class FileTagAssetMetadataSource : IAssetMetadataSource
{
private sealed class FileAbstraction : IFileAbstraction
{
private readonly AssetFile file;
private readonly IAssetFile file;
public string Name
{
@ -36,7 +36,7 @@ public sealed class FileTagAssetMetadataSource : IAssetMetadataSource
get => throw new NotSupportedException();
}
public FileAbstraction(AssetFile file)
public FileAbstraction(IAssetFile file)
{
this.file = file;
}

4
backend/src/Squidex.Domain.Apps.Entities/Squidex.Domain.Apps.Entities.csproj

@ -24,10 +24,10 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="32.0.2" />
<PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.DataLoader" Version="7.8.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

1
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;

2
backend/src/Squidex.Domain.Apps.Events/Squidex.Domain.Apps.Events.csproj

@ -14,7 +14,7 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
backend/src/Squidex.Domain.Users.MongoDb/Squidex.Domain.Users.MongoDb.csproj

@ -19,7 +19,7 @@
<ProjectReference Include="..\Squidex.Shared\Squidex.Shared.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

6
backend/src/Squidex.Domain.Users/Squidex.Domain.Users.csproj

@ -18,13 +18,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="IdentityModel" Version="7.0.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Identity.Stores" Version="8.0.5" />
<PackageReference Include="Microsoft.Win32.Registry" Version="5.0.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="5.5.0" />
<PackageReference Include="OpenIddict.AspNetCore" Version="5.6.0" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="SharpPwned.NET" Version="2.0.1" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />

10
backend/src/Squidex.Infrastructure.GetEventStore/Squidex.Infrastructure.GetEventStore.csproj

@ -11,11 +11,11 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="EventStore.Client.Grpc.PersistentSubscriptions" Version="23.2.1" />
<PackageReference Include="EventStore.Client.Grpc.ProjectionManagement" Version="23.2.1" />
<PackageReference Include="EventStore.Client.Grpc.Streams" Version="23.2.1" />
<PackageReference Include="Grpc.Net.Client" Version="2.62.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="EventStore.Client.Grpc.PersistentSubscriptions" Version="23.3.1" />
<PackageReference Include="EventStore.Client.Grpc.ProjectionManagement" Version="23.3.1" />
<PackageReference Include="EventStore.Client.Grpc.Streams" Version="23.3.1" />
<PackageReference Include="Grpc.Net.Client" Version="2.63.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

2
backend/src/Squidex.Infrastructure.MongoDb/Squidex.Infrastructure.MongoDb.csproj

@ -14,7 +14,7 @@
<ProjectReference Include="..\Squidex.Infrastructure\Squidex.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

22
backend/src/Squidex.Infrastructure/Squidex.Infrastructure.csproj

@ -11,30 +11,30 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MailKit" Version="4.5.0" />
<PackageReference Include="MailKit" Version="4.6.0" />
<PackageReference Include="McMaster.NETCore.Plugins" Version="1.4.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.4" />
<PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.Abstractions" Version="8.0.5" />
<PackageReference Include="Microsoft.IO.RecyclableMemoryStream" Version="3.0.0" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.2" />
<PackageReference Include="NodaTime" Version="3.1.11" />
<PackageReference Include="OpenTelemetry.Api" Version="1.8.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets" Version="6.6.4" />
<PackageReference Include="Squidex.Caching" Version="6.6.4" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="6.6.4" />
<PackageReference Include="Squidex.Log" Version="6.6.4" />
<PackageReference Include="Squidex.Messaging" Version="6.6.4" />
<PackageReference Include="Squidex.Text" Version="6.6.4" />
<PackageReference Include="Squidex.Assets" Version="6.8.8" />
<PackageReference Include="Squidex.Caching" Version="6.8.8" />
<PackageReference Include="Squidex.Hosting.Abstractions" Version="6.8.8" />
<PackageReference Include="Squidex.Log" Version="6.8.8" />
<PackageReference Include="Squidex.Messaging" Version="6.8.8" />
<PackageReference Include="Squidex.Text" Version="6.8.8" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Collections.Immutable" Version="8.0.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="System.Linq.Async" Version="6.0.1" />
<PackageReference Include="System.Reactive" Version="6.0.0" />
<PackageReference Include="System.Reactive" Version="6.0.1" />
<PackageReference Include="System.Reflection.TypeExtensions" Version="4.7.0" />
<PackageReference Include="System.Security.Claims" Version="4.3.0" />
</ItemGroup>

2
backend/src/Squidex.Shared/Squidex.Shared.csproj

@ -10,7 +10,7 @@
<DebugSymbols>True</DebugSymbols>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

31
backend/src/Squidex.Web/FileExtensions.cs

@ -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);
}
}

8
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);
}
}

2
backend/src/Squidex.Web/Squidex.Web.csproj

@ -16,7 +16,7 @@
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="GraphQL.Server.Transports.AspNetCore" Version="7.7.1" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

144
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<IAssetFile> 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<IAssetFile?> 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<IRequestSizeLimitMetadata>()?.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<bool> 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;
}
}

16
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<ErrorDtoProcessor>()
.As<IOperationProcessor>();
services.AddSingletonAs<ScopesProcessor>()
.As<IOperationProcessor>();
services.AddSingletonAs<TagByGroupNameProcessor>()
.As<IOperationProcessor>();
services.AddSingletonAs<CommonProcessor>()
.As<IDocumentProcessor>();
@ -39,15 +44,12 @@ public static class OpenApiServices
services.AddSingletonAs<SecurityProcessor>()
.As<IDocumentProcessor>();
services.AddSingletonAs<ScopesProcessor>()
.As<IOperationProcessor>();
services.AddSingletonAs<TagByGroupNameProcessor>()
.As<IOperationProcessor>();
services.AddSingletonAs<SchemaNameGenerator>()
.As<ISchemaNameGenerator>();
services.AddSingletonAs<AssetFileResolver>()
.AsSelf();
services.AddSingletonAs<JsonSchemaGenerator>()
.AsSelf();

4
backend/src/Squidex/Areas/Api/Controllers/Apps/AppAssetsController.cs

@ -61,7 +61,9 @@ public sealed class AppAssetsController : ApiController
[ApiCosts(0)]
public async Task<IActionResult> PutAssetScripts(string app, [FromBody] UpdateAssetScriptsDto request)
{
var response = await InvokeCommandAsync(request.ToCommand());
var command = request.ToCommand();
var response = await InvokeCommandAsync(command);
return Ok(response);
}

4
backend/src/Squidex/Areas/Api/Controllers/Apps/AppSettingsController.cs

@ -61,7 +61,9 @@ public sealed class AppSettingsController : ApiController
[ApiCosts(0)]
public async Task<IActionResult> PutSettings(string app, [FromBody] UpdateAppSettingsDto request)
{
var response = await InvokeCommandAsync(request.ToCommand());
var command = request.ToCommand();
var response = await InvokeCommandAsync(command);
return Ok(response);
}

34
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<IActionResult> 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<IActionResult> 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<IActionResult> 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.
/// </summary>
/// <param name="app">The name of the app to update.</param>
/// <param name="file">The file to upload.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">App image uploaded.</response>
/// <response code="400">App request not valid.</response>
/// <response code="404">App not found.</response>
@ -193,9 +197,11 @@ public sealed class AppsController : ApiController
[ProducesResponseType(typeof(AppDto), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppImageUpload)]
[ApiCosts(0)]
public async Task<IActionResult> UploadImage(string app, IFormFile file)
public async Task<IActionResult> 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() };
}
}

21
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<UploadAppImage> ToCommandAsync(HttpContext httpContext)
{
var file = await ToFileAsync(httpContext, null);
return SimpleMapper.Map(this, new UploadAppImage { File = file });
}
}

40
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<IActionResult> 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<IActionResult> 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
/// </summary>
/// <param name="app">The name of the app.</param>
/// <param name="id">The ID of the asset.</param>
/// <param name="file">The file to upload.</param>
/// <param name="request">The request parameters.</param>
/// <response code="200">Asset updated.</response>
/// <response code="400">Asset request not valid.</response>
/// <response code="413">Asset exceeds the maximum upload size.</response>
@ -321,9 +314,9 @@ public sealed class AssetsController : ApiController
[AssetRequestSizeLimit]
[ApiPermissionOrAnonymous(PermissionIds.AppAssetsUpload)]
[ApiCosts(1)]
public async Task<IActionResult> PutAssetContent(string app, DomainId id, IFormFile file)
public async Task<IActionResult> 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<AssetFile> 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

16
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
{
/// <summary>
/// The file to upload.
/// </summary>
[FromForm(Name = "file")]
public IFormFile File { get; set; }
/// <summary>
/// The optional parent folder id.
/// </summary>
[FromQuery(Name = "parentId")]
// [FromQuery(Name = "parentId")]
public DomainId ParentId { get; set; }
/// <summary>
@ -41,8 +35,10 @@ public sealed class CreateAssetDto
[FromQuery(Name = "duplicate")]
public bool Duplicate { get; set; }
public CreateAsset ToCommand(AssetFile file)
public async Task<CreateAsset> 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()))

25
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<UpdateAsset> ToCommandAsync(DomainId id, HttpContext httpContext, App app)
{
var file = await ToFileAsync(httpContext, app);
return SimpleMapper.Map(this, new UpdateAsset { AssetId = id, File = file });
}
}

15
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
{
/// <summary>
/// The file to upload.
/// </summary>
[FromForm(Name = "file")]
public IFormFile File { get; set; }
/// <summary>
/// The optional parent folder id.
/// </summary>
@ -72,8 +67,10 @@ public sealed class UpsertAssetDto
return command;
}
public UpsertAsset ToCommand(DomainId id, AssetFile file)
public async Task<UpsertAsset> 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 });
}
}

6
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
};

8
backend/src/Squidex/Areas/Api/Controllers/Teams/TeamsController.cs

@ -98,7 +98,9 @@ public sealed class TeamsController : ApiController
[ApiCosts(0)]
public async Task<IActionResult> 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<IActionResult> PutTeam(string team, [FromBody] UpdateTeamDto request)
{
var response = await InvokeCommandAsync(request.ToCommand());
var command = request.ToCommand();
var response = await InvokeCommandAsync(command);
return Ok(response);
}

15
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
{
/// <summary>
/// Optional conversation ID.
/// </summary>
[FromQuery(Name = "conversationId")]
public string? ConversationId { get; set; }
/// <summary>
/// Optional configuration.
/// </summary>
[FromQuery(Name = "configuration")]
public string? Configuration { get; set; }
/// <summary>
/// The text to ask.
/// </summary>
[LocalizedRequired]
public string Prompt { get; set; }
[FromQuery(Name = "prompt")]
public string? Prompt { get; set; }
}

71
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
};
}
/// <summary>
/// Translate a text.
/// </summary>
@ -57,16 +78,52 @@ public sealed class TranslationsController : ApiController
/// <param name="app">The name of the app.</param>
/// <param name="request">The question request.</param>
/// <response code="200">Question asked.</response>
[HttpPost]
[HttpGet]
[Route("apps/{app}/ask/")]
[ProducesResponseType(typeof(string[]), StatusCodes.Status200OK)]
[ApiPermissionOrAnonymous(PermissionIds.AppTranslate)]
[ApiCosts(10)]
public async Task<IActionResult> 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);
}
}
});
}
}

42
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
{
/// <summary>
/// The file to upload.
/// </summary>
[FromForm(Name = "file")]
public IFormFile File { get; set; }
/// <summary>
/// The alternative URL to download from.
/// </summary>
[FromForm(Name = "fileUrl")]
public string? FileUrl { get; set; }
/// <summary>
/// The file name if the URL is specified.
/// </summary>
[FromForm(Name = "fileName")]
public string? FileName { get; set; }
public Task<IAssetFile> ToFileAsync(HttpContext httpContext, App? app)
{
var resolver = httpContext.RequestServices.GetRequiredService<AssetFileResolver>();
return resolver.ToFileAsync(this, httpContext, app,
httpContext.RequestAborted);
}
}

15
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<IActionResult> UploadPicture(List<IFormFile> file)
public Task<IActionResult> UploadPicture(List<IFormFile> 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);
}
}
}

24
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<LanguagesOptions>(config,
"languages");
services.Configure<ChatOptions>(config,
"chatbot");
services.AddSingletonAs<LanguagesInitializer>()
.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)

3
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<UrlGenerator>()
.As<IUrlGenerator>();
.As<IUrlGenerator>().As<IHttpImageEndpoint>();
services.AddSingletonAs<InstantGraphType>()
.AsSelf();

3
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";
});

3
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<AssetCleanupProcess>()
.AsSelf();

44
backend/src/Squidex/Squidex.csproj

@ -34,26 +34,26 @@
<ItemGroup>
<PackageReference Include="AspNet.Security.OAuth.GitHub" Version="8.0.0" />
<PackageReference Include="Google.Cloud.Trace.V2" Version="3.5.0" />
<PackageReference Include="Google.Cloud.Trace.V2" Version="3.6.0" />
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.MicrosoftDI" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.4" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.Google" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.MicrosoftAccount" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Razor.RuntimeCompilation" Version="8.0.5" />
<PackageReference Include="Microsoft.AspNetCore.SpaServices.Extensions" Version="8.0.5" />
<PackageReference Include="Microsoft.Bcl.AsyncInterfaces" Version="8.0.0" />
<PackageReference Include="Microsoft.CodeAnalysis.RulesetToEditorconfigConverter" Version="3.3.3" />
<PackageReference Include="Microsoft.Data.Edm" Version="5.8.5" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="8.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.5.1" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.1" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="7.5.2" />
<PackageReference Include="Microsoft.OData.Core" Version="7.21.2" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="MongoDB.Driver.Core.Extensions.OpenTelemetry" Version="1.0.0" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0" />
@ -64,19 +64,19 @@
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.8.1" />
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.8.1" />
<PackageReference Include="RefactoringEssentials" Version="5.6.0" PrivateAssets="all" />
<PackageReference Include="ReportGenerator" Version="5.2.5" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.FTP" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.Mongo" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.S3" Version="6.6.4" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.6.4" />
<PackageReference Include="Squidex.ClientLibrary" Version="19.1.0" />
<PackageReference Include="Squidex.Hosting" Version="6.6.4" />
<PackageReference Include="Squidex.Messaging.All" Version="6.6.4" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.6.4" />
<PackageReference Include="ReportGenerator" Version="5.3.4" PrivateAssets="all" />
<PackageReference Include="Squidex.Assets.Azure" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.GoogleCloud" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.FTP" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.ImageMagick" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.ImageSharp" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.Mongo" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.S3" Version="6.8.8" />
<PackageReference Include="Squidex.Assets.TusAdapter" Version="6.8.8" />
<PackageReference Include="Squidex.ClientLibrary" Version="19.2.0" />
<PackageReference Include="Squidex.Hosting" Version="6.8.8" />
<PackageReference Include="Squidex.Messaging.All" Version="6.8.8" />
<PackageReference Include="Squidex.Messaging.Subscriptions" Version="6.8.8" />
<PackageReference Include="Squidex.OpenIddict.MongoDb" Version="5.1.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="YDotNet" Version="0.4.0" />

17
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."
]
}
}
},

9
backend/tests/Squidex.Domain.Apps.Core.Tests/Operations/Scripting/JintScriptEngineHelperTests.cs

@ -619,8 +619,11 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
[Fact]
public async Task Should_generate_content()
{
A.CallTo(() => chatAgent.PromptAsync("prompt", A<string>._, A<CancellationToken>._))
.Returns(ChatBotResponse.Success("Generated"));
A.CallTo(() => chatAgent.PromptAsync(
A<ChatRequest>.That.Matches(x => x.Prompt == "prompt"),
A<ChatContext>._,
A<CancellationToken>._))
.Returns(new ChatResult { Content = "Generated", Metadata = new ChatMetadata() });
var vars = new ScriptVars
{
@ -660,7 +663,7 @@ public class JintScriptEngineHelperTests : IClassFixture<TranslationsFixture>
Assert.Equal(JsonValue.Null, actual);
A.CallTo(() => chatAgent.PromptAsync(A<string>._, A<string>._, A<CancellationToken>._))
A.CallTo(() => chatAgent.PromptAsync(A<ChatRequest>._, A<ChatContext>._, A<CancellationToken>._))
.MustNotHaveHappened();
A.CallTo(() => chatAgent.StopConversationAsync(A<string>._, A<CancellationToken>._))

8
backend/tests/Squidex.Domain.Apps.Core.Tests/Squidex.Domain.Apps.Core.Tests.csproj

@ -16,19 +16,19 @@
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NetTopologySuite.IO.GeoJSON4STJ" Version="4.0.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

4
backend/tests/Squidex.Domain.Apps.Entities.Tests/Assets/DomainObject/AssetCommandMiddlewareTests.cs

@ -20,11 +20,11 @@ public class AssetCommandMiddlewareTests : HandlerTestBase<Asset>
private readonly IDomainObjectCache domainObjectCache = A.Fake<IDomainObjectCache>();
private readonly IDomainObjectFactory domainObjectFactory = A.Fake<IDomainObjectFactory>();
private readonly IAssetEnricher assetEnricher = A.Fake<IAssetEnricher>();
private readonly IAssetFile file = new NoopAssetFile();
private readonly IAssetFileStore assetFileStore = A.Fake<IAssetFileStore>();
private readonly IAssetMetadataSource assetMetadataSource = A.Fake<IAssetMetadataSource>();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
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<Asset>
public AssetCommandMiddlewareTests()
{
file = new NoopAssetFile();
A.CallTo(() => assetQuery.FindByHashAsync(A<Context>._, A<string>._, A<string>._, A<long>._, CancellationToken))
.Returns(Task.FromResult<EnrichedAsset?>(null));

2
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<Asset>
{
private readonly IAssetFile file = new NoopAssetFile();
private readonly IAssetQueryService assetQuery = A.Fake<IAssetQueryService>();
private readonly IContentRepository contentRepository = A.Fake<IContentRepository>();
private readonly IScriptEngine scriptEngine = A.Fake<IScriptEngine>();
private readonly ITagService tagService = A.Fake<ITagService>();
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

2
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<IAssetThumbnailGenerator>();
private readonly IAssetFile file;
private readonly MemoryStream stream = new MemoryStream();
private readonly AssetFile file;
private readonly ImageAssetMetadataSource sut;
public ImageAssetMetadataSourceTests()

12
backend/tests/Squidex.Domain.Apps.Entities.Tests/Squidex.Domain.Apps.Entities.Tests.csproj

@ -27,21 +27,21 @@
<PackageReference Include="GraphQL" Version="7.8.0" />
<PackageReference Include="GraphQL.SystemTextJson" Version="7.8.0" />
<PackageReference Include="Lorem.Universal.Net" Version="4.0.80" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="8.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NodaTime.Serialization.JsonNet" Version="3.1.0" />
<PackageReference Include="Squidex.YDotNet.Native" Version="0.2.9" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.Reactive.Linq" Version="6.0.0" />
<PackageReference Include="System.Reactive.Linq" Version="6.0.1" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="Verify.Xunit" Version="24.1.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PackageReference Include="Verify.Xunit" Version="24.2.0" />
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

9
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();
}
}

8
backend/tests/Squidex.Domain.Users.Tests/Squidex.Domain.Users.Tests.csproj

@ -16,15 +16,15 @@
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

8
backend/tests/Squidex.Infrastructure.Tests/Squidex.Infrastructure.Tests.csproj

@ -16,7 +16,7 @@
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@ -24,12 +24,12 @@
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="NodaTime.Serialization.SystemTextJson" Version="1.2.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="System.ValueTuple" Version="4.5.0" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

8
backend/tests/Squidex.Web.Tests/Squidex.Web.Tests.csproj

@ -16,14 +16,14 @@
<ItemGroup>
<PackageReference Include="FakeItEasy" Version="8.2.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Meziantou.Analyzer" Version="2.0.150">
<PackageReference Include="Meziantou.Analyzer" Version="2.0.153">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.10.0" />
<PackageReference Include="StyleCop.Analyzers" Version="1.1.118" PrivateAssets="all" />
<PackageReference Include="xunit" Version="2.8.0" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.0">
<PackageReference Include="xunit" Version="2.8.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets>
</PackageReference>

2
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();

4
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<UsersDto> {
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<any>(url).pipe(
map(body => {

31
frontend/src/app/features/content/shared/forms/assets-editor.component.html

@ -10,6 +10,11 @@
{{ 'contents.assetsUpload' | sqxTranslate }}
</div>
</div>
<div class="col-auto" *ngIf="hasChatBot">
<button type="button" class="btn btn-outline-secondary force no-focus-shadow" (click)="chatDialog.show()" tabindex="-1">
AI
</button>
</div>
<div class="col-auto">
<div class="btn-group">
<button type="button" class="btn btn-secondary btn-toggle" [class.btn-primary]="snapshot.isListView" [disabled]="snapshot.isListView" (click)="changeView(true)">
@ -28,18 +33,18 @@
<div class="row g-0">
<sqx-asset *ngFor="let file of snapshot.assetFiles"
[assetFile]="file"
[isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact"
[isDisabled]="snapshot.isDisabled"
[folderId]="folderId"
(loadDone)="addAsset(file, $event)"
(loadError)="removeLoadingAsset(file)">
</sqx-asset>
<sqx-asset *ngFor="let asset of snapshot.assets; trackBy: trackByAsset"
<sqx-asset *ngFor="let asset of snapshot.assetItems; trackBy: trackByAsset"
[asset]="asset"
(edit)="editStart($event)"
[isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact"
[isDisabled]="snapshot.isDisabled"
removeMode="true"
(remove)="removeLoadedAsset(asset)"
(update)="notifyOthers(asset)">
@ -61,18 +66,18 @@
<div cdkDropList
[cdkDropListDisabled]="snapshot.isDisabled"
[cdkDropListData]="snapshot.assets"
[cdkDropListData]="snapshot.assetItems"
(cdkDropListDropped)="sortAssets($event)">
<div *ngFor="let asset of snapshot.assets; trackBy: trackByAsset" class="table-drag" cdkDrag cdkDragLockAxis="y">
<div *ngFor="let asset of snapshot.assetItems; trackBy: trackByAsset" class="table-drag" cdkDrag cdkDragLockAxis="y">
<sqx-asset
[asset]="asset"
(edit)="editStart($event)"
isListView="true"
[isDisabled]="snapshot.isDisabled"
[isCompact]="snapshot.isCompact"
(update)="notifyOthers(asset)"
removeMode="true"
(remove)="removeLoadedAsset(asset)">
[isDisabled]="snapshot.isDisabled"
isListView="true"
[removeMode]="true"
(remove)="removeLoadedAsset(asset)"
(update)="notifyOthers(asset)">
</sqx-asset>
</div>
</div>
@ -90,4 +95,8 @@
(assetReplaced)="notifyOthers($event)"
(assetUpdated)="notifyOthers($event)"
(dialogClose)="editDone()">
</sqx-asset-dialog>
</sqx-asset-dialog>
<sqx-chat-dialog *sqxModal="chatDialog" configuration="image" copyMode="Image"
(contentSelect)="addAssetFromAI($event)">
</sqx-chat-dialog>

52
frontend/src/app/features/content/shared/forms/assets-editor.component.ts

@ -9,7 +9,7 @@ import { CdkDrag, CdkDragDrop, CdkDropList } from '@angular/cdk/drag-drop';
import { NgFor, NgIf } from '@angular/common';
import { booleanAttribute, ChangeDetectionStrategy, Component, forwardRef, Input, OnInit } from '@angular/core';
import { NG_VALUE_ACCESSOR } from '@angular/forms';
import { AssetComponent, AssetDialogComponent, AssetDto, AssetSelectorComponent, DialogModel, FileDropDirective, LocalStoreService, MessageBus, ModalDirective, ResizedDirective, ResolveAssets, Settings, sorted, StatefulControlComponent, Subscriptions, TranslatePipe, Types } from '@app/shared';
import { AssetComponent, AssetDialogComponent, AssetDto, AssetSelectorComponent, ChatDialogComponent, DialogModel, FileDropDirective, HTTP, LocalStoreService, MessageBus, ModalDirective, ResizedDirective, ResolveAssets, Settings, sorted, StatefulControlComponent, Subscriptions, TranslatePipe, Types } from '@app/shared';
export const SQX_ASSETS_EDITOR_CONTROL_VALUE_ACCESSOR: any = {
provide: NG_VALUE_ACCESSOR, useExisting: forwardRef(() => AssetsEditorComponent), multi: true,
@ -25,10 +25,10 @@ class AssetUpdated {
interface State {
// The uploading files.
assetFiles: ReadonlyArray<File>;
assetFiles: ReadonlyArray<HTTP.UploadFile>;
// The assets to render.
assets: ReadonlyArray<AssetDto>;
assetItems: ReadonlyArray<AssetDto>;
// The asset to edit.
editAsset?: AssetDto;
@ -55,6 +55,7 @@ interface State {
AssetSelectorComponent,
CdkDrag,
CdkDropList,
ChatDialogComponent,
FileDropDirective,
ModalDirective,
NgFor,
@ -69,6 +70,9 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
@Input()
public folderId?: string;
@Input({ required: true, transform: booleanAttribute })
public hasChatBot = false;
@Input({ transform: booleanAttribute })
public isExpanded = false;
@ -77,6 +81,8 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
this.setDisabledState(value === true);
}
public chatDialog = new DialogModel();
public assetsDialog = new DialogModel();
constructor(localStore: LocalStoreService,
@ -84,7 +90,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
private readonly messageBus: MessageBus,
) {
super({
assets: [],
assetItems: [],
assetFiles: [],
isListView: localStore.getBoolean(Settings.Local.ASSETS_MODE),
});
@ -96,7 +102,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
public writeValue(obj: any) {
if (Types.isArrayOfString(obj)) {
if (!Types.equals(obj, this.snapshot.assets.map(x => x.id))) {
if (!Types.equals(obj, this.snapshot.assetItems.map(x => x.id))) {
const assetIds: string[] = obj;
this.assetsResolver.resolveMany(obj)
@ -104,7 +110,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
next: ({ items }) => {
this.setAssets(items);
if (this.snapshot.assets.length !== assetIds.length) {
if (this.snapshot.assetItems.length !== assetIds.length) {
this.updateValue();
}
},
@ -126,7 +132,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
this.subscriptions.add(
this.messageBus.of(AssetUpdated)
.subscribe(event => {
this.setAssets(this.snapshot.assets.replacedBy('id', event.asset));
this.setAssets(this.snapshot.assetItems.replacedBy('id', event.asset));
}));
}
@ -135,10 +141,26 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
}
public setAssets(assets: ReadonlyArray<AssetDto>) {
this.next({ assets });
this.next({ assetItems: assets });
}
public addBlob(content: string | HTTP.UploadFile | null | undefined) {
this.chatDialog.hide();
if (content && !Types.isString(content)) {
this.addFiles([content]);
}
}
public addAssetFromAI(file: string | HTTP.UploadFile | null | undefined) {
this.chatDialog.hide();
if (!Types.isString(file) && file) {
this.addFiles([file]);
}
}
public addFiles(files: ReadonlyArray<File>) {
public addFiles(files: ReadonlyArray<HTTP.UploadFile>) {
for (const file of files) {
this.next(s => ({
...s,
@ -148,7 +170,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
}
public selectAssets(assets: ReadonlyArray<AssetDto>) {
this.setAssets([...this.snapshot.assets, ...assets]);
this.setAssets([...this.snapshot.assetItems, ...assets]);
if (assets.length > 0) {
this.updateValue();
@ -157,12 +179,12 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
this.assetsDialog.hide();
}
public addAsset(file: File, asset: AssetDto) {
public addAsset(file: HTTP.UploadFile, asset: AssetDto) {
if (asset && file) {
this.next(s => ({
...s,
assetFiles: s.assetFiles.removed(file),
assets: [asset, ...s.assets],
assetItems: [asset, ...s.assetItems],
}));
this.updateValue();
@ -179,13 +201,13 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
public removeLoadedAsset(asset: AssetDto) {
if (asset) {
this.setAssets(this.snapshot.assets.removed(asset));
this.setAssets(this.snapshot.assetItems.removed(asset));
this.updateValue();
}
}
public removeLoadingAsset(file: File) {
public removeLoadingAsset(file: HTTP.UploadFile) {
this.next(s => ({
...s,
assetFiles: s.assetFiles.removed(file),
@ -205,7 +227,7 @@ export class AssetsEditorComponent extends StatefulControlComponent<State, Reado
}
private updateValue() {
const ids = this.snapshot.assets.map(x => x.id);
const ids = this.snapshot.assetItems.map(x => x.id);
if (ids.length === 0) {
this.callChange(null);

13
frontend/src/app/features/content/shared/forms/field-editor.component.html

@ -1,7 +1,7 @@
<div class="field" [class.expanded]="isExpanded" *ngIf="formModel">
<fieldset class="buttons-container" [disabled]="isDisabled | async">
<div class="buttons">
<button type="button" class="btn btn-sm btn-outline-secondary force no-focus-shadow" (click)="chatDialog.show()" [disabled]="!hasChatBot" tabindex="-1">
<button type="button" class="btn btn-sm btn-outline-secondary force no-focus-shadow" (click)="chatDialog.show()" [disabled]="!hasChatBot || !isString" tabindex="-1">
AI
</button>
@ -60,7 +60,12 @@
</sqx-array-editor>
</ng-container>
<ng-container *ngSwitchCase="'Assets'">
<sqx-assets-editor [formControl]="$any(fieldForm)" [folderId]="field.rawProperties.folderId" [isExpanded]="isExpanded"></sqx-assets-editor>
<sqx-assets-editor
[formControl]="$any(fieldForm)"
[folderId]="field.rawProperties.folderId"
[hasChatBot]="hasChatBot"
[isExpanded]="isExpanded">
</sqx-assets-editor>
</ng-container>
<ng-container *ngSwitchCase="'Boolean'">
<ng-container [ngSwitch]="field.rawProperties.editor">
@ -287,6 +292,6 @@
</sqx-form-hint>
</div>
<sqx-chat-dialog *sqxModal="chatDialog" showFormatHint="true"
(textSelect)="setValue($event)">
<sqx-chat-dialog *sqxModal="chatDialog"
(contentSelect)="setValue($event)">
</sqx-chat-dialog>

16
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);
}
}
}

5
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<T>(event: CdkDragDrop<ReadonlyArray<T>>): T[] {
const items = <T[]>event.container.data;
@ -16,12 +17,12 @@ export function sorted<T>(event: CdkDragDrop<ReadonlyArray<T>>): T[] {
return items;
}
export function getFiles(files: FileList | ReadonlyArray<File>) {
export function getFiles(files: FileList | ReadonlyArray<HTTP.UploadFile>) {
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]);

14
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<T = any>(http: HttpClient, method: string, url: string, file: Blob, version?: Version): Observable<HttpEvent<T>> {
export type UploadFile = File | { url: string; name: string };
export function upload<T = any>(http: HttpClient, method: string, url: string, file: UploadFile, version?: Version): Observable<HttpEvent<T>> {
const req = new HttpRequest(method, url, getFormData(file), { headers: createHeaders(version, undefined), reportProgress: true });
return http.request<T>(req);
@ -60,10 +62,16 @@ export module HTTP {
return handleVersion(http.request<T>(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;
}

16
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);
}
}

30
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');
});
});

30
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<string, any>) {
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) {

4
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<Blob | null>) {
public uploadEdited(fileChange: Promise<HTTP.UploadFile | null>) {
fileChange.then(file => {
if (file) {
this.setProgress(0);

10
frontend/src/app/shared/components/assets/asset-text-editor.component.ts

@ -48,13 +48,17 @@ export class AssetTextEditorComponent implements OnInit {
});
}
public toFile(): Promise<Blob | null> {
return new Promise<Blob | null>(resolve => {
public toFile(): Promise<File | null> {
return new Promise<File | null>(resolve => {
const blob = new Blob([this.text || ''], {
type: this.mimeType,
});
resolve(blob);
const file = new File([blob], 'content.txt', {
type: this.mimeType,
});
resolve(file);
});
}
}

4
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<File>) {
public addFiles(files: ReadonlyArray<HTTP.UploadFile>) {
for (const file of files) {
this.assetUploader.uploadFile(file)
.subscribe({

6
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<State> implements OnInit {
public selectFolder = new EventEmitter<string>();
@Input()
public assetFile?: File;
public assetFile?: HTTP.UploadFile;
@Input()
public asset?: AssetDto;
@ -127,7 +127,7 @@ export class AssetComponent extends StatefulComponent<State> implements OnInit {
}
}
public updateFile(files: ReadonlyArray<File>) {
public updateFile(files: ReadonlyArray<HTTP.UploadFile>) {
const asset = this.asset;
if (files.length === 1 && asset?.canUpload) {

10
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<File>;
newFiles: ReadonlyArray<HTTP.UploadFile>;
}
@Component({
@ -64,7 +64,7 @@ export class AssetsListComponent extends StatefulComponent<State> {
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<State> {
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<State> {
return true;
}
public addFiles(files: ReadonlyArray<File>) {
public addFiles(files: ReadonlyArray<HTTP.UploadFile>) {
this.next(s => ({
...s,
newFiles: [...getFiles(files), ...s.newFiles],

14
frontend/src/app/shared/components/assets/image-cropper.component.ts

@ -115,8 +115,8 @@ export class ImageCropperComponent implements AfterViewInit, OnDestroy {
}
}
public toFile(): Promise<Blob | null> {
return new Promise<Blob | null>(resolve => {
public async toFile(): Promise<File | null> {
return new Promise<File | null>(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');
}
}

23
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<string, any> = {};
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<string, any> = {
version: asset.version,
};
return url;
if (this.authService.user) {
query['access_token'] = this.authService.user.accessToken;
}
return url + StringHelper.buildQuery(query);
}
}

99
frontend/src/app/shared/components/chat-dialog.component.html

@ -1,97 +1,20 @@
<sqx-modal-dialog size="lg" (dialogClose)="textSelect.emit()" fullHeight="true" flexBody="true">
<sqx-modal-dialog size="lg" (dialogClose)="contentSelect.emit()" fullHeight="true" flexBody="true">
<ng-container title>
{{ 'chat.title' | sqxTranslate }}
</ng-container>
<ng-container content>
<div class="scroll-container" #container>
<div>
<div class="item">
<div class="row">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right">
<p>{{ 'chat.description' | sqxTranslate }}</p>
<p *ngIf="showFormatHint">{{ 'chat.describeFormat' | sqxTranslate }}</p>
</div>
</div>
</div>
</div>
<div class="item" *ngFor="let item of snapshot.chatTalk"
[sqxScrollActive]="true"
[sqxScrollOffset]="30"
[sqxScrollContainer]="container">
<div class="row mt-3" *ngIf="item.type === 'user'">
<div class="col">
<div class="bubble bubble-left">
{{item.text}}
</div>
</div>
<div class="col-auto">
<img class="user-picture" title="{{user.displayName}}" [src]="user.id | sqxUserIdPicture" />
</div>
</div>
<div class="row mt-3" *ngIf="item.type === 'system'">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right">
{{ item.text | sqxTranslate}}
</div>
</div>
</div>
<div class="row mt-3" *ngIf="item.type === 'bot'">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right use-container">
<div class="mb-2">
{{ 'chat.answer' | sqxTranslate}}
</div>
<span [sqxMarkdown]="item.text" optional="true" inline="false"></span>
<button type="button" class="btn btn-secondary" (click)="textSelect.emit(item.text)">
{{ 'chat.use' | sqxTranslate }}
</button>
</div>
</div>
</div>
</div>
<div class="row mt-3" *ngIf="snapshot.isRunning"
[sqxScrollActive]="true"
[sqxScrollOffset]="30"
[sqxScrollContainer]="container">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right">
<svg height="40" width="40" class="loader">
<circle class="dot" cx="10" cy="20" r="3" />
<circle class="dot" cx="20" cy="20" r="3" />
<circle class="dot" cx="30" cy="20" r="3" />
</svg>
</div>
</div>
</div>
</div>
<sqx-chat-item *ngFor="let item of snapshot.chatItems; let isLast = last; let isFirst = first"
[content]="item.content"
(contentSelect)="contentSelect.emit($event)"
[copyMode]="copyMode"
(done)="setCompleted()"
[isFirst]="isFirst"
[isLast]="isLast"
[type]="item.type"
[user]="user">
</sqx-chat-item>
</div>
</ng-container>

61
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;
}

98
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<ChatEventDto>; 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<State> {
private readonly conversationId = MathHelper.guid();
@Output()
public textSelect = new EventEmitter<string | undefined | null>();
public contentSelect = new EventEmitter<string | HTTP.UploadFile | undefined | null>();
@Input()
public configuration?: string;
@Input({ required: true, transform: booleanAttribute })
public showFormatHint = false;
@Input()
public copyMode?: 'Text' | 'Image';
@ViewChild('input', { static: false })
public input!: ElementRef<HTMLInputElement>;
@ -63,16 +63,32 @@ export class ChatDialogComponent extends StatefulComponent<State> {
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<State> {
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 });
},
});
}
}

62
frontend/src/app/shared/components/chat-item.component.html

@ -0,0 +1,62 @@
<div class="row mt-3" *ngIf="type === 'User'">
<div class="col">
<div class="bubble bubble-left">
<span [sqxMarkdown]="snapshot.content" optional="false" inline="false"></span>
</div>
</div>
<div class="col-auto">
<img class="user-picture" title="{{user.displayName}}" [src]="user.id | sqxUserIdPicture" />
</div>
</div>
<div class="row mt-3" *ngIf="type === 'System'">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right">
{{ content | sqxTranslate}}
</div>
</div>
</div>
<div class="row mt-3" *ngIf="type === 'Bot'">
<div class="col-auto">
<div class="squid squid-sm d-flex align-items-center justify-content-center">
<img src="./images/squid.svg">
</div>
</div>
<div class="col">
<div class="bubble bubble-right use-container">
<div class="content" (sqxResized)="scrollIntoView()" #contentElement>
<div class="mb-2" *ngIf="snapshot.runningTools.length > 0">
<div class="badge badge-secondary me-1" *ngFor="let tool of snapshot.runningTools">
{{tool}}
</div>
</div>
<span *ngIf="!snapshot.isFailed" [sqxMarkdown]="snapshot.content" (load)="scrollIntoView()" optional="false" inline="false"></span>
<span *ngIf="snapshot.isFailed">
{{ 'chat.failed' | sqxTranslate }}
</span>
<ng-container *ngIf="!snapshot.isRunning && !isFirst && type === 'Bot'">
<button type="button" class="btn btn-secondary btn-sm btn-text" (click)="selectContent()">
{{ 'chat.use' | sqxTranslate }}
</button>
</ng-container>
</div>
<svg height="10" viewBox="0 0 40 16" class="loader" *ngIf="snapshot.isRunning">
<circle class="dot" cx="8" cy="8" r="4" />
<circle class="dot" cx="20" cy="8" r="4" />
<circle class="dot" cx="32" cy="8" r="4" />
</svg>
</div>
</div>
</div>
<div #focusElement></div>

82
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;
}

142
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<State> {
@ViewChild('focusElement', { static: false })
public focusElement!: ElementRef<HTMLElement>;
@ViewChild('contentElement', { static: false })
public contentElement!: ElementRef<HTMLElement>;
@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<ChatEventDto>) {
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<string | HTTP.UploadFile | undefined | null>();
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 });
}
}

6
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<State,
}
}
private async ngAfterViewInitGoogle(apiKey: string) {
await this.resourceLoader.loadScript(`https://maps.googleapis.com/maps/api/js?key=${apiKey}&libraries=places`);
private async ngAfterViewInitGoogle(key: string) {
await this.resourceLoader.loadScript(`https://maps.googleapis.com/maps/api/js${StringHelper.buildQuery({ key, libraries: 'places' })}`);
this.map = new google.maps.Map(this.editor.nativeElement, {
zoom: 1,

4
frontend/src/app/shared/components/forms/rich-editor.component.html

@ -15,6 +15,6 @@
[schemaIdentifiers]="schemaIds">
</sqx-content-selector>
<sqx-chat-dialog *sqxModal="chatDialog" showFormatHint="false"
(textSelect)="insertText($event)">
<sqx-chat-dialog *sqxModal="chatDialog"
(contentSelect)="insertText($event)">
</sqx-chat-dialog>

8
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;
}

2
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<number | AppDto> {
public postAppImage(appName: string, resource: Resource, file: HTTP.UploadFile, version: Version): Observable<number | AppDto> {
const link = resource._links['image/upload'];
const url = this.apiUrl.buildUrl(link.href);

25
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<AssetFoldersDto> {
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<any>(url).pipe(
map(body => {
@ -269,12 +262,8 @@ export class AssetsService {
pretifyError('i18n:assets.loadFailed'));
}
public postAssetFile(appName: string, file: Blob, parentId?: string): Observable<number | AssetDto> {
let url = this.apiUrl.buildUrl(`api/apps/${appName}/assets`);
if (parentId) {
url += `?parentId=${parentId}`;
}
public postAssetFile(appName: string, file: HTTP.UploadFile, parentId?: string): Observable<number | AssetDto> {
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<number | AssetDto> {
public putAssetFile(appName: string, resource: Resource, file: HTTP.UploadFile, version: Version): Observable<number | AssetDto> {
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<Versioned<any>> {
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'));

10
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<ContentsDto> {
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<any>(url, buildHeaders(q)).pipe(
map(body => {
@ -253,7 +253,7 @@ export class ContentsService {
public getContentReferencing(appName: string, schemaName: string, id: string, q?: ContentsByQuery): Observable<ContentsDto> {
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<any>(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<ContentDto> {
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 } {

4
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<ReadonlyArray<HistoryEventDto>> {
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({

4
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<FeaturesDto> {
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<FeaturesDto>(url).pipe(
pretifyError('i18n:features.loadFailed'));

4
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<RuleEventsDto> {
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<any>(url).pipe(
map(body => {

2
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();

4
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<ReadonlyArray<SearchResultDto>> {
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<any[]>(url).pipe(
map(body => {

4
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();

3
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<ReadonlyArray<StockPhotoDto>> {
const url = `https://stockphoto.squidex.io/?query=${query}&page=${page}`;
const url = `https://stockphoto.squidex.io${StringHelper.buildQuery({ query, page })}`;
return this.http.get<any[]>(url).pipe(
map(body => {

26
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<string>;
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',
]);
}));
});

79
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<ReadonlyArray<string>> {
const url = this.apiUrl.buildUrl(`api/apps/${appName}/ask`);
public ask(appName: string, request: AskDto): Observable<ChatEventDto> {
const token = this.authService.user!.accessToken;
return this.http.post<any>(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<ChatEventDto>((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();
};
});
}
}

2
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();

4
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<ReadonlyArray<UserDto>> {
const url = this.apiUrl.buildUrl(`api/users?query=${query || ''}`);
const url = this.apiUrl.buildUrl(`api/users${StringHelper.buildQuery({ query })}`);
return this.http.get<any[]>(url).pipe(
map(body => {

6
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<Snapshot> {
}, 'Stopped');
}
public uploadFile(file: File, parentId?: string): Observable<AssetDto | number> {
public uploadFile(file: HTTP.UploadFile, parentId?: string): Observable<AssetDto | number> {
const stream = this.assetsService.postAssetFile(this.appName, file, parentId);
return this.upload(stream, MathHelper.guid(), file.name);
}
public uploadAsset(asset: AssetDto, file: Blob): Observable<AssetDto | number> {
public uploadAsset(asset: AssetDto, file: HTTP.UploadFile): Observable<AssetDto | number> {
const stream = this.assetsService.putAssetFile(this.appName, asset, file, asset.version);
return this.upload(stream, asset.id, (file as any)['name'] || asset.fileName);

Loading…
Cancel
Save