From c0d2f7528516895b9e67a0f66996c85d49030fd9 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Fri, 13 Mar 2026 16:51:47 +0100 Subject: [PATCH] Support typed execution inputs and task node IO labels --- .../task-executions-call.base.ts | 12 +++ .../task-executions-call.fake.ts | 24 ++++++ .../task-executions/task-executions-call.ts | 24 ++++++ .../task-executions/task-executions.ts | 20 +++++ .../nodes/task-step-node/task-step-node.css | 27 ++++++- .../nodes/task-step-node/task-step-node.html | 10 ++- .../nodes/task-step-node/task-step-node.ts | 52 +++++++++++- .../task-execution-inputs-panel.html | 65 ++++++++++++--- .../task-execution-inputs-panel.ts | 47 +++++++++-- .../task-execution-viewer.html | 2 +- .../task-execution-viewer.ts | 80 ++++++++++++++++--- 11 files changed, 321 insertions(+), 42 deletions(-) diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 77d0cb6..8a98da8 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -12,12 +12,24 @@ export abstract class TaskExecutionsCallServiceBase { inputName: string, value: string ): Observable; + abstract prepareStringArrayInput( + executionId: string, + nodeId: string, + inputName: string, + values: string[] + ): Observable; abstract prepareFileInput( executionId: string, nodeId: string, inputName: string, file: File ): Observable; + abstract prepareFileArrayInput( + executionId: string, + nodeId: string, + inputName: string, + files: File[] + ): Observable; abstract submitInteractionText( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index cb11e6c..a0dcb93 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -506,6 +506,18 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override prepareStringArrayInput( + executionId: string, + nodeId: string, + inputName: string, + values: string[] + ): Observable { + const execution = this.findExecution(executionId); + execution.context.inputs[`${nodeId}:${inputName}`] = values; + execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status; + return of(execution); + } + override prepareFileInput( executionId: string, nodeId: string, @@ -518,6 +530,18 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override prepareFileArrayInput( + executionId: string, + nodeId: string, + inputName: string, + files: File[] + ): Observable { + const execution = this.findExecution(executionId); + execution.context.inputs[`${nodeId}:${inputName}`] = files.map((file) => file?.name ?? ''); + execution.context.status = execution.context.waitingSteps.length ? 'WAITING' : execution.context.status; + return of(execution); + } + override submitInteractionText( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index a355d89..dd235e4 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -36,6 +36,16 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { }); } + override prepareStringArrayInput( + executionId: string, + nodeId: string, + inputName: string, + values: string[] + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/texts`; + return this.http.put(url, values); + } + override prepareFileInput( executionId: string, nodeId: string, @@ -48,6 +58,20 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return this.http.put(url, formData); } + override prepareFileArrayInput( + executionId: string, + nodeId: string, + inputName: string, + files: File[] + ): Observable { + const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/files`; + const formData = new FormData(); + for (const file of files) { + formData.append('files', file); + } + return this.http.put(url, formData); + } + override submitInteractionText( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 6380cb2..2911b31 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -77,6 +77,16 @@ export class TaskExecutionsService { ); } + prepareStringArrayInput(executionId: string, nodeId: string, inputName: string, values: string[]) { + return this.taskExecutionsCallService.prepareStringArrayInput(executionId, nodeId, inputName, values).pipe( + tap(() => this.refresh()), + catchError((err) => { + console.error('Prepare string array input failed', err); + return throwError(() => err); + }) + ); + } + prepareFileInput(executionId: string, nodeId: string, inputName: string, file: File) { return this.taskExecutionsCallService.prepareFileInput(executionId, nodeId, inputName, file).pipe( tap(() => this.refresh()), @@ -87,6 +97,16 @@ export class TaskExecutionsService { ); } + prepareFileArrayInput(executionId: string, nodeId: string, inputName: string, files: File[]) { + return this.taskExecutionsCallService.prepareFileArrayInput(executionId, nodeId, inputName, files).pipe( + tap(() => this.refresh()), + catchError((err) => { + console.error('Prepare file array input failed', err); + return throwError(() => err); + }) + ); + } + submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) { return this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe( tap(() => this.refresh()), diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 2233742..58c7d82 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -275,7 +275,7 @@ .llm-row { display: grid; align-items: center; - height: 24px; + min-height: 34px; min-width: 0; } @@ -312,12 +312,12 @@ } .llm-pill { - height: 24px; + min-height: 32px; border-radius: 7px; border: 1px solid transparent; font-size: 11px; font-weight: 600; - padding: 0 8px; + padding: 4px 8px; display: inline-flex; align-items: center; min-width: 0; @@ -325,13 +325,32 @@ overflow: visible; } -.llm-pill-label { +.llm-pill-meta { + min-width: 0; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 1px; +} + +.llm-pill-meta-output { + align-items: flex-end; +} + +.llm-pill-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.llm-pill-kind { + font-size: 10px; + line-height: 1; + font-weight: 700; + opacity: 0.86; +} + .llm-pill-input { justify-content: flex-start; color: #166534; diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index a41283a..aa1ca27 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -98,7 +98,10 @@ } - {{ input.key }} + + {{ inputDisplayLabel(input.key) }} + {{ inputKindLabel(input.key) }} + } @@ -109,7 +112,10 @@ @for (output of outputs; track output.key) {
- {{ output.key }} + + {{ outputDisplayLabel(output.key) }} + {{ outputKindLabel(output.key) }} + @if (isOutputConnected(output.key)) { diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index d509d56..089d431 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; +import { FlowPort } from '@models/flow'; import { BlocksService } from '@services/blocks/blocks'; import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog'; import { TaskExecutionsService } from '@services/task-executions/task-executions'; @@ -206,6 +207,22 @@ export class TaskStepNodeComponent { return null; } + inputDisplayLabel(inputKey: string): string { + return this.portDisplayLabel('input', inputKey); + } + + outputDisplayLabel(outputKey: string): string { + return this.portDisplayLabel('output', outputKey); + } + + inputKindLabel(inputKey: string): string { + return this.portKindLabel('input', inputKey); + } + + outputKindLabel(outputKey: string): string { + return this.portKindLabel('output', outputKey); + } + hasMainContent(): boolean { return this.mainContentFields.length > 0; } @@ -389,6 +406,28 @@ export class TaskStepNodeComponent { return values.filter((value): value is string => typeof value === 'string' && value.trim().length > 0); } + private portDisplayLabel(kind: 'input' | 'output', key: string): string { + const ports = this.resolvePorts(kind); + const port = ports.find((candidate) => candidate.name === key); + return port?.name ?? key; + } + + private portTypeLabel(port: FlowPort): string { + const type = String(port.type ?? 'TEXT').toUpperCase(); + return port.multiple ? `${type}[]` : type; + } + + private portKindLabel(kind: 'input' | 'output', key: string): string { + const ports = this.resolvePorts(kind); + const port = ports.find((candidate) => candidate.name === key); + return port ? this.portTypeLabel(port) : 'ANY'; + } + + private resolvePorts(kind: 'input' | 'output'): FlowPort[] { + const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs']; + return Array.isArray(ports) ? ports as FlowPort[] : []; + } + private toMainContentParts(path: string, value: string): { text: string; isDynamicInput: boolean }[] { if (this.variablePlaceholderPaths.has(path)) { return splitTemplatedTextParts(value); @@ -590,11 +629,16 @@ export class TaskStepNodeComponent { }); } - private isPathVisible(path: string): boolean { + private isPathVisible(path: string, visited = new Set()): boolean { + if (visited.has(path)) return true; + visited.add(path); + const ui = this.getFieldUiMeta(path); - return ui.visibleWhen.every((rule) => - evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)) - ); + return ui.visibleWhen.every((rule) => { + if (!rule) return true; + if (!this.isPathVisible(rule.field, visited)) return false; + return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath)); + }); } private groupLabelForPath(path: string): string | null { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html index b87aa36..5e795d8 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.html @@ -79,21 +79,62 @@
{{ executionInput.title }}
{{ executionInput.subtitle }}
-
Type: {{ executionInput.type }}
+
Type: {{ inputTypeLabel(executionInput) }}
@if (isFileInput(executionInput)) { - + } @else { - - {{ executionInput.subtitle }} - - + @if (isMultipleInput(executionInput)) { +
+ @for (textValue of textValues(executionInput); track $index) { +
+ + {{ executionInput.subtitle }} {{ $index + 1 }} + + + + +
+ } + + +
+ } @else { + + {{ executionInput.subtitle }} + + + } } @if (isInputSaving(executionInput.key)) { diff --git a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts index 6674608..14a7ec8 100644 --- a/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts +++ b/src/app/shared/task-execution-inputs-panel/task-execution-inputs-panel.ts @@ -13,7 +13,8 @@ export type EditableExecutionInput = { title: string; subtitle: string; type: string; - value: string; + multiple: boolean; + value: string | string[]; }; @Component({ @@ -31,8 +32,8 @@ export class TaskExecutionInputsPanelComponent { readonly savingErrors = input>({}); readonly readOnly = input(false); - readonly textInputChange = output<{ input: EditableExecutionInput; value: string }>(); - readonly fileInputChange = output<{ input: EditableExecutionInput; file: File }>(); + readonly textInputChange = output<{ input: EditableExecutionInput; value: string | string[] }>(); + readonly fileInputChange = output<{ input: EditableExecutionInput; files: File[] }>(); readonly authorizationValueChange = output<{ requirement: TaskExecutionAuthorizationRequirement; value: string }>(); readonly authorizationSubmit = output(); @@ -42,7 +43,18 @@ export class TaskExecutionInputsPanelComponent { return input.type.includes('FILE') || input.type.includes('BINARY'); } - onTextInputChange(input: EditableExecutionInput, value: string) { + isMultipleInput(input: EditableExecutionInput): boolean { + return input.multiple; + } + + textValues(input: EditableExecutionInput): string[] { + if (Array.isArray(input.value)) { + return input.value; + } + return [input.value ?? '']; + } + + onTextInputChange(input: EditableExecutionInput, value: string | string[]) { if (this.readOnly()) return; this.textInputChange.emit({ input, value }); } @@ -50,9 +62,30 @@ export class TaskExecutionInputsPanelComponent { onFileInputChange(input: EditableExecutionInput, event: Event) { if (this.readOnly()) return; const target = event.target as HTMLInputElement | null; - const file = target?.files?.[0]; - if (!file) return; - this.fileInputChange.emit({ input, file }); + const files = target?.files ? Array.from(target.files) : []; + if (!files.length) return; + this.fileInputChange.emit({ input, files }); + } + + addTextItem(input: EditableExecutionInput) { + const values = [...this.textValues(input), '']; + this.onTextInputChange(input, values); + } + + updateTextItem(input: EditableExecutionInput, index: number, value: string) { + const values = [...this.textValues(input)]; + values[index] = value; + this.onTextInputChange(input, values); + } + + removeTextItem(input: EditableExecutionInput, index: number) { + const values = [...this.textValues(input)]; + values.splice(index, 1); + this.onTextInputChange(input, values); + } + + inputTypeLabel(input: EditableExecutionInput): string { + return input.multiple ? `${input.type}[]` : input.type; } isInputSaving(key: string): boolean { diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index ea27aac..0fc1fd8 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -97,7 +97,7 @@ (authorizationValueChange)="onAuthorizationValueChange($event.requirement, $event.value)" (authorizationSubmit)="submitAuthorization($event)" (textInputChange)="onTextInputChange($event.input, $event.value)" - (fileInputChange)="onFileInputChange($event.input, $event.file)"> + (fileInputChange)="onFileInputChange($event.input, $event.files)"> } @else {
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index dcfa539..7edbf1d 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -3,7 +3,7 @@ import { Component, computed, effect, inject, input, OnDestroy, signal } from '@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; -import { FlowBlockConnection, FlowData } from '@models/flow'; +import { areFlowValueKindsCompatible, FlowBlockConnection, FlowData, normalizeFlowPortValueKinds } from '@models/flow'; import { getExecutionStatusGroup, TaskExecution, @@ -44,7 +44,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { readonly startInProgress = signal(false); readonly savingInputs = signal>({}); readonly savingErrors = signal>({}); - readonly pendingTextInputs = signal>({}); + readonly pendingTextInputs = signal>({}); readonly pendingAuthorizationValues = signal>({}); readonly savingAuthorizations = signal>({}); readonly authorizationErrors = signal>({}); @@ -207,7 +207,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { ? execution.context.inputs[key] : input.value; - if (!this.isInputSet(value)) return false; + if (!this.isInputSet(value, Boolean(input.descriptor?.multiple))) return false; } } @@ -241,7 +241,8 @@ export class TaskExecutionViewerComponent implements OnDestroy { title: step.block.name, subtitle: inputName, type: String(input.descriptor?.type ?? 'TEXT').toUpperCase(), - value: pendingValue ?? (rawValue == null ? '' : String(rawValue)) + multiple: Boolean(input.descriptor?.multiple), + value: pendingValue ?? this.normalizeEditableInputValue(rawValue, Boolean(input.descriptor?.multiple)) }); } } @@ -282,7 +283,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { }); } - onTextInputChange(input: EditableExecutionInput, value: string) { + onTextInputChange(input: EditableExecutionInput, value: string | string[]) { if (this.inputsReadOnly()) return; const executionId = this.execution()?.id; if (!executionId) return; @@ -298,13 +299,17 @@ export class TaskExecutionViewerComponent implements OnDestroy { this.textInputDebounceTimers.set(timerKey, timer); } - onFileInputChange(input: EditableExecutionInput, file: File) { + onFileInputChange(input: EditableExecutionInput, files: File[]) { if (this.inputsReadOnly()) return; const executionId = this.execution()?.id; - if (!executionId) return; + if (!executionId || !files.length) return; this.setInputSaving(input.key, true); - this.taskExecutionsService.prepareFileInput(executionId, input.nodeId, input.inputName, file).subscribe({ + const request$ = input.multiple + ? this.taskExecutionsService.prepareFileArrayInput(executionId, input.nodeId, input.inputName, files) + : this.taskExecutionsService.prepareFileInput(executionId, input.nodeId, input.inputName, files[0]); + + request$.subscribe({ next: () => this.clearInputSaving(input.key), error: () => this.setInputError(input.key, 'Failed to upload file') }); @@ -400,9 +405,25 @@ export class TaskExecutionViewerComponent implements OnDestroy { private sendPreparedTextInput(input: EditableExecutionInput, executionId: string) { if (this.inputsReadOnly() || this.execution()?.id !== executionId) return; - const value = this.pendingTextInputs()[input.key] ?? ''; + const value = this.pendingTextInputs()[input.key] ?? this.normalizeEditableInputValue(input.value, input.multiple); this.setInputSaving(input.key, true); - this.taskExecutionsService.prepareStringInput(executionId, input.nodeId, input.inputName, value).subscribe({ + const request$ = input.multiple + ? this.taskExecutionsService.prepareStringArrayInput( + executionId, + input.nodeId, + input.inputName, + (Array.isArray(value) ? value : [String(value)]) + .map((item) => item.trim()) + .filter((item) => item.length > 0) + ) + : this.taskExecutionsService.prepareStringInput( + executionId, + input.nodeId, + input.inputName, + String(Array.isArray(value) ? value[0] ?? '' : value) + ); + + request$.subscribe({ next: () => { this.pendingTextInputs.update((current) => { const next = { ...current }; @@ -491,7 +512,13 @@ export class TaskExecutionViewerComponent implements OnDestroy { .filter(({ step }) => step.id !== targetStep.id) .flatMap((sourceEntry) => (sourceEntry.step.outputs ?? []) - .filter((output) => output.connected && output.descriptor.type === input.descriptor.type) + .filter((output) => output.connected) + .filter((output) => + areFlowValueKindsCompatible( + normalizeFlowPortValueKinds(output.descriptor), + normalizeFlowPortValueKinds(input.descriptor) + ) + ) .map((output) => ({ sourceStep: sourceEntry.step, sourceIndex: sourceEntry.index, @@ -625,9 +652,38 @@ export class TaskExecutionViewerComponent implements OnDestroy { return raw && raw.trim().length > 0 ? [raw] : []; } - private isInputSet(value: unknown): boolean { + private isInputSet(value: unknown, multiple = false): boolean { + if (multiple) { + if (!Array.isArray(value)) return false; + return value.some((item) => typeof item === 'string' ? item.trim().length > 0 : item != null); + } if (value == null) return false; if (typeof value === 'string') return value.trim().length > 0; return true; } + + private normalizeEditableInputValue(value: unknown, multiple: boolean): string | string[] { + if (multiple) { + if (Array.isArray(value)) { + return value.map((item) => this.stringifyEditableInputItem(item)); + } + if (value == null) { + return ['']; + } + return [this.stringifyEditableInputItem(value)]; + } + + if (value == null) return ''; + return this.stringifyEditableInputItem(value); + } + + private stringifyEditableInputItem(value: unknown): string { + if (value == null) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value); + } catch { + return String(value); + } + } }