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