Support typed execution inputs and task node IO labels

This commit is contained in:
Lucio Lelii 2026-03-13 16:51:47 +01:00
parent 34f400ca40
commit c0d2f75285
11 changed files with 321 additions and 42 deletions

View File

@ -12,12 +12,24 @@ export abstract class TaskExecutionsCallServiceBase {
inputName: string,
value: string
): Observable<TaskExecution>;
abstract prepareStringArrayInput(
executionId: string,
nodeId: string,
inputName: string,
values: string[]
): Observable<TaskExecution>;
abstract prepareFileInput(
executionId: string,
nodeId: string,
inputName: string,
file: File
): Observable<TaskExecution>;
abstract prepareFileArrayInput(
executionId: string,
nodeId: string,
inputName: string,
files: File[]
): Observable<TaskExecution>;
abstract submitInteractionText(
executionId: string,
nodeId: string,

View File

@ -506,6 +506,18 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(execution);
}
override prepareStringArrayInput(
executionId: string,
nodeId: string,
inputName: string,
values: string[]
): Observable<TaskExecution> {
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<TaskExecution> {
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,

View File

@ -36,6 +36,16 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
});
}
override prepareStringArrayInput(
executionId: string,
nodeId: string,
inputName: string,
values: string[]
): Observable<TaskExecution> {
const url = `${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/node/${encodeURIComponent(nodeId)}/input/${encodeURIComponent(inputName)}/texts`;
return this.http.put<TaskExecution>(url, values);
}
override prepareFileInput(
executionId: string,
nodeId: string,
@ -48,6 +58,20 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.put<TaskExecution>(url, formData);
}
override prepareFileArrayInput(
executionId: string,
nodeId: string,
inputName: string,
files: File[]
): Observable<TaskExecution> {
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<TaskExecution>(url, formData);
}
override submitInteractionText(
executionId: string,
nodeId: string,

View File

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

View File

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

View File

@ -98,7 +98,10 @@
</span>
}
<span class="llm-pill llm-pill-input">
<span class="llm-pill-label">{{ input.key }}</span>
<span class="llm-pill-meta">
<span class="llm-pill-name">{{ inputDisplayLabel(input.key) }}</span>
<span class="llm-pill-kind">{{ inputKindLabel(input.key) }}</span>
</span>
</span>
</div>
}
@ -109,7 +112,10 @@
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
<span class="llm-pill-label">{{ output.key }}</span>
<span class="llm-pill-meta llm-pill-meta-output">
<span class="llm-pill-name">{{ outputDisplayLabel(output.key) }}</span>
<span class="llm-pill-kind">{{ outputKindLabel(output.key) }}</span>
</span>
</span>
@if (isOutputConnected(output.key)) {
<span class="llm-socket-wrap llm-socket-wrap-output">

View File

@ -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<string>()): 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 {

View File

@ -79,21 +79,62 @@
<div class="text-[14px] font-extrabold leading-tight">{{ executionInput.title }}</div>
<div class="mt-1 text-[11px] font-semibold uppercase tracking-[0.04em] text-blue-600">{{ executionInput.subtitle }}</div>
</div>
<div class="text-[10px] text-slate-500 mb-2">Type: {{ executionInput.type }}</div>
<div class="mb-2 text-[10px] text-slate-500">Type: {{ inputTypeLabel(executionInput) }}</div>
@if (isFileInput(executionInput)) {
<input type="file" class="block w-full text-sm text-slate-700" [disabled]="readOnly()" (change)="onFileInputChange(executionInput, $event)" />
<input
type="file"
class="block w-full text-sm text-slate-700"
[attr.multiple]="isMultipleInput(executionInput) ? '' : null"
[disabled]="readOnly()"
(change)="onFileInputChange(executionInput, $event)" />
} @else {
<mat-form-field appearance="outline">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea
matInput
rows="4"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)">
</textarea>
</mat-form-field>
@if (isMultipleInput(executionInput)) {
<div class="space-y-3">
@for (textValue of textValues(executionInput); track $index) {
<div class="flex items-start gap-2">
<mat-form-field appearance="outline" class="flex-1">
<mat-label>{{ executionInput.subtitle }} {{ $index + 1 }}</mat-label>
<textarea
matInput
rows="3"
[readonly]="readOnly()"
[ngModel]="textValue"
(ngModelChange)="updateTextItem(executionInput, $index, $event)">
</textarea>
</mat-form-field>
<button
type="button"
mat-stroked-button
class="shrink-0"
[disabled]="readOnly() || textValues(executionInput).length <= 1"
(click)="removeTextItem(executionInput, $index)">
Remove
</button>
</div>
}
<button
type="button"
mat-stroked-button
[disabled]="readOnly()"
(click)="addTextItem(executionInput)">
Add item
</button>
</div>
} @else {
<mat-form-field appearance="outline" class="w-full">
<mat-label>{{ executionInput.subtitle }}</mat-label>
<textarea
matInput
rows="4"
[readonly]="readOnly()"
[ngModel]="executionInput.value"
(ngModelChange)="onTextInputChange(executionInput, $event)">
</textarea>
</mat-form-field>
}
}
@if (isInputSaving(executionInput.key)) {

View File

@ -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<Record<string, string>>({});
readonly readOnly = input<boolean>(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<TaskExecutionAuthorizationRequirement>();
@ -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 {

View File

@ -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)">
</app-task-execution-inputs-panel>
} @else {
<div class="p-3 space-y-3">

View File

@ -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<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
readonly pendingAuthorizationValues = signal<Record<string, string>>({});
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
@ -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);
}
}
}