From 514339599b87243ff5208cd4eac9be2e6e2bad6d Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 10 Mar 2026 16:59:35 +0100 Subject: [PATCH] Extend execution graph and schema-driven node rendering --- src/app/models/task-execution.ts | 3 +- .../nodes/generic-node/generic-node.css | 12 +++ .../nodes/generic-node/generic-node.html | 14 +-- .../shared/nodes/generic-node/generic-node.ts | 99 +++++++++++-------- .../nodes/task-step-node/task-step-node.css | 24 +++++ .../nodes/task-step-node/task-step-node.html | 11 ++- .../nodes/task-step-node/task-step-node.ts | 62 ++++++------ .../task-execution-viewer.ts | 53 ++++++++-- 8 files changed, 188 insertions(+), 90 deletions(-) diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 799d5bd..81c6599 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -1,4 +1,4 @@ -import { FlowBlock, FlowPort } from './flow'; +import { FlowBlock, FlowBlockConnection, FlowPort } from './flow'; export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUCCESS' | 'ERROR'; export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'FINAL'; @@ -10,6 +10,7 @@ export type TaskExecution = { name: string; creationTime: number; context: TaskExecutionContext; + stepConnections?: FlowBlockConnection[]; }; export type TaskExecutionContext = { diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index d05ecf7..0a1cf56 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -452,6 +452,18 @@ border-color: #fecdd3; } +.llm-pill-output-true { + color: #166534; + background: #ecfdf3; + border-color: #bbf7d0; +} + +.llm-pill-output-false { + color: #9f1239; + background: #fff1f2; + border-color: #fecdd3; +} + .llm-input-info { border: none; background: transparent; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index a91f9bd..d372e00 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -82,10 +82,10 @@
-
Outputs
+
{{ outputsTitle() }}
@for (output of outputs; track output.key) {
- {{ output.key }} + {{ output.key }}
-
{{ mainContentLabel() }}
-
- @if (!mainContentParts().length) { + @if (!contentField.parts.length) { - } @else { - @for (part of mainContentParts(); track $index) { + @for (part of contentField.parts; track $index) { @if (part.isDynamicInput) { {{ formatDynamicInputToken(part.text) }} } @else { @@ -175,6 +176,7 @@ }
+ } }
diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 3eec25c..2c0a24b 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -63,6 +63,12 @@ type EditableFieldGroupView = { fields: EditableFieldView[]; }; +type RichContentView = { + path: string; + label: string; + parts: { text: string; isDynamicInput: boolean }[]; +}; + @Component({ selector: 'app-generic-node', imports: [CommonModule, FormsModule, ReteModule], @@ -92,6 +98,7 @@ export class GenericNodeComponent { inputs: { key: string; socket: ClassicPreset.Socket }[] = []; parameterFields: EditableFieldView[] = []; parameterFieldGroups: EditableFieldGroupView[] = []; + richContentFields: RichContentView[] = []; name = 'noName'; localEditorOpen = false; @@ -116,6 +123,7 @@ export class GenericNodeComponent { this.inputs = []; this.parameterFields = []; this.parameterFieldGroups = []; + this.richContentFields = []; Object.entries(this.data.outputs).forEach(([key, output]) => { this.outputs.push({ key, socket: (output as any).socket }); @@ -241,18 +249,16 @@ export class GenericNodeComponent { this.closeSimpleParamEditor(); } - async openMainContentEditor(event?: Event) { + async openMainContentEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); - const contentKey = this.mainContentKey(); - if (!contentKey) return; - if (!this.isPathVisible(contentKey)) return; + if (!this.isPathVisible(path)) return; - const contentLabel = this.mainContentLabel(); - const ui = this.getFieldUiMeta(contentKey); - const currentValue = String(this.getByPath(this.blockConfiguration ?? {}, contentKey) ?? ''); - await this.openTextareaEditor(contentKey, contentLabel, currentValue, ui); + const contentLabel = pathToLabel(path); + const ui = this.getFieldUiMeta(path); + const currentValue = String(this.getByPath(this.blockConfiguration ?? {}, path) ?? ''); + await this.openTextareaEditor(path, contentLabel, currentValue, ui); } async confirmDelete(event?: Event) { @@ -272,6 +278,10 @@ export class GenericNodeComponent { return this.blockType === 'HumanInteractionBlock'; } + isConditionalNode(): boolean { + return this.blockType === 'ConditionalBlock'; + } + nodeTitle(): string { const type = this.blockType; if (!type) return 'Node'; @@ -282,27 +292,21 @@ export class GenericNodeComponent { .trim(); } - mainContentLabel(): string { - const contentKey = this.mainContentKey(); - if (!contentKey) return 'Content'; - return pathToLabel(contentKey); + outputsTitle(): string { + return this.isConditionalNode() ? 'On Condition' : 'Outputs'; + } + + outputPillClass(outputKey: string): string | null { + if (!this.isConditionalNode()) return null; + + const normalized = outputKey.trim().toLowerCase(); + if (normalized === 'true') return 'llm-pill-output-true'; + if (normalized === 'false') return 'llm-pill-output-false'; + return null; } hasMainContent(): boolean { - const contentKey = this.mainContentKey(); - return !!contentKey && this.isPathVisible(contentKey); - } - - mainContentParts(): { text: string; isDynamicInput: boolean }[] { - const contentKey = this.mainContentKey(); - if (!contentKey || !this.isPathVisible(contentKey)) return []; - const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, contentKey)); - if (!content) return []; - const ui = this.getFieldUiMeta(contentKey); - if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { - return splitTemplatedTextParts(content); - } - return [{ text: content, isDynamicInput: false }]; + return this.richContentFields.length > 0; } formatDynamicInputToken(token: string): string { @@ -619,10 +623,18 @@ export class GenericNodeComponent { private refreshParameterFields() { const config = this.blockConfiguration ?? {}; - const contentKey = this.mainContentKey(); + const richContentPaths = new Set(this.richContentPaths()); const grouped = new Map(); const rootFields: EditableFieldView[] = []; + this.richContentFields = this.richContentPaths() + .filter((path) => this.isPathVisible(path)) + .map((path) => ({ + path, + label: pathToLabel(path), + parts: this.toRichContentParts(path) + })); + if (this.editableFieldDefinitions.length) { const orderedFields = this.editableFieldDefinitions.map((definition) => { const value = this.getByPath(config, definition.path); @@ -631,7 +643,7 @@ export class GenericNodeComponent { label: definition.label, value: valueToDisplayString(value) }; - }).filter((field) => field.path !== contentKey) + }).filter((field) => !richContentPaths.has(field.path)) .filter((field) => this.isPathVisible(field.path)); for (const field of orderedFields) { @@ -657,7 +669,7 @@ export class GenericNodeComponent { } const fallbackFields = flattenPrimitiveValues(config) - .filter((entry) => entry.path !== 'name' && entry.path !== 'type' && entry.path !== contentKey) + .filter((entry) => entry.path !== 'name' && entry.path !== 'type' && !richContentPaths.has(entry.path)) .filter((entry) => this.isPathVisible(entry.path)) .map((entry) => ({ path: entry.path, @@ -751,22 +763,27 @@ export class GenericNodeComponent { return JSON.stringify(left) === JSON.stringify(right); } - private mainContentKey(): string | null { - const preferredPath = this.editableFieldDefinitions.find((field) => - field.ui.widget === 'textarea' - && field.ui.acceptVariableAsPlaceholder - && this.isPathVisible(field.path) - )?.path; - if (preferredPath) { - return preferredPath; + private richContentPaths(): string[] { + const preferredPaths = this.editableFieldDefinitions + .filter((field) => field.ui.widget === 'textarea' && field.ui.acceptVariableAsPlaceholder) + .map((field) => field.path); + if (preferredPaths.length) { + return preferredPaths; } - const schemaCandidates = this.findMainContentCandidatePaths(this.blockSchema); - for (const candidate of schemaCandidates) { - if (this.isPathVisible(candidate)) return candidate; + return this.findMainContentCandidatePaths(this.blockSchema); + } + + private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] { + const content = toStringOrNull(this.getByPath(this.blockConfiguration ?? {}, path)); + if (!content) return []; + + const ui = this.getFieldUiMeta(path); + if (ui.widget === 'textarea' && ui.acceptVariableAsPlaceholder) { + return splitTemplatedTextParts(content); } - return null; + return [{ text: content, isDynamicInput: false }]; } private findMainContentCandidatePaths(schema: Record | null): string[] { 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 50426fb..f9491e9 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 @@ -44,6 +44,18 @@ animation: llmAttentionBorderPulse 1.2s ease-in-out infinite; } +.llm-node-skipped { + border: 2px solid #94a3b8; + background: linear-gradient(180deg, #f8fafc 0%, #e2e8f0 100%); + box-shadow: 0 8px 20px rgba(100, 116, 139, 0.16); + filter: grayscale(1); +} + +.llm-node-skipped .llm-header { + background: linear-gradient(135deg, #94a3b8 0%, #64748b 100%); + border-bottom-color: #cbd5e1; +} + .llm-error-alert-wrap, .llm-warning-alert-wrap { position: absolute; @@ -330,6 +342,18 @@ border-color: #fecdd3; } +.llm-pill-output-true { + color: #166534; + background: #ecfdf3; + border-color: #bbf7d0; +} + +.llm-pill-output-false { + color: #9f1239; + background: #fff1f2; + border-color: #fecdd3; +} + .llm-input-value-wrap { position: relative; display: flex; 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 a5225ab..7ef705b 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 @@ -1,5 +1,6 @@
-
Outputs
+
{{ outputsTitle() }}
@for (output of outputs; track output.key) {
- + {{ output.key }} @if (isOutputConnected(output.key)) { @@ -180,12 +181,13 @@ } @if (hasMainContent()) { + @for (contentField of mainContentFields; track contentField.path) {
-
{{ mainContentLabel() }}
+
{{ contentField.label }}
- @for (part of mainContentParts(); track $index) { + @for (part of contentField.parts; track $index) { @if (part.isDynamicInput) { {{ formatDynamicInputToken(part.text) }} } @else { @@ -194,6 +196,7 @@ }
+ } }
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 42ecb84..f443e58 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 @@ -64,7 +64,7 @@ export class TaskStepNodeComponent { parameterFieldGroups: DisplayFieldGroup[] = []; name = 'Step'; - mainContent: MainContentView | null = null; + mainContentFields: MainContentView[] = []; private blockSchema: Record | null = null; private variablePlaceholderPaths = new Set(); @@ -92,21 +92,18 @@ export class TaskStepNodeComponent { this.name = toStringOrNull(config['name']) ?? this.name; const primitiveEntries = flattenPrimitiveValues(config); const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path)); - const contentEntry = this.pickMainContentEntry(visibleEntries); - - this.mainContent = contentEntry - ? { - path: contentEntry.path, - label: pathToLabel(contentEntry.path), - parts: this.toMainContentParts(contentEntry.path, String(contentEntry.value)) - } - : null; + const richContentPaths = this.pickMainContentEntries(visibleEntries).map((entry) => entry.path); + this.mainContentFields = this.pickMainContentEntries(visibleEntries).map((entry) => ({ + path: entry.path, + label: pathToLabel(entry.path), + parts: this.toMainContentParts(entry.path, String(entry.value)) + })); const grouped = new Map(); const rootFields: DisplayField[] = []; const orderedFields = visibleEntries .filter((entry) => !['name', 'type'].includes(entry.path)) - .filter((entry) => entry.path !== contentEntry?.path) + .filter((entry) => !richContentPaths.includes(entry.path)) .map((entry) => ({ path: entry.path, label: pathToLabel(entry.path), @@ -144,6 +141,10 @@ export class TaskStepNodeComponent { return this.blockType === 'HumanInteractionBlock'; } + isConditionalNode(): boolean { + return this.blockType === 'ConditionalBlock'; + } + nodeTitle(): string { const type = this.blockType; if (!type) return 'Task Step'; @@ -153,16 +154,21 @@ export class TaskStepNodeComponent { .trim(); } + outputsTitle(): string { + return this.isConditionalNode() ? 'On Condition' : 'Outputs'; + } + + outputPillClass(outputKey: string): string | null { + if (!this.isConditionalNode()) return null; + + const normalized = outputKey.trim().toLowerCase(); + if (normalized === 'true') return 'llm-pill-output-true'; + if (normalized === 'false') return 'llm-pill-output-false'; + return null; + } + hasMainContent(): boolean { - return (this.mainContent?.parts.length ?? 0) > 0; - } - - mainContentLabel(): string { - return this.mainContent?.label ?? 'Content'; - } - - mainContentParts(): { text: string; isDynamicInput: boolean }[] { - return this.mainContent?.parts ?? []; + return this.mainContentFields.length > 0; } formatDynamicInputToken(token: string): string { @@ -257,6 +263,10 @@ export class TaskStepNodeComponent { return this.stepStatus() === 'RUNNING'; } + isSkipped(): boolean { + return this.stepStatus() === 'SKIPPED'; + } + stepStatus(): string { const status = this.blockConfiguration?.['__stepStatus']; return typeof status === 'string' ? status.toUpperCase() : ''; @@ -324,27 +334,23 @@ export class TaskStepNodeComponent { return this.executionOutputTooltip(outputKey) ?? ''; } - private pickMainContentEntry(entries: Array<{ path: string; value: unknown }>) { + private pickMainContentEntries(entries: Array<{ path: string; value: unknown }>) { const candidates = entries .filter((entry) => !['name', 'type'].includes(entry.path)) .filter((entry) => typeof entry.value === 'string') .map((entry) => ({ ...entry, text: String(entry.value).trim() })) .filter((entry) => entry.text.length > 0); - if (!candidates.length) return null; + if (!candidates.length) return []; const placeholderCandidates = candidates.filter((entry) => this.variablePlaceholderPaths.has(entry.path) ); const scope = placeholderCandidates.length ? placeholderCandidates : candidates; - scope.sort((a, b) => { - if (b.text.length !== a.text.length) return b.text.length - a.text.length; - return a.path.localeCompare(b.path); - }); + scope.sort((a, b) => a.path.localeCompare(b.path)); - const chosen = scope[0]; - return { path: chosen.path, value: chosen.text }; + return scope.map((chosen) => ({ path: chosen.path, value: chosen.text })); } private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] { 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 b655092..cfdbef0 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -1,6 +1,6 @@ import { CommonModule } from '@angular/common'; import { Component, computed, inject, input, OnDestroy, signal } from '@angular/core'; -import { FlowData } from '@models/flow'; +import { FlowBlockConnection, FlowData } from '@models/flow'; import { getExecutionStatusGroup, TaskExecution, TaskExecutionStep } from '@models/task-execution'; import { EditableExecutionInput, @@ -61,7 +61,7 @@ export class TaskExecutionViewerComponent implements OnDestroy { } })); - const connections = this.inferConnections(steps); + const connections = this.getExecutionConnections(steps); return { blocks, connections }; }); @@ -266,25 +266,28 @@ export class TaskExecutionViewerComponent implements OnDestroy { private inferConnections(steps: TaskExecutionStep[]) { const connections: FlowData['connections'] = []; + const indexedSteps = steps.map((step, index) => ({ step, index })); - for (const targetStep of steps) { + for (const targetEntry of indexedSteps) { + const targetStep = targetEntry.step; for (const input of targetStep.inputs ?? []) { if (!input.registered) continue; - const candidates = steps - .filter((step) => step.id !== targetStep.id) - .flatMap((sourceStep) => - (sourceStep.outputs ?? []) + const candidates = indexedSteps + .filter(({ step }) => step.id !== targetStep.id) + .flatMap((sourceEntry) => + (sourceEntry.step.outputs ?? []) .filter((output) => output.connected && output.descriptor.type === input.descriptor.type) .map((output) => ({ - sourceStep, + sourceStep: sourceEntry.step, + sourceIndex: sourceEntry.index, sourceOutputName: output.descriptor.name })) ); - if (candidates.length !== 1) continue; + if (!candidates.length) continue; - const source = candidates[0]; + const source = this.pickBestConnectionCandidate(candidates, targetEntry.index); const id = `${source.sourceStep.id}_${source.sourceOutputName}_${targetStep.id}_${input.descriptor.name}`; if (connections.some((connection) => connection.id === id)) continue; @@ -301,6 +304,36 @@ export class TaskExecutionViewerComponent implements OnDestroy { return connections; } + private getExecutionConnections(steps: TaskExecutionStep[]): FlowBlockConnection[] { + const explicitConnections = this.execution()?.stepConnections; + if (explicitConnections?.length) { + return explicitConnections.map((connection) => ({ + id: String(connection.id), + sourceId: String(connection.sourceId), + sourceName: String(connection.sourceName), + targetId: String(connection.targetId), + targetName: String(connection.targetName) + })); + } + + return this.inferConnections(steps); + } + + private pickBestConnectionCandidate( + candidates: Array<{ sourceStep: TaskExecutionStep; sourceIndex: number; sourceOutputName: string }>, + targetIndex: number + ) { + const previousCandidates = candidates + .filter((candidate) => candidate.sourceIndex < targetIndex) + .sort((left, right) => right.sourceIndex - left.sourceIndex); + + if (previousCandidates.length) { + return previousCandidates[0]; + } + + return [...candidates].sort((left, right) => left.sourceIndex - right.sourceIndex)[0]; + } + private getExecutionInputValues( step: TaskExecutionStep, contextInputs: Record