Add execution authorization input flow and inline node deletion UI
This commit is contained in:
parent
78d8527a85
commit
f9a2504273
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,6 +90,12 @@
|
|||
font-size: 17px;
|
||||
}
|
||||
|
||||
.llm-attention-trigger:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.7;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.llm-error-alert-wrap {
|
||||
right: -12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 }>) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue