Add block example caching and structural refresh UX

This commit is contained in:
Lucio Lelii 2026-03-09 14:39:58 +01:00
parent c29bc7b98b
commit 376234539e
14 changed files with 262 additions and 118 deletions

View File

@ -28,6 +28,8 @@ export type BlockType = {
type: BlockTypeName;
description: string;
userInteractive: boolean;
hasExampleBlock?: boolean;
exampleBlockEndpoint?: string | null;
configurationType: string | null;
configurationClass: string | null;
schema: BlockTypeSchema;

View File

@ -3,8 +3,8 @@
[class.w-14]="collapsed()"
[class.w-72]="!collapsed()"
>
<aside class="h-full w-72 bg-white border-r border-gray-200 flex">
<div class="flex flex-col h-full">
<aside class="h-full w-full min-w-0 bg-white border-r border-gray-200 flex overflow-hidden">
<div class="flex flex-col h-full shrink-0">
<button (click)="collapse()" class="relative
w-6 h-6 rounded-full
@ -28,9 +28,9 @@
@if (!collapsed()) {
<!-- CONTENT -->
<div class="h-full bg-white flex-1">
<div class="h-full min-w-0 bg-white flex-1 overflow-hidden">
<div class="space-y-6 h-full">
<div class="space-y-6 h-full min-w-0 overflow-hidden">
@switch (open) {
@case ('flows') {
<app-group-holder title="Flows" icon="bi-lightning-charge-fill">

View File

@ -2,7 +2,7 @@ import { BlockType, FlowBlock } from "@models/flow";
import { HttpClient } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { map, Observable, of, switchMap, take } from "rxjs";
import { catchError, map, Observable, of, switchMap, take, throwError } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
@ -26,6 +26,14 @@ export class BlocksCallService extends BlocksCallServiceBase {
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === blockType);
const exampleEndpoint = this.resolveExampleBlockEndpoint(descriptor);
if (exampleEndpoint) {
return this.http
.get<unknown>(exampleEndpoint)
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType)));
}
const configuration = descriptor
? this.buildObjectFromSchema(descriptor.schema, descriptor.schema)
: {};
@ -48,6 +56,12 @@ export class BlocksCallService extends BlocksCallServiceBase {
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(
map((raw) => {
if (!raw || typeof raw !== "object") {
throw new Error(`Invalid updateBlock response for ${blockType}`);
}
return raw;
}),
map((raw) =>
this.flowBlockFromApi(
{
@ -57,7 +71,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
blockType,
payload
)
)
),
catchError((error) => throwError(() => this.toUpdateBlockError(error, blockType)))
);
}
@ -74,6 +89,8 @@ export class BlocksCallService extends BlocksCallServiceBase {
type: String(value["type"] ?? value["blockType"] ?? value["name"] ?? "LLMBlock"),
description: String(value["description"] ?? ""),
userInteractive: Boolean(value["userInteractive"] ?? value["interactive"] ?? false),
hasExampleBlock: Boolean(value["hasExampleBlock"] ?? false),
exampleBlockEndpoint: this.toApiPath(value["exampleBlockEndpoint"]),
configurationType: this.toNullableString(value["configurationType"]),
configurationClass: this.toNullableString(value["configurationClass"]),
schema: this.toSchema(value["schema"] ?? value["configurationSchema"] ?? null)
@ -134,6 +151,12 @@ export class BlocksCallService extends BlocksCallServiceBase {
return typeof value === "string" && value.length > 0 ? value : null;
}
private toApiPath(value: unknown): string | null {
if (typeof value !== "string" || value.length === 0) return null;
if (/^https?:\/\//.test(value)) return value;
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
private buildBlockConfigurationPayload(blockType: string, configuration: Record<string, unknown>) {
const { typeName: _ignoreTypeName, ...sanitized } = configuration;
return {
@ -144,6 +167,18 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private resolveExampleBlockEndpoint(descriptor?: BlockType): string | null {
if (!descriptor?.hasExampleBlock) return null;
return descriptor.exampleBlockEndpoint ?? null;
}
private toUpdateBlockError(error: unknown, blockType: string): Error {
if (error instanceof Error) {
return new Error(`updateBlock failed for ${blockType}: ${error.message}`);
}
return new Error(`updateBlock failed for ${blockType}`);
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {

View File

@ -2,7 +2,7 @@ import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { catchError, firstValueFrom, of, throwError } from 'rxjs';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
@Injectable({
providedIn: 'root',
@ -12,6 +12,8 @@ export class BlocksService {
toInit: boolean = true;
private loadingPromise: Promise<void> | null = null;
private readonly emptyBlockCache = new Map<string, FlowBlock>();
private readonly pendingEmptyBlockRequests = new Map<string, Observable<FlowBlock>>();
private _blockTypes = signal<BlockType[]>([]);
@ -32,6 +34,7 @@ export class BlocksService {
this.loadingPromise = firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes())
.then((blockTypes) => {
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
})
.catch((err) => {
console.error('Retrieve blocks types failed', err);
@ -50,30 +53,42 @@ export class BlocksService {
const blockTypes = await firstValueFrom(this.blocksCallService.retrieveAllBlocksTypes());
this._blockTypes.set(blockTypes);
this.clearEmptyBlockCache();
return blockTypes.find((blockType) => blockType.type === typeName);
}
createEmptyBlock(blockType: BlockTypeName) {
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 cacheKey = String(blockType);
const cached = this.emptyBlockCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyBlock(cached));
}
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)
};
const pending = this.pendingEmptyBlockRequests.get(cacheKey);
if (pending) {
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
}
return of({
...block,
__needsServerCreate: true
} as FlowBlock);
const request = this.blocksCallService.createEmptyBlock(blockType).pipe(
map((block) => {
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
return block;
}),
finalize(() => {
this.pendingEmptyBlockRequests.delete(cacheKey);
}),
shareReplay(1)
);
this.pendingEmptyBlockRequests.set(cacheKey, request);
return request.pipe(
map((block) => this.cloneEmptyBlock(block)),
catchError((err) => {
console.error('Create empty block failed', err);
return throwError(() => err);
})
);
}
updateBlock(blockId: string, configuration: any) {
@ -85,86 +100,24 @@ 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 clearEmptyBlockCache() {
this.emptyBlockCache.clear();
this.pendingEmptyBlockRequests.clear();
}
private defaultIOForBlockType(typeName: string) {
void typeName;
private cloneEmptyBlock(block: FlowBlock): FlowBlock {
const clone = this.deepClone(block);
return {
inputs: [],
outputs: []
...clone,
id: globalThis.crypto?.randomUUID?.() ?? `${Date.now()}`,
position: undefined
};
}
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);
private deepClone<T>(value: T): T {
if (typeof globalThis.structuredClone === 'function') {
return globalThis.structuredClone(value);
}
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>;
return JSON.parse(JSON.stringify(value)) as T;
}
}

View File

@ -1,4 +1,4 @@
<div class="w-full text-left p-3 rounded-md border transition-all duration-150 select-none hover:-translate-y-0.5 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md"
<div class="w-full overflow-hidden text-left p-3 rounded-md border transition-all duration-150 select-none hover:-translate-y-0.5 hover:border-blue-300 hover:bg-blue-100 hover:shadow-md"
[class.cursor-pointer]="flow().id !== openedFlowId()"
[class.cursor-default]="flow().id === openedFlowId()"
[class.border-blue-300]="flow().id === openedFlowId()"
@ -8,11 +8,11 @@
(click)="flow().id !== openedFlowId() && open()">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0">
<div class="flex-1 min-w-0 overflow-hidden">
<div class="text-sm font-medium text-slate-800 truncate">{{ flow().name }}</div>
<div class="text-xs text-slate-500 truncate">{{ flow().author }}</div>
</div>
<span class="text-[10px] px-2 py-0.5 rounded-full border font-semibold"
<span class="shrink-0 text-[10px] px-2 py-0.5 rounded-full border font-semibold"
[class.bg-indigo-100]="flow().visibility === 'PUBLIC'"
[class.text-indigo-700]="flow().visibility === 'PUBLIC'"
[class.border-indigo-200]="flow().visibility === 'PUBLIC'"

View File

@ -1,7 +1,7 @@
<div
class=" border-gray-200
bg-white shadow-sm
transition flex-1 h-full">
transition flex flex-col flex-1 h-full min-w-0 overflow-hidden">
<!-- Header -->
<div
@ -11,9 +11,9 @@
bg-gray-50 h-18"
>
<div class="flex items-center gap-2">
<div class="flex items-center gap-2 min-w-0">
<i class="bi {{ icon }} text-lg"></i>
<span>{{ title }}</span>
<span class="truncate">{{ title }}</span>
</div>
<span>
<ng-content select="header-button"></ng-content>
@ -21,7 +21,7 @@
</div>
<!-- Body -->
<div
class="transition-all duration-300 px-2 pb-2 flex flex-col flex-1 overflow-hidden h-[calc(100%-72px)]">
class="transition-all duration-300 px-2 pb-2 flex flex-col flex-1 min-w-0 overflow-hidden">
<ng-content></ng-content>
</div>

View File

@ -11,6 +11,47 @@
color: #0f172a;
}
.llm-loading-overlay {
position: absolute;
inset: 0;
z-index: 150;
display: flex;
align-items: center;
justify-content: center;
background: rgba(248, 250, 252, 0.68);
backdrop-filter: blur(2px);
border-radius: 16px;
}
.llm-loading-chip {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
color: #1d4ed8;
font-size: 12px;
font-weight: 600;
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.16);
}
.llm-spinner {
width: 14px;
height: 14px;
border-radius: 999px;
border: 2px solid rgba(37, 99, 235, 0.2);
border-top-color: #2563eb;
animation: llm-spin 0.75s linear infinite;
}
@keyframes llm-spin {
to {
transform: rotate(360deg);
}
}
.llm-node--human {
border-color: #f7d7a7;
background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%);

View File

@ -1,5 +1,14 @@
<div class="llm-node"
[class.llm-node-error]="hasUpdateBlockError()"
[class.llm-node--human]="isHumanNode()">
@if (isCreatingOnServer()) {
<div class="llm-loading-overlay">
<div class="llm-loading-chip">
<span class="llm-spinner" aria-hidden="true"></span>
<span>Updating block...</span>
</div>
</div>
}
<div class="llm-header cursor-move">
<div class="llm-icon">
@if (isHumanNode()) {
@ -18,6 +27,17 @@
</div>
</div>
<div class="llm-header-actions">
@if (hasUpdateBlockError()) {
<div class="llm-error-alert-wrap">
<div class="llm-error-alert">
<i class="bi bi-exclamation-circle-fill"></i>
</div>
<div class="llm-error-tooltip">
<div class="llm-error-title">Block update failed</div>
<div class="llm-error-item">{{ updateBlockErrorMessage() }}</div>
</div>
</div>
}
@if (missingRequiredParams.length) {
<div class="llm-warning-wrap">
<div class="llm-warning">

View File

@ -308,6 +308,20 @@ export class GenericNodeComponent {
return match ? match[1] : token;
}
isCreatingOnServer(): boolean {
return this.data?.data?.['__isCreatingOnServer'] === true;
}
hasUpdateBlockError(): boolean {
return typeof this.data?.data?.['__updateBlockError'] === 'string'
&& this.data.data['__updateBlockError'].length > 0;
}
updateBlockErrorMessage(): string | null {
const error = this.data?.data?.['__updateBlockError'];
return typeof error === 'string' && error.length > 0 ? error : null;
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
@ -799,12 +813,12 @@ export class GenericNodeComponent {
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;
nodeData['__updateBlockError'] = null;
const configuration = this.ensureBlockConfiguration();
this.blocksService.updateBlock(String(nodeData['id'] ?? ''), {
@ -830,7 +844,8 @@ export class GenericNodeComponent {
position: (current['position'] as { x: number; y: number } | undefined) ?? createdBlock.position,
__needsServerCreate: false,
__isCreatingOnServer: false,
__createdOnServer: true
__createdOnServer: true,
__updateBlockError: null
};
this.name = toStringOrNull(this.ensureBlockConfiguration()['name']) || createdBlock.name || this.name;
this.refreshParameterFields();
@ -839,6 +854,8 @@ export class GenericNodeComponent {
},
error: (err) => {
nodeData['__isCreatingOnServer'] = false;
nodeData['__updateBlockError'] = err instanceof Error ? err.message : 'Block update failed';
this.refreshView();
console.error('Create block failed', err);
}
});
@ -849,5 +866,6 @@ export class GenericNodeComponent {
if (!nodeData) return;
nodeData['__needsServerCreate'] = true;
nodeData['__createdOnServer'] = false;
nodeData['__updateBlockError'] = null;
}
}

View File

@ -1,3 +1,56 @@
:host {
display: block;
position: relative;
width: 100%;
height: 100%;
}
.rete-editor-shell {
position: relative;
width: 100%;
height: 100%;
}
.rete-editor-loading {
position: absolute;
inset: 0;
z-index: 40;
display: flex;
align-items: center;
justify-content: center;
background: rgba(248, 250, 252, 0.24);
pointer-events: none;
}
.rete-editor-loading-card {
display: inline-flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border: 1px solid #cbd5e1;
border-radius: 999px;
background: rgba(255, 255, 255, 0.96);
color: #0f172a;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.16);
font-size: 13px;
font-weight: 600;
}
.rete-editor-spinner {
width: 15px;
height: 15px;
border-radius: 999px;
border: 2px solid rgba(15, 23, 42, 0.18);
border-top-color: #2563eb;
animation: rete-editor-spin 0.75s linear infinite;
}
@keyframes rete-editor-spin {
to {
transform: rotate(360deg);
}
}
:host ::ng-deep [data-testid="node"] {
user-select: none;
-webkit-user-select: none;

View File

@ -1,2 +1,13 @@
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
<div class="rete-editor-shell">
<div class="h-full w-full" #editor (dragover)="onDragOver($event)" (drop)="onDrop($event)">
</div>
@if (creatingEmptyBlock) {
<div class="rete-editor-loading">
<div class="rete-editor-loading-card">
<span class="rete-editor-spinner" aria-hidden="true"></span>
<span>Creating {{ creatingEmptyBlockType || 'block' }}...</span>
</div>
</div>
}
</div>

View File

@ -29,6 +29,8 @@ export class ReteEditor implements OnChanges, OnDestroy {
private rete?: ReteEditorInstance;
private viewReady = false;
private loadVersion = 0;
creatingEmptyBlock = false;
creatingEmptyBlockType = '';
private readonly dirtyEventTypes = new Set([
'nodecreated',
'noderemoved',
@ -72,11 +74,16 @@ export class ReteEditor implements OnChanges, OnDestroy {
const blockType: BlockType = JSON.parse(payload);
const position = this.getDropPosition(event);
let newBlock: FlowBlock;
this.creatingEmptyBlock = true;
this.creatingEmptyBlockType = blockType.type;
try {
newBlock = await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
} catch (error) {
console.error('Failed to create empty block', error);
return;
} finally {
this.creatingEmptyBlock = false;
this.creatingEmptyBlockType = '';
}
newBlock = {

View File

@ -1,3 +1,7 @@
.title-toolbar-input {
width: min(100%, 48vw);
}
.save-snackbar {
position: fixed;
right: 24px;

View File

@ -1,32 +1,32 @@
<div class="flex items-center justify-between
<div class="flex items-center justify-between gap-4
px-4 py-2
bg-white border-b border-gray-200
shadow-sm">
<!-- Title -->
<div class="flex items-center gap-2">
<i class="bi bi-pencil-square text-indigo-500"></i>
<h2 class="m-0 text-sm font-semibold text-gray-800">
<input type="text" class="border-0 text-sm font-semibold text-gray-800
<div class="flex min-w-0 flex-1 items-center gap-2">
<i class="bi bi-pencil-square shrink-0 text-indigo-500"></i>
<h2 class="m-0 min-w-0 flex-1 text-sm font-semibold text-gray-800">
<input type="text" class="title-toolbar-input border-0 text-sm font-semibold text-gray-800
focus:ring-0 p-0 m-0 box-border
w-52 max-w-52
min-w-0
bg-transparent"
[value]="title()"
(blur)="changeTitle(titleInput.value)"
(keydown.enter)="changeTitle(titleInput.value); $event.target.blur()"
maxlength="20"
size="20"
#titleInput
#titleInput
/>
</h2>
@if (notSaved()) {
<span class="inline-flex items-center gap-x-1.5 py-1.5 px-3 rounded-full text-xs font-medium border border-yellow-500 text-yellow-500">not saved</span>
<span class="inline-flex shrink-0 items-center gap-x-1.5 rounded-full border border-yellow-500 px-3 py-1.5 text-xs font-medium text-yellow-500">not saved</span>
}
</div>
<!-- Actions -->
<div class="flex items-center gap-2" >
<div class="flex shrink-0 items-center gap-2" >
<button tabindex="-1"
class="border-0
flex items-center gap-1