Extend execution graph and schema-driven node rendering

This commit is contained in:
Lucio Lelii 2026-03-10 16:59:35 +01:00
parent 53a045a6ef
commit 514339599b
8 changed files with 188 additions and 90 deletions

View File

@ -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 = {

View File

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

View File

@ -82,10 +82,10 @@
</div>
<div class="llm-column">
<div class="llm-column-title llm-column-title-right">Outputs</div>
<div class="llm-column-title llm-column-title-right">{{ outputsTitle() }}</div>
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output">{{ output.key }}</span>
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">{{ output.key }}</span>
<div
refComponent
class="llm-socket llm-socket-right"
@ -154,18 +154,19 @@
}
@if (hasMainContent()) {
@for (contentField of richContentFields; track contentField.path) {
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ mainContentLabel() }}</div>
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + mainContentLabel().toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor($event)">
<div class="llm-param-key">{{ contentField.label }}</div>
<button type="button" class="llm-edit-btn" [attr.title]="'Edit ' + contentField.label.toLowerCase()" (pointerdown)="$event.stopPropagation()" (click)="openMainContentEditor(contentField.path, $event)">
<i class="bi bi-pen"></i>
</button>
</div>
<div class="llm-param-text">
@if (!mainContentParts().length) {
@if (!contentField.parts.length) {
-
} @else {
@for (part of mainContentParts(); track $index) {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
@ -175,6 +176,7 @@
}
</div>
</div>
}
}
</div>

View File

@ -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<string, EditableFieldView[]>();
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<string, any> | null): string[] {

View File

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

View File

@ -1,5 +1,6 @@
<div class="llm-node"
[class.llm-node--human]="isHumanNode()"
[class.llm-node-skipped]="isSkipped() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-running]="isRunning() && !hasExecutionErrors()"
[class.llm-node-completed]="isCompleted() && !needsAttention() && !hasExecutionErrors() && !hasExecutionWarnings()"
[class.llm-node-attention]="needsAttention()"
@ -103,10 +104,10 @@
</div>
<div class="llm-column">
<div class="llm-column-title llm-column-title-right">Outputs</div>
<div class="llm-column-title llm-column-title-right">{{ outputsTitle() }}</div>
@for (output of outputs; track output.key) {
<div class="llm-row llm-row-output">
<span class="llm-pill llm-pill-output">
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
<span class="llm-pill-label">{{ output.key }}</span>
</span>
@if (isOutputConnected(output.key)) {
@ -180,12 +181,13 @@
}
@if (hasMainContent()) {
@for (contentField of mainContentFields; track contentField.path) {
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ mainContentLabel() }}</div>
<div class="llm-param-key">{{ contentField.label }}</div>
</div>
<div class="llm-param-text">
@for (part of mainContentParts(); track $index) {
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
@ -194,6 +196,7 @@
}
</div>
</div>
}
}
</div>

View File

@ -64,7 +64,7 @@ export class TaskStepNodeComponent {
parameterFieldGroups: DisplayFieldGroup[] = [];
name = 'Step';
mainContent: MainContentView | null = null;
mainContentFields: MainContentView[] = [];
private blockSchema: Record<string, any> | null = null;
private variablePlaceholderPaths = new Set<string>();
@ -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<string, DisplayField[]>();
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[] {

View File

@ -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<string, unknown>