Make container nodes schema-driven

This commit is contained in:
Lucio Lelii 2026-03-17 11:54:07 +01:00
parent 08d83863a1
commit 971d829685
5 changed files with 449 additions and 21 deletions

View File

@ -257,6 +257,67 @@
color: #475569;
}
.container-node__params {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
padding: 0 14px 14px;
}
.container-node__param-chip {
min-width: 0;
border: 1px solid #dbe2ea;
border-radius: 12px;
background: rgba(255, 255, 255, 0.9);
padding: 8px 10px;
}
.container-node__param-chip--wide {
grid-column: 1 / -1;
}
.container-node__param-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 4px;
}
.container-node__param-key {
min-width: 0;
font-size: 10px;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.container-node__param-value {
display: block;
min-width: 0;
font-size: 12px;
color: #0f172a;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.container-node__param-edit {
border: 0;
background: transparent;
color: #0f766e;
width: 20px;
height: 20px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.container-node__param-edit:hover {
background: rgba(15, 118, 110, 0.12);
}
.container-node__dropzone {
margin: 0 14px 14px;
min-height: 128px;

View File

@ -7,7 +7,7 @@
<i class="bi bi-box-seam"></i>
</div>
<div class="container-node__titles">
<div class="container-node__eyebrow">Generic Container</div>
<div class="container-node__eyebrow">{{ containerLabel }}</div>
<div class="container-node__name">{{ name }}</div>
</div>
<div class="container-node__header-actions">
@ -97,6 +97,29 @@
</div>
</div>
@if (hasParameterFields()) {
<div class="container-node__params">
@for (field of parameterFields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
title="Edit field"
(pointerdown)="$event.stopPropagation()"
(click)="openParameterEditor(field.path, $event)">
<i class="bi bi-pen"></i>
</button>
}
</div>
<span class="container-node__param-value">{{ field.value }}</span>
</div>
}
</div>
}
<div
class="container-node__dropzone"
[class.container-node__dropzone--active]="!isReadonly && selectedCount > 0"

View File

@ -1,7 +1,7 @@
import { CommonModule } from '@angular/common';
import { Component, HostBinding, Input, inject } from '@angular/core';
import { ChangeDetectorRef, Component, HostBinding, Input, inject } from '@angular/core';
import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData } from '@models/flow';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { ContainersService } from '@services/containers/containers';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { ClassicPreset } from 'rete';
@ -10,6 +10,26 @@ import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-d
import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, pathToLabel, readUiConditionRule, resolveSchemaRef, valueToDisplayString } from '../node-utility';
type ContainerFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown';
type ContainerFieldDefinition = {
path: string;
label: string;
type: ContainerFieldType;
enumOptions: string[];
widget: 'textarea' | null;
structural: boolean;
};
type ContainerFieldView = {
path: string;
label: string;
value: string;
wide: boolean;
};
type StructuredRetrieverConfig = {
retrieverName: string;
@ -30,14 +50,19 @@ type StructuredRetrieverConfig = {
})
export class ContainerNodeComponent {
private editorState = inject(EditorStateHolder);
private cdr = inject(ChangeDetectorRef);
private subflowPreview = inject(SubflowPreviewDialogService);
private fieldRetriever = inject(FieldRetriever);
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
private containerSchema: Record<string, any> | null = null;
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
private containerFieldDefinitions: ContainerFieldDefinition[] = [];
deleteConfirmOpen = false;
replaceConfirmSelection: string[] | null = null;
importLoading = false;
private importErrorMessage: string | null = null;
parameterFields: ContainerFieldView[] = [];
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -55,6 +80,10 @@ export class ContainerNodeComponent {
return this.isReadonly;
}
ngOnInit() {
void this.loadSchemaContext();
}
ngAfterViewInit() {
this.rendered();
}
@ -67,6 +96,10 @@ export class ContainerNodeComponent {
return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container');
}
get containerLabel() {
return pathToLabel(this.typeName.replace(/Container$/, ' Container'));
}
get inputs() {
return Object.entries(this.data?.inputs ?? {}).map(([key, input]) => ({
key,
@ -125,14 +158,25 @@ export class ContainerNodeComponent {
}
get missingRequiredParams() {
const missing: string[] = [];
if (!this.name.trim()) {
missing.push('Name');
}
if (!this.subFlow) {
missing.push('Sub Flow');
}
return missing;
const config = this.configuration ?? {};
const requiredFields = [
...this.schemaRequirements.required,
...this.schemaRequirements.conditional.filter((field) =>
field.requiredWhen
? evaluateUiConditionRule(field.requiredWhen, config, (path) => this.resolveFieldSchema(path))
: false
)
].filter((field) => field.path !== 'type');
return requiredFields
.filter((field, index, fields) => fields.findIndex((candidate) => candidate.path === field.path) === index)
.filter((field) => field.path !== 'name' && field.path !== 'subFlow')
.filter((field) => this.isMissingValue(getValueByPath(config, field.path)))
.map((field) => field.label);
}
hasParameterFields() {
return this.parameterFields.length > 0;
}
inputDisplayLabel(inputKey: string) {
@ -230,6 +274,7 @@ export class ContainerNodeComponent {
}
await assignImportedSubflow(selectedItem.data, retriever.validationUrl);
this.refreshParameterFields();
} catch {
this.importErrorMessage = 'Failed to load importable flows.';
} finally {
@ -237,6 +282,36 @@ export class ContainerNodeComponent {
}
}
async openParameterEditor(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.containerFieldDefinitions.find((field) => field.path === path);
if (!definition) return;
const initialValue = this.getEditorInitialValue(definition);
const field: NodeSettingField = {
key: definition.path,
label: definition.label,
type: this.toDialogFieldType(definition),
required: this.missingRequiredParams.includes(definition.label),
rows: definition.widget === 'textarea' ? 6 : undefined,
options: definition.enumOptions.map((option) => ({ label: option, value: option }))
};
const result = await this.settingsDialog.open({
title: `Edit ${definition.label}`,
fields: [field],
initial: {
[definition.path]: initialValue
}
});
if (!result) return;
await this.applyFieldValue(definition, result[definition.path]);
}
onDropZoneDragOver(event: DragEvent) {
if (this.isReadonly) return;
if (!this.canAcceptSelectionDrop()) return;
@ -443,4 +518,279 @@ export class ContainerNodeComponent {
};
}
private async loadSchemaContext() {
const containerType = await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while schema was loading.
}
});
}
private isMissingValue(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') return value.trim().length === 0;
return false;
}
private refreshParameterFields() {
const config = this.configuration ?? {};
this.parameterFields = this.containerFieldDefinitions
.filter((field) => this.isFieldVisible(field.path))
.map((field) => ({
path: field.path,
label: field.label,
value: valueToDisplayString(getValueByPath(config, field.path)),
wide: field.widget === 'textarea' || field.label.length >= 18
}));
}
private buildContainerFieldDefinitions(schema: Record<string, any> | null): ContainerFieldDefinition[] {
if (!schema) return [];
const definitions: ContainerFieldDefinition[] = [];
const walk = (node: Record<string, any>, pathPrefix: string) => {
const resolved = resolveSchemaRef(node, schema);
if (!resolved || typeof resolved !== 'object') return;
const properties = resolved.properties as Record<string, any> | undefined;
if (!properties) return;
for (const [key, childSchema] of Object.entries(properties)) {
if (key === 'type') continue;
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
if (path === 'name' || path === 'subFlow') continue;
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
if (hasChildren) {
walk(childResolved as Record<string, any>, path);
continue;
}
definitions.push({
path,
label: pathToLabel(path),
type: this.toFieldType(childResolved),
enumOptions: Array.isArray(childResolved?.enum)
? childResolved.enum.filter((item: unknown): item is string => typeof item === 'string')
: [],
widget: typeof childResolved?.['x-ui-widget'] === 'string' && String(childResolved['x-ui-widget']).toLowerCase() === 'textarea'
? 'textarea'
: null,
structural: childResolved?.['x-ui-structural'] === true
});
}
};
walk(schema, '');
return definitions;
}
private isFieldVisible(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
const schema = this.resolveFieldSchema(path);
const rule = readUiConditionRule(schema?.['x-ui-visible-when']);
if (!rule) return true;
const parent = typeof rule.field === 'string' ? rule.field : null;
if (parent && !this.isFieldVisible(parent, visited)) return false;
return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath));
}
private toFieldType(schema: Record<string, any> | null): ContainerFieldType {
const type = typeof schema?.['type'] === 'string' ? String(schema['type']) : 'unknown';
if (type === 'string' || type === 'number' || type === 'integer' || type === 'boolean') {
return type;
}
return 'unknown';
}
private toDialogFieldType(definition: ContainerFieldDefinition): NodeSettingField['type'] {
if (definition.type === 'boolean') return 'checkbox';
if (definition.widget === 'textarea') return 'textarea';
if (definition.enumOptions.length) return 'select';
return 'text';
}
private getEditorInitialValue(definition: ContainerFieldDefinition): string | boolean {
const raw = getValueByPath(this.configuration ?? {}, definition.path);
if (definition.type === 'boolean') return raw === true;
if (raw == null) return '';
return String(raw);
}
private async applyFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined) {
const nextValue = this.parseFieldValue(definition, rawValue);
const nextConfiguration = this.cloneConfiguration();
this.setByPath(nextConfiguration, definition.path, nextValue);
if (definition.structural) {
this.updateCurrentFlowData(nextConfiguration);
await this.recreateContainer(nextConfiguration);
return;
}
this.data.data = {
...this.data.data,
specificConfiguration: nextConfiguration
};
this.refreshParameterFields();
this.updateCurrentFlowData(nextConfiguration);
this.refreshView();
}
private parseFieldValue(definition: ContainerFieldDefinition, rawValue: string | boolean | undefined): unknown {
if (definition.type === 'boolean') return rawValue === true;
const stringValue = typeof rawValue === 'string' ? rawValue : '';
if (definition.type === 'number' || definition.type === 'integer') {
const parsed = Number(stringValue);
return Number.isFinite(parsed) ? parsed : null;
}
return stringValue;
}
private cloneConfiguration(): Record<string, unknown> {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(this.configuration ?? {});
} catch {
// Ignore non-cloneable runtime metadata.
}
}
return JSON.parse(JSON.stringify(this.configuration ?? {})) as Record<string, unknown>;
}
private async recreateContainer(nextConfiguration: Record<string, unknown>) {
const current = (this.data?.data ?? {}) as Record<string, unknown>;
const containerId = String(current['id'] ?? '');
if (!containerId) return;
this.data.data = {
...current,
__containerAssigning: true,
__containerAssignmentError: null,
specificConfiguration: nextConfiguration
};
this.refreshView();
try {
const createdContainer = await firstValueFrom(
this.containersService.createContainer(containerId, {
...nextConfiguration,
position: current['position'],
typeName: this.typeName
})
);
const replaceNode = current['replaceWithCreatedNode'];
if (typeof replaceNode === 'function') {
await replaceNode({
...createdContainer,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position
});
return;
}
this.data.data = {
...current,
...createdContainer,
specificConfiguration: nextConfiguration,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdContainer.position,
__containerAssigning: false,
__containerAssignmentError: null
};
this.refreshParameterFields();
this.updateCurrentFlowData(nextConfiguration);
this.refreshView();
} catch (error) {
this.data.data = {
...current,
__containerAssigning: false,
__containerAssignmentError: error instanceof Error ? error.message : 'Container update failed'
};
this.refreshView();
}
}
private updateCurrentFlowData(nextConfiguration: Record<string, unknown>) {
const flow = this.editorState.currentFlow();
const blockId = this.blockId;
if (!flow || !blockId) return;
const nextFlow: FlowData = {
blocks: flow.data.blocks,
containers: flow.data.containers.map((container) =>
container.id === blockId
? {
...container,
name: String(nextConfiguration['name'] ?? container.name),
specificConfiguration: this.cloneConfigurationValue(nextConfiguration)
}
: container
),
connections: flow.data.connections
};
this.editorState.updateData(nextFlow);
}
private cloneConfigurationValue<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
try {
return globalThis.structuredClone(value);
} catch {
// Ignore non-cloneable runtime metadata.
}
}
return JSON.parse(JSON.stringify(value)) as T;
}
private setByPath(target: Record<string, any>, path: string, value: unknown) {
const parts = path.split('.');
let current = target;
for (let index = 0; index < parts.length - 1; index += 1) {
const key = parts[index];
const next = current[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
current[key] = {};
}
current = current[key] as Record<string, any>;
}
current[parts[parts.length - 1]] = value;
}
private refreshView() {
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while async refresh was running.
}
});
}
private resolveFieldSchema(path: string): Record<string, any> | null {
if (!this.containerSchema) return null;
let current: Record<string, any> | null = this.containerSchema;
for (const key of path.split('.')) {
const resolved = current ? resolveSchemaRef(current, this.containerSchema) : null;
const properties = resolved?.properties as Record<string, any> | undefined;
if (!properties?.[key]) return null;
current = resolveSchemaRef(properties[key], this.containerSchema) as Record<string, any> | null;
}
return current;
}
}

View File

@ -68,8 +68,8 @@ export async function createEditor(
customize: {
node(context: any) {
if (nodeView === "execution") return TaskStepNodeComponent;
const typeName = context?.payload?.data?.typeName;
return typeName === "GenericContainer" ? ContainerNodeComponent : GenericNodeComponent;
const nodeFamily = context?.payload?.data?.nodeFamily;
return nodeFamily === "container" ? ContainerNodeComponent : GenericNodeComponent;
},
socket(context: any) {
// rete-angular passes only `payload` to the socket component.
@ -173,7 +173,7 @@ export async function addBlockToEditor(
runtime?: ReteRuntimeContext
) {
const resolvedRuntime = runtime ?? editorRuntime.get(editor);
const node = new ClassicPreset.Node(toNodeLabel(block.typeName)) as HFNode;
const node = new ClassicPreset.Node(block.typeName) as HFNode;
const removeNode = async () => {
if (!editor.getNode(node.id)) return;
const relatedConnectionIds = editor.getConnections()
@ -516,12 +516,6 @@ function resolveNodePort(node: HFNode | undefined, kind: "input" | "output", por
return ports.find((port) => port?.name === portName) ?? null;
}
function toNodeLabel(typeName: string) {
if (typeName === "InputBlock" || typeName === "SourceBlock") return "Input";
if (typeName === "OutputBlock") return "Output";
return typeName;
}
async function applyNodePosition(
area: AreaPlugin<HFSchemes, AreaExtra>,
nodeId: string,

View File

@ -10,7 +10,7 @@ export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
assistantEnabled: true,
tourModeAlwaysOn: true,
tourModeAlwaysOn: false,
assistantCallService: AssistantCallService,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,