Add schema-driven block IO compatibility and bindable inputs
This commit is contained in:
parent
c0d2f75285
commit
6f3ca99b1e
|
|
@ -47,10 +47,16 @@ export type FlowBlock = {
|
|||
typeName: BlockTypeName;
|
||||
};
|
||||
|
||||
export type FlowValueKind = {
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
};
|
||||
|
||||
export type FlowPort = {
|
||||
name: string;
|
||||
type: string;
|
||||
multiple: boolean;
|
||||
valueKinds?: FlowValueKind[];
|
||||
};
|
||||
|
||||
export type FlowBlockConnection = {
|
||||
|
|
@ -86,3 +92,46 @@ export type HumanInteractiveBlockConfiguration = {
|
|||
inputAsList: boolean;
|
||||
outputAsList: boolean;
|
||||
};
|
||||
|
||||
export function currentFlowPortValueKind(port: FlowPort): FlowValueKind {
|
||||
return {
|
||||
type: String(port.type ?? 'ANY').toUpperCase(),
|
||||
multiple: Boolean(port.multiple)
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeFlowPortValueKinds(port: FlowPort): FlowValueKind[] {
|
||||
const rawKinds = Array.isArray(port.valueKinds) ? port.valueKinds : [];
|
||||
const normalized = rawKinds
|
||||
.filter((kind): kind is FlowValueKind => !!kind && typeof kind.type === 'string')
|
||||
.map((kind) => ({
|
||||
type: String(kind.type).toUpperCase(),
|
||||
multiple: Boolean(kind.multiple)
|
||||
}));
|
||||
|
||||
if (!normalized.length) {
|
||||
return [currentFlowPortValueKind(port)];
|
||||
}
|
||||
|
||||
const unique = new Map<string, FlowValueKind>();
|
||||
for (const kind of normalized) {
|
||||
unique.set(`${kind.type}:${kind.multiple ? 'multi' : 'single'}`, kind);
|
||||
}
|
||||
return Array.from(unique.values());
|
||||
}
|
||||
|
||||
export function flowValueKindLabel(kind: FlowValueKind): string {
|
||||
const type = String(kind.type ?? 'ANY').toUpperCase();
|
||||
return kind.multiple ? `${type}[]` : type;
|
||||
}
|
||||
|
||||
export function areFlowValueKindsCompatible(sourceKinds: FlowValueKind[], targetKinds: FlowValueKind[]): boolean {
|
||||
return sourceKinds.some((source) =>
|
||||
targetKinds.some((target) => {
|
||||
if (Boolean(source.multiple) !== Boolean(target.multiple)) return false;
|
||||
const sourceType = String(source.type ?? 'ANY').toUpperCase();
|
||||
const targetType = String(target.type ?? 'ANY').toUpperCase();
|
||||
return sourceType === 'ANY' || targetType === 'ANY' || sourceType === targetType;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -124,10 +124,30 @@ export class BlocksCallService extends BlocksCallServiceBase {
|
|||
.map((port) => ({
|
||||
name: String(port["name"]),
|
||||
type: String(port["type"] ?? "TEXT"),
|
||||
multiple: Boolean(port["multiple"] ?? false)
|
||||
multiple: Boolean(port["multiple"] ?? false),
|
||||
valueKinds: this.toValueKinds(port["valueKinds"], {
|
||||
type: String(port["type"] ?? "TEXT"),
|
||||
multiple: Boolean(port["multiple"] ?? false)
|
||||
})
|
||||
}));
|
||||
}
|
||||
|
||||
private toValueKinds(raw: unknown, fallback: { type: string; multiple: boolean }) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [{ type: fallback.type, multiple: fallback.multiple }];
|
||||
}
|
||||
|
||||
const kinds = raw
|
||||
.map((item) => this.toRecord(item))
|
||||
.filter((item) => typeof item["type"] === "string")
|
||||
.map((item) => ({
|
||||
type: String(item["type"] ?? fallback.type),
|
||||
multiple: Boolean(item["multiple"] ?? false)
|
||||
}));
|
||||
|
||||
return kinds.length ? kinds : [{ type: fallback.type, multiple: fallback.multiple }];
|
||||
}
|
||||
|
||||
private toPosition(raw: unknown): { x: number; y: number } | undefined {
|
||||
const value = this.toRecord(raw);
|
||||
const x = value["x"];
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@
|
|||
.llm-row {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
min-height: 34px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
|
|
@ -469,20 +469,45 @@
|
|||
}
|
||||
|
||||
.llm-pill {
|
||||
height: 24px;
|
||||
min-height: 32px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 0 8px;
|
||||
padding: 4px 8px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.llm-pill-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
.llm-pill-meta-output {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.llm-pill-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.llm-pill-kind,
|
||||
.llm-pill-kind-select {
|
||||
font-size: 10px;
|
||||
line-height: 1;
|
||||
font-weight: 700;
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
.llm-pill-input {
|
||||
justify-content: flex-start;
|
||||
color: #166534;
|
||||
|
|
@ -523,6 +548,17 @@
|
|||
border-color: #fecdd3;
|
||||
}
|
||||
|
||||
.llm-pill-kind-select {
|
||||
max-width: 88px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||
border-radius: 4px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
color: inherit;
|
||||
padding: 1px 16px 1px 4px;
|
||||
cursor: pointer;
|
||||
appearance: auto;
|
||||
}
|
||||
|
||||
.llm-input-info {
|
||||
border: none;
|
||||
background: transparent;
|
||||
|
|
|
|||
|
|
@ -86,7 +86,23 @@
|
|||
[emit]="emit">
|
||||
</div>
|
||||
<span class="llm-pill llm-pill-input">
|
||||
{{ input.key }}
|
||||
<span class="llm-pill-meta">
|
||||
<span class="llm-pill-name">{{ inputDisplayLabel(input.key) }}</span>
|
||||
@if (canTogglePortMultiplicity('input', input.key)) {
|
||||
<select
|
||||
class="llm-pill-kind-select"
|
||||
[ngModel]="portCurrentKindValue('input', input.key)"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(ngModelChange)="onPortKindChange('input', input.key, $event)">
|
||||
@for (option of portSelectableKindOptions('input', input.key); track option.value) {
|
||||
<option [value]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="llm-pill-kind">{{ portCurrentKindLabel('input', input.key) }}</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
|
|
@ -96,7 +112,25 @@
|
|||
<div class="llm-column-title llm-column-title-right">{{ outputsTitle() }}</div>
|
||||
@for (output of outputs; track output.key) {
|
||||
<div class="llm-row llm-row-output">
|
||||
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">{{ output.key }}</span>
|
||||
<span class="llm-pill llm-pill-output" [ngClass]="outputPillClass(output.key)">
|
||||
<span class="llm-pill-meta llm-pill-meta-output">
|
||||
<span class="llm-pill-name">{{ outputDisplayLabel(output.key) }}</span>
|
||||
@if (canTogglePortMultiplicity('output', output.key)) {
|
||||
<select
|
||||
class="llm-pill-kind-select"
|
||||
[ngModel]="portCurrentKindValue('output', output.key)"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="$event.stopPropagation()"
|
||||
(ngModelChange)="onPortKindChange('output', output.key, $event)">
|
||||
@for (option of portSelectableKindOptions('output', output.key); track option.value) {
|
||||
<option [value]="option.value">{{ option.label }}</option>
|
||||
}
|
||||
</select>
|
||||
} @else {
|
||||
<span class="llm-pill-kind">{{ portCurrentKindLabel('output', output.key) }}</span>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
<div
|
||||
refComponent
|
||||
class="llm-socket llm-socket-right"
|
||||
|
|
@ -242,9 +276,32 @@
|
|||
<div class="llm-modal-backdrop" (pointerdown)="$event.stopPropagation()" (click)="closeSimpleParamEditor($event)">
|
||||
<div class="llm-modal" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
|
||||
<div class="llm-modal-title">Edit {{ localEditorLabel }}</div>
|
||||
@if (localEditorBindableAsInput && localEditorPath !== 'name') {
|
||||
<div class="llm-modal-field">
|
||||
<label>Source</label>
|
||||
<select
|
||||
[ngModel]="localEditorUseInput ? 'input' : 'static'"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(ngModelChange)="onLocalEditorSourceModeChange($event)">
|
||||
<option value="static">Static value</option>
|
||||
<option value="input">Workflow input</option>
|
||||
</select>
|
||||
@if (localEditorUseInput) {
|
||||
<div class="llm-modal-tip">
|
||||
This field will be provided by node input{{ localEditorBindableInputName ? (' "' + localEditorBindableInputName + '"') : '' }}.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="llm-modal-field">
|
||||
<label>{{ localEditorLabel }}</label>
|
||||
@if (localEditorLoading) {
|
||||
@if (localEditorUseInput && localEditorPath !== 'name') {
|
||||
<input
|
||||
type="text"
|
||||
[ngModel]="localEditorBindableInputName ? ('Provided by input ' + localEditorBindableInputName) : 'Provided by workflow input'"
|
||||
readonly
|
||||
(pointerdown)="$event.stopPropagation()" />
|
||||
} @else if (localEditorLoading) {
|
||||
<input type="text" [ngModel]="localEditorValue" placeholder="Loading..." disabled (pointerdown)="$event.stopPropagation()" />
|
||||
} @else if (localEditorType === 'boolean') {
|
||||
<button
|
||||
|
|
@ -259,6 +316,13 @@
|
|||
</span>
|
||||
<span class="node-toggle-label">{{ localEditorValue === 'true' ? 'true' : 'false' }}</span>
|
||||
</button>
|
||||
} @else if (localEditorWidget === 'textarea') {
|
||||
<textarea
|
||||
rows="6"
|
||||
[(ngModel)]="localEditorValue"
|
||||
[attr.maxlength]="localEditorMaxLength"
|
||||
[placeholder]="localEditorMaxLength ? 'Max ' + localEditorMaxLength + ' characters' : 'Value...'"
|
||||
(pointerdown)="$event.stopPropagation()"></textarea>
|
||||
} @else if (localEditorHasRetriever) {
|
||||
<select [(ngModel)]="localEditorValue" (pointerdown)="$event.stopPropagation()">
|
||||
<option value="">{{ localEditorOptions.length ? ('Select ' + (localEditorLabel | lowercase) + '...') : 'No options available' }}</option>
|
||||
|
|
@ -277,7 +341,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" [disabled]="localEditorLoading" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
|
||||
<button type="button" class="llm-btn llm-btn-primary" [disabled]="!canSaveLocalEditor()" (pointerdown)="$event.stopPropagation()" (click)="saveSimpleParamEditor($event)">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { ChangeDetectorRef, Component, HostBinding, inject, Input } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { currentFlowPortValueKind, flowValueKindLabel, FlowData, FlowPort, FlowValueKind, normalizeFlowPortValueKinds } from '@models/flow';
|
||||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { FlowData } from '@models/flow';
|
||||
import {
|
||||
NodeSettingField,
|
||||
NodeSettingOption,
|
||||
|
|
@ -40,6 +40,7 @@ type EditableFieldDefinition = {
|
|||
path: string;
|
||||
label: string;
|
||||
type: FieldType;
|
||||
enumOptions: string[];
|
||||
retrieverBlockType: string | null;
|
||||
retrieverKey: string | null;
|
||||
retrieverUrl: string | null;
|
||||
|
|
@ -48,6 +49,10 @@ type EditableFieldDefinition = {
|
|||
widget: 'textarea' | null;
|
||||
acceptVariableAsPlaceholder: boolean;
|
||||
structural: boolean;
|
||||
bindableAsInput: boolean;
|
||||
inputName: string | null;
|
||||
inputType: string | null;
|
||||
inputMultiple: boolean | null;
|
||||
structuralReason?: string;
|
||||
placeholder?: string;
|
||||
tip?: string;
|
||||
|
|
@ -140,6 +145,10 @@ export class GenericNodeComponent {
|
|||
localEditorHasRetriever = false;
|
||||
localEditorType: FieldType = 'string';
|
||||
localEditorMaxLength: number | null = null;
|
||||
localEditorWidget: 'textarea' | null = null;
|
||||
localEditorBindableAsInput = false;
|
||||
localEditorUseInput = false;
|
||||
localEditorBindableInputName: string | null = null;
|
||||
deleteConfirmOpen = false;
|
||||
|
||||
missingRequiredParams: string[] = [];
|
||||
|
|
@ -192,6 +201,10 @@ export class GenericNodeComponent {
|
|||
this.localEditorLoading = false;
|
||||
this.localEditorHasRetriever = false;
|
||||
this.localEditorOpen = true;
|
||||
this.localEditorWidget = null;
|
||||
this.localEditorBindableAsInput = false;
|
||||
this.localEditorUseInput = false;
|
||||
this.localEditorBindableInputName = null;
|
||||
}
|
||||
|
||||
async openParameterEditor(path: string, event?: Event) {
|
||||
|
|
@ -202,26 +215,21 @@ export class GenericNodeComponent {
|
|||
if (!definition) return;
|
||||
if (!this.isFieldVisible(definition)) return;
|
||||
|
||||
if (definition.ui.widget === 'textarea') {
|
||||
const currentValue = this.valueToEditorString(
|
||||
this.getByPath(this.blockConfiguration ?? {}, definition.path),
|
||||
definition.type
|
||||
);
|
||||
await this.openTextareaEditor(definition.path, definition.label, currentValue, definition.ui);
|
||||
return;
|
||||
}
|
||||
|
||||
this.localEditorPath = definition.path;
|
||||
this.localEditorLabel = definition.label;
|
||||
this.localEditorType = definition.type;
|
||||
this.localEditorMaxLength = null;
|
||||
this.localEditorWidget = definition.ui.widget;
|
||||
this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type);
|
||||
this.localEditorOptions = [];
|
||||
this.localEditorLoading = !!definition.retrieverKey;
|
||||
this.localEditorHasRetriever = !!definition.retrieverKey;
|
||||
this.localEditorOptions = [...definition.enumOptions];
|
||||
this.localEditorBindableAsInput = definition.ui.bindableAsInput;
|
||||
this.localEditorUseInput = this.isBindableFieldUsingInput(definition);
|
||||
this.localEditorBindableInputName = definition.ui.inputName;
|
||||
this.localEditorLoading = !!definition.retrieverKey && !this.localEditorUseInput;
|
||||
this.localEditorHasRetriever = definition.enumOptions.length > 0 || !!definition.retrieverKey;
|
||||
this.localEditorOpen = true;
|
||||
|
||||
if (definition.retrieverKey) {
|
||||
if (definition.retrieverKey && !this.localEditorUseInput) {
|
||||
const missingDependencies = definition.retrieverDependsOn
|
||||
.filter((dep) => {
|
||||
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
|
||||
|
|
@ -250,6 +258,10 @@ export class GenericNodeComponent {
|
|||
this.localEditorHasRetriever = false;
|
||||
this.localEditorType = 'string';
|
||||
this.localEditorMaxLength = null;
|
||||
this.localEditorWidget = null;
|
||||
this.localEditorBindableAsInput = false;
|
||||
this.localEditorUseInput = false;
|
||||
this.localEditorBindableInputName = null;
|
||||
}
|
||||
|
||||
saveSimpleParamEditor(event?: Event) {
|
||||
|
|
@ -257,6 +269,7 @@ export class GenericNodeComponent {
|
|||
event?.stopPropagation();
|
||||
|
||||
if (!this.localEditorPath) return;
|
||||
if (!this.canSaveLocalEditor()) return;
|
||||
const config = this.ensureBlockConfiguration();
|
||||
|
||||
if (this.localEditorPath === 'name') {
|
||||
|
|
@ -265,7 +278,9 @@ export class GenericNodeComponent {
|
|||
this.name = nameValue || this.name;
|
||||
} else {
|
||||
const previousValue = this.getByPath(config, this.localEditorPath);
|
||||
const parsedValue = this.parseEditorValue(this.localEditorValue, this.localEditorType);
|
||||
const parsedValue = this.localEditorUseInput
|
||||
? this.emptyValueForFieldType(this.localEditorType)
|
||||
: this.parseEditorValue(this.localEditorValue, this.localEditorType);
|
||||
this.setByPath(config, this.localEditorPath, parsedValue);
|
||||
if (!this.areValuesEqual(previousValue, parsedValue)) {
|
||||
this.resetDependentRetrieverFields(config, this.localEditorPath);
|
||||
|
|
@ -345,6 +360,71 @@ export class GenericNodeComponent {
|
|||
return null;
|
||||
}
|
||||
|
||||
inputDisplayLabel(inputKey: string): string {
|
||||
return this.portDisplayLabel('input', inputKey);
|
||||
}
|
||||
|
||||
outputDisplayLabel(outputKey: string): string {
|
||||
return this.portDisplayLabel('output', outputKey);
|
||||
}
|
||||
|
||||
canTogglePortMultiplicity(kind: 'input' | 'output', key: string): boolean {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
return !!port && this.portSelectableKinds(port).length > 1;
|
||||
}
|
||||
|
||||
portCurrentKindLabel(kind: 'input' | 'output', key: string): string {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
return port ? flowValueKindLabel(currentFlowPortValueKind(port)) : 'ANY';
|
||||
}
|
||||
|
||||
portCurrentKindValue(kind: 'input' | 'output', key: string): string {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
if (!port) return '';
|
||||
|
||||
const current = currentFlowPortValueKind(port);
|
||||
const exact = this.portSelectableKinds(port).find((kindOption) =>
|
||||
kindOption.type === current.type && kindOption.multiple === current.multiple
|
||||
);
|
||||
const selected = exact ?? this.portSelectableKinds(port)[0];
|
||||
return selected ? this.flowValueKindValue(selected) : '';
|
||||
}
|
||||
|
||||
portSelectableKindOptions(kind: 'input' | 'output', key: string): Array<{ value: string; label: string }> {
|
||||
const port = this.resolvePorts(kind).find((candidate) => candidate.name === key);
|
||||
if (!port) return [];
|
||||
return this.portSelectableKinds(port).map((kindOption) => ({
|
||||
value: this.flowValueKindValue(kindOption),
|
||||
label: flowValueKindLabel(kindOption)
|
||||
}));
|
||||
}
|
||||
|
||||
onPortKindChange(kind: 'input' | 'output', key: string, nextValue: string, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const ports = this.resolvePorts(kind);
|
||||
const index = ports.findIndex((candidate) => candidate.name === key);
|
||||
if (index < 0) return;
|
||||
|
||||
const port = ports[index];
|
||||
const nextKind = this.portSelectableKinds(port).find(
|
||||
(kindOption) => this.flowValueKindValue(kindOption) === nextValue
|
||||
);
|
||||
if (!nextKind) return;
|
||||
|
||||
ports[index] = {
|
||||
...port,
|
||||
type: nextKind.type,
|
||||
multiple: nextKind.multiple
|
||||
};
|
||||
|
||||
this.syncPortSocketType(kind, key, nextKind.type);
|
||||
|
||||
this.markFlowDirty();
|
||||
this.refreshView();
|
||||
}
|
||||
|
||||
hasMainContent(): boolean {
|
||||
return this.richContentFields.length > 0;
|
||||
}
|
||||
|
|
@ -489,6 +569,7 @@ export class GenericNodeComponent {
|
|||
path,
|
||||
label: pathToLabel(path),
|
||||
type: this.toFieldType(childResolved?.type),
|
||||
enumOptions: this.toEnumOptions(childResolved),
|
||||
retrieverBlockType: this.toRetrieverBlockType(childResolved),
|
||||
retrieverKey: this.toRetrieverKey(childResolved),
|
||||
retrieverUrl: this.toRetrieverUrl(childResolved),
|
||||
|
|
@ -577,6 +658,16 @@ export class GenericNodeComponent {
|
|||
: undefined;
|
||||
const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true;
|
||||
const structural = schema?.['x-ui-structural'] === true;
|
||||
const bindableAsInput = schema?.['x-ui-bindable-as-input'] === true;
|
||||
const inputName = typeof schema?.['x-ui-input-name'] === 'string'
|
||||
? String(schema['x-ui-input-name'])
|
||||
: null;
|
||||
const inputType = typeof schema?.['x-ui-input-type'] === 'string'
|
||||
? String(schema['x-ui-input-type']).toUpperCase()
|
||||
: null;
|
||||
const inputMultiple = typeof schema?.['x-ui-input-multiple'] === 'boolean'
|
||||
? Boolean(schema['x-ui-input-multiple'])
|
||||
: null;
|
||||
const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string'
|
||||
? String(schema['x-ui-structural-reason'])
|
||||
: undefined;
|
||||
|
|
@ -587,6 +678,10 @@ export class GenericNodeComponent {
|
|||
widget: normalizedWidget,
|
||||
acceptVariableAsPlaceholder,
|
||||
structural,
|
||||
bindableAsInput,
|
||||
inputName,
|
||||
inputType,
|
||||
inputMultiple,
|
||||
structuralReason,
|
||||
placeholder,
|
||||
tip,
|
||||
|
|
@ -665,6 +760,12 @@ export class GenericNodeComponent {
|
|||
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType ?? null;
|
||||
}
|
||||
|
||||
private toEnumOptions(schema: Record<string, any> | null | undefined): string[] {
|
||||
const raw = schema?.['enum'];
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw.filter((value): value is string => typeof value === 'string');
|
||||
}
|
||||
|
||||
private toRetrieverUrl(schema: Record<string, any> | null | undefined): string | null {
|
||||
if (!schema || typeof schema !== 'object') return null;
|
||||
const rawUrl = schema['x-retriever-url'];
|
||||
|
|
@ -686,6 +787,103 @@ export class GenericNodeComponent {
|
|||
return { blockType, key };
|
||||
}
|
||||
|
||||
private portDisplayLabel(kind: 'input' | 'output', key: string): string {
|
||||
const ports = this.resolvePorts(kind);
|
||||
const port = ports.find((candidate) => candidate.name === key);
|
||||
return port?.name ?? key;
|
||||
}
|
||||
|
||||
private portValueKinds(port: FlowPort) {
|
||||
return normalizeFlowPortValueKinds(port);
|
||||
}
|
||||
|
||||
private portSelectableKinds(port: FlowPort): FlowValueKind[] {
|
||||
const expanded = new Map<string, FlowValueKind>();
|
||||
|
||||
for (const kind of this.portValueKinds(port)) {
|
||||
const type = String(kind.type ?? 'ANY').toUpperCase();
|
||||
if (type === 'ANY') {
|
||||
for (const concreteType of ['TEXT', 'FILE']) {
|
||||
const concreteKind = { type: concreteType, multiple: Boolean(kind.multiple) };
|
||||
expanded.set(this.flowValueKindValue(concreteKind), concreteKind);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
expanded.set(this.flowValueKindValue(kind), kind);
|
||||
}
|
||||
|
||||
return Array.from(expanded.values());
|
||||
}
|
||||
|
||||
private resolvePorts(kind: 'input' | 'output'): FlowPort[] {
|
||||
const ports = this.data?.data?.[kind === 'input' ? 'inputs' : 'outputs'];
|
||||
return Array.isArray(ports) ? ports as FlowPort[] : [];
|
||||
}
|
||||
|
||||
private flowValueKindValue(kind: FlowValueKind): string {
|
||||
return `${String(kind.type ?? 'ANY').toUpperCase()}::${kind.multiple ? 'multi' : 'single'}`;
|
||||
}
|
||||
|
||||
private isBindableFieldUsingInput(definition: EditableFieldDefinition): boolean {
|
||||
if (!definition.ui.bindableAsInput) return false;
|
||||
|
||||
const value = this.getByPath(this.blockConfiguration ?? {}, definition.path);
|
||||
if (!this.isMissingValue(value)) return false;
|
||||
|
||||
const actualInputName = definition.ui.inputName;
|
||||
if (!actualInputName) return true;
|
||||
|
||||
return this.resolvePorts('input').some((port) => port.name === actualInputName);
|
||||
}
|
||||
|
||||
private fieldDisplayValue(definition: EditableFieldDefinition, value: unknown): string {
|
||||
if (this.isBindableFieldUsingInput(definition)) {
|
||||
const actualPort = this.resolvePorts('input').find((port) => port.name === definition.ui.inputName);
|
||||
const fallbackType = definition.ui.inputType ?? definition.type.toUpperCase();
|
||||
const fallbackMultiple = Boolean(definition.ui.inputMultiple);
|
||||
const kindLabel = actualPort
|
||||
? flowValueKindLabel(currentFlowPortValueKind(actualPort))
|
||||
: flowValueKindLabel({ type: fallbackType, multiple: fallbackMultiple });
|
||||
const inputName = definition.ui.inputName ?? definition.label;
|
||||
return `Provided by input ${inputName} (${kindLabel})`;
|
||||
}
|
||||
|
||||
return valueToDisplayString(value);
|
||||
}
|
||||
|
||||
canSaveLocalEditor(): boolean {
|
||||
if (this.localEditorLoading) return false;
|
||||
if (!this.localEditorBindableAsInput || this.localEditorUseInput) return true;
|
||||
if (this.localEditorType === 'boolean') return true;
|
||||
return this.localEditorValue.trim().length > 0;
|
||||
}
|
||||
|
||||
private emptyValueForFieldType(type: FieldType): unknown {
|
||||
if (type === 'number' || type === 'integer' || type === 'boolean') return null;
|
||||
return '';
|
||||
}
|
||||
|
||||
private syncPortSocketType(kind: 'input' | 'output', key: string, type: string) {
|
||||
const socketHost = this.data?.[kind === 'input' ? 'inputs' : 'outputs']?.[key];
|
||||
if (!socketHost) return;
|
||||
socketHost.socket = new ClassicPreset.Socket(type);
|
||||
}
|
||||
|
||||
async onLocalEditorSourceModeChange(nextMode: string) {
|
||||
this.localEditorUseInput = nextMode === 'input';
|
||||
if (this.localEditorUseInput || !this.localEditorPath) {
|
||||
this.localEditorLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
const definition = this.editableFieldDefinitions.find((field) => field.path === this.localEditorPath);
|
||||
if (!definition?.retrieverKey || this.localEditorOptions.length > 0) return;
|
||||
|
||||
this.localEditorLoading = true;
|
||||
await this.loadLocalEditorOptions(definition);
|
||||
}
|
||||
|
||||
private toRetrieverDependsOn(schema: Record<string, any> | null | undefined, pathPrefix: string): RetrieverDependency[] {
|
||||
if (!schema || typeof schema !== 'object') return [];
|
||||
|
||||
|
|
@ -759,7 +957,7 @@ export class GenericNodeComponent {
|
|||
return {
|
||||
path: definition.path,
|
||||
label: definition.label,
|
||||
value: valueToDisplayString(value),
|
||||
value: this.fieldDisplayValue(definition, value),
|
||||
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea')
|
||||
};
|
||||
}).filter((field) => !richContentPaths.has(field.path))
|
||||
|
|
@ -1403,7 +1601,11 @@ export class GenericNodeComponent {
|
|||
.filter((field) => this.isFieldConditionSatisfied(field.path));
|
||||
|
||||
const missingFields = requiredFields
|
||||
.filter((field) => this.isMissingValue(this.getByPath(config, field.path)));
|
||||
.filter((field) => {
|
||||
const definition = this.editableFieldDefinitions.find((candidate) => candidate.path === field.path);
|
||||
if (definition && this.isBindableFieldUsingInput(definition)) return false;
|
||||
return this.isMissingValue(this.getByPath(config, field.path));
|
||||
});
|
||||
|
||||
this.missingRequiredParams = missingFields.map((field) => field.label);
|
||||
|
||||
|
|
@ -1569,10 +1771,15 @@ export class GenericNodeComponent {
|
|||
return this.isPathVisible(field.path);
|
||||
}
|
||||
|
||||
private isFieldConditionSatisfied(path: string): boolean {
|
||||
private isFieldConditionSatisfied(path: string, visited = new Set<string>()): boolean {
|
||||
if (visited.has(path)) return true;
|
||||
visited.add(path);
|
||||
|
||||
const ui = this.getFieldUiMeta(path);
|
||||
return ui.visibleWhen.every((rule) =>
|
||||
evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath))
|
||||
);
|
||||
return ui.visibleWhen.every((rule) => {
|
||||
if (!rule) return true;
|
||||
if (!this.isFieldConditionSatisfied(rule.field, visited)) return false;
|
||||
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "rete-connection-plugin";
|
||||
import { AngularPlugin, Presets, AngularArea2D } from "rete-angular-plugin/21";
|
||||
import { HFNode, HFSchemes } from "@models/nodes";
|
||||
import { FlowBlock, FlowData } from "@models/flow";
|
||||
import { areFlowValueKindsCompatible, FlowBlock, FlowData, normalizeFlowPortValueKinds } from "@models/flow";
|
||||
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
|
||||
import { TaskStepNodeComponent } from "@shared/nodes/task-step-node/task-step-node";
|
||||
import { CustomSocket } from "@shared/custom-socket/custom-socket";
|
||||
|
|
@ -55,10 +55,22 @@ export async function createEditor(
|
|||
},
|
||||
})
|
||||
);
|
||||
editor.addPipe((context) => {
|
||||
if (context.type !== "connectioncreate") return context;
|
||||
|
||||
editor.addPipe((c) => {
|
||||
if (c.type === "connectioncreate") console.log(c.data);
|
||||
return c;
|
||||
const sourceNode = editor.getNode(context.data.source) as HFNode | undefined;
|
||||
const targetNode = editor.getNode(context.data.target) as HFNode | undefined;
|
||||
const sourcePort = resolveNodePort(sourceNode, "output", context.data.sourceOutput);
|
||||
const targetPort = resolveNodePort(targetNode, "input", context.data.targetInput);
|
||||
|
||||
if (!sourcePort || !targetPort) return;
|
||||
|
||||
const compatible = areFlowValueKindsCompatible(
|
||||
normalizeFlowPortValueKinds(sourcePort),
|
||||
normalizeFlowPortValueKinds(targetPort)
|
||||
);
|
||||
|
||||
return compatible ? context : undefined;
|
||||
});
|
||||
|
||||
connection.addPreset(ConnectionPresets.classic.setup());
|
||||
|
|
@ -85,17 +97,8 @@ export function exportGraph(editor: NodeEditor<HFSchemes>) {
|
|||
const blockId = blockData?.id ?? node.id;
|
||||
nodeIdToBlockId.set(node.id, blockId);
|
||||
|
||||
const inputs = Object.entries(node.inputs).map(([name, input]) => ({
|
||||
name,
|
||||
type: ((input as any).socket?.name as string) ?? "ANY",
|
||||
multiple: false
|
||||
}));
|
||||
|
||||
const outputs = Object.entries(node.outputs).map(([name, output]) => ({
|
||||
name,
|
||||
type: ((output as any).socket?.name as string) ?? "ANY",
|
||||
multiple: false
|
||||
}));
|
||||
const inputs = cloneValue(blockData?.inputs ?? []);
|
||||
const outputs = cloneValue(blockData?.outputs ?? []);
|
||||
|
||||
return {
|
||||
id: blockId,
|
||||
|
|
@ -256,6 +259,12 @@ function getSocket(editor: NodeEditor<HFSchemes>, type: string) {
|
|||
return map.get(type)!;
|
||||
}
|
||||
|
||||
function resolveNodePort(node: HFNode | undefined, kind: "input" | "output", portName: string) {
|
||||
const ports = node?.data?.[kind === "input" ? "inputs" : "outputs"];
|
||||
if (!Array.isArray(ports)) return null;
|
||||
return ports.find((port) => port?.name === portName) ?? null;
|
||||
}
|
||||
|
||||
function toNodeLabel(typeName: string) {
|
||||
if (typeName === "InputBlock" || typeName === "SourceBlock") return "Input";
|
||||
if (typeName === "OutputBlock") return "Output";
|
||||
|
|
|
|||
Loading…
Reference in New Issue