Implement schema-driven block creation lifecycle and retriever-dependent field behavior

This commit is contained in:
Lucio Lelii 2026-03-09 11:37:20 +01:00
parent e13e06d858
commit c29bc7b98b
9 changed files with 544 additions and 42 deletions

View File

@ -3,6 +3,7 @@ import { FlowBlock } from "./flow";
export type HFNodeData = FlowBlock & {
deleteNode?: () => Promise<void>;
replaceWithCreatedBlock?: (block: FlowBlock) => Promise<void>;
};
export type HFNode = ClassicPreset.Node & {

View File

@ -1,17 +1,211 @@
import { BlockType, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { map, Observable, of, switchMap, take } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
private readonly http = inject(HttpClient);
private blockTypesCache: BlockType[] | null = null;
override retrieveAllBlocksTypes(): Observable<BlockType[]> {
throw new Error("Method not implemented.");
return this.http
.get<unknown[]>(`${environment.apiUrl}/blocks/types`)
.pipe(
map((raw) => (Array.isArray(raw) ? raw.map((value) => this.blockTypeFromApi(value)) : [])),
map((types) => {
this.blockTypesCache = types;
return types;
})
);
}
override createEmptyBlock(_blockType: string): Observable<FlowBlock> {
throw new Error("Method not implemented.");
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
return this.getBlockTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === blockType);
const configuration = descriptor
? this.buildObjectFromSchema(descriptor.schema, descriptor.schema)
: {};
const payload = this.buildBlockConfigurationPayload(blockType, configuration);
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload)));
})
);
}
override updateBlock(_blockId: string, _configuration: any): Observable<FlowBlock> {
throw new Error("Method not implemented.");
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
const blockType = String(configuration?.typeName ?? configuration?.type ?? "LLMBlock");
const payload = this.buildBlockConfigurationPayload(
blockType,
this.toRecord(configuration?.specificConfiguration ?? configuration)
);
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(
map((raw) =>
this.flowBlockFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? blockId
},
blockType,
payload
)
)
);
}
private getBlockTypesForCreate(): Observable<BlockType[]> {
if (this.blockTypesCache) {
return of(this.blockTypesCache);
}
return this.retrieveAllBlocksTypes();
}
private blockTypeFromApi(raw: unknown): BlockType {
const value = this.toRecord(raw);
return {
type: String(value["type"] ?? value["blockType"] ?? value["name"] ?? "LLMBlock"),
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
configurationType: this.toNullableString(value["configurationType"]),
configurationClass: this.toNullableString(value["configurationClass"]),
schema: this.toSchema(value["schema"] ?? value["configurationSchema"] ?? null)
};
}
private flowBlockFromApi(raw: unknown, fallbackTypeName = "LLMBlock", fallbackConfig?: Record<string, unknown>): FlowBlock {
const root = this.toRecord(raw);
const value = this.toRecord(root["block"] ?? root["data"] ?? root);
const specificConfigurationRaw = value["specificConfiguration"] ?? value["configuration"] ?? value["blockConfiguration"] ?? fallbackConfig ?? {};
const specificConfiguration = this.toRecord(specificConfigurationRaw);
const typeName = String(value["typeName"] ?? value["blockType"] ?? specificConfiguration["typeName"] ?? fallbackTypeName);
const io = this.defaultIOForBlockType(typeName);
return {
id: String(value["id"] ?? crypto.randomUUID()),
sink: typeof value["sink"] === "boolean" ? value["sink"] : typeName === "HumanInteractionBlock",
name: String(value["name"] ?? specificConfiguration["name"] ?? typeName),
position: this.toPosition(value["position"]),
inputs: this.toPorts(value["inputs"], io.inputs),
outputs: this.toPorts(value["outputs"], io.outputs),
specificConfiguration,
typeName
};
}
private toPorts(raw: unknown, fallback: Array<{ name: string; type: string; multiple: boolean }>) {
if (!Array.isArray(raw)) return fallback;
return raw
.map((port) => this.toRecord(port))
.filter((port) => typeof port["name"] === "string" && (port["name"] as string).length > 0)
.map((port) => ({
name: String(port["name"]),
type: String(port["type"] ?? "TEXT"),
multiple: Boolean(port["multiple"] ?? false)
}));
}
private toPosition(raw: unknown): { x: number; y: number } | undefined {
const value = this.toRecord(raw);
const x = value["x"];
const y = value["y"];
if (typeof x !== "number" || typeof y !== "number") return undefined;
return { x, y };
}
private toSchema(raw: unknown): Record<string, unknown> | null {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
return raw as Record<string, unknown>;
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
private toNullableString(value: unknown): string | null {
return typeof value === "string" && value.length > 0 ? value : null;
}
private buildBlockConfigurationPayload(blockType: string, configuration: Record<string, unknown>) {
const { typeName: _ignoreTypeName, ...sanitized } = configuration;
return {
...sanitized,
name: typeof sanitized["name"] === "string" && sanitized["name"].length > 0
? sanitized["name"]
: blockType
};
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {
inputs: [],
outputs: [{ name: "output", type: "TEXT", multiple: false }]
};
}
return {
inputs: [{ name: "input", type: "TEXT", multiple: false }],
outputs: [{ name: "output", type: "TEXT", multiple: false }]
};
}
private buildObjectFromSchema(node: unknown, root: unknown): Record<string, unknown> {
const resolved = this.resolveRef(node, root);
const resolvedRecord = this.toRecord(resolved);
const properties = this.toRecord(resolvedRecord["properties"]);
const result: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(properties)) {
result[key] = this.buildValueFromSchema(propSchema, root);
}
return result;
}
private buildValueFromSchema(node: unknown, root: unknown): unknown {
const resolved = this.resolveRef(node, root);
const value = this.toRecord(resolved);
if (Object.prototype.hasOwnProperty.call(value, "default")) {
return value["default"];
}
const enumValues = value["enum"];
if (Array.isArray(enumValues) && enumValues.length > 0) {
return enumValues[0];
}
const type = value["type"];
if (type === "string") return "";
if (type === "boolean") return false;
if (type === "number" || type === "integer") return 0;
if (type === "array") return [];
if (type === "object" || Object.prototype.hasOwnProperty.call(value, "properties")) {
return this.buildObjectFromSchema(value, root);
}
return null;
}
private resolveRef(node: unknown, root: unknown): unknown {
const value = this.toRecord(node);
const ref = value["$ref"];
if (typeof ref !== "string" || !ref.startsWith("#/")) return node;
const path = ref.slice(2).split("/");
let current: unknown = root;
for (const segment of path) {
if (!current || typeof current !== "object" || Array.isArray(current)) return node;
current = (current as Record<string, unknown>)[segment];
}
return current ?? node;
}
}

View File

@ -1,8 +1,8 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName } from '@models/flow';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, firstValueFrom, throwError } from 'rxjs';
import { catchError, firstValueFrom, of, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -11,22 +11,37 @@ export class BlocksService {
blocksCallService: BlocksCallServiceBase = new environment.blocksCallService();
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private _blockTypes = signal<BlockType[]>([]);
async getAllBlocksTypes() {
if (this.toInit) {
this.refresh();
await this.refresh();
this.toInit = false;
}
return this._blockTypes.asReadonly();
}
refresh() {
this.blocksCallService.retrieveAllBlocksTypes().subscribe((blockTypes) => {
this._blockTypes.set(blockTypes);
});
async refresh(force = false): Promise<void> {
if (this.loadingPromise && !force) {
return this.loadingPromise;
}
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
})
.catch((err) => {
console.error('Retrieve blocks types failed', err);
throw err;
})
.finally(() => {
this.loadingPromise = null;
});
return this.loadingPromise;
}
async getBlockType(typeName: BlockTypeName) {
@ -39,12 +54,26 @@ export class BlocksService {
}
createEmptyBlock(blockType: BlockTypeName) {
return this.blocksCallService.createEmptyBlock(blockType).pipe(
catchError((err) => {
console.error('Create empty block failed', err);
return throwError(() => err);
})
);
const descriptor = this._blockTypes().find((type) => type.type === blockType);
const schema = (descriptor?.schema ?? null) as Record<string, unknown> | null;
const specificConfiguration = this.buildObjectFromSchema(schema, schema);
const io = this.defaultIOForBlockType(String(blockType));
const block: FlowBlock = {
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
sink: String(blockType) === 'HumanInteractionBlock',
name: String(blockType),
position: undefined,
inputs: io.inputs,
outputs: io.outputs,
specificConfiguration: this.ensureBlockName(specificConfiguration, String(blockType)),
typeName: String(blockType)
};
return of({
...block,
__needsServerCreate: true
} as FlowBlock);
}
updateBlock(blockId: string, configuration: any) {
@ -55,4 +84,87 @@ export class BlocksService {
})
);
}
private ensureBlockName(configuration: Record<string, unknown>, blockType: string) {
const next = { ...configuration };
if (typeof next['name'] !== 'string' || next['name'].length === 0) {
next['name'] = blockType;
}
return next;
}
private defaultIOForBlockType(typeName: string) {
void typeName;
return {
inputs: [],
outputs: []
};
}
private buildObjectFromSchema(
node: Record<string, unknown> | null,
root: Record<string, unknown> | null
): Record<string, unknown> {
if (!node || !root) return {};
const resolved = this.resolveRef(node, root);
const properties = this.toRecord(resolved['properties']);
const result: Record<string, unknown> = {};
for (const [key, propSchema] of Object.entries(properties)) {
result[key] = this.buildValueFromSchema(this.toRecord(propSchema), root);
}
return result;
}
private buildValueFromSchema(node: Record<string, unknown>, root: Record<string, unknown>): unknown {
const resolved = this.resolveRef(node, root);
if (Object.prototype.hasOwnProperty.call(resolved, 'default')) {
return resolved['default'];
}
const enumValues = resolved['enum'];
if (Array.isArray(enumValues) && enumValues.length > 0) {
return enumValues[0];
}
const type = resolved['type'];
if (type === 'string') return '';
if (type === 'boolean') return false;
if (type === 'number' || type === 'integer') return 0;
if (type === 'array') return [];
if (type === 'object' || Object.prototype.hasOwnProperty.call(resolved, 'properties')) {
return this.buildObjectFromSchema(resolved, root);
}
return null;
}
private resolveRef(node: Record<string, unknown>, root: Record<string, unknown>): Record<string, unknown> {
const ref = node['$ref'];
if (typeof ref !== 'string' || !ref.startsWith('#/')) {
return node;
}
const segments = ref.slice(2).split('/');
let current: unknown = root;
for (const segment of segments) {
const asRecord = this.toRecord(current);
current = asRecord[segment];
if (current == null) {
return node;
}
}
return this.toRecord(current);
}
private toRecord(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return {};
}
return value as Record<string, unknown>;
}
}

View File

@ -1,7 +1,7 @@
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { Observable } from "rxjs";
import { map, Observable } from "rxjs";
import { FieldRetrieverCallServiceBase } from "./field-retriever-call.base";
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
@ -10,14 +10,8 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
private buildParams(context?: Record<string, string>) {
let params = new HttpParams();
const entries = Object.entries(context ?? {});
if (!entries.length) {
// New API marks `params` as required even when empty.
return params.set('params', '{}');
}
for (const [ctxKey, ctxValue] of entries) {
params = params.set(`params[${ctxKey}]`, ctxValue);
params = params.set(ctxKey, ctxValue);
}
return params;
@ -30,7 +24,9 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
): Observable<string[]> {
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}`;
const params = this.buildParams(context);
return this.http.get<string[]>(url, { params });
return this.http.get<unknown>(url, { params }).pipe(
map((raw) => this.normalizeStringList(raw))
);
}
override isFieldRequired(
@ -42,4 +38,28 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
const params = this.buildParams(context);
return this.http.get<boolean>(url, { params });
}
private normalizeStringList(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === 'string');
}
if (!raw || typeof raw !== 'object') {
return [];
}
const payload = raw as Record<string, unknown>;
const candidate =
payload['values'] ??
payload['items'] ??
payload['data'] ??
payload['result'] ??
[];
if (!Array.isArray(candidate)) {
return [];
}
return candidate.filter((item): item is string => typeof item === 'string');
}
}

View File

@ -43,8 +43,11 @@ export class BlocksList extends ListStateViewHolder<BlockType> {
this.blocksService.getAllBlocksTypes().then((blockTypesSignal) => {
this.blockTypes = blockTypesSignal;
this.loading.set(false);
this.view.list = this.blockTypes;
}).catch((err) => {
console.error('Error loading block types', err);
}).finally(() => {
this.loading.set(false);
});
}

View File

@ -166,14 +166,22 @@
<label>{{ localEditorLabel }}</label>
@if (localEditorLoading) {
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
} @else if (localEditorDisabled) {
<input
type="text"
[ngModel]="localEditorValue"
[placeholder]="localEditorDisabledHint"
disabled
(pointerdown)="$event.stopPropagation()" />
<small class="text-slate-500 mt-1 block">{{ localEditorDisabledHint }}</small>
} @else if (localEditorType === 'boolean') {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<select [(ngModel)]="localEditorValue" [disabled]="!localEditorOptions.length" (pointerdown)="$event.stopPropagation()">
<option value="true">true</option>
<option value="false">false</option>
</select>
} @else if (localEditorOptions.length) {
} @else if (localEditorHasRetriever) {
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
<option value="">Select {{ localEditorLabel | lowercase }}...</option>
<option value="">{{ localEditorOptions.length ? ('Select ' + (localEditorLabel | lowercase) + '...') : 'No options available' }}</option>
@for (option of localEditorOptions; track option) {
<option [value]="option">{{ option }}</option>
}
@ -189,7 +197,7 @@
</div>
<div class="llm-modal-actions">
<button type="button" class="llm-btn llm-btn-ghost" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">Cancel</button>
<button type="button" class="llm-btn llm-btn-primary" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
<button type="button" class="llm-btn llm-btn-primary" [disabled]="localEditorDisabled || localEditorLoading" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
</div>
</div>
</div>

View File

@ -7,7 +7,7 @@ import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialo
import { EditorStateHolder } from '@stores/flow-editor';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { BlocksService } from '@services/blocks/blocks';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, take } from 'rxjs';
import { ConditionalRequiredField, extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import {
flattenPrimitiveValues,
@ -36,6 +36,8 @@ type EditableFieldDefinition = {
ui: {
widget: 'textarea' | null;
acceptVariableAsPlaceholder: boolean;
structural: boolean;
structuralReason?: string;
placeholder?: string;
tip?: string;
rows?: number;
@ -91,10 +93,14 @@ export class GenericNodeComponent {
localEditorValue = '';
localEditorOptions: string[] = [];
localEditorLoading = false;
localEditorHasRetriever = false;
localEditorDisabled = false;
localEditorDisabledHint = '';
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
missingRequiredParams: string[] = [];
private missingRequiredPaths: string[] = [];
private blockSchema: Record<string, any> | null = null;
private editableFieldDefinitions: EditableFieldDefinition[] = [];
@ -140,6 +146,9 @@ export class GenericNodeComponent {
this.localEditorValue = this.name ?? '';
this.localEditorOptions = [];
this.localEditorLoading = false;
this.localEditorHasRetriever = false;
this.localEditorDisabled = false;
this.localEditorDisabledHint = '';
this.localEditorOpen = true;
}
@ -166,9 +175,26 @@ export class GenericNodeComponent {
this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type);
this.localEditorOptions = [];
this.localEditorLoading = !!definition.retrieverKey;
this.localEditorHasRetriever = !!definition.retrieverKey;
this.localEditorDisabled = false;
this.localEditorDisabledHint = '';
this.localEditorOpen = true;
if (definition.retrieverKey) {
const missingDependencies = definition.retrieverDependsOn
.filter((dep) => {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
return this.isMissingValue(value);
})
.map((dep) => pathToLabel(dep.path));
if (missingDependencies.length > 0) {
this.localEditorLoading = false;
this.localEditorDisabled = true;
this.localEditorDisabledHint = `Select ${missingDependencies.join(', ')} first`;
return;
}
await this.loadLocalEditorOptions(definition);
}
}
@ -182,6 +208,9 @@ export class GenericNodeComponent {
this.localEditorLabel = '';
this.localEditorOptions = [];
this.localEditorLoading = false;
this.localEditorHasRetriever = false;
this.localEditorDisabled = false;
this.localEditorDisabledHint = '';
this.localEditorType = 'string';
this.localEditorMaxLength = null;
}
@ -191,6 +220,7 @@ export class GenericNodeComponent {
event?.stopPropagation();
if (!this.localEditorPath) return;
if (this.localEditorDisabled) return;
const config = this.ensureBlockConfiguration();
@ -199,13 +229,21 @@ export class GenericNodeComponent {
config['name'] = nameValue;
this.name = nameValue || this.name;
} else {
const previousValue = this.getByPath(config, this.localEditorPath);
const parsedValue = this.parseEditorValue(this.localEditorValue, this.localEditorType);
this.setByPath(config, this.localEditorPath, parsedValue);
if (!this.areValuesEqual(previousValue, parsedValue)) {
this.resetDependentRetrieverFields(config, this.localEditorPath);
if (this.isStructuralField(this.localEditorPath)) {
this.markBlockForServerRecreate();
}
}
}
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
this.closeSimpleParamEditor();
}
@ -307,6 +345,7 @@ export class GenericNodeComponent {
await this.refreshConditionalRequirements();
this.refreshParameterFields();
this.refreshValidationState();
this.maybeCreateBlockOnServer();
}
private async openTextareaEditor(
@ -332,10 +371,19 @@ export class GenericNodeComponent {
if (!result) return;
const config = this.ensureBlockConfiguration();
this.setByPath(config, path, String(result[path] ?? ''));
const nextValue = String(result[path] ?? '');
const previousValue = this.getByPath(config, path);
this.setByPath(config, path, nextValue);
if (!this.areValuesEqual(previousValue, nextValue)) {
this.resetDependentRetrieverFields(config, path);
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
}
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
private buildEditableFieldDefinitions(schema: Record<string, any> | null): EditableFieldDefinition[] {
@ -400,10 +448,16 @@ export class GenericNodeComponent {
? Math.trunc(rowsRaw)
: undefined;
const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true;
const structural = schema?.['x-ui-structural'] === true;
const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string'
? String(schema['x-ui-structural-reason'])
: undefined;
return {
widget: normalizedWidget,
acceptVariableAsPlaceholder,
structural,
structuralReason,
placeholder,
tip,
rows
@ -414,6 +468,10 @@ export class GenericNodeComponent {
return this.toFieldUiMeta(this.resolveFieldSchema(path));
}
private isStructuralField(path: string): boolean {
return this.getFieldUiMeta(path).structural;
}
private resolveFieldSchema(path: string): Record<string, any> | null {
const root = this.blockSchema;
if (!root) return null;
@ -617,6 +675,28 @@ export class GenericNodeComponent {
current[keys[keys.length - 1]] = value;
}
private resetDependentRetrieverFields(
config: Record<string, any>,
changedPath: string,
visited = new Set<string>()
) {
const dependentFields = this.editableFieldDefinitions.filter((definition) =>
definition.retrieverDependsOn.some((dep) => dep.path === changedPath)
);
for (const field of dependentFields) {
if (visited.has(field.path)) continue;
visited.add(field.path);
this.setByPath(config, field.path, '');
this.resetDependentRetrieverFields(config, field.path, visited);
}
}
private areValuesEqual(left: unknown, right: unknown): boolean {
if (left === right) return true;
return JSON.stringify(left) === JSON.stringify(right);
}
private mainContentKey(): string | null {
const config = this.blockConfiguration ?? {};
if (Object.prototype.hasOwnProperty.call(config, 'actionDescription')) return 'actionDescription';
@ -651,9 +731,11 @@ export class GenericNodeComponent {
...this.schemaRequirements.conditional.filter((field) => this.conditionalRequiredByPath.get(field.path))
].filter((field) => field.path !== 'name');
this.missingRequiredParams = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)))
.map((field) => field.label);
const missingFields = requiredFields
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)));
this.missingRequiredPaths = missingFields.map((field) => field.path);
this.missingRequiredParams = missingFields.map((field) => field.label);
if (!this.refreshingConditionalRequirements) {
void this.refreshConditionalRequirements();
@ -682,6 +764,7 @@ export class GenericNodeComponent {
this.refreshingConditionalRequirements = false;
if (changed) {
this.refreshValidationState();
this.maybeCreateBlockOnServer();
}
}
@ -711,4 +794,60 @@ export class GenericNodeComponent {
}
});
}
private maybeCreateBlockOnServer() {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
if (!nodeData?.['__needsServerCreate']) return;
if (nodeData['__isCreatingOnServer'] === true) return;
if (this.missingRequiredPaths.length > 0) return;
const blockType = this.blockType;
if (!blockType) return;
nodeData['__isCreatingOnServer'] = true;
const configuration = this.ensureBlockConfiguration();
this.blocksService.updateBlock(String(nodeData['id'] ?? ''), {
...configuration,
typeName: blockType
}).pipe(
take(1)
).subscribe({
next: (createdBlock) => {
const current = (this.data?.data ?? {}) as Record<string, unknown>;
const replaceNode = current['replaceWithCreatedBlock'];
if (typeof replaceNode === 'function') {
void replaceNode({
...createdBlock,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position
});
return;
}
this.data.data = {
...current,
...createdBlock,
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
__needsServerCreate: false,
__isCreatingOnServer: false,
__createdOnServer: true
};
this.name = toStringOrNull(this.ensureBlockConfiguration()['name']) || createdBlock.name || this.name;
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
},
error: (err) => {
nodeData['__isCreatingOnServer'] = false;
console.error('Create block failed', err);
}
});
}
private markBlockForServerRecreate() {
const nodeData = this.data?.data as Record<string, unknown> | undefined;
if (!nodeData) return;
nodeData['__needsServerCreate'] = true;
nodeData['__createdOnServer'] = false;
}
}

View File

@ -38,7 +38,8 @@ function walkSchema(
required: RequiredField[],
conditional: ConditionalRequiredField[],
seenRequired: Set<string>,
seenConditional: Set<string>
seenConditional: Set<string>,
requireAllDescendants = false
) {
const resolved = resolveSchemaRef(node, root);
if (!resolved || typeof resolved !== 'object') return;
@ -53,8 +54,11 @@ function walkSchema(
const propertyResolved = resolveSchemaRef(propertySchema as Record<string, any>, root);
const hasChildren = !!propertyResolved?.properties || propertyResolved?.type === 'object';
const label = toLabel(key);
const isRequiredBySchema = requiredSet.has(key);
const isRequiredByAncestor = requireAllDescendants && key !== 'type';
const isRequired = isRequiredBySchema || isRequiredByAncestor;
if (requiredSet.has(key) && !hasChildren && key !== 'type' && !seenRequired.has(propertyPath)) {
if (isRequired && !hasChildren && key !== 'type' && !seenRequired.has(propertyPath)) {
seenRequired.add(propertyPath);
required.push({ path: propertyPath, label });
}
@ -89,7 +93,17 @@ function walkSchema(
}
if (hasChildren) {
walkSchema(propertyResolved as Record<string, any>, root, propertyPath, required, conditional, seenRequired, seenConditional);
const childHasOwnRequired = Array.isArray(propertyResolved?.required) && propertyResolved.required.length > 0;
walkSchema(
propertyResolved as Record<string, any>,
root,
propertyPath,
required,
conditional,
seenRequired,
seenConditional,
isRequired && !childHasOwnRequired
);
}
}
}

View File

@ -134,7 +134,18 @@ export async function addBlockToEditor(
if (!editor.getNode(node.id)) return;
await editor.removeNode(node.id);
};
node.data = { ...block, position: position ?? block.position, deleteNode: removeNode };
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {
if (!editor.getNode(node.id)) return;
const currentPosition = (node.data?.position ?? position ?? createdBlock.position) as { x: number; y: number } | undefined;
await editor.removeNode(node.id);
await addBlockToEditor(editor, area, { ...createdBlock, position: currentPosition }, currentPosition);
};
node.data = {
...block,
position: position ?? block.position,
deleteNode: removeNode,
replaceWithCreatedBlock
};
for (const output of block.outputs ?? []) {
node.addOutput(output.name, new ClassicPreset.Output(getSocket(editor, output.type ?? "ANY")));