mirror of https://github.com/Squidex/squidex.git
committed by
GitHub
98 changed files with 1159 additions and 642 deletions
@ -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); |
|
||||
} |
|
||||
} |
|
||||
@ -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; |
||||
|
} |
||||
|
} |
||||
@ -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 }); |
||||
|
} |
||||
|
} |
||||
@ -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 }); |
||||
|
} |
||||
|
} |
||||
@ -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); |
||||
|
} |
||||
|
} |
||||
@ -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> |
||||
@ -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; |
||||
|
} |
||||
@ -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 }); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue