From c29bc7b98bd9088a0b6e9ce38cc64374a63fcb36 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Mon, 9 Mar 2026 11:37:20 +0100 Subject: [PATCH] Implement schema-driven block creation lifecycle and retriever-dependent field behavior --- src/app/models/nodes.ts | 1 + src/app/services/blocks/blocks-call.ts | 206 +++++++++++++++++- src/app/services/blocks/blocks.ts | 138 ++++++++++-- .../retriever/field-retriever-call.ts | 38 +++- src/app/shared/blocks-list/blocks-list.ts | 5 +- .../nodes/generic-node/generic-node.html | 16 +- .../shared/nodes/generic-node/generic-node.ts | 149 ++++++++++++- src/app/shared/nodes/schema-requirements.ts | 20 +- src/app/utilities/rete-editor.ts | 13 +- 9 files changed, 544 insertions(+), 42 deletions(-) diff --git a/src/app/models/nodes.ts b/src/app/models/nodes.ts index 93b7349..b9353bf 100644 --- a/src/app/models/nodes.ts +++ b/src/app/models/nodes.ts @@ -3,6 +3,7 @@ import { FlowBlock } from "./flow"; export type HFNodeData = FlowBlock & { deleteNode?: () => Promise; + replaceWithCreatedBlock?: (block: FlowBlock) => Promise; }; export type HFNode = ClassicPreset.Node & { diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 20f1689..34a4ee1 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { - throw new Error("Method not implemented."); + return this.http + .get(`${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 { - throw new Error("Method not implemented."); + override createEmptyBlock(blockType: string): Observable { + 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(`${environment.apiUrl}/blocks`, payload) + .pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload))); + }) + ); } - override updateBlock(_blockId: string, _configuration: any): Observable { - throw new Error("Method not implemented."); + override updateBlock(blockId: string, configuration: any): Observable { + const blockType = String(configuration?.typeName ?? configuration?.type ?? "LLMBlock"); + const payload = this.buildBlockConfigurationPayload( + blockType, + this.toRecord(configuration?.specificConfiguration ?? configuration) + ); + + return this.http + .post(`${environment.apiUrl}/blocks`, payload) + .pipe( + map((raw) => + this.flowBlockFromApi( + { + ...(this.toRecord(raw)), + id: this.toRecord(raw)["id"] ?? blockId + }, + blockType, + payload + ) + ) + ); + } + + private getBlockTypesForCreate(): Observable { + 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): 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 | null { + if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null; + return raw as Record; + } + + private toRecord(value: unknown): Record { + if (!value || typeof value !== "object" || Array.isArray(value)) return {}; + return value as Record; + } + + private toNullableString(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; + } + + private buildBlockConfigurationPayload(blockType: string, configuration: Record) { + 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 { + const resolved = this.resolveRef(node, root); + const resolvedRecord = this.toRecord(resolved); + const properties = this.toRecord(resolvedRecord["properties"]); + + const result: Record = {}; + 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)[segment]; + } + return current ?? node; } } diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index 35a188c..978d875 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -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 | null = null; private _blockTypes = signal([]); 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 { + 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 | 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, 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 | null, + root: Record | null + ): Record { + if (!node || !root) return {}; + const resolved = this.resolveRef(node, root); + const properties = this.toRecord(resolved['properties']); + const result: Record = {}; + + for (const [key, propSchema] of Object.entries(properties)) { + result[key] = this.buildValueFromSchema(this.toRecord(propSchema), root); + } + + return result; + } + + private buildValueFromSchema(node: Record, root: Record): 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, root: Record): Record { + 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 { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return value as Record; + } } diff --git a/src/app/services/retriever/field-retriever-call.ts b/src/app/services/retriever/field-retriever-call.ts index 9700c0a..4c41b8b 100644 --- a/src/app/services/retriever/field-retriever-call.ts +++ b/src/app/services/retriever/field-retriever-call.ts @@ -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) { 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 { const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}`; const params = this.buildParams(context); - return this.http.get(url, { params }); + return this.http.get(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(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; + 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'); + } } diff --git a/src/app/shared/blocks-list/blocks-list.ts b/src/app/shared/blocks-list/blocks-list.ts index f341ae2..85044e5 100644 --- a/src/app/shared/blocks-list/blocks-list.ts +++ b/src/app/shared/blocks-list/blocks-list.ts @@ -43,8 +43,11 @@ export class BlocksList extends ListStateViewHolder { 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); }); } diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 6365c43..ff30459 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -166,14 +166,22 @@ @if (localEditorLoading) { + } @else if (localEditorDisabled) { + + {{ localEditorDisabledHint }} } @else if (localEditorType === 'boolean') { - - } @else if (localEditorOptions.length) { + } @else if (localEditorHasRetriever) {