Add execution authorization input flow and inline node deletion UI

This commit is contained in:
Lucio Lelii 2026-03-11 16:57:14 +01:00
parent 78d8527a85
commit f9a2504273
21 changed files with 653 additions and 39 deletions

View File

@ -1,3 +1,4 @@
<router-outlet />
<app-confirm-dialog-host></app-confirm-dialog-host>
<app-human-interaction-dialog-host></app-human-interaction-dialog-host>
<app-node-settings-dialog-host></app-node-settings-dialog-host>

View File

@ -1,11 +1,12 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { ConfirmDialogHostComponent } from '@shared/confirm-dialog/confirm-dialog';
import { HumanInteractionDialogHostComponent } from '@shared/human-interaction-dialog/human-interaction-dialog';
import { NodeSettingsDialogHostComponent } from '@shared/node-settings-dialog/node-settings-dialog';
@Component({
selector: 'app-root',
imports: [RouterOutlet, ConfirmDialogHostComponent, NodeSettingsDialogHostComponent],
imports: [RouterOutlet, ConfirmDialogHostComponent, HumanInteractionDialogHostComponent, NodeSettingsDialogHostComponent],
templateUrl: './app.html',
styleUrl: './app.css'
})

View File

@ -11,6 +11,17 @@ export type TaskExecution = {
creationTime: number;
context: TaskExecutionContext;
stepConnections?: FlowBlockConnection[];
requiredAuthorizations?: Record<string, TaskExecutionAuthorizationRequirement>;
providedAuthorizations?: Record<string, unknown>;
missingAuthorizationKeys?: string[];
};
export type TaskExecutionAuthorizationRequirement = {
key: string;
provider: string;
fieldName: string;
description: string;
requiredBySteps: string[];
};
export type TaskExecutionContext = {

View File

@ -0,0 +1,42 @@
import { Injectable, signal } from '@angular/core';
export type HumanInteractionDialogInput = {
title?: string;
actionDescription: string;
currentInput: string;
};
export type HumanInteractionDialogResult = {
mode: 'confirm' | 'edit';
value: string;
};
@Injectable({ providedIn: 'root' })
export class HumanInteractionDialogService {
private _state = signal<{
title: string;
actionDescription: string;
currentInput: string;
resolve: (value: HumanInteractionDialogResult | null) => void;
} | null>(null);
readonly state = this._state.asReadonly();
open(input: HumanInteractionDialogInput): Promise<HumanInteractionDialogResult | null> {
return new Promise((resolve) => {
this._state.set({
title: input.title ?? 'Human interaction',
actionDescription: input.actionDescription,
currentInput: input.currentInput,
resolve
});
});
}
close(value: HumanInteractionDialogResult | null) {
const state = this._state();
if (!state) return;
state.resolve(value);
this._state.set(null);
}
}

View File

@ -18,4 +18,15 @@ export abstract class TaskExecutionsCallServiceBase {
inputName: string,
file: File
): Observable<TaskExecution>;
abstract submitInteractionText(
executionId: string,
nodeId: string,
fieldName: string,
value: string
): Observable<TaskExecution>;
abstract provideAuthorization(
executionId: string,
key: string,
value: string
): Observable<TaskExecution>;
}

View File

@ -8,6 +8,17 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
id: 'c106be9d-5467-428c-8992-0b5f40a59aac',
name: 'Test Flow',
creationTime: 1772619308394,
requiredAuthorizations: {
'LLMProvider::testProvider::authorization': {
key: 'LLMProvider::testProvider::authorization',
provider: 'testProvider',
fieldName: 'authorization',
description: 'API key required for testProvider',
requiredBySteps: ['first', 'second']
}
},
providedAuthorizations: {},
missingAuthorizationKeys: ['LLMProvider::testProvider::authorization'],
context: {
inputs: {
'feeb2977-370f-4bf9-aa84-04fa2f11e365:name': 'Leonardo da Vinci'
@ -100,6 +111,9 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
id: 'eda4040e-ae58-4ffb-a3b4-16dfef45a6c2',
name: 'Test Flow',
creationTime: 1772623910033,
requiredAuthorizations: {},
providedAuthorizations: {},
missingAuthorizationKeys: [],
context: {
inputs: {
'95ebb03f-80e0-412d-87ee-2d4b7ddef240:name': 'marie curie'
@ -217,6 +231,9 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
id: '74ec477f-b04e-494c-80cc-968a40527bef',
name: 'Test Flow',
creationTime: 1772705504567,
requiredAuthorizations: {},
providedAuthorizations: {},
missingAuthorizationKeys: [],
context: {
inputs: {
'f91ec0f7-03e8-4208-89ac-bd9db46dca8c:name': 'marie curie'
@ -501,6 +518,40 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override submitInteractionText(
executionId: string,
nodeId: string,
fieldName: string,
value: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.executionResult[`${nodeId}:${fieldName}`] = value;
execution.context.result[`${nodeId}:${fieldName}`] = value;
execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId);
const step = execution.context.steps[nodeId];
if (step) {
step.status = 'COMPLETED';
}
execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : 'RUNNING';
return of(execution);
}
override provideAuthorization(
executionId: string,
key: string,
value: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.providedAuthorizations = {
...(execution.providedAuthorizations ?? {}),
[key]: value ? 'provided' : ''
};
execution.missingAuthorizationKeys = (execution.missingAuthorizationKeys ?? []).filter((item) => item !== key);
return of(execution);
}
private findExecution(executionId: string): TaskExecution {
const execution = this.data.find((item) => item.id === executionId);
if (!execution) {

View File

@ -47,4 +47,25 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
formData.append('file', file);
return this.http.put<TaskExecution>(url, formData);
}
override submitInteractionText(
executionId: string,
nodeId: string,
fieldName: string,
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/interaction/${encodeURIComponent(fieldName)}/text`;
return this.http.put<TaskExecution>(url, value, {
headers: { 'Content-Type': 'text/plain' }
});
}
override provideAuthorization(
executionId: string,
key: string,
value: string
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/authorizations`;
return this.http.put<TaskExecution>(url, { key, value });
}
}

View File

@ -87,6 +87,26 @@ export class TaskExecutionsService {
);
}
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) {
return this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Submit interaction text failed', err);
return throwError(() => err);
})
);
}
provideAuthorization(executionId: string, key: string, value: string) {
return this.taskExecutionsCallService.provideAuthorization(executionId, key, value).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Provide authorization failed', err);
return throwError(() => err);
})
);
}
private updatePollingState(taskExecutions: TaskExecution[]) {
const shouldPoll = taskExecutions.some((execution) =>
getExecutionStatusGroup(execution.context.status) === 'RUNNING'

View File

@ -0,0 +1,51 @@
@if (state(); as currentState) {
<div class="fixed inset-0 z-9999 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/45" (click)="cancel($event)"></div>
<div class="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div>
<h3 class="text-lg font-semibold text-slate-900">{{ currentState.title }}</h3>
<p class="mt-1 text-sm text-slate-500">Confirm the input as node output or edit it before sending.</p>
</div>
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="cancel($event)">Close</button>
</div>
<div class="grid flex-1 gap-4 overflow-y-auto px-5 py-4">
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Action Description</legend>
<div class="max-h-32 overflow-y-auto whitespace-pre-wrap break-words text-sm text-slate-800">{{ currentState.actionDescription || '-' }}</div>
</fieldset>
<fieldset class="rounded-lg border border-slate-200 bg-slate-50 p-3">
<legend class="px-1 text-xs font-semibold uppercase tracking-wide text-slate-500">Current Input</legend>
<div class="max-h-56 overflow-y-auto whitespace-pre-wrap break-words font-mono text-sm text-slate-800">{{ currentState.currentInput || '-' }}</div>
</fieldset>
@if (editing()) {
<label class="block text-sm font-medium text-slate-700">
Edit Response
<textarea
class="form-control mt-2 min-h-56"
rows="10"
[ngModel]="draftValue"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
</textarea>
</label>
}
</div>
<div class="flex justify-end gap-2 border-t border-slate-200 px-5 py-4">
<button type="button" class="btn btn-outline-secondary" (click)="cancel($event)">Cancel</button>
@if (!editing()) {
<button type="button" class="btn btn-outline-primary" (click)="startEditing($event)">Edit Response</button>
<button type="button" class="btn btn-primary" (click)="confirmInput($event)">Confirm Input</button>
} @else {
<button type="button" class="btn btn-outline-secondary" (click)="backToConfirm($event)">Back</button>
<button type="button" class="btn btn-primary" [disabled]="!canSendEditedOutput()" (click)="sendEditedOutput($event)">Send Output</button>
}
</div>
</div>
</div>
}

View File

@ -0,0 +1,82 @@
import { Component, effect, ElementRef, inject, signal } from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
HumanInteractionDialogResult,
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
@Component({
selector: 'app-human-interaction-dialog-host',
standalone: true,
imports: [FormsModule],
templateUrl: './human-interaction-dialog.html'
})
export class HumanInteractionDialogHostComponent {
private dialog = inject(HumanInteractionDialogService);
private host = inject(ElementRef<HTMLElement>);
readonly state = this.dialog.state;
readonly editing = signal(false);
draftValue = '';
constructor() {
effect(() => {
const state = this.state();
if (!state) return;
this.editing.set(false);
this.draftValue = state.currentInput;
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
});
});
}
cancel(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.dialog.close(null);
}
startEditing(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.editing.set(true);
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
});
}
backToConfirm(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.editing.set(false);
}
setDraftValue(value: string) {
this.draftValue = value;
}
confirmInput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.closeWith({ mode: 'confirm', value: this.state()?.currentInput ?? '' });
}
sendEditedOutput(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'edit', value: this.draftValue });
}
canSendEditedOutput(): boolean {
return this.draftValue.trim().length > 0;
}
private closeWith(value: HumanInteractionDialogResult) {
this.dialog.close(value);
}
}

View File

@ -11,6 +11,19 @@
color: #0f172a;
}
.llm-node-delete-pending {
filter: grayscale(1);
}
.llm-delete-overlay {
position: absolute;
inset: 0;
z-index: 135;
border-radius: 16px;
background: rgba(226, 232, 240, 0.62);
backdrop-filter: blur(1px);
}
.llm-loading-overlay {
position: absolute;
inset: 0;
@ -248,6 +261,54 @@
display: inline-flex;
align-items: center;
gap: 6px;
position: relative;
}
.llm-delete-confirm {
position: absolute;
top: calc(100% + 8px);
right: 32px;
z-index: 145;
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border: 1px solid #fecaca;
border-radius: 10px;
background: #fff7f7;
color: #7f1d1d;
box-shadow: 0 10px 24px rgba(15, 23, 42, 0.18);
white-space: nowrap;
}
.llm-delete-confirm-text {
font-size: 11px;
font-weight: 700;
}
.llm-delete-confirm-cancel,
.llm-delete-confirm-action {
border: 0;
border-radius: 999px;
padding: 4px 9px;
font-size: 11px;
font-weight: 700;
cursor: pointer;
}
.llm-delete-confirm-cancel {
background: #e2e8f0;
color: #334155;
}
.llm-delete-confirm-action {
background: #dc2626;
color: #fff7ed;
}
.llm-delete-btn {
position: relative;
z-index: 146;
}
.llm-warning {

View File

@ -1,5 +1,6 @@
<div class="llm-node"
[class.llm-node-error]="hasUpdateBlockError()"
[class.llm-node-delete-pending]="deleteConfirmOpen"
[class.llm-node--human]="isHumanNode()">
@if (isCreatingOnServer()) {
<div class="llm-loading-overlay">
@ -9,6 +10,9 @@
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="llm-delete-overlay"></div>
}
<div class="llm-header cursor-move">
<div class="llm-icon">
@if (isHumanNode()) {
@ -51,6 +55,13 @@
</div>
</div>
}
@if (deleteConfirmOpen) {
<div class="llm-delete-confirm" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<span class="llm-delete-confirm-text">Delete node?</span>
<button type="button" class="llm-delete-confirm-cancel" (click)="cancelDelete($event)">Cancel</button>
<button type="button" class="llm-delete-confirm-action" (click)="confirmDelete($event)">Delete</button>
</div>
}
<button type="button" class="llm-delete-btn" title="Delete node" (pointerdown)="$event.stopPropagation()" (click)="confirmDelete($event)">
<i class="bi bi-trash"></i>
</button>

View File

@ -111,6 +111,7 @@ export class GenericNodeComponent {
localEditorHasRetriever = false;
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
deleteConfirmOpen = false;
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
@ -265,9 +266,10 @@ export class GenericNodeComponent {
async confirmDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
const confirmed = window.confirm('Do you want to delete this node from the flow?');
if (!confirmed) return;
if (!this.deleteConfirmOpen) {
this.deleteConfirmOpen = true;
return;
}
const deleteNode = this.data?.data?.deleteNode;
if (typeof deleteNode === 'function') {
@ -275,6 +277,12 @@ export class GenericNodeComponent {
}
}
cancelDelete(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.deleteConfirmOpen = false;
}
isHumanNode(): boolean {
return this.blockType === 'HumanInteractionBlock';
}

View File

@ -90,6 +90,12 @@
font-size: 17px;
}
.llm-attention-trigger:disabled {
cursor: wait;
opacity: 0.7;
animation: none;
}
.llm-error-alert-wrap {
right: -12px;
}

View File

@ -12,6 +12,7 @@
type="button"
class="llm-attention-trigger"
aria-label="Open waiting step details"
[disabled]="interactionSubmitting"
(click)="openInteractionModal($event)">
<i class="bi bi-hand-index-thumb-fill"></i>
</button>

View File

@ -3,7 +3,8 @@ import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angul
import { ClassicPreset } from 'rete';
import { ReteModule } from 'rete-angular-plugin/21';
import { BlocksService } from '@services/blocks/blocks';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import {
type UiConditionRule,
evaluateUiConditionRule,
@ -48,7 +49,8 @@ type MainContentView = {
export class TaskStepNodeComponent {
private blocksService = inject(BlocksService);
private cdr = inject(ChangeDetectorRef);
private settingsDialog = inject(NodeSettingsDialogService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private taskExecutionsService = inject(TaskExecutionsService);
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -65,6 +67,7 @@ export class TaskStepNodeComponent {
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
private blockSchema: Record<string, any> | null = null;
private variablePlaceholderPaths = new Set<string>();
@ -275,36 +278,36 @@ export class TaskStepNodeComponent {
async openInteractionModal(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.interactionSubmitting) return;
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
const interactionFieldName = this.interactionFieldName();
if (!executionId || !executionNodeId || !interactionFieldName) return;
const currentInput = this.currentInputValue();
const actionDescription = this.actionDescriptionValue();
const currentOutput = this.currentOutputValue();
await this.settingsDialog.open({
title: `Filling output of ${this.name || 'Interaction Step'}`,
fields: [
{
key: 'actionDescription',
label: 'Action Description',
type: 'display'
},
{
key: 'currentInput',
label: 'Current Input',
type: 'display',
copyable: true
},
{
key: 'output',
label: '',
type: 'textarea',
rows: 12,
autofocus: true
}
],
initial: {
actionDescription,
currentInput,
output: currentOutput
const result = await this.humanInteractionDialog.open({
title: `Send output for ${this.name || 'Interaction Step'}`,
actionDescription,
currentInput
});
if (!result) return;
this.interactionSubmitting = true;
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
interactionFieldName,
result.value
).subscribe({
next: () => {
this.interactionSubmitting = false;
},
error: (error) => {
this.interactionSubmitting = false;
console.error('Submit interaction output failed', error);
}
});
}
@ -329,9 +332,19 @@ export class TaskStepNodeComponent {
return this.executionInputTooltip(inputKey) ?? 'not ready yet';
}
private currentOutputValue(): string {
const outputKey = this.outputs[0]?.key ?? 'output';
return this.executionOutputTooltip(outputKey) ?? '';
private executionId(): string | null {
const value = this.blockConfiguration?.['__executionId'];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private executionNodeId(): string | null {
const value = this.blockConfiguration?.['__executionNodeId'];
return typeof value === 'string' && value.length > 0 ? value : null;
}
private interactionFieldName(): string | null {
const outputKey = this.outputs[0]?.key;
return typeof outputKey === 'string' && outputKey.length > 0 ? outputKey : null;
}
private pickMainContentEntries(entries: Array<{ path: string; value: unknown }>) {

View File

@ -1,7 +1,73 @@
<div class="p-3 space-y-3">
@if (!editableInputs().length) {
@if (authorizationRequirements().length) {
<section class="space-y-3">
<div>
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Provider Authorizations</div>
<div class="mt-1 text-[11px] text-slate-500">Provide the missing runtime credentials required by this execution.</div>
</div>
@for (requirement of authorizationRequirements(); track requirement.key) {
<div class="rounded-md border border-amber-200 bg-amber-50 p-3">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="text-xs font-semibold text-slate-800">{{ requirement.provider }}</div>
<div class="mt-1 text-[11px] text-slate-500 break-all">{{ requirement.key }}</div>
</div>
<span class="rounded-full bg-white px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-amber-700">
{{ requirement.fieldName || 'authorization' }}
</span>
</div>
<div class="mt-2 text-xs text-slate-600">{{ requirement.description || 'Runtime authorization required.' }}</div>
@if (requirement.requiredBySteps.length) {
<div class="mt-2 text-[11px] text-slate-500">
Required by: {{ requirement.requiredBySteps.join(', ') }}
</div>
}
<div class="mt-3 flex items-start gap-2">
<input
class="form-control form-control-sm"
[type]="isAuthorizationVisible(requirement.key) ? 'text' : 'password'"
[readonly]="readOnly()"
[ngModel]="authorizationValue(requirement.key)"
(ngModelChange)="onAuthorizationValueChange(requirement, $event)" />
<button
type="button"
class="btn btn-outline-secondary btn-sm shrink-0"
[disabled]="readOnly()"
(click)="toggleAuthorizationVisibility(requirement.key, $event)">
{{ isAuthorizationVisible(requirement.key) ? 'Hide' : 'Show' }}
</button>
<button
type="button"
class="btn btn-primary btn-sm shrink-0"
[disabled]="!canSubmitAuthorization(requirement.key)"
(click)="submitAuthorization(requirement, $event)">
Save
</button>
</div>
@if (isAuthorizationSaving(requirement.key)) {
<div class="mt-2 text-[11px] text-blue-600">Saving authorization...</div>
}
@if (authorizationSavingError(requirement.key); as errorMessage) {
<div class="mt-2 text-[11px] text-red-600">{{ errorMessage }}</div>
}
</div>
}
</section>
}
@if (!editableInputs().length && !authorizationRequirements().length) {
<div class="text-xs text-slate-500">No manual inputs required.</div>
} @else {
@if (editableInputs().length) {
<div class="text-xs font-semibold uppercase tracking-wide text-slate-600">Manual Inputs</div>
}
@for (executionInput of editableInputs(); track executionInput.key) {
<div class="rounded-md border border-slate-200 bg-slate-50 p-2">
<div class="text-xs font-semibold text-slate-700 mb-1">{{ executionInput.label }}</div>

View File

@ -1,6 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, input, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { TaskExecutionAuthorizationRequirement } from '@models/task-execution';
export type EditableExecutionInput = {
key: string;
@ -18,12 +19,20 @@ export type EditableExecutionInput = {
})
export class TaskExecutionInputsPanelComponent {
readonly editableInputs = input<EditableExecutionInput[]>([]);
readonly authorizationRequirements = input<TaskExecutionAuthorizationRequirement[]>([]);
readonly authorizationValues = input<Record<string, string>>({});
readonly savingAuthorizations = input<Record<string, boolean>>({});
readonly authorizationErrors = input<Record<string, string>>({});
readonly savingInputs = input<Record<string, boolean>>({});
readonly savingErrors = input<Record<string, string>>({});
readonly readOnly = input<boolean>(false);
readonly textInputChange = output<{ input: EditableExecutionInput; value: string }>();
readonly fileInputChange = output<{ input: EditableExecutionInput; file: File }>();
readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>();
readonly authorizationSubmit = output<TaskExecutionAuthorizationRequirement>();
private readonly authorizationVisibility = new Map<string, boolean>();
isFileInput(input: EditableExecutionInput): boolean {
return input.type.includes('FILE') || input.type.includes('BINARY');
@ -49,4 +58,44 @@ export class TaskExecutionInputsPanelComponent {
inputSavingError(key: string): string | null {
return this.savingErrors()[key] ?? null;
}
authorizationValue(key: string): string {
return this.authorizationValues()[key] ?? '';
}
onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) {
if (this.readOnly()) return;
this.authorizationValueChange.emit({ requirement, value });
}
submitAuthorization(requirement: TaskExecutionAuthorizationRequirement, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.readOnly() || !this.canSubmitAuthorization(requirement.key)) return;
this.authorizationSubmit.emit(requirement);
}
canSubmitAuthorization(key: string): boolean {
if (this.readOnly()) return false;
if (this.isAuthorizationSaving(key)) return false;
return this.authorizationValue(key).trim().length > 0;
}
isAuthorizationSaving(key: string): boolean {
return this.savingAuthorizations()[key] === true;
}
authorizationSavingError(key: string): string | null {
return this.authorizationErrors()[key] ?? null;
}
toggleAuthorizationVisibility(key: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.authorizationVisibility.set(key, !this.isAuthorizationVisible(key));
}
isAuthorizationVisible(key: string): boolean {
return this.authorizationVisibility.get(key) === true;
}
}

View File

@ -67,9 +67,15 @@
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-slate-50">Execution Inputs</div>
<app-task-execution-inputs-panel
[editableInputs]="editableInputs()"
[authorizationRequirements]="missingAuthorizationRequirements()"
[authorizationValues]="pendingAuthorizationValues()"
[savingAuthorizations]="savingAuthorizations()"
[authorizationErrors]="authorizationErrors()"
[savingInputs]="savingInputs()"
[savingErrors]="savingErrors()"
[readOnly]="inputsReadOnly()"
(authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)"
(authorizationSubmit)="submitAuthorization($event)"
(textInputChange)="onTextInputChange($event.input, $event.value)"
(fileInputChange)="onFileInputChange($event.input, $event.file)">
</app-task-execution-inputs-panel>

View File

@ -1,7 +1,12 @@
import { CommonModule } from '@angular/common';
import { Component, computed, inject, input, OnDestroy, signal } from '@angular/core';
import { Component, computed, effect, inject, input, OnDestroy, signal } from '@angular/core';
import { FlowBlockConnection, FlowData } from '@models/flow';
import { getExecutionStatusGroup, TaskExecution, TaskExecutionStep } from '@models/task-execution';
import {
getExecutionStatusGroup,
TaskExecution,
TaskExecutionAuthorizationRequirement,
TaskExecutionStep
} from '@models/task-execution';
import {
EditableExecutionInput,
TaskExecutionInputsPanelComponent
@ -19,17 +24,48 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200;
private taskExecutionsService = inject(TaskExecutionsService);
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private lastExecutionId: string | null = null;
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly startInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string>>({});
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
constructor() {
effect(() => {
const executionId = this.execution()?.id ?? null;
if (executionId === this.lastExecutionId) return;
this.lastExecutionId = executionId;
this.pendingAuthorizationValues.set({});
this.savingAuthorizations.set({});
this.authorizationErrors.set({});
});
}
readonly stepsArray = computed(() =>
Object.values(this.execution()?.context.steps ?? {})
);
readonly authorizationRequirements = computed<TaskExecutionAuthorizationRequirement[]>(() => {
const execution = this.execution();
if (!execution?.requiredAuthorizations) return [];
const required = execution.requiredAuthorizations;
const entries = Array.isArray(required) ? required : Object.values(required);
return entries
.filter((entry): entry is TaskExecutionAuthorizationRequirement => !!entry && typeof entry.key === 'string')
.sort((a, b) => a.provider.localeCompare(b.provider) || a.key.localeCompare(b.key));
});
readonly missingAuthorizationRequirements = computed<TaskExecutionAuthorizationRequirement[]>(() => {
const missingKeys = new Set(this.execution()?.missingAuthorizationKeys ?? []);
return this.authorizationRequirements().filter((requirement) => missingKeys.has(requirement.key));
});
readonly executionFlowData = computed<FlowData>(() => {
const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status);
const contextInputs = this.execution()?.context.inputs ?? {};
@ -45,6 +81,8 @@ export class TaskExecutionViewerComponent implements OnDestroy {
...step.block,
specificConfiguration: {
...(step.block.specificConfiguration ?? {}),
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
@ -79,6 +117,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly canStartExecution = computed(() => {
const execution = this.execution();
if (!execution) return false;
if ((execution.missingAuthorizationKeys?.length ?? 0) > 0) return false;
const statusGroup = getExecutionStatusGroup(execution.context.status);
if (statusGroup !== 'INIT') return false;
@ -182,6 +221,36 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
onAuthorizationValueChange(requirement: TaskExecutionAuthorizationRequirement, value: string) {
this.pendingAuthorizationValues.update((current) => ({ ...current, [requirement.key]: value }));
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[requirement.key];
return next;
});
}
submitAuthorization(requirement: TaskExecutionAuthorizationRequirement) {
const executionId = this.execution()?.id;
if (!executionId) return;
const value = (this.pendingAuthorizationValues()[requirement.key] ?? '').trim();
if (!value) return;
this.setAuthorizationSaving(requirement.key, true);
this.taskExecutionsService.provideAuthorization(executionId, requirement.key, value).subscribe({
next: () => {
this.pendingAuthorizationValues.update((current) => {
const next = { ...current };
delete next[requirement.key];
return next;
});
this.clearAuthorizationSaving(requirement.key);
},
error: () => this.setAuthorizationError(requirement.key, 'Failed to save authorization')
});
}
private setInputSaving(key: string, saving: boolean) {
this.savingInputs.update((current) => ({ ...current, [key]: saving }));
if (saving) {
@ -214,6 +283,31 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.textInputDebounceTimers.clear();
}
private setAuthorizationSaving(key: string, saving: boolean) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: saving }));
if (saving) {
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
}
private clearAuthorizationSaving(key: string) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
this.authorizationErrors.update((current) => {
const next = { ...current };
delete next[key];
return next;
});
}
private setAuthorizationError(key: string, message: string) {
this.savingAuthorizations.update((current) => ({ ...current, [key]: false }));
this.authorizationErrors.update((current) => ({ ...current, [key]: message }));
}
private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) {
if (this.inputsReadOnly() || this.execution()?.id !== executionId) return;

View File

@ -131,6 +131,14 @@ export async function addBlockToEditor(
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
const removeNode = async () => {
if (!editor.getNode(node.id)) return;
const relatedConnectionIds = editor.getConnections()
.filter((connection) => connection.source === node.id || connection.target === node.id)
.map((connection) => connection.id);
for (const connectionId of relatedConnectionIds) {
await editor.removeConnection(connectionId);
}
await editor.removeNode(node.id);
};
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {