diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css
index b5fef64..7cb5067 100644
--- a/src/app/shared/nodes/container-node/container-node.css
+++ b/src/app/shared/nodes/container-node/container-node.css
@@ -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;
diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html
index 877e473..bb55934 100644
--- a/src/app/shared/nodes/container-node/container-node.html
+++ b/src/app/shared/nodes/container-node/container-node.html
@@ -7,7 +7,7 @@
-
Generic Container
+
{{ containerLabel }}
{{ name }}
+ @if (hasParameterFields()) {
+
+ @for (field of parameterFields; track field.path) {
+
+
+ {{ field.label }}
+ @if (!isReadonly) {
+
+ }
+
+
{{ field.value }}
+
+ }
+
+ }
+
0"
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts
index d846053..7ae6f60 100644
--- a/src/app/shared/nodes/container-node/container-node.ts
+++ b/src/app/shared/nodes/container-node/container-node.ts
@@ -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 | 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 | 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 | null): ContainerFieldDefinition[] {
+ if (!schema) return [];
+
+ const definitions: ContainerFieldDefinition[] = [];
+ const walk = (node: Record, pathPrefix: string) => {
+ const resolved = resolveSchemaRef(node, schema);
+ if (!resolved || typeof resolved !== 'object') return;
+
+ const properties = resolved.properties as Record | 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, schema);
+ const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
+ if (hasChildren) {
+ walk(childResolved as Record, 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()): 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 | 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 {
+ 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;
+ }
+
+ private async recreateContainer(nextConfiguration: Record) {
+ const current = (this.data?.data ?? {}) as Record;
+ 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) {
+ 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(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, 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;
+ }
+ 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 | null {
+ if (!this.containerSchema) return null;
+
+ let current: Record | null = this.containerSchema;
+ for (const key of path.split('.')) {
+ const resolved = current ? resolveSchemaRef(current, this.containerSchema) : null;
+ const properties = resolved?.properties as Record | undefined;
+ if (!properties?.[key]) return null;
+ current = resolveSchemaRef(properties[key], this.containerSchema) as Record | null;
+ }
+
+ return current;
+ }
+
}
diff --git a/src/app/utilities/rete-editor.ts b/src/app/utilities/rete-editor.ts
index c808367..5ff7d0e 100644
--- a/src/app/utilities/rete-editor.ts
+++ b/src/app/utilities/rete-editor.ts
@@ -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,
nodeId: string,
diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts
index b6d3017..519fdd3 100644
--- a/src/environments/environment.staging.ts
+++ b/src/environments/environment.staging.ts
@@ -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,