Refine task previews and add node clone actions

This commit is contained in:
Lucio Lelii 2026-03-25 16:53:05 +01:00
parent 518fff6de2
commit 167e301a21
21 changed files with 276 additions and 54 deletions

View File

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

View File

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

View File

@ -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>

View File

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

View File

@ -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 {

View File

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

View File

@ -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">

View File

@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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,

View File

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