diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index 9d5726f..514a17f 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -15,7 +15,7 @@
- +
} @else { @@ -26,7 +26,7 @@ } - @if (assistantEnabled) { + @if (assistantEnabled && !readonly()) {
- diff --git a/src/app/shared/flows-list/flow-item/flow-item.ts b/src/app/shared/flows-list/flow-item/flow-item.ts index a85219a..a34feb8 100644 --- a/src/app/shared/flows-list/flow-item/flow-item.ts +++ b/src/app/shared/flows-list/flow-item/flow-item.ts @@ -5,6 +5,7 @@ import { MatCardModule } from '@angular/material/card'; import { MatIconModule } from '@angular/material/icon'; import { MatTooltipModule } from '@angular/material/tooltip'; import { Flow } from '@models/flow'; +import { Authorization } from '@services/authorization/authorization'; import { ConfirmDialogService } from '@services/dialogs/confirm-dialog'; import { FlowsService } from '@services/flows/flows'; import { EditorStateHolder } from '@stores/flow-editor'; @@ -19,6 +20,7 @@ export class FlowItem { private editorState = inject(EditorStateHolder); private confirm = inject(ConfirmDialogService); + private authorization = inject(Authorization); private flowsService = inject(FlowsService); @@ -27,6 +29,15 @@ export class FlowItem { detailOpenedId = model(null); openedFlowId = computed(() => this.editorState.currentFlow()?.id); + canDelete = computed(() => { + const username = this.authorization.loggedInUser()?.username ?? null; + return !!username && this.flow().author === username; + }); + deleteTooltip = computed(() => + this.canDelete() + ? 'Delete flow' + : 'Only the owner can delete this flow' + ); async open() { console.log('Opening flow:', this.flow()); @@ -42,7 +53,7 @@ export class FlowItem { } clone() { - this.flowsService.cloneFlow(this.flow().id).subscribe({ + this.flowsService.cloneFlow(this.flow()).subscribe({ next: clonedFlow => { console.log('Flow cloned:', clonedFlow); }, @@ -51,6 +62,8 @@ export class FlowItem { } async remove() { + if (!this.canDelete()) return; + const confirmed = await this.confirm.open( this.editorState.isDirty() ? 'You have unsaved changes in the current flow. Delete this flow anyway?' diff --git a/src/app/shared/flows-list/flows-list.ts b/src/app/shared/flows-list/flows-list.ts index af2844a..8a590e8 100644 --- a/src/app/shared/flows-list/flows-list.ts +++ b/src/app/shared/flows-list/flows-list.ts @@ -60,16 +60,24 @@ export class FlowsList extends ListStateViewHolder { this.loading.set(false); if (existingState.filter) this.filter.set(existingState.filter as FlowFilter || 'all'); - console.log('FlowsList loaded from ListState'); - } else { - this.flowsService.getAllFlows().then(flowsSignal => { - this.flows = flowsSignal; - this.loading.set(false); - this.view.list = this.flows; - console.log('FlowsList initialized from service'); - }); + return; } - + + if (this.flowsService.hasLoadedFlows()) { + this.flows = this.flowsService.flows; + this.view.list = this.flows; + this.loading.set(false); + return; + } + + this.flowsService.getAllFlows().then(flowsSignal => { + this.flows = flowsSignal; + this.view.list = this.flows; + }).catch((err) => { + console.error('Error loading flows', err); + }).finally(() => { + this.loading.set(false); + }); } filteredFlows = computed(() => { diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 4382a51..1e7a0ce 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -92,14 +92,14 @@ width: 26px; height: 26px; border-radius: 999px; - border: 1px solid #99f6e4; - background: #14b8a6; - color: #ecfeff; + border: 1px solid #fecaca; + background: #dc2626; + color: #fff7ed; display: inline-flex; align-items: center; justify-content: center; font-size: 13px; - box-shadow: 0 0 0 3px rgba(20, 184, 166, 0.22); + box-shadow: 0 0 0 3px rgba(127, 29, 29, 0.2); } .container-node__warning-tooltip { @@ -108,10 +108,10 @@ right: 0; min-width: 220px; max-width: 320px; - border: 1px solid #99f6e4; + border: 1px solid #fecaca; border-radius: 8px; - background: #f0fdfa; - color: #115e59; + background: #fff1f2; + color: #7f1d1d; box-shadow: 0 10px 24px rgba(15, 23, 42, 0.2); padding: 8px; z-index: 13; @@ -213,15 +213,23 @@ display: inline-flex; flex-direction: column; align-items: flex-start; - min-height: 28px; - padding: 0 10px; - border-radius: 999px; + min-height: 36px; + padding: 4px 10px; + border-radius: 14px; background: #e2e8f0; color: #0f172a; font-size: 12px; font-weight: 600; } +.container-node__port-context { + font-size: 9px; + line-height: 1.1; + font-weight: 700; + letter-spacing: 0.04em; + color: #64748b; +} + .container-node__port-name { line-height: 1.1; } @@ -248,6 +256,41 @@ text-align: center; } +.container-node__snapshot { + position: relative; + width: 100%; + height: 74px; + border-radius: 14px; + border: 1px solid rgba(148, 163, 184, 0.3); + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.88) 0%, rgba(226, 232, 240, 0.56) 100%); + overflow: hidden; +} + +.container-node__snapshot::before { + content: ""; + position: absolute; + inset: 0; + background-image: + linear-gradient(rgba(148, 163, 184, 0.12) 1px, transparent 1px), + linear-gradient(90deg, rgba(148, 163, 184, 0.12) 1px, transparent 1px); + background-size: 18px 18px; +} + +.container-node__snapshot-node { + position: absolute; + z-index: 1; + display: block; + border-radius: 5px; + background: linear-gradient(135deg, #0f766e 0%, #14b8a6 100%); + box-shadow: 0 2px 4px rgba(15, 23, 42, 0.16); +} + +.container-node__snapshot-node--container { + background: linear-gradient(135deg, #0284c7 0%, #38bdf8 100%); + border-radius: 6px; +} + .container-node__dropzone--active { border-color: #0f766e; background: @@ -267,12 +310,86 @@ color: #0f172a; } +.container-node__replace-confirm { + display: flex; + flex-direction: column; + align-items: center; + gap: 10px; + text-align: center; +} + +.container-node__replace-confirm-text { + font-size: 15px; + font-weight: 800; + color: #0f172a; +} + +.container-node__replace-confirm-note { + font-size: 12px; + line-height: 1.45; + color: #475569; + max-width: 260px; +} + +.container-node__replace-confirm-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} + +.container-node__replace-confirm-cancel, +.container-node__replace-confirm-action { + border: 0; + border-radius: 999px; + padding: 8px 12px; + font-size: 11px; + font-weight: 700; +} + +.container-node__replace-confirm-cancel { + background: #ffffff; + color: #0f172a; + box-shadow: inset 0 0 0 1px #cbd5e1; +} + +.container-node__replace-confirm-action { + background: #0f766e; + color: #f8fafc; +} + +:host.container-node--readonly, +:host.container-node--readonly * { + cursor: default !important; +} + .container-node__dropzone-note { font-size: 12px; line-height: 1.45; color: #475569; } +.container-node__dropzone-actions { + display: flex; + justify-content: center; + margin-top: 2px; +} + +.container-node__import { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + min-width: 116px; + border: 0; + border-radius: 999px; + padding: 8px 12px; + background: #0f766e; + color: #f8fafc; + font-size: 11px; + font-weight: 700; + box-shadow: 0 10px 18px rgba(15, 118, 110, 0.18); +} + .container-node__subflow-preview { display: grid; gap: 8px; @@ -325,12 +442,6 @@ font-size: 16px; } -.container-node__actions { - display: flex; - justify-content: flex-end; - padding: 0 14px 14px; -} - .container-node__subflow-section { margin: 0 14px 14px; border: 1px solid #dbe2ea; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 169d3fc..856c091 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -2,7 +2,7 @@ @if (deleteConfirmOpen) {
} -
+
@@ -24,16 +24,18 @@
} - @if (deleteConfirmOpen) { + @if (!isReadonly && deleteConfirmOpen) {
Delete container?
} + @if (!isReadonly) { + } @@ -50,12 +52,16 @@ side: 'input', key: input.key, nodeId: data.id, + __readonly: isReadonly, payload: input.socket }" [emit]="emit"> - {{ inputDisplayLabel(input.key) }} + @if (inputDisplayLabelParts(input.key).context; as context) { + {{ context }} + } + {{ inputDisplayLabelParts(input.key).name }} {{ inputKindLabel(input.key) }} @@ -67,7 +73,10 @@ @for (output of outputs; track output.key) {
- {{ outputDisplayLabel(output.key) }} + @if (outputDisplayLabelParts(output.key).context; as context) { + {{ context }} + } + {{ outputDisplayLabelParts(output.key).name }} {{ outputKindLabel(output.key) }}
@@ -89,17 +99,38 @@
- @if (isAssigning) { + @if (replaceConfirmOpen) { +
+
Replace current subflow?
+
The existing embedded flow will be removed and replaced by the dropped selection.
+
+ + +
+
+ } @else if (isAssigning) {
- Validating subflow... + Updating container...
} @else if (subFlowBlockCount > 0) { +
{{ subFlowBlockCount }} nodes {{ subFlowConnectionCount }} connections @@ -115,6 +146,18 @@ Use the selection box in the editor, then drag the floating selection badge into this area.
} + @if (!isReadonly && !replaceConfirmOpen && !isAssigning) { +
+ +
+ }
@if (subFlowBlockCount > 0) { @@ -139,11 +182,6 @@ }
-
- -
} @if (assignmentErrorMessage) { @@ -151,25 +189,4 @@ {{ assignmentErrorMessage }} } - - @if (validationErrors.length) { -
- @for (error of validationErrors; track $index) { -
- {{ error.message }} - @if (error.entity || error.id || error.field) { - - {{ error.entity || 'entity' }} - @if (error.id) { - · {{ error.id }} - } - @if (error.field) { - · {{ error.field }} - } - - } -
- } -
- } diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index baea90e..3ecfdc2 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -1,12 +1,24 @@ import { CommonModule } from '@angular/common'; import { Component, HostBinding, Input, inject } from '@angular/core'; +import { currentFlowPortValueKind, flowValueKindLabel, FlowBlock, FlowContainer, FlowData, FlowNode } from '@models/flow'; +import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog'; +import { ContainersService } from '@services/containers/containers'; +import { FieldRetriever } from '@services/retriever/field-retriever'; import { ClassicPreset } from 'rete'; import { ReteModule } from 'rete-angular-plugin/21'; -import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowNode, FlowSubflowValidationError } from '@models/flow'; import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog'; import { EditorStateHolder } from '@stores/flow-editor'; import { pathToLabel } from '../node-utility'; import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag'; +import { firstValueFrom } from 'rxjs'; + +type StructuredRetrieverConfig = { + retrieverName: string; + retrieverUrl: string; + validationUrl: string | null; + structuredData: boolean; + requiresAuth: boolean; +}; @Component({ selector: 'app-container-node', @@ -20,7 +32,13 @@ import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag'; export class ContainerNodeComponent { private editorState = inject(EditorStateHolder); private subflowPreview = inject(SubflowPreviewDialogService); + private fieldRetriever = inject(FieldRetriever); + private containersService = inject(ContainersService); + private settingsDialog = inject(NodeSettingsDialogService); deleteConfirmOpen = false; + replaceConfirmSelection: string[] | null = null; + importLoading = false; + private importErrorMessage: string | null = null; @Input() data!: any; @Input() emit!: (data: any) => void; @@ -34,10 +52,18 @@ export class ContainerNodeComponent { return this.blockId; } + @HostBinding('class.container-node--readonly') get readonlyClass() { + return this.isReadonly; + } + ngAfterViewInit() { this.rendered(); } + get isReadonly() { + return this.data?.data?.['__readonly'] === true; + } + get name() { return String(this.configuration?.['name'] ?? this.data?.data?.name ?? 'Container'); } @@ -63,10 +89,12 @@ export class ContainerNodeComponent { get subFlow(): FlowData | null { const value = this.configuration?.['subFlow']; if (!value || typeof value !== 'object') return null; - const candidate = value as Partial; - const blocks = Array.isArray(candidate.blocks) ? candidate.blocks : []; - const containers = Array.isArray(candidate.containers) ? candidate.containers : []; - const connections = Array.isArray(candidate.connections) ? candidate.connections : []; + const candidate = value as Record; + const blocks = this.normalizeSubFlowBlocks(candidate['blocks']); + const containers = this.normalizeSubFlowContainers(candidate['containers']); + const connections = Array.isArray(candidate['connections']) + ? candidate['connections'].filter((item): item is FlowData['connections'][number] => !!item && typeof item === 'object') + : []; if (!blocks.length && !containers.length && !connections.length) { return null; @@ -99,14 +127,47 @@ export class ContainerNodeComponent { return this.subFlowBlockCount > this.subFlowPreviewNodes.length; } - get validationErrors(): FlowSubflowValidationError[] { - const errors = this.data?.data?.['__containerValidationErrors']; - return Array.isArray(errors) ? errors : []; + get subFlowSnapshotNodes() { + const subFlow = this.subFlow; + if (!subFlow) return []; + + const allNodes = [...(subFlow.blocks ?? []), ...(subFlow.containers ?? [])]; + if (!allNodes.length) return []; + + const positioned = allNodes.map((node) => ({ + id: node.id, + family: node.nodeFamily === 'container' ? 'container' : 'block', + x: typeof node.position?.x === 'number' ? node.position.x : 0, + y: typeof node.position?.y === 'number' ? node.position.y : 0, + width: node.nodeFamily === 'container' ? 24 : 18, + height: node.nodeFamily === 'container' ? 14 : 12 + })); + + const minX = Math.min(...positioned.map((node) => node.x)); + const minY = Math.min(...positioned.map((node) => node.y)); + const maxX = Math.max(...positioned.map((node) => node.x + node.width)); + const maxY = Math.max(...positioned.map((node) => node.y + node.height)); + const spanX = Math.max(1, maxX - minX); + const spanY = Math.max(1, maxY - minY); + + return positioned.map((node) => ({ + id: node.id, + family: node.family, + left: 6 + ((node.x - minX) / spanX) * 100, + top: 6 + ((node.y - minY) / spanY) * 52, + width: node.width, + height: node.height + })); + } + + get replaceConfirmOpen() { + return Array.isArray(this.replaceConfirmSelection) && this.replaceConfirmSelection.length > 0; } get assignmentErrorMessage() { const value = this.data?.data?.['__containerAssignmentError']; - return typeof value === 'string' && value.length > 0 ? value : null; + if (typeof value === 'string' && value.length > 0) return value; + return this.importErrorMessage; } get isAssigning() { @@ -121,35 +182,113 @@ export class ContainerNodeComponent { if (!this.subFlow) { missing.push('Sub Flow'); } - const config = this.configuration ?? {}; - if (!Array.isArray(config['publicInputs'])) { - missing.push('Public Inputs'); - } - if (!Array.isArray(config['publicOutputs'])) { - missing.push('Public Outputs'); - } return missing; } inputDisplayLabel(inputKey: string) { - return pathToLabel(inputKey); + return this.resolvePortName('input', inputKey); } outputDisplayLabel(outputKey: string) { - return pathToLabel(outputKey); + return this.resolvePortName('output', outputKey); } inputKindLabel(inputKey: string) { - const port = this.inputs.find((candidate) => candidate.key === inputKey); - return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.inputs ?? []).find((item: any) => item?.name === inputKey) ?? { type: 'ANY', multiple: false })) : 'ANY'; + const port = this.resolvePortDefinition('input', inputKey); + return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY'; } outputKindLabel(outputKey: string) { - const port = this.outputs.find((candidate) => candidate.key === outputKey); - return port ? flowValueKindLabel(currentFlowPortValueKind((this.data?.data?.outputs ?? []).find((item: any) => item?.name === outputKey) ?? { type: 'ANY', multiple: false })) : 'ANY'; + const port = this.resolvePortDefinition('output', outputKey); + return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY'; + } + + inputDisplayLabelParts(inputKey: string) { + return this.toPortLabelParts(this.inputDisplayLabel(inputKey)); + } + + outputDisplayLabelParts(outputKey: string) { + return this.toPortLabelParts(this.outputDisplayLabel(outputKey)); + } + + async importSubflow(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + if (this.isReadonly || this.importLoading || this.replaceConfirmOpen) return; + + this.importLoading = true; + this.importErrorMessage = null; + + try { + const retriever = await this.resolveStructuredRetrieverConfig(); + if (!retriever || !retriever.structuredData) { + this.importErrorMessage = 'Subflow import is not available for this container.'; + return; + } + + const items = await firstValueFrom( + this.fieldRetriever.retrieveItems( + retriever.retrieverName || this.typeName, + 'subFlow', + { + context: 'CONTAINER', + validOnly: 'true', + includeValidation: 'false' + }, + retriever.retrieverUrl + ) + ); + + if (!items.length) { + this.importErrorMessage = 'No importable flows were returned by the retriever.'; + return; + } + + const result = await this.settingsDialog.open({ + title: 'Import flow into container', + fields: [ + { + key: 'selectedFlow', + label: 'Available flows', + type: 'select', + required: true, + options: items.map((item, index) => ({ + value: String(index), + label: item.descriptor.description + ? `${item.descriptor.label} - ${item.descriptor.description}` + : item.descriptor.label + })) + } + ], + initial: { + selectedFlow: '0' + } + }); + if (!result) return; + + const selectedIndex = Number(result['selectedFlow'] ?? -1); + const selectedItem = Number.isInteger(selectedIndex) ? items[selectedIndex] : undefined; + if (!selectedItem?.data) { + this.importErrorMessage = 'Invalid flow selection.'; + return; + } + + const assignImportedSubflow = this.data?.data?.assignImportedSubflow; + if (typeof assignImportedSubflow !== 'function') { + this.importErrorMessage = 'Container import is not available in the editor runtime.'; + return; + } + + await assignImportedSubflow(selectedItem.data, retriever.validationUrl); + } catch { + this.importErrorMessage = 'Failed to load importable flows.'; + } finally { + this.importLoading = false; + } } onDropZoneDragOver(event: DragEvent) { + if (this.isReadonly) return; if (!this.canAcceptSelectionDrop()) return; event.preventDefault(); if (event.dataTransfer) { @@ -158,35 +297,50 @@ export class ContainerNodeComponent { } onDropZoneDrop(event: DragEvent) { + if (this.isReadonly) return; event.preventDefault(); event.stopPropagation(); const raw = event.dataTransfer?.getData(CONTAINER_SUBFLOW_DRAG_MIME); const payload = this.parseDraggedSelection(raw); - const assign = this.data?.data?.assignSelectedBlocksToContainer; - if (!payload.length || typeof assign !== 'function') return; + if (!payload.length) return; - void assign(payload); - this.editorState.stopDraggingSelectedBlocks(); + if (this.subFlowBlockCount > 0) { + this.replaceConfirmSelection = payload; + this.editorState.stopDraggingSelectedBlocks(); + return; + } + + this.assignSelectionToContainer(payload); } onDropZoneDragLeave(_: DragEvent) { + if (this.isReadonly) return; this.editorState.stopDraggingSelectedBlocks(); } - removeSubflow(event?: Event) { + confirmReplaceSubflow(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; - const clear = this.data?.data?.clearContainerSubflow; - if (typeof clear === 'function') { - void clear(); - } + const payload = this.replaceConfirmSelection; + this.replaceConfirmSelection = null; + if (!payload?.length) return; + + this.assignSelectionToContainer(payload); + } + + cancelReplaceSubflow(event?: Event) { + event?.preventDefault(); + event?.stopPropagation(); + this.replaceConfirmSelection = null; } deleteNode(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; if (!this.deleteConfirmOpen) { this.deleteConfirmOpen = true; return; @@ -221,8 +375,21 @@ export class ContainerNodeComponent { return typeof blockId === 'string' && blockId.length > 0 ? blockId : null; } + private get typeName(): string { + return String(this.data?.data?.typeName ?? 'GenericContainer'); + } + private canAcceptSelectionDrop() { - return !this.isAssigning && this.selectedCount > 0; + return !this.isAssigning && !this.replaceConfirmOpen && this.selectedCount > 0; + } + + private assignSelectionToContainer(payload: string[]) { + this.importErrorMessage = null; + const assign = this.data?.data?.assignSelectedBlocksToContainer; + if (typeof assign !== 'function' || !payload.length) return; + + void assign(payload); + this.editorState.stopDraggingSelectedBlocks(); } private parseDraggedSelection(raw: string | undefined) { @@ -238,6 +405,33 @@ export class ContainerNodeComponent { } } + private resolvePortName(kind: 'input' | 'output', key: string) { + const port = this.resolvePortDefinition(kind, key); + return typeof port?.name === 'string' && port.name.length > 0 ? port.name : key; + } + + private toPortLabelParts(label: string) { + const trimmed = String(label ?? '').trim(); + const separatorIndex = trimmed.lastIndexOf('.'); + if (separatorIndex <= 0 || separatorIndex >= trimmed.length - 1) { + return { + context: null, + name: trimmed + }; + } + + return { + context: trimmed.slice(0, separatorIndex), + name: trimmed.slice(separatorIndex + 1) + }; + } + + private resolvePortDefinition(kind: 'input' | 'output', key: string) { + const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs']; + if (!Array.isArray(ports)) return null; + return ports.find((item: any) => item?.name === key) ?? null; + } + private toPreviewNode(node: FlowNode) { return { id: node.id, @@ -247,6 +441,67 @@ export class ContainerNodeComponent { }; } + private normalizeSubFlowBlocks(raw: unknown): FlowBlock[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + .map((item) => ({ + ...item, + position: this.normalizePosition(item['position']), + nodeFamily: 'block' + })) as FlowBlock[]; + } + + private normalizeSubFlowContainers(raw: unknown): FlowContainer[] { + if (!Array.isArray(raw)) return []; + + return raw + .filter((item): item is Record => !!item && typeof item === 'object' && !Array.isArray(item)) + .map((item) => ({ + ...item, + position: this.normalizePosition(item['position']), + nodeFamily: 'container' + })) as FlowContainer[]; + } + + private normalizePosition(raw: unknown): { x: number; y: number } | undefined { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined; + const value = raw as Record; + const x = typeof value['x'] === 'number' ? value['x'] : Number(value['x']); + const y = typeof value['y'] === 'number' ? value['y'] : Number(value['y']); + if (!Number.isFinite(x) || !Number.isFinite(y)) return undefined; + return { x, y }; + } + + private async resolveStructuredRetrieverConfig(): Promise { + const containerType = await this.containersService.getContainerType(this.typeName); + const schema = containerType?.schema; + const properties = schema?.['properties']; + const propertySchema = properties && typeof properties === 'object' && !Array.isArray(properties) + ? (properties as Record)['subFlow'] + : null; + + if (!propertySchema || typeof propertySchema !== 'object' || Array.isArray(propertySchema)) { + return null; + } + + const fieldSchema = propertySchema as Record; + const retrieverUrl = typeof fieldSchema['x-retriever-url'] === 'string' ? fieldSchema['x-retriever-url'] : null; + const retrieverName = typeof fieldSchema['x-retriever-name'] === 'string' ? fieldSchema['x-retriever-name'] : this.typeName; + if (!retrieverUrl) return null; + + return { + retrieverName, + retrieverUrl, + validationUrl: typeof fieldSchema['x-retriever-validation-url'] === 'string' + ? fieldSchema['x-retriever-validation-url'] + : null, + structuredData: fieldSchema['x-retriever-structured-data'] === true, + requiresAuth: fieldSchema['x-retriever-requires-auth'] === true + }; + } + private nodeTypeLabel(typeName: string) { if (typeName === 'HumanInteractionBlock') return 'Human Task'; return pathToLabel(typeName.replace(/Block$/, '')); diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index f3582f1..202ada2 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -11,6 +11,11 @@ color: #0f172a; } +:host.llm-node-readonly, +:host.llm-node-readonly * { + cursor: default !important; +} + :host.selected .llm-node { border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.24), 0 12px 28px rgba(15, 23, 42, 0.16); @@ -180,6 +185,18 @@ transition: opacity 0.15s ease, transform 0.15s ease, visibility 0.15s ease; } +.llm-node-id { + margin-top: 2px; + font-size: 9px; + line-height: 1.1; + font-weight: 500; + letter-spacing: 0.03em; + color: rgba(255, 255, 255, 0.78); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + .llm-warning-alert-wrap:hover .llm-warning-list-tooltip { opacity: 1; visibility: visible; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index 6c8e787..ae8e114 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -13,7 +13,7 @@ @if (deleteConfirmOpen) {
} -
+
@if (isHumanNode()) { @@ -25,10 +25,13 @@ {{ nodeTitle() }}
{{ name }} + @if (!isReadonly) { + }
+
{{ nodeIdLabel }}
@if (hasUpdateBlockError()) { @@ -55,16 +58,18 @@
} - @if (deleteConfirmOpen) { + @if (!isReadonly && deleteConfirmOpen) {
Delete node?
} + @if (!isReadonly) { + }
@@ -81,6 +86,7 @@ side: 'input', key: input.key, nodeId: data.id, + __readonly: isReadonly, payload: input.socket }" [emit]="emit"> @@ -88,7 +94,7 @@ {{ inputDisplayLabel(input.key) }} - @if (canTogglePortMultiplicity('input', input.key)) { + @if (!isReadonly && canTogglePortMultiplicity('input', input.key)) { @@ -159,6 +166,7 @@
{{ field.label }} + @if (!isReadonly) { + }
{{ field.value }}
@@ -183,6 +192,7 @@
{{ field.label }} + @if (!isReadonly) { + }
{{ field.value }}
@@ -203,9 +214,11 @@
{{ contentField.label }}
+ @if (!isReadonly) { + }
@if (!contentField.parts.length) { @@ -230,6 +243,7 @@
{{ arrayField.label }}
+ @if (!isReadonly) { + }
@if (!arrayField.items.length) {
No items
@@ -246,6 +261,7 @@
{{ item.summary }}
+ @if (!isReadonly) { + } + @if (!isReadonly) { + }
} diff --git a/src/app/shared/nodes/generic-node/generic-node.ts b/src/app/shared/nodes/generic-node/generic-node.ts index 5087479..7504aaf 100644 --- a/src/app/shared/nodes/generic-node/generic-node.ts +++ b/src/app/shared/nodes/generic-node/generic-node.ts @@ -132,6 +132,10 @@ export class GenericNodeComponent { return this.blockId; } + @HostBinding('class.llm-node-readonly') get readonlyClass() { + return this.isReadonly; + } + outputs: { key: string; socket: ClassicPreset.Socket }[] = []; inputs: { key: string; socket: ClassicPreset.Socket }[] = []; parameterFields: EditableFieldView[] = []; @@ -192,9 +196,18 @@ export class GenericNodeComponent { this.rendered(); } + get isReadonly() { + return this.data?.data?.['__readonly'] === true; + } + + get nodeIdLabel() { + return this.blockId ?? 'unknown-id'; + } + async openNameEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; this.localEditorPath = 'name'; this.localEditorLabel = 'Name'; @@ -214,6 +227,7 @@ export class GenericNodeComponent { async openParameterEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; const definition = this.editableFieldDefinitions.find((field) => field.path === path); if (!definition) return; @@ -271,6 +285,7 @@ export class GenericNodeComponent { saveSimpleParamEditor(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; if (!this.localEditorPath) return; if (!this.canSaveLocalEditor()) return; @@ -304,6 +319,7 @@ export class GenericNodeComponent { async openMainContentEditor(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; if (!this.isPathVisible(path)) return; @@ -316,6 +332,7 @@ export class GenericNodeComponent { async confirmDelete(event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; if (!this.deleteConfirmOpen) { this.deleteConfirmOpen = true; return; @@ -406,6 +423,7 @@ export class GenericNodeComponent { onPortKindChange(kind: 'input' | 'output', key: string, nextValue: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; const ports = this.resolvePorts(kind); const index = ports.findIndex((candidate) => candidate.name === key); @@ -1029,18 +1047,21 @@ export class GenericNodeComponent { async addArrayItem(path: string, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; await this.openArrayItemEditor(path, null); } async editArrayItem(path: string, index: number, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; await this.openArrayItemEditor(path, index); } removeArrayItem(path: string, index: number, event?: Event) { event?.preventDefault(); event?.stopPropagation(); + if (this.isReadonly) return; const config = this.ensureBlockConfiguration(); const current = this.getByPath(config, path); @@ -1650,6 +1671,12 @@ export class GenericNodeComponent { } private async fetchConditionalRequirement(blockType: string, field: ConditionalRequiredField) { + if (field.requiredWhen) { + return evaluateUiConditionRule(field.requiredWhen, this.blockConfiguration ?? {}, (path) => this.resolveFieldSchema(path)); + } + + if (!field.retrieverKey) return false; + const retrieverBlockType = field.retrieverBlockType ?? blockType; const context: Record = {}; for (const dep of field.dependsOn) { @@ -1690,7 +1717,11 @@ export class GenericNodeComponent { private cloneFlowData(value: T): T { if (typeof globalThis.structuredClone === 'function') { - return globalThis.structuredClone(value); + try { + return globalThis.structuredClone(value); + } catch { + // Node runtime objects may include non-cloneable values. + } } return JSON.parse(JSON.stringify(value)) as T; } @@ -1732,7 +1763,7 @@ export class GenericNodeComponent { ).subscribe({ next: (createdBlock) => { const current = (this.data?.data ?? {}) as Record; - const replaceNode = current['replaceWithCreatedBlock']; + const replaceNode = current['replaceWithCreatedNode']; if (typeof replaceNode === 'function') { void replaceNode({ ...createdBlock, diff --git a/src/app/shared/nodes/schema-requirements.ts b/src/app/shared/nodes/schema-requirements.ts index efe1601..f35e6ad 100644 --- a/src/app/shared/nodes/schema-requirements.ts +++ b/src/app/shared/nodes/schema-requirements.ts @@ -1,4 +1,4 @@ -import { resolveSchemaRef } from './node-utility'; +import { readUiConditionRule, resolveSchemaRef, type UiConditionRule } from './node-utility'; export type RequiredField = { path: string; @@ -9,9 +9,10 @@ export type ConditionalRequiredField = { path: string; label: string; retrieverBlockType: string | null; - retrieverKey: string; + retrieverKey: string | null; retrieverUrl: string | null; dependsOn: Array<{ key: string; path: string }>; + requiredWhen: UiConditionRule | null; }; export type SchemaRequirements = { @@ -40,7 +41,8 @@ function walkSchema( conditional: ConditionalRequiredField[], seenRequired: Set, seenConditional: Set, - requireAllDescendants = false + requireAllDescendants = false, + inheritedRequiredWhen: UiConditionRule | null = null ) { const resolved = resolveSchemaRef(node, root); if (!resolved || typeof resolved !== 'object') return; @@ -58,6 +60,7 @@ function walkSchema( const isRequiredBySchema = requiredSet.has(key); const isRequiredByAncestor = requireAllDescendants && key !== 'type'; const isRequired = isRequiredBySchema || isRequiredByAncestor; + const requiredWhen = readUiConditionRule(propertyResolved?.['x-ui-required-when']) ?? inheritedRequiredWhen; if (isRequired && !hasChildren && key !== 'type' && !seenRequired.has(propertyPath)) { seenRequired.add(propertyPath); @@ -65,9 +68,10 @@ function walkSchema( } const retrieverRequiredUrl = propertyResolved?.['x-retriever-required-url']; - if (typeof retrieverRequiredUrl === 'string') { + if ((requiredWhen || typeof retrieverRequiredUrl === 'string') && key !== 'type' && !hasChildren) { const parsedRetriever = parseRetrieverUrl(retrieverRequiredUrl); - const retrieverKey = parsedRetriever?.key ?? String(propertyResolved?.['x-retriever-name'] ?? key); + const retrieverKey = parsedRetriever?.key + ?? (typeof propertyResolved?.['x-retriever-name'] === 'string' ? String(propertyResolved['x-retriever-name']) : null); const retrieverBlockType = parsedRetriever?.blockType ?? null; const rawDepends = Array.isArray(propertyResolved?.['x-retriever-required-depends-on']) ? (propertyResolved['x-retriever-required-depends-on'] as unknown[]) @@ -80,7 +84,7 @@ function walkSchema( path: pathPrefix ? `${pathPrefix}.${dep}` : dep })); - const signature = `${propertyPath}|${retrieverKey}|${dependsOn.map((d) => d.path).join(',')}`; + const signature = `${propertyPath}|${retrieverKey ?? 'local'}|${dependsOn.map((d) => d.path).join(',')}|${JSON.stringify(requiredWhen ?? null)}`; if (!seenConditional.has(signature)) { seenConditional.add(signature); conditional.push({ @@ -88,8 +92,9 @@ function walkSchema( label, retrieverBlockType, retrieverKey, - retrieverUrl: retrieverRequiredUrl, - dependsOn + retrieverUrl: typeof retrieverRequiredUrl === 'string' ? retrieverRequiredUrl : null, + dependsOn, + requiredWhen }); } } @@ -104,7 +109,8 @@ function walkSchema( conditional, seenRequired, seenConditional, - isRequired && !childHasOwnRequired + isRequired && !childHasOwnRequired, + requiredWhen ); } } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.css b/src/app/shared/nodes/task-step-node/task-step-node.css index 58c7d82..8d23617 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.css +++ b/src/app/shared/nodes/task-step-node/task-step-node.css @@ -11,6 +11,11 @@ color: #0f172a; } +:host.llm-node-readonly, +:host.llm-node-readonly * { + cursor: default !important; +} + .llm-node--human { border-color: #f7d7a7; background: linear-gradient(180deg, #fffdf9 0%, #fff7ed 100%); diff --git a/src/app/shared/nodes/task-step-node/task-step-node.html b/src/app/shared/nodes/task-step-node/task-step-node.html index aa1ca27..07f18f9 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.html +++ b/src/app/shared/nodes/task-step-node/task-step-node.html @@ -45,7 +45,7 @@
} -
+
@if (isHumanNode()) { @@ -76,6 +76,7 @@ side: 'input', key: input.key, nodeId: data.id, + __readonly: true, payload: input.socket }" [emit]="emit"> @@ -127,6 +128,7 @@ side: 'output', key: output.key, nodeId: data.id, + __readonly: true, payload: output.socket }" [emit]="emit"> diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index 089d431..9c15127 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -79,6 +79,8 @@ export class TaskStepNodeComponent { return this.data.selected; } + @HostBinding('class.llm-node-readonly') readonlyClass = true; + outputs: { key: string; socket: ClassicPreset.Socket }[] = []; inputs: { key: string; socket: ClassicPreset.Socket }[] = []; parameterFields: DisplayField[] = []; diff --git a/src/app/shared/title-toolbar/title-toolbar.html b/src/app/shared/title-toolbar/title-toolbar.html index 78f54c5..da59274 100644 --- a/src/app/shared/title-toolbar/title-toolbar.html +++ b/src/app/shared/title-toolbar/title-toolbar.html @@ -24,7 +24,8 @@ type="button" mat-icon-button class="title-toolbar-edit-button" - matTooltip="Edit flow title" + [matTooltip]="readOnly() ? 'Read-only public flow' : 'Edit flow title'" + [disabled]="readOnly()" (click)="startEditingTitle()"> @@ -39,7 +40,11 @@ Updating blocks... - } @else if (canExecute()) { + } + @if (readOnly()) { + Read only + } + @if (canExecute()) {