Refine task previews and add node clone actions
This commit is contained in:
parent
518fff6de2
commit
167e301a21
|
|
@ -8,6 +8,7 @@ let lastServiceErrorNotificationAt = 0;
|
|||
export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
||||
const token = getToken();
|
||||
const requestPath = req.url.split('?')[0];
|
||||
const isChangePasswordEndpoint = requestPath.endsWith('/auth/change-password');
|
||||
const isAuthEndpoint =
|
||||
requestPath.endsWith('/auth/login') ||
|
||||
requestPath.endsWith('/auth/register');
|
||||
|
|
@ -40,6 +41,7 @@ export const authTokenInterceptor: HttpInterceptorFn = (req, next) => {
|
|||
if (
|
||||
hasToken &&
|
||||
!isAuthEndpoint &&
|
||||
!isChangePasswordEndpoint &&
|
||||
error instanceof HttpErrorResponse &&
|
||||
error.status === 401
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -80,3 +80,22 @@
|
|||
.app-user-trigger:hover {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
.app-password-success-banner {
|
||||
position: fixed;
|
||||
top: 76px;
|
||||
left: 50%;
|
||||
z-index: 10010;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
transform: translateX(-50%);
|
||||
border: 1px solid #86efac;
|
||||
border-radius: 999px;
|
||||
background: #f0fdf4;
|
||||
box-shadow: 0 14px 30px rgba(15, 23, 42, 0.14);
|
||||
color: #166534;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
padding: 10px 14px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,11 +27,18 @@
|
|||
</button>
|
||||
</mat-menu>
|
||||
|
||||
@if (changePasswordOpen && loggedUser()?.username; as username) {
|
||||
@if (changePasswordSuccess()) {
|
||||
<div class="app-password-success-banner">
|
||||
<mat-icon fontIcon="check_circle"></mat-icon>
|
||||
<span>{{ changePasswordSuccess() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (changePasswordOpen() && loggedUser()?.username; as username) {
|
||||
<app-change-password-dialog
|
||||
[username]="username"
|
||||
[saving]="changePasswordSaving"
|
||||
[submitError]="changePasswordError"
|
||||
[saving]="changePasswordSaving()"
|
||||
[submitError]="changePasswordError()"
|
||||
(closed)="closeChangePasswordDialog()"
|
||||
(submitted)="submitPasswordChange($event)">
|
||||
</app-change-password-dialog>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { afterNextRender, Component, inject } from '@angular/core';
|
||||
import { afterNextRender, Component, inject, signal } from '@angular/core';
|
||||
import { MatButtonModule } from '@angular/material/button';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { MatMenuModule } from '@angular/material/menu';
|
||||
|
|
@ -26,9 +26,10 @@ export class AppLayout {
|
|||
private containersService = inject(ContainersService);
|
||||
|
||||
loggedUser = this.authService.loggedInUser;
|
||||
changePasswordOpen = false;
|
||||
changePasswordSaving = false;
|
||||
changePasswordError: string | null = null;
|
||||
changePasswordOpen = signal(false);
|
||||
changePasswordSaving = signal(false);
|
||||
changePasswordError = signal<string | null>(null);
|
||||
changePasswordSuccess = signal<string | null>(null);
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
|
|
@ -48,29 +49,33 @@ logout() {
|
|||
|
||||
openChangePasswordDialog() {
|
||||
if (!this.loggedUser()?.username?.trim()) return;
|
||||
this.changePasswordError = null;
|
||||
this.changePasswordOpen = true;
|
||||
this.changePasswordError.set(null);
|
||||
this.changePasswordOpen.set(true);
|
||||
}
|
||||
|
||||
closeChangePasswordDialog() {
|
||||
if (this.changePasswordSaving) return;
|
||||
this.changePasswordOpen = false;
|
||||
this.changePasswordError = null;
|
||||
if (this.changePasswordSaving()) return;
|
||||
this.changePasswordOpen.set(false);
|
||||
this.changePasswordError.set(null);
|
||||
}
|
||||
|
||||
submitPasswordChange(request: ChangePasswordRequest) {
|
||||
this.changePasswordSaving = true;
|
||||
this.changePasswordError = null;
|
||||
this.changePasswordSaving.set(true);
|
||||
this.changePasswordError.set(null);
|
||||
|
||||
this.authService.changePassword(request).subscribe({
|
||||
next: () => {
|
||||
this.changePasswordSaving = false;
|
||||
this.changePasswordOpen = false;
|
||||
window.alert('Password changed successfully.');
|
||||
this.changePasswordSaving.set(false);
|
||||
this.changePasswordOpen.set(false);
|
||||
this.changePasswordError.set(null);
|
||||
this.changePasswordSuccess.set('Password changed successfully.');
|
||||
setTimeout(() => {
|
||||
this.changePasswordSuccess.set(null);
|
||||
}, 3000);
|
||||
},
|
||||
error: (error) => {
|
||||
this.changePasswordSaving = false;
|
||||
this.changePasswordError = error instanceof Error ? error.message : 'Unable to change password.';
|
||||
this.changePasswordSaving.set(false);
|
||||
this.changePasswordError.set(error instanceof Error ? error.message : 'Unable to change password.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,45 @@ export class AuthorizationCallService extends AuthorizationCallServiceBase {
|
|||
}
|
||||
|
||||
override changePassword(request: ChangePasswordRequest): Observable<void> {
|
||||
return this.http.post<void>(`${environment.apiUrl}/auth/change-password`, request);
|
||||
return this.http
|
||||
.post(`${environment.apiUrl}/auth/change-password`, request, { responseType: 'text' })
|
||||
.pipe(
|
||||
map(() => undefined),
|
||||
catchError((error: unknown) => {
|
||||
if (error instanceof HttpErrorResponse) {
|
||||
const message = this.extractHttpErrorMessage(error)
|
||||
?? (error.status === 400 ? 'Missing required fields.' : null)
|
||||
?? (error.status === 401 ? 'Current password is invalid' : null)
|
||||
?? (error.status === 404 ? 'User not found' : null)
|
||||
?? 'Unable to change password.';
|
||||
return throwError(() => new Error(message));
|
||||
}
|
||||
return throwError(() => error);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
private extractHttpErrorMessage(error: HttpErrorResponse): string | null {
|
||||
const payload = error.error;
|
||||
if (typeof payload === 'string' && payload.trim().length > 0) {
|
||||
return payload.trim();
|
||||
}
|
||||
if (payload && typeof payload === 'object') {
|
||||
const record = payload as Record<string, unknown>;
|
||||
const directMessage = record['message'];
|
||||
if (typeof directMessage === 'string' && directMessage.trim().length > 0) {
|
||||
return directMessage.trim();
|
||||
}
|
||||
const errorMessage = record['error'];
|
||||
if (typeof errorMessage === 'string' && errorMessage.trim().length > 0) {
|
||||
return errorMessage.trim();
|
||||
}
|
||||
const details = record['details'];
|
||||
if (typeof details === 'string' && details.trim().length > 0) {
|
||||
return details.trim();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private extractToken(...sources: Array<Record<string, unknown> | undefined>): unknown {
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export type NodeSettingsDialogInput = {
|
|||
title?: string;
|
||||
fields: NodeSettingField[];
|
||||
initial?: NodeSettingsValues;
|
||||
previewOnly?: boolean;
|
||||
onValuesChange?: (draft: NodeSettingsValues) =>
|
||||
Promise<NodeSettingsDialogRefresh | null> | NodeSettingsDialogRefresh | null;
|
||||
};
|
||||
|
|
@ -42,6 +43,7 @@ export class NodeSettingsDialogService {
|
|||
title: string;
|
||||
fields: NodeSettingField[];
|
||||
initial: NodeSettingsValues;
|
||||
previewOnly: boolean;
|
||||
onValuesChange: ((draft: NodeSettingsValues) =>
|
||||
Promise<NodeSettingsDialogRefresh | null> | NodeSettingsDialogRefresh | null) | null;
|
||||
resolve: (value: NodeSettingsValues | null) => void;
|
||||
|
|
@ -55,6 +57,7 @@ export class NodeSettingsDialogService {
|
|||
title: input.title ?? "Node Settings",
|
||||
fields: input.fields,
|
||||
initial: input.initial ?? {},
|
||||
previewOnly: input.previewOnly === true,
|
||||
onValuesChange: input.onValuesChange ?? null,
|
||||
resolve
|
||||
});
|
||||
|
|
|
|||
|
|
@ -32,9 +32,6 @@
|
|||
<button type="button" mat-icon-button matSuffix (click)="hideNewPassword.set(!hideNewPassword())">
|
||||
<mat-icon [fontIcon]="hideNewPassword() ? 'visibility' : 'visibility_off'"></mat-icon>
|
||||
</button>
|
||||
@if (isInvalid(passwordForm.newPassword())) {
|
||||
<mat-error>{{ passwordForm.newPassword().errors()[0].message }}</mat-error>
|
||||
}
|
||||
</mat-form-field>
|
||||
|
||||
<div class="change-password-checklist">
|
||||
|
|
|
|||
|
|
@ -106,8 +106,12 @@
|
|||
</div>
|
||||
|
||||
<div class="mt-auto flex justify-end gap-2">
|
||||
@if (isPreviewOnly()) {
|
||||
<button type="button" mat-stroked-button (click)="cancel($event)">Close</button>
|
||||
} @else {
|
||||
<button type="button" mat-stroked-button (click)="cancel($event)">Cancel</button>
|
||||
<button type="button" mat-flat-button (click)="save($event)">Save</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ export class NodeSettingsDialogHostComponent {
|
|||
this.dialog.close({ ...this.draft });
|
||||
}
|
||||
|
||||
isPreviewOnly(): boolean {
|
||||
return this.state()?.previewOnly === true;
|
||||
}
|
||||
|
||||
setFieldValue(key: string, value: string | boolean) {
|
||||
this.draft[key] = value;
|
||||
void this.refreshFieldsFromDraft();
|
||||
|
|
|
|||
|
|
@ -157,6 +157,15 @@
|
|||
color: #f8fafc;
|
||||
}
|
||||
|
||||
.container-node__clone {
|
||||
border: 0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.22);
|
||||
color: #eff6ff;
|
||||
}
|
||||
|
||||
.container-node__header-actions {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
|
|
|
|||
|
|
@ -32,7 +32,10 @@
|
|||
</div>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button type="button" class="container-node__delete" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
|
||||
<button type="button" class="container-node__clone" matTooltip="Clone node" (pointerdown)="$event.stopPropagation()" (click)="cloneCurrentNode($event)">
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button type="button" class="container-node__delete" matTooltip="Delete node" (pointerdown)="$event.stopPropagation()" (click)="deleteNode($event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow';
|
||||
import { NodeSettingField, NodeSettingOption, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
|
|
@ -61,7 +62,7 @@ type StructuredRetrieverConfig = {
|
|||
|
||||
@Component({
|
||||
selector: 'app-container-node',
|
||||
imports: [CommonModule, ReteModule],
|
||||
imports: [CommonModule, ReteModule, MatTooltipModule],
|
||||
templateUrl: './container-node.html',
|
||||
styleUrl: './container-node.css',
|
||||
host: {
|
||||
|
|
@ -435,6 +436,17 @@ export class ContainerNodeComponent {
|
|||
this.deleteConfirmOpen = false;
|
||||
}
|
||||
|
||||
cloneCurrentNode(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const cloneNode = this.data?.data?.cloneNode;
|
||||
if (typeof cloneNode === 'function') {
|
||||
void cloneNode();
|
||||
}
|
||||
}
|
||||
|
||||
openSubflowPreview(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
|
|
|||
|
|
@ -366,6 +366,26 @@
|
|||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-clone-btn {
|
||||
position: relative;
|
||||
z-index: 146;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(191, 219, 254, 0.7);
|
||||
background: rgba(30, 64, 175, 0.18);
|
||||
color: #eff6ff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-clone-btn:hover {
|
||||
background: rgba(30, 64, 175, 0.34);
|
||||
}
|
||||
|
||||
.llm-delete-btn:hover {
|
||||
background: rgba(127, 29, 29, 0.35);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -66,7 +66,10 @@
|
|||
</div>
|
||||
}
|
||||
@if (!isReadonly) {
|
||||
<button type="button" class="llm-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
|
||||
<button type="button" class="llm-clone-btn" matTooltip="Clone node" (pointerdown)="$event.stopPropagation()" (click)="cloneCurrentNode($event)">
|
||||
<i class="bi bi-copy"></i>
|
||||
</button>
|
||||
<button type="button" class="llm-delete-btn" matTooltip="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { MatTooltipModule } from '@angular/material/tooltip';
|
||||
import { BlockType, currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
|
|
@ -132,7 +133,7 @@ type RichContentView = {
|
|||
|
||||
@Component({
|
||||
selector: 'app-generic-node',
|
||||
imports: [CommonModule, FormsModule, ReteModule],
|
||||
imports: [CommonModule, FormsModule, ReteModule, MatTooltipModule],
|
||||
templateUrl: './generic-node.html',
|
||||
styleUrl: './generic-node.css',
|
||||
host: {
|
||||
|
|
@ -390,6 +391,17 @@ export class GenericNodeComponent {
|
|||
this.deleteConfirmOpen = false;
|
||||
}
|
||||
|
||||
async cloneCurrentNode(event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (this.isReadonly) return;
|
||||
|
||||
const cloneNode = this.data?.data?.cloneNode;
|
||||
if (typeof cloneNode === 'function') {
|
||||
await cloneNode();
|
||||
}
|
||||
}
|
||||
|
||||
isHumanNode(): boolean {
|
||||
return !!this.blockDescriptor?.interactionContract;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@
|
|||
cursor: default !important;
|
||||
}
|
||||
|
||||
:host.llm-node-readonly .llm-subflow-view,
|
||||
:host.llm-node-readonly .llm-subflow-view * {
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.llm-node--human {
|
||||
border-color: #f7d7a7;
|
||||
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);
|
||||
|
|
|
|||
|
|
@ -290,11 +290,8 @@
|
|||
|
||||
@if (hasViewableSubflow()) {
|
||||
<div class="llm-subflow-section">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">Subflow</div>
|
||||
</div>
|
||||
<button type="button" class="llm-subflow-view" (pointerdown)="$event.stopPropagation()" (click)="openSubflowPreview($event)">
|
||||
View Flow
|
||||
View Subflow
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ export class TaskStepNodeComponent {
|
|||
}
|
||||
|
||||
isContainerNode(): boolean {
|
||||
return this.data?.data?.nodeFamily === 'container';
|
||||
return this.resolvedNodeFamily() === 'container';
|
||||
}
|
||||
|
||||
hasViewableSubflow(): boolean {
|
||||
|
|
@ -429,14 +429,14 @@ export class TaskStepNodeComponent {
|
|||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!field.expandable) return;
|
||||
void this.openReadonlyTextDialog(field.label, field.value);
|
||||
void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.value));
|
||||
}
|
||||
|
||||
openMainContentPreview(field: MainContentView, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
if (!field.expandable) return;
|
||||
void this.openReadonlyTextDialog(field.label, field.rawValue);
|
||||
void this.openReadonlyTextDialog(field.label, this.resolvePreviewText(field.rawValue));
|
||||
}
|
||||
|
||||
private get blockConfiguration(): Record<string, any> | null {
|
||||
|
|
@ -540,9 +540,7 @@ export class TaskStepNodeComponent {
|
|||
const type = this.blockType;
|
||||
if (!type) return;
|
||||
|
||||
const nodeFamily = this.data?.data?.nodeFamily === 'container'
|
||||
? 'container'
|
||||
: 'block';
|
||||
const nodeFamily = this.resolvedNodeFamily();
|
||||
const cachedDescriptor = nodeFamily === 'container'
|
||||
? this.containersService.peekContainerType(type)
|
||||
: this.blocksService.peekBlockType(type);
|
||||
|
|
@ -1035,10 +1033,29 @@ export class TaskStepNodeComponent {
|
|||
private nodeTypeCacheKey(): string | null {
|
||||
const type = this.blockType;
|
||||
if (!type) return null;
|
||||
const family = this.isContainerNode() ? 'container' : 'block';
|
||||
const family = this.resolvedNodeFamily();
|
||||
return `${family}:${type}`;
|
||||
}
|
||||
|
||||
private resolvedNodeFamily(): 'block' | 'container' {
|
||||
if (this.data?.data?.nodeFamily === 'container') {
|
||||
return 'container';
|
||||
}
|
||||
|
||||
const config = this.blockConfiguration;
|
||||
const subFlow = config?.['subFlow'];
|
||||
if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) {
|
||||
return 'container';
|
||||
}
|
||||
|
||||
const type = this.blockType;
|
||||
if (type && this.containersService.peekContainerType(type)) {
|
||||
return 'container';
|
||||
}
|
||||
|
||||
return 'block';
|
||||
}
|
||||
|
||||
private getGlobalCache<T>(store: Map<string, Map<string, T>>, typeKey: string): Map<string, T> {
|
||||
let cache = store.get(typeKey);
|
||||
if (!cache) {
|
||||
|
|
@ -1087,6 +1104,7 @@ export class TaskStepNodeComponent {
|
|||
private async openReadonlyTextDialog(label: string, value: string) {
|
||||
await this.settingsDialog.open({
|
||||
title: label,
|
||||
previewOnly: true,
|
||||
fields: [
|
||||
{
|
||||
key: 'value',
|
||||
|
|
@ -1102,4 +1120,27 @@ export class TaskStepNodeComponent {
|
|||
});
|
||||
}
|
||||
|
||||
private resolvePreviewText(value: string): string {
|
||||
const source = String(value ?? '');
|
||||
if (!source.includes('${{')) return source;
|
||||
|
||||
return source.replace(/\$\{\{\s*([^}]+?)\s*\}\}/g, (token, rawKey: string) => {
|
||||
const key = String(rawKey ?? '').trim();
|
||||
if (!key) return token;
|
||||
|
||||
const configInputs = this.blockConfiguration?.['__executionInputs'];
|
||||
const inputs = configInputs && typeof configInputs === 'object' && !Array.isArray(configInputs)
|
||||
? configInputs as Record<string, unknown>
|
||||
: null;
|
||||
if (!inputs || !Object.prototype.hasOwnProperty.call(inputs, key)) {
|
||||
return token;
|
||||
}
|
||||
|
||||
const resolved = inputs[key];
|
||||
if (resolved == null) return token;
|
||||
if (typeof resolved === 'string') return resolved;
|
||||
return valueToDisplayString(resolved);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ import {
|
|||
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
import { TaskExecutionsService } from '@services/task-executions/task-executions';
|
||||
import { ContainersService } from '@services/containers/containers';
|
||||
import { firstValueFrom } from 'rxjs';
|
||||
|
||||
type ExecutionOutputEntry = {
|
||||
|
|
@ -68,6 +69,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
private humanInteractionDialog = inject(HumanInteractionDialogService);
|
||||
private settingsDialog = inject(NodeSettingsDialogService);
|
||||
private fieldRetriever = inject(FieldRetriever);
|
||||
private containersService = inject(ContainersService);
|
||||
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private lastExecutionId: string | null = null;
|
||||
private lastExecutionStatus: string | null = null;
|
||||
|
|
@ -254,7 +256,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
|
||||
const executionNode: FlowNode = {
|
||||
...stepNode,
|
||||
nodeFamily: stepNode.nodeFamily === 'container' ? 'container' : 'block',
|
||||
nodeFamily: this.isContainerExecutionNode(stepNode) ? 'container' : 'block',
|
||||
specificConfiguration: {
|
||||
...(stepNode.specificConfiguration ?? {}),
|
||||
__executionId: this.execution()?.id ?? null,
|
||||
|
|
@ -773,6 +775,15 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
});
|
||||
}
|
||||
|
||||
private isContainerExecutionNode(stepNode: FlowNode): boolean {
|
||||
if (stepNode.nodeFamily === 'container') return true;
|
||||
|
||||
const subFlow = (stepNode.specificConfiguration as Record<string, unknown> | null | undefined)?.['subFlow'];
|
||||
if (subFlow && typeof subFlow === 'object' && !Array.isArray(subFlow)) return true;
|
||||
|
||||
return !!this.containersService.peekContainerType(stepNode.typeName);
|
||||
}
|
||||
|
||||
private scrollLogsToBottom() {
|
||||
const element = this.logsScrollViewport?.nativeElement;
|
||||
if (!element || this.activeAsideTab() !== 'logs') return;
|
||||
|
|
|
|||
|
|
@ -431,11 +431,41 @@ export async function addBlockToEditor(
|
|||
}
|
||||
}
|
||||
};
|
||||
const cloneNode = async () => {
|
||||
if (resolvedRuntime?.readonly) return;
|
||||
|
||||
const currentNode = editor.getNode(node.id) as HFNode | undefined;
|
||||
if (!currentNode?.data) return;
|
||||
|
||||
const sourceData = currentNode.data as Record<string, unknown>;
|
||||
const sourcePosition = sourceData['position'] as { x: number; y: number } | undefined;
|
||||
const nextPosition = sourcePosition
|
||||
? { x: sourcePosition.x + 48, y: sourcePosition.y + 48 }
|
||||
: { x: 168, y: 148 };
|
||||
|
||||
const clonedNode = {
|
||||
...cloneValue(block),
|
||||
...cloneValue(sourceData),
|
||||
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
|
||||
position: nextPosition,
|
||||
inputs: cloneValue((sourceData['inputs'] as FlowNode['inputs'] | undefined) ?? block.inputs ?? []),
|
||||
outputs: cloneValue((sourceData['outputs'] as FlowNode['outputs'] | undefined) ?? block.outputs ?? []),
|
||||
specificConfiguration: cloneValue((sourceData['specificConfiguration'] as FlowNode['specificConfiguration'] | undefined) ?? block.specificConfiguration ?? {}),
|
||||
nodeFamily: sourceData['nodeFamily'] === 'container' ? 'container' : 'block',
|
||||
__needsServerCreate: sourceData['nodeFamily'] === 'container' ? false : true,
|
||||
__createdOnServer: false,
|
||||
__isCreatingOnServer: false,
|
||||
__updateBlockError: null
|
||||
} as FlowNode & Record<string, unknown>;
|
||||
|
||||
await addBlockToEditor(editor, area, clonedNode, nextPosition, resolvedRuntime);
|
||||
};
|
||||
node.data = {
|
||||
...cloneValue(block),
|
||||
position: position ?? block.position,
|
||||
__readonly: resolvedRuntime?.readonly === true,
|
||||
deleteNode: removeNode,
|
||||
cloneNode,
|
||||
replaceWithCreatedNode,
|
||||
assignSelectedBlocksToContainer,
|
||||
assignImportedSubflow,
|
||||
|
|
|
|||
|
|
@ -437,30 +437,30 @@ body {
|
|||
}
|
||||
|
||||
.llm-subflow-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
padding: 10px 12px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.llm-subflow-view {
|
||||
align-self: flex-start;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 999px;
|
||||
background: #eff6ff;
|
||||
color: #1d4ed8;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid #c7d2fe;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #eef4ff 0%, #e0ecff 100%);
|
||||
color: #1e40af;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
padding: 7px 10px;
|
||||
padding: 11px 12px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
||||
transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.llm-subflow-view:hover {
|
||||
background: #dbeafe;
|
||||
background: linear-gradient(180deg, #dbeafe 0%, #c7ddff 100%);
|
||||
border-color: #93c5fd;
|
||||
}
|
||||
.link {
|
||||
@apply font-medium text-primary-600 hover:underline dark:text-primary-500;
|
||||
|
|
|
|||
Loading…
Reference in New Issue