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