Support dynamic array schemas in node editors
This commit is contained in:
parent
d6e3a2a534
commit
b24b8177a9
|
|
@ -4,12 +4,19 @@ export abstract class FieldRetrieverCallServiceBase {
|
|||
abstract retrieveValues(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<string[]>;
|
||||
|
||||
abstract isFieldRequired(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<boolean>;
|
||||
|
||||
abstract retrieveSchema(
|
||||
schemaUrl: string,
|
||||
context?: Record<string, string>
|
||||
): Observable<Record<string, unknown> | null>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
|
|||
override retrieveValues(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
_retrieverUrl?: string | null
|
||||
): Observable<string[]> {
|
||||
if (key === "providers") {
|
||||
return of(this.providersByBlockType[blockType] ?? []);
|
||||
|
|
@ -32,9 +33,41 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
|
|||
override isFieldRequired(
|
||||
_blockType: string,
|
||||
_key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
_retrieverUrl?: string | null
|
||||
): Observable<boolean> {
|
||||
void context;
|
||||
return of(false);
|
||||
}
|
||||
|
||||
override retrieveSchema(
|
||||
schemaUrl: string,
|
||||
context?: Record<string, string>
|
||||
): Observable<Record<string, unknown> | null> {
|
||||
if (!schemaUrl.includes('/retriever/MCPServers/definitions/schema')) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
const serverName = context?.['serverName'] ?? '';
|
||||
if (!serverName) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
return of({
|
||||
type: 'object',
|
||||
properties: {
|
||||
endpoint: {
|
||||
type: 'string'
|
||||
},
|
||||
tool: {
|
||||
type: 'string',
|
||||
default: `${serverName}-default`
|
||||
},
|
||||
enabled: {
|
||||
type: 'boolean',
|
||||
default: true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,29 +1,19 @@
|
|||
import { HttpClient, HttpParams } from "@angular/common/http";
|
||||
import { inject } from "@angular/core";
|
||||
import { environment } from "@environment";
|
||||
import { map, Observable } from "rxjs";
|
||||
import { map, Observable, of } from "rxjs";
|
||||
import { FieldRetrieverCallServiceBase } from "./field-retriever-call.base";
|
||||
|
||||
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
private buildParams(context?: Record<string, string>) {
|
||||
let params = new HttpParams();
|
||||
const entries = Object.entries(context ?? {});
|
||||
for (const [ctxKey, ctxValue] of entries) {
|
||||
params = params.set(ctxKey, ctxValue);
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
override retrieveValues(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<string[]> {
|
||||
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}`;
|
||||
const params = this.buildParams(context);
|
||||
const { url, params } = this.resolveRequest(blockType, key, context, retrieverUrl);
|
||||
return this.http.get<unknown>(url, { params }).pipe(
|
||||
map((raw) => this.normalizeStringList(raw))
|
||||
);
|
||||
|
|
@ -32,13 +22,88 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
|||
override isFieldRequired(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<boolean> {
|
||||
const url = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}/required`;
|
||||
const params = this.buildParams(context);
|
||||
const requiredRetrieverUrl = this.appendRequiredSuffix(retrieverUrl);
|
||||
const { url, params } = this.resolveRequest(blockType, key, context, requiredRetrieverUrl, true);
|
||||
return this.http.get<boolean>(url, { params });
|
||||
}
|
||||
|
||||
override retrieveSchema(
|
||||
schemaUrl: string,
|
||||
context?: Record<string, string>
|
||||
): Observable<Record<string, unknown> | null> {
|
||||
const resolvedUrl = this.resolveApiUrl(schemaUrl);
|
||||
if (!resolvedUrl) {
|
||||
return of(null);
|
||||
}
|
||||
|
||||
const parsed = this.parseUrl(resolvedUrl);
|
||||
let params = parsed.params;
|
||||
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
for (const [ctxKey, ctxValue] of Object.entries(context)) {
|
||||
params = params.set(ctxKey, ctxValue);
|
||||
}
|
||||
}
|
||||
|
||||
return this.http.get<unknown>(parsed.url, { params }).pipe(
|
||||
map((raw) => (raw && typeof raw === 'object' && !Array.isArray(raw) ? raw as Record<string, unknown> : null))
|
||||
);
|
||||
}
|
||||
|
||||
private resolveRequest(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null,
|
||||
isRequired = false
|
||||
) {
|
||||
const fallbackUrl = `${environment.apiUrl}/retriever/${encodeURIComponent(blockType)}/${encodeURIComponent(key)}${isRequired ? '/required' : ''}`;
|
||||
const baseUrl = this.resolveApiUrl(retrieverUrl) ?? fallbackUrl;
|
||||
const parsed = this.parseUrl(baseUrl);
|
||||
let params = parsed.params;
|
||||
|
||||
if (context && Object.keys(context).length > 0) {
|
||||
for (const [ctxKey, ctxValue] of Object.entries(context)) {
|
||||
params = params.set(ctxKey, ctxValue);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url: parsed.url,
|
||||
params
|
||||
};
|
||||
}
|
||||
|
||||
private resolveApiUrl(rawUrl?: string | null): string | null {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
|
||||
if (/^https?:\/\//.test(rawUrl)) return rawUrl;
|
||||
return `${environment.apiUrl}${rawUrl.startsWith('/') ? rawUrl : `/${rawUrl}`}`;
|
||||
}
|
||||
|
||||
private parseUrl(rawUrl: string) {
|
||||
const [url, queryString] = rawUrl.split('?', 2);
|
||||
let params = new HttpParams();
|
||||
|
||||
if (queryString) {
|
||||
const searchParams = new URLSearchParams(queryString);
|
||||
for (const [key, value] of searchParams.entries()) {
|
||||
params = params.append(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return { url, params };
|
||||
}
|
||||
|
||||
private appendRequiredSuffix(rawUrl?: string | null): string | null {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
|
||||
const [path, queryString] = rawUrl.split('?', 2);
|
||||
const normalizedPath = path.endsWith('/required') ? path : `${path}/required`;
|
||||
return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
|
||||
}
|
||||
|
||||
private normalizeStringList(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) {
|
||||
return raw.filter((item): item is string => typeof item === 'string');
|
||||
|
|
|
|||
|
|
@ -12,9 +12,10 @@ export class FieldRetriever {
|
|||
retrieveValues(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
) {
|
||||
return this.fieldRetrieverCallService.retrieveValues(blockType, key, context).pipe(
|
||||
return this.fieldRetrieverCallService.retrieveValues(blockType, key, context, retrieverUrl).pipe(
|
||||
catchError((err) => {
|
||||
console.error('Field retrieval failed', err);
|
||||
return throwError(() => err);
|
||||
|
|
@ -25,13 +26,26 @@ export class FieldRetriever {
|
|||
isFieldRequired(
|
||||
blockType: string,
|
||||
key: string,
|
||||
context?: Record<string, string>
|
||||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
) {
|
||||
return this.fieldRetrieverCallService.isFieldRequired(blockType, key, context).pipe(
|
||||
return this.fieldRetrieverCallService.isFieldRequired(blockType, key, context, retrieverUrl).pipe(
|
||||
catchError((err) => {
|
||||
console.error('Field required check failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
retrieveSchema(
|
||||
schemaUrl: string,
|
||||
context?: Record<string, string>
|
||||
) {
|
||||
return this.fieldRetrieverCallService.retrieveSchema(schemaUrl, context).pipe(
|
||||
catchError((err) => {
|
||||
console.error('Schema retrieval failed', err);
|
||||
return throwError(() => err);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -726,6 +726,58 @@
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-array-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.llm-array-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.llm-array-empty {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.llm-array-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
border: 1px solid rgba(191, 219, 254, 0.9);
|
||||
}
|
||||
|
||||
.llm-array-item-summary {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.llm-array-item-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.llm-array-remove-btn {
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.llm-param-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -122,7 +122,7 @@
|
|||
<legend class="llm-param-legend">{{ group.legend }}</legend>
|
||||
<div class="llm-param-grid">
|
||||
@for (field of group.fields; track field.path) {
|
||||
<div class="llm-param-chip">
|
||||
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
|
||||
<div class="llm-param-row-head">
|
||||
<span class="llm-param-key">{{ field.label }}</span>
|
||||
<button
|
||||
|
|
@ -146,7 +146,7 @@
|
|||
@if (parameterFields.length) {
|
||||
<div class="llm-param-grid">
|
||||
@for (field of parameterFields; track field.path) {
|
||||
<div class="llm-param-chip">
|
||||
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
|
||||
<div class="llm-param-row-head">
|
||||
<span class="llm-param-key">{{ field.label }}</span>
|
||||
<button
|
||||
|
|
@ -189,6 +189,53 @@
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (arrayFields.length) {
|
||||
<div class="llm-array-sections">
|
||||
@for (arrayField of arrayFields; track arrayField.path) {
|
||||
<div class="llm-array-block">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">{{ arrayField.label }}</div>
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
[attr.title]="'Add ' + arrayField.label.toLowerCase()"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="addArrayItem(arrayField.path, $event)">
|
||||
<i class="bi bi-plus-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
@if (!arrayField.items.length) {
|
||||
<div class="llm-array-empty">No items</div>
|
||||
} @else {
|
||||
@for (item of arrayField.items; track item.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ item.summary }}</span>
|
||||
<div class="llm-array-item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn"
|
||||
title="Edit item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="editArrayItem(arrayField.path, item.index, $event)">
|
||||
<i class="bi bi-pen"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="llm-edit-btn llm-array-remove-btn"
|
||||
title="Remove item"
|
||||
(pointerdown)="$event.stopPropagation()"
|
||||
(click)="removeArrayItem(arrayField.path, item.index, $event)">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (localEditorOpen) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { FormsModule } from '@angular/forms';
|
|||
import { ClassicPreset } from 'rete';
|
||||
import { ReteModule } from 'rete-angular-plugin/21';
|
||||
import { FlowData } from '@models/flow';
|
||||
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
|
||||
import {
|
||||
NodeSettingField,
|
||||
NodeSettingOption,
|
||||
NodeSettingsDialogService
|
||||
} from '@services/dialogs/node-settings-dialog';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { FieldRetriever } from '@services/retriever/field-retriever';
|
||||
import { BlocksService } from '@services/blocks/blocks';
|
||||
|
|
@ -38,6 +42,7 @@ type EditableFieldDefinition = {
|
|||
type: FieldType;
|
||||
retrieverBlockType: string | null;
|
||||
retrieverKey: string | null;
|
||||
retrieverUrl: string | null;
|
||||
retrieverDependsOn: RetrieverDependency[];
|
||||
ui: {
|
||||
widget: 'textarea' | null;
|
||||
|
|
@ -56,6 +61,29 @@ type EditableFieldView = {
|
|||
path: string;
|
||||
label: string;
|
||||
value: string;
|
||||
wide: boolean;
|
||||
};
|
||||
|
||||
type ArrayFieldDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
itemSchema: Record<string, any> | null;
|
||||
ui: {
|
||||
structural: boolean;
|
||||
visibleWhen: UiConditionRule[];
|
||||
group: string | null;
|
||||
};
|
||||
};
|
||||
|
||||
type ArrayFieldItemView = {
|
||||
index: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type ArrayFieldView = {
|
||||
path: string;
|
||||
label: string;
|
||||
items: ArrayFieldItemView[];
|
||||
};
|
||||
|
||||
type EditableFieldGroupView = {
|
||||
|
|
@ -100,6 +128,7 @@ export class GenericNodeComponent {
|
|||
parameterFields: EditableFieldView[] = [];
|
||||
parameterFieldGroups: EditableFieldGroupView[] = [];
|
||||
richContentFields: RichContentView[] = [];
|
||||
arrayFields: ArrayFieldView[] = [];
|
||||
name = 'noName';
|
||||
|
||||
localEditorOpen = false;
|
||||
|
|
@ -116,6 +145,7 @@ export class GenericNodeComponent {
|
|||
missingRequiredParams: string[] = [];
|
||||
private blockSchema: Record<string, any> | null = null;
|
||||
private editableFieldDefinitions: EditableFieldDefinition[] = [];
|
||||
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
|
||||
private schemaRequirements: SchemaRequirements = { required: [], conditional: [] };
|
||||
private conditionalRequiredByPath = new Map<string, boolean>();
|
||||
private refreshingConditionalRequirements = false;
|
||||
|
|
@ -126,6 +156,7 @@ export class GenericNodeComponent {
|
|||
this.parameterFields = [];
|
||||
this.parameterFieldGroups = [];
|
||||
this.richContentFields = [];
|
||||
this.arrayFields = [];
|
||||
|
||||
Object.entries(this.data.outputs).forEach(([key, output]) => {
|
||||
this.outputs.push({ key, socket: (output as any).socket });
|
||||
|
|
@ -370,6 +401,7 @@ export class GenericNodeComponent {
|
|||
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
|
||||
this.schemaRequirements = extractSchemaRequirements(this.blockSchema);
|
||||
this.editableFieldDefinitions = this.buildEditableFieldDefinitions(this.blockSchema);
|
||||
this.arrayFieldDefinitions = this.buildArrayFieldDefinitions(this.blockSchema);
|
||||
|
||||
await this.refreshConditionalRequirements();
|
||||
this.refreshParameterFields();
|
||||
|
|
@ -435,6 +467,9 @@ export class GenericNodeComponent {
|
|||
for (const [key, childSchema] of Object.entries(properties)) {
|
||||
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
|
||||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
if (childResolved?.type === 'array') {
|
||||
continue;
|
||||
}
|
||||
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
|
||||
const childUi = this.toFieldUiMeta(childResolved, inheritedUi);
|
||||
|
||||
|
|
@ -456,6 +491,7 @@ export class GenericNodeComponent {
|
|||
type: this.toFieldType(childResolved?.type),
|
||||
retrieverBlockType: this.toRetrieverBlockType(childResolved),
|
||||
retrieverKey: this.toRetrieverKey(childResolved),
|
||||
retrieverUrl: this.toRetrieverUrl(childResolved),
|
||||
retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix),
|
||||
ui: childUi
|
||||
});
|
||||
|
|
@ -466,6 +502,60 @@ export class GenericNodeComponent {
|
|||
return definitions;
|
||||
}
|
||||
|
||||
private buildArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
|
||||
if (!schema) return [];
|
||||
|
||||
const definitions: ArrayFieldDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const walk = (
|
||||
node: Record<string, any>,
|
||||
pathPrefix: string,
|
||||
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
|
||||
) => {
|
||||
const resolved = resolveSchemaRef(node, schema);
|
||||
if (!resolved || typeof resolved !== 'object') return;
|
||||
|
||||
const properties = resolved.properties as Record<string, any> | undefined;
|
||||
if (!properties) return;
|
||||
|
||||
for (const [key, childSchema] of Object.entries(properties)) {
|
||||
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
|
||||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
const childUi = this.toFieldUiMeta(childResolved, inheritedUi);
|
||||
|
||||
if (childResolved?.type === 'array') {
|
||||
if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(path);
|
||||
definitions.push({
|
||||
path,
|
||||
label: pathToLabel(path),
|
||||
itemSchema: this.resolveArrayItemSchema(childResolved, schema),
|
||||
ui: {
|
||||
structural: childUi.structural,
|
||||
visibleWhen: childUi.visibleWhen,
|
||||
group: childUi.group
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
|
||||
if (hasChildren) {
|
||||
walk(childResolved as Record<string, any>, path, {
|
||||
visibleWhen: childUi.visibleWhen,
|
||||
group: childUi.group
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(schema, '');
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private toFieldUiMeta(
|
||||
schema: Record<string, any> | null | undefined,
|
||||
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
|
||||
|
|
@ -575,6 +665,12 @@ export class GenericNodeComponent {
|
|||
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType ?? null;
|
||||
}
|
||||
|
||||
private toRetrieverUrl(schema: Record<string, any> | null | undefined): string | null {
|
||||
if (!schema || typeof schema !== 'object') return null;
|
||||
const rawUrl = schema['x-retriever-url'];
|
||||
return typeof rawUrl === 'string' && rawUrl.trim().length > 0 ? rawUrl : null;
|
||||
}
|
||||
|
||||
private parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string } | null {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
|
||||
|
||||
|
|
@ -620,7 +716,12 @@ export class GenericNodeComponent {
|
|||
|
||||
try {
|
||||
const options = await firstValueFrom(
|
||||
this.fieldRetriever.retrieveValues(blockType, definition.retrieverKey, context)
|
||||
this.fieldRetriever.retrieveValues(
|
||||
blockType,
|
||||
definition.retrieverKey,
|
||||
definition.retrieverDependsOn.length ? context : undefined,
|
||||
definition.retrieverUrl
|
||||
)
|
||||
);
|
||||
this.localEditorOptions = options ?? [];
|
||||
} catch {
|
||||
|
|
@ -644,13 +745,22 @@ export class GenericNodeComponent {
|
|||
parts: this.toRichContentParts(path)
|
||||
}));
|
||||
|
||||
this.arrayFields = this.arrayFieldDefinitions
|
||||
.filter((definition) => this.isPathVisible(definition.path))
|
||||
.map((definition) => ({
|
||||
path: definition.path,
|
||||
label: definition.label,
|
||||
items: this.toArrayFieldItems(definition, this.getByPath(config, definition.path))
|
||||
}));
|
||||
|
||||
if (this.editableFieldDefinitions.length) {
|
||||
const orderedFields = this.editableFieldDefinitions.map((definition) => {
|
||||
const value = this.getByPath(config, definition.path);
|
||||
return {
|
||||
path: definition.path,
|
||||
label: definition.label,
|
||||
value: valueToDisplayString(value)
|
||||
value: valueToDisplayString(value),
|
||||
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea')
|
||||
};
|
||||
}).filter((field) => !richContentPaths.has(field.path))
|
||||
.filter((field) => this.isPathVisible(field.path));
|
||||
|
|
@ -683,7 +793,8 @@ export class GenericNodeComponent {
|
|||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
label: pathToLabel(entry.path),
|
||||
value: valueToDisplayString(entry.value)
|
||||
value: valueToDisplayString(entry.value),
|
||||
wide: this.shouldRenderWideField(pathToLabel(entry.path), false)
|
||||
}));
|
||||
|
||||
for (const field of fallbackFields) {
|
||||
|
|
@ -708,6 +819,441 @@ export class GenericNodeComponent {
|
|||
this.refreshView();
|
||||
}
|
||||
|
||||
async addArrayItem(path: string, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
await this.openArrayItemEditor(path, null);
|
||||
}
|
||||
|
||||
async editArrayItem(path: string, index: number, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
await this.openArrayItemEditor(path, index);
|
||||
}
|
||||
|
||||
removeArrayItem(path: string, index: number, event?: Event) {
|
||||
event?.preventDefault();
|
||||
event?.stopPropagation();
|
||||
|
||||
const config = this.ensureBlockConfiguration();
|
||||
const current = this.getByPath(config, path);
|
||||
const items = Array.isArray(current) ? [...current] : [];
|
||||
if (index < 0 || index >= items.length) return;
|
||||
|
||||
items.splice(index, 1);
|
||||
this.setByPath(config, path, items);
|
||||
if (this.isStructuralField(path)) {
|
||||
this.markBlockForServerRecreate();
|
||||
}
|
||||
this.refreshParameterFields();
|
||||
this.refreshValidationState();
|
||||
this.markFlowDirty();
|
||||
this.maybeCreateBlockOnServer();
|
||||
}
|
||||
|
||||
private async openArrayItemEditor(path: string, index: number | null) {
|
||||
const definition = this.arrayFieldDefinitions.find((field) => field.path === path);
|
||||
if (!definition || !this.isPathVisible(path)) return;
|
||||
|
||||
const config = this.ensureBlockConfiguration();
|
||||
const current = this.getByPath(config, path);
|
||||
const items = Array.isArray(current) ? [...current] : [];
|
||||
const currentItem = index == null ? this.createEmptyArrayItem(definition.itemSchema) : this.cloneFlowData(items[index] ?? {});
|
||||
const dialog = await this.buildArrayItemDialog(definition, currentItem, index);
|
||||
if (!dialog) return;
|
||||
|
||||
const result = await this.settingsDialog.open(dialog);
|
||||
if (!result) return;
|
||||
|
||||
const nextItem = this.parseArrayItemDialogResult(definition, result, currentItem);
|
||||
if (index == null) {
|
||||
items.push(nextItem);
|
||||
} else {
|
||||
items[index] = nextItem;
|
||||
}
|
||||
|
||||
this.setByPath(config, path, items);
|
||||
if (this.isStructuralField(path)) {
|
||||
this.markBlockForServerRecreate();
|
||||
}
|
||||
this.refreshParameterFields();
|
||||
this.refreshValidationState();
|
||||
this.markFlowDirty();
|
||||
this.maybeCreateBlockOnServer();
|
||||
}
|
||||
|
||||
private async buildArrayItemDialog(
|
||||
definition: ArrayFieldDefinition,
|
||||
item: Record<string, unknown>,
|
||||
index: number | null
|
||||
) {
|
||||
const itemSchema = definition.itemSchema;
|
||||
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
|
||||
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
|
||||
|
||||
if (!properties) {
|
||||
return {
|
||||
title: `${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
|
||||
fields: [
|
||||
{
|
||||
key: '__raw',
|
||||
label: 'Item JSON',
|
||||
type: 'textarea' as const,
|
||||
rows: 10
|
||||
}
|
||||
],
|
||||
initial: {
|
||||
__raw: JSON.stringify(item ?? {}, null, 2)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const fields: NodeSettingField[] = [];
|
||||
const initial: Record<string, string | boolean> = {};
|
||||
|
||||
for (const [key, rawPropertySchema] of Object.entries(properties)) {
|
||||
if (key === 'type' || key.startsWith('__')) continue;
|
||||
|
||||
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
|
||||
if (this.hasDynamicSchema(propertySchema)) {
|
||||
const dynamicFields = await this.buildDynamicSchemaFields(key, propertySchema, item);
|
||||
fields.push(...dynamicFields.fields);
|
||||
Object.assign(initial, dynamicFields.initial);
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = pathToLabel(key);
|
||||
const currentValue = item[key];
|
||||
const options = await this.loadNodeSettingOptions(propertySchema, item, '');
|
||||
const isObjectLike = propertySchema?.type === 'object';
|
||||
const fieldType =
|
||||
options ? 'select' :
|
||||
propertySchema?.type === 'boolean' ? 'checkbox' :
|
||||
propertySchema?.['x-ui-widget'] === 'textarea' || isObjectLike ? 'textarea' :
|
||||
'text';
|
||||
|
||||
fields.push({
|
||||
key,
|
||||
label,
|
||||
type: fieldType,
|
||||
rows: fieldType === 'textarea' ? 8 : undefined,
|
||||
options,
|
||||
placeholder: typeof propertySchema?.['x-ui-placeholder'] === 'string' ? String(propertySchema['x-ui-placeholder']) : undefined,
|
||||
tip: typeof propertySchema?.['x-ui-tip'] === 'string' ? String(propertySchema['x-ui-tip']) : undefined
|
||||
});
|
||||
|
||||
if (fieldType === 'checkbox') {
|
||||
initial[key] = currentValue === true;
|
||||
} else if (fieldType === 'textarea' && isObjectLike) {
|
||||
initial[key] = JSON.stringify(currentValue ?? {}, null, 2);
|
||||
} else {
|
||||
initial[key] = currentValue == null ? '' : String(currentValue);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
title: `${index == null ? 'Add' : 'Edit'} ${definition.label} item`,
|
||||
fields,
|
||||
initial
|
||||
};
|
||||
}
|
||||
|
||||
private parseArrayItemDialogResult(
|
||||
definition: ArrayFieldDefinition,
|
||||
result: Record<string, string | boolean>,
|
||||
previousItem: Record<string, unknown>
|
||||
) {
|
||||
const itemSchema = definition.itemSchema;
|
||||
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
|
||||
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
|
||||
if (!properties) {
|
||||
try {
|
||||
return JSON.parse(String(result['__raw'] ?? '{}')) as Record<string, unknown>;
|
||||
} catch {
|
||||
return previousItem;
|
||||
}
|
||||
}
|
||||
|
||||
const nextItem: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, rawPropertySchema] of Object.entries(properties)) {
|
||||
if (key === 'type' || key.startsWith('__')) continue;
|
||||
|
||||
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
|
||||
if (this.hasDynamicSchema(propertySchema)) {
|
||||
const dynamicValue = this.extractNestedDialogValues(result, key);
|
||||
nextItem[key] = Object.keys(dynamicValue).length ? dynamicValue : (previousItem[key] ?? {});
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawValue = result[key];
|
||||
const isObjectLike = propertySchema?.type === 'object';
|
||||
|
||||
if (propertySchema?.type === 'boolean') {
|
||||
nextItem[key] = rawValue === true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isObjectLike) {
|
||||
try {
|
||||
nextItem[key] = JSON.parse(String(rawValue ?? '{}'));
|
||||
} catch {
|
||||
nextItem[key] = previousItem[key] ?? {};
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') {
|
||||
const numeric = Number(rawValue ?? 0);
|
||||
nextItem[key] = Number.isFinite(numeric)
|
||||
? (propertySchema.type === 'integer' ? Math.trunc(numeric) : numeric)
|
||||
: 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
nextItem[key] = String(rawValue ?? '');
|
||||
}
|
||||
|
||||
return nextItem;
|
||||
}
|
||||
|
||||
private createEmptyArrayItem(itemSchema: Record<string, any> | null) {
|
||||
const properties = itemSchema?.['properties'] as Record<string, any> | undefined;
|
||||
const schemaRoot = this.blockSchema ?? itemSchema ?? {};
|
||||
if (!properties) return {};
|
||||
|
||||
const item: Record<string, unknown> = {};
|
||||
for (const [key, rawPropertySchema] of Object.entries(properties)) {
|
||||
if (key === 'type' || key.startsWith('__')) continue;
|
||||
const propertySchema = resolveSchemaRef(rawPropertySchema as Record<string, any>, schemaRoot);
|
||||
if (Object.prototype.hasOwnProperty.call(propertySchema ?? {}, 'default')) {
|
||||
item[key] = propertySchema.default;
|
||||
continue;
|
||||
}
|
||||
if (propertySchema?.type === 'boolean') {
|
||||
item[key] = false;
|
||||
} else if (propertySchema?.type === 'number' || propertySchema?.type === 'integer') {
|
||||
item[key] = 0;
|
||||
} else if (propertySchema?.type === 'object' || this.hasDynamicSchema(propertySchema)) {
|
||||
item[key] = {};
|
||||
} else {
|
||||
item[key] = '';
|
||||
}
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
private resolveArrayItemSchema(node: Record<string, any> | null | undefined, root: Record<string, any>) {
|
||||
const items = node?.['items'];
|
||||
if (!items || typeof items !== 'object') return null;
|
||||
const resolved = resolveSchemaRef(items as Record<string, any>, root);
|
||||
return resolved && typeof resolved === 'object' ? resolved as Record<string, any> : null;
|
||||
}
|
||||
|
||||
private hasDynamicSchema(schema: Record<string, any> | null | undefined) {
|
||||
return typeof schema?.['x-ui-schema-url'] === 'string' && String(schema['x-ui-schema-url']).trim().length > 0;
|
||||
}
|
||||
|
||||
private async buildDynamicSchemaFields(
|
||||
baseKey: string,
|
||||
propertySchema: Record<string, any>,
|
||||
item: Record<string, unknown>
|
||||
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
|
||||
const schemaUrl = String(propertySchema['x-ui-schema-url'] ?? '');
|
||||
const dependsOn = Array.isArray(propertySchema['x-ui-schema-depends-on'])
|
||||
? (propertySchema['x-ui-schema-depends-on'] as unknown[]).filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
|
||||
: [];
|
||||
|
||||
const context: Record<string, string> = {};
|
||||
for (const key of dependsOn) {
|
||||
const value = item[key];
|
||||
if (value != null && String(value).trim().length > 0) {
|
||||
context[key] = String(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (dependsOn.some((key) => !context[key])) {
|
||||
return {
|
||||
fields: [{
|
||||
key: `${baseKey}.__hint`,
|
||||
label: pathToLabel(baseKey),
|
||||
type: 'display',
|
||||
readonly: true
|
||||
}],
|
||||
initial: {
|
||||
[`${baseKey}.__hint`]: `Select ${dependsOn.map((key) => pathToLabel(key)).join(', ')} first`
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dynamicSchema = await firstValueFrom(this.fieldRetriever.retrieveSchema(schemaUrl, context));
|
||||
const resolvedSchema = dynamicSchema && typeof dynamicSchema === 'object'
|
||||
? dynamicSchema as Record<string, any>
|
||||
: null;
|
||||
|
||||
if (!resolvedSchema) {
|
||||
return {
|
||||
fields: [{
|
||||
key: `${baseKey}.__hint`,
|
||||
label: pathToLabel(baseKey),
|
||||
type: 'display',
|
||||
readonly: true
|
||||
}],
|
||||
initial: {
|
||||
[`${baseKey}.__hint`]: 'No dynamic schema available'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return this.buildDialogFieldsFromSchema(baseKey, pathToLabel(baseKey), resolvedSchema, item[baseKey]);
|
||||
}
|
||||
|
||||
private async buildDialogFieldsFromSchema(
|
||||
keyPrefix: string,
|
||||
labelPrefix: string,
|
||||
schema: Record<string, any>,
|
||||
currentValue: unknown
|
||||
): Promise<{ fields: NodeSettingField[]; initial: Record<string, string | boolean> }> {
|
||||
const fields: NodeSettingField[] = [];
|
||||
const initial: Record<string, string | boolean> = {};
|
||||
const currentRecord =
|
||||
currentValue && typeof currentValue === 'object' && !Array.isArray(currentValue)
|
||||
? currentValue as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
const walk = async (
|
||||
node: Record<string, any>,
|
||||
pathPrefix: string,
|
||||
titlePrefix: string
|
||||
) => {
|
||||
const resolved = resolveSchemaRef(node, schema);
|
||||
const properties = resolved?.['properties'] as Record<string, any> | undefined;
|
||||
if (!properties) return;
|
||||
|
||||
for (const [childKey, rawChildSchema] of Object.entries(properties)) {
|
||||
if (childKey === 'type' || childKey.startsWith('__')) continue;
|
||||
|
||||
const childSchema = resolveSchemaRef(rawChildSchema as Record<string, any>, schema);
|
||||
const nextPath = `${pathPrefix}.${childKey}`;
|
||||
const nextLabel = `${titlePrefix} ${pathToLabel(childKey)}`;
|
||||
const currentNestedValue = getValueByPath(currentRecord, nextPath.slice(`${keyPrefix}.`.length));
|
||||
|
||||
const hasChildren = !!childSchema?.['properties'] || childSchema?.type === 'object';
|
||||
if (hasChildren) {
|
||||
await walk(childSchema as Record<string, any>, nextPath, nextLabel);
|
||||
continue;
|
||||
}
|
||||
|
||||
const options = await this.loadNodeSettingOptions(
|
||||
childSchema,
|
||||
currentRecord,
|
||||
pathPrefix === keyPrefix ? '' : pathPrefix.slice(`${keyPrefix}.`.length)
|
||||
);
|
||||
|
||||
const fieldType =
|
||||
options ? 'select' :
|
||||
childSchema?.type === 'boolean' ? 'checkbox' :
|
||||
childSchema?.['x-ui-widget'] === 'textarea' ? 'textarea' :
|
||||
'text';
|
||||
|
||||
fields.push({
|
||||
key: nextPath,
|
||||
label: nextLabel,
|
||||
type: fieldType,
|
||||
rows: fieldType === 'textarea' ? 8 : undefined,
|
||||
options,
|
||||
placeholder: typeof childSchema?.['x-ui-placeholder'] === 'string' ? String(childSchema['x-ui-placeholder']) : undefined,
|
||||
tip: typeof childSchema?.['x-ui-tip'] === 'string' ? String(childSchema['x-ui-tip']) : undefined
|
||||
});
|
||||
|
||||
if (fieldType === 'checkbox') {
|
||||
initial[nextPath] = currentNestedValue === true;
|
||||
} else {
|
||||
initial[nextPath] = currentNestedValue == null ? '' : String(currentNestedValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
await walk(schema, keyPrefix, labelPrefix);
|
||||
return { fields, initial };
|
||||
}
|
||||
|
||||
private extractNestedDialogValues(result: Record<string, string | boolean>, keyPrefix: string) {
|
||||
const nested: Record<string, unknown> = {};
|
||||
const prefix = `${keyPrefix}.`;
|
||||
|
||||
for (const [key, value] of Object.entries(result)) {
|
||||
if (!key.startsWith(prefix)) continue;
|
||||
const nestedPath = key.slice(prefix.length);
|
||||
this.setByPath(nested, nestedPath, value);
|
||||
}
|
||||
|
||||
return nested;
|
||||
}
|
||||
|
||||
private async loadNodeSettingOptions(
|
||||
propertySchema: Record<string, any>,
|
||||
item: Record<string, unknown>,
|
||||
pathPrefix: string
|
||||
): Promise<NodeSettingOption[] | undefined> {
|
||||
const retrieverKey = this.toRetrieverKey(propertySchema);
|
||||
const retrieverBlockType = this.toRetrieverBlockType(propertySchema);
|
||||
if (!retrieverKey || !retrieverBlockType) return undefined;
|
||||
|
||||
const retrieverDependsOn = this.toRetrieverDependsOn(propertySchema, pathPrefix);
|
||||
const retrieverContext: Record<string, string> = {};
|
||||
for (const dep of retrieverDependsOn) {
|
||||
const depValue = getValueByPath(item as Record<string, any>, dep.path);
|
||||
retrieverContext[dep.key] = depValue == null ? '' : String(depValue);
|
||||
}
|
||||
|
||||
try {
|
||||
const values = await firstValueFrom(
|
||||
this.fieldRetriever.retrieveValues(
|
||||
retrieverBlockType,
|
||||
retrieverKey,
|
||||
retrieverDependsOn.length ? retrieverContext : undefined,
|
||||
this.toRetrieverUrl(propertySchema)
|
||||
)
|
||||
);
|
||||
return values.map((value) => ({ label: value, value }));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.map((item, index) => ({
|
||||
index,
|
||||
summary: this.toArrayItemSummary(definition, item, index)
|
||||
}));
|
||||
}
|
||||
|
||||
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return `Item ${index + 1}`;
|
||||
}
|
||||
|
||||
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
|
||||
if (!properties) return `Item ${index + 1}`;
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
for (const key of Object.keys(properties)) {
|
||||
const value = (item as Record<string, unknown>)[key];
|
||||
if (this.isMissingValue(value)) continue;
|
||||
if (typeof value === 'string') {
|
||||
summaryParts.push(value);
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
summaryParts.push(String(value));
|
||||
}
|
||||
if (summaryParts.length === 2) break;
|
||||
}
|
||||
|
||||
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
|
||||
}
|
||||
|
||||
private valueToEditorString(value: unknown, type: FieldType): string {
|
||||
if (type === 'boolean') {
|
||||
return value === true ? 'true' : 'false';
|
||||
|
|
@ -844,6 +1390,10 @@ export class GenericNodeComponent {
|
|||
return false;
|
||||
}
|
||||
|
||||
private shouldRenderWideField(label: string, isTextarea: boolean) {
|
||||
return isTextarea || label.trim().length >= 18;
|
||||
}
|
||||
|
||||
private refreshValidationState() {
|
||||
const config = this.blockConfiguration ?? {};
|
||||
const requiredFields = [
|
||||
|
|
@ -898,7 +1448,12 @@ export class GenericNodeComponent {
|
|||
|
||||
try {
|
||||
return await firstValueFrom(
|
||||
this.fieldRetriever.isFieldRequired(retrieverBlockType, field.retrieverKey, context)
|
||||
this.fieldRetriever.isFieldRequired(
|
||||
retrieverBlockType,
|
||||
field.retrieverKey,
|
||||
field.dependsOn.length ? context : undefined,
|
||||
field.retrieverUrl
|
||||
)
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export type ConditionalRequiredField = {
|
|||
label: string;
|
||||
retrieverBlockType: string | null;
|
||||
retrieverKey: string;
|
||||
retrieverUrl: string | null;
|
||||
dependsOn: Array<{ key: string; path: string }>;
|
||||
};
|
||||
|
||||
|
|
@ -87,6 +88,7 @@ function walkSchema(
|
|||
label,
|
||||
retrieverBlockType,
|
||||
retrieverKey,
|
||||
retrieverUrl: retrieverRequiredUrl,
|
||||
dependsOn
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -545,6 +545,10 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.llm-param-chip-wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.llm-param-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -595,3 +599,43 @@
|
|||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.llm-array-sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.llm-array-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dbe7f5;
|
||||
border-radius: 12px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #f8fbff 100%);
|
||||
}
|
||||
|
||||
.llm-array-empty {
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.llm-array-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(239, 246, 255, 0.9);
|
||||
border: 1px solid rgba(191, 219, 254, 0.9);
|
||||
}
|
||||
|
||||
.llm-array-item-summary {
|
||||
min-width: 0;
|
||||
font-size: 12px;
|
||||
color: #0f172a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@
|
|||
<legend class="llm-param-legend">{{ group.legend }}</legend>
|
||||
<div class="llm-param-grid">
|
||||
@for (field of group.fields; track field.path) {
|
||||
<div class="llm-param-chip">
|
||||
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
|
||||
<div class="llm-param-row-head">
|
||||
<span class="llm-param-key">{{ field.label }}</span>
|
||||
</div>
|
||||
|
|
@ -171,7 +171,7 @@
|
|||
@if (parameterFields.length) {
|
||||
<div class="llm-param-grid">
|
||||
@for (field of parameterFields; track field.path) {
|
||||
<div class="llm-param-chip">
|
||||
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide">
|
||||
<div class="llm-param-row-head">
|
||||
<span class="llm-param-key">{{ field.label }}</span>
|
||||
</div>
|
||||
|
|
@ -199,6 +199,27 @@
|
|||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
@if (arrayFields.length) {
|
||||
<div class="llm-array-sections">
|
||||
@for (arrayField of arrayFields; track arrayField.path) {
|
||||
<div class="llm-array-block">
|
||||
<div class="llm-param-row-head">
|
||||
<div class="llm-param-key">{{ arrayField.label }}</div>
|
||||
</div>
|
||||
@if (!arrayField.items.length) {
|
||||
<div class="llm-array-empty">No items</div>
|
||||
} @else {
|
||||
@for (item of arrayField.items; track item.index) {
|
||||
<div class="llm-array-item">
|
||||
<span class="llm-array-item-summary">{{ item.summary }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,24 @@ type DisplayField = {
|
|||
path: string;
|
||||
label: string;
|
||||
value: string;
|
||||
wide: boolean;
|
||||
};
|
||||
|
||||
type ArrayFieldDefinition = {
|
||||
path: string;
|
||||
label: string;
|
||||
itemSchema: Record<string, any> | null;
|
||||
};
|
||||
|
||||
type ArrayFieldItemView = {
|
||||
index: number;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
type ArrayFieldView = {
|
||||
path: string;
|
||||
label: string;
|
||||
items: ArrayFieldItemView[];
|
||||
};
|
||||
|
||||
type DisplayFieldGroup = {
|
||||
|
|
@ -64,6 +82,7 @@ export class TaskStepNodeComponent {
|
|||
inputs: { key: string; socket: ClassicPreset.Socket }[] = [];
|
||||
parameterFields: DisplayField[] = [];
|
||||
parameterFieldGroups: DisplayFieldGroup[] = [];
|
||||
arrayFields: ArrayFieldView[] = [];
|
||||
|
||||
name = 'Step';
|
||||
mainContentFields: MainContentView[] = [];
|
||||
|
|
@ -71,12 +90,15 @@ export class TaskStepNodeComponent {
|
|||
|
||||
private blockSchema: Record<string, any> | null = null;
|
||||
private variablePlaceholderPaths = new Set<string>();
|
||||
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
|
||||
private mainContentPaths = new Set<string>();
|
||||
|
||||
ngOnInit() {
|
||||
this.outputs = [];
|
||||
this.inputs = [];
|
||||
this.parameterFields = [];
|
||||
this.parameterFieldGroups = [];
|
||||
this.arrayFields = [];
|
||||
|
||||
Object.entries(this.data.outputs).forEach(([key, output]) => {
|
||||
this.outputs.push({ key, socket: (output as any).socket });
|
||||
|
|
@ -93,24 +115,38 @@ export class TaskStepNodeComponent {
|
|||
private rebuildDisplayState() {
|
||||
const config = this.blockConfiguration ?? {};
|
||||
this.name = toStringOrNull(config['name']) ?? this.name;
|
||||
const primitiveEntries = flattenPrimitiveValues(config);
|
||||
const arrayFieldPaths = new Set(this.arrayFieldDefinitions.map((definition) => definition.path));
|
||||
const primitiveEntries = flattenPrimitiveValues(config)
|
||||
.filter((entry) => !arrayFieldPaths.has(entry.path));
|
||||
const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path));
|
||||
const richContentPaths = this.pickMainContentEntries(visibleEntries).map((entry) => entry.path);
|
||||
this.mainContentFields = this.pickMainContentEntries(visibleEntries).map((entry) => ({
|
||||
const mainContentEntries = visibleEntries
|
||||
.filter((entry) => this.mainContentPaths.has(entry.path))
|
||||
.filter((entry) => typeof entry.value === 'string' && String(entry.value).trim().length > 0);
|
||||
const richContentPaths = mainContentEntries.map((entry) => entry.path);
|
||||
this.mainContentFields = mainContentEntries.map((entry) => ({
|
||||
path: entry.path,
|
||||
label: pathToLabel(entry.path),
|
||||
parts: this.toMainContentParts(entry.path, String(entry.value))
|
||||
}));
|
||||
this.arrayFields = this.arrayFieldDefinitions
|
||||
.filter((definition) => this.isPathVisible(definition.path))
|
||||
.map((definition) => ({
|
||||
path: definition.path,
|
||||
label: definition.label,
|
||||
items: this.toArrayFieldItems(definition, this.getByPath(config, definition.path))
|
||||
}));
|
||||
|
||||
const grouped = new Map<string, DisplayField[]>();
|
||||
const rootFields: DisplayField[] = [];
|
||||
const orderedFields = visibleEntries
|
||||
.filter((entry) => !['name', 'type'].includes(entry.path))
|
||||
.filter((entry) => !richContentPaths.includes(entry.path))
|
||||
.filter((entry) => !this.isEmptyDisplayValue(entry.value))
|
||||
.map((entry) => ({
|
||||
path: entry.path,
|
||||
label: pathToLabel(entry.path),
|
||||
value: valueToDisplayString(entry.value)
|
||||
value: valueToDisplayString(entry.value),
|
||||
wide: this.shouldRenderWideField(pathToLabel(entry.path), this.mainContentPaths.has(entry.path))
|
||||
}));
|
||||
|
||||
for (const field of orderedFields) {
|
||||
|
|
@ -347,25 +383,6 @@ export class TaskStepNodeComponent {
|
|||
return typeof outputKey === 'string' && outputKey.length > 0 ? outputKey : null;
|
||||
}
|
||||
|
||||
private pickMainContentEntries(entries: Array<{ path: string; value: unknown }>) {
|
||||
const candidates = entries
|
||||
.filter((entry) => !['name', 'type'].includes(entry.path))
|
||||
.filter((entry) => typeof entry.value === 'string')
|
||||
.map((entry) => ({ ...entry, text: String(entry.value).trim() }))
|
||||
.filter((entry) => entry.text.length > 0);
|
||||
|
||||
if (!candidates.length) return [];
|
||||
|
||||
const placeholderCandidates = candidates.filter((entry) =>
|
||||
this.variablePlaceholderPaths.has(entry.path)
|
||||
);
|
||||
const scope = placeholderCandidates.length ? placeholderCandidates : candidates;
|
||||
|
||||
scope.sort((a, b) => a.path.localeCompare(b.path));
|
||||
|
||||
return scope.map((chosen) => ({ path: chosen.path, value: chosen.text }));
|
||||
}
|
||||
|
||||
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
|
||||
const values = this.blockConfiguration?.[key];
|
||||
if (!Array.isArray(values)) return [];
|
||||
|
|
@ -386,6 +403,8 @@ export class TaskStepNodeComponent {
|
|||
const blockType = await this.blocksService.getBlockType(type);
|
||||
this.blockSchema = (blockType?.schema ?? null) as Record<string, any> | null;
|
||||
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
|
||||
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
|
||||
this.mainContentPaths = this.extractMainContentPaths(this.blockSchema);
|
||||
this.rebuildDisplayState();
|
||||
}
|
||||
|
||||
|
|
@ -424,6 +443,143 @@ export class TaskStepNodeComponent {
|
|||
return paths;
|
||||
}
|
||||
|
||||
private extractArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
|
||||
if (!schema) return [];
|
||||
|
||||
const definitions: ArrayFieldDefinition[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
const walk = (node: Record<string, any>, pathPrefix: string) => {
|
||||
const resolved = resolveSchemaRef(node, schema);
|
||||
if (!resolved || typeof resolved !== 'object') return;
|
||||
|
||||
const properties = resolved.properties as Record<string, any> | undefined;
|
||||
if (!properties) return;
|
||||
|
||||
for (const [key, childSchema] of Object.entries(properties)) {
|
||||
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
|
||||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
|
||||
if (childResolved?.type === 'array') {
|
||||
if (key === 'type' || key === 'name' || key.startsWith('__') || seen.has(path)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(path);
|
||||
definitions.push({
|
||||
path,
|
||||
label: pathToLabel(path),
|
||||
itemSchema: this.resolveArrayItemSchema(childResolved, schema)
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
|
||||
if (hasChildren) {
|
||||
walk(childResolved as Record<string, any>, path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(schema, '');
|
||||
return definitions;
|
||||
}
|
||||
|
||||
private extractMainContentPaths(schema: Record<string, any> | null): Set<string> {
|
||||
const paths = new Set<string>();
|
||||
if (!schema) return paths;
|
||||
|
||||
const walk = (node: Record<string, any>, pathPrefix: string) => {
|
||||
const resolved = resolveSchemaRef(node, schema);
|
||||
if (!resolved || typeof resolved !== 'object') return;
|
||||
|
||||
const properties = resolved.properties as Record<string, any> | undefined;
|
||||
if (!properties) return;
|
||||
|
||||
for (const [key, childSchema] of Object.entries(properties)) {
|
||||
const childResolved = resolveSchemaRef(childSchema as Record<string, any>, schema);
|
||||
const path = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
const hasChildren = !!childResolved?.properties || childResolved?.type === 'object';
|
||||
|
||||
if (hasChildren) {
|
||||
walk(childResolved as Record<string, any>, path);
|
||||
continue;
|
||||
}
|
||||
|
||||
const rawWidget = typeof childResolved?.['x-ui-widget'] === 'string'
|
||||
? String(childResolved['x-ui-widget']).toLowerCase().trim()
|
||||
: '';
|
||||
const isTextarea = rawWidget === 'textarea' || rawWidget === 'text-area';
|
||||
const acceptsVariable = childResolved?.['x-ui-accept-variable-as-placeholder'] === true;
|
||||
if (isTextarea && acceptsVariable) {
|
||||
paths.add(path);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
walk(schema, '');
|
||||
return paths;
|
||||
}
|
||||
|
||||
private toArrayFieldItems(definition: ArrayFieldDefinition, value: unknown): ArrayFieldItemView[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value.map((item, index) => ({
|
||||
index,
|
||||
summary: this.toArrayItemSummary(definition, item, index)
|
||||
}));
|
||||
}
|
||||
|
||||
private toArrayItemSummary(definition: ArrayFieldDefinition, item: unknown, index: number) {
|
||||
if (!item || typeof item !== 'object' || Array.isArray(item)) {
|
||||
return `Item ${index + 1}`;
|
||||
}
|
||||
|
||||
const properties = definition.itemSchema?.['properties'] as Record<string, any> | undefined;
|
||||
if (!properties) return `Item ${index + 1}`;
|
||||
|
||||
const summaryParts: string[] = [];
|
||||
for (const key of Object.keys(properties)) {
|
||||
const value = (item as Record<string, unknown>)[key];
|
||||
if (value == null) continue;
|
||||
if (typeof value === 'string' && value.trim().length > 0) {
|
||||
summaryParts.push(value);
|
||||
} else if (typeof value === 'number' || typeof value === 'boolean') {
|
||||
summaryParts.push(String(value));
|
||||
}
|
||||
if (summaryParts.length === 2) break;
|
||||
}
|
||||
|
||||
return summaryParts.length ? summaryParts.join(' · ') : `Item ${index + 1}`;
|
||||
}
|
||||
|
||||
private resolveArrayItemSchema(node: Record<string, any> | null | undefined, root: Record<string, any>) {
|
||||
const items = node?.['items'];
|
||||
if (!items || typeof items !== 'object') return null;
|
||||
const resolved = resolveSchemaRef(items as Record<string, any>, root);
|
||||
return resolved && typeof resolved === 'object' ? resolved as Record<string, any> : null;
|
||||
}
|
||||
|
||||
private getByPath(source: Record<string, any>, path: string): unknown {
|
||||
const keys = path.split('.').filter(Boolean);
|
||||
let current: unknown = source;
|
||||
for (const key of keys) {
|
||||
if (!current || typeof current !== 'object' || Array.isArray(current)) return undefined;
|
||||
current = (current as Record<string, unknown>)[key];
|
||||
}
|
||||
return current;
|
||||
}
|
||||
|
||||
private shouldRenderWideField(label: string, isTextarea: boolean) {
|
||||
return isTextarea || label.trim().length >= 18;
|
||||
}
|
||||
|
||||
private isEmptyDisplayValue(value: unknown) {
|
||||
if (value == null) return true;
|
||||
if (typeof value === 'string') return value.trim().length === 0;
|
||||
if (Array.isArray(value)) return value.length === 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private refreshView() {
|
||||
queueMicrotask(() => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
private taskExecutionsService = inject(TaskExecutionsService);
|
||||
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
private lastExecutionId: string | null = null;
|
||||
private lastExecutionStatus: string | null = null;
|
||||
readonly execution = input<TaskExecution | null>(null);
|
||||
readonly contextAsideOpen = signal(true);
|
||||
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
|
||||
|
|
@ -54,6 +55,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
const executionId = this.execution()?.id ?? null;
|
||||
if (executionId === this.lastExecutionId) return;
|
||||
this.lastExecutionId = executionId;
|
||||
this.lastExecutionStatus = String(this.execution()?.context.status ?? '').toUpperCase() || null;
|
||||
this.pendingAuthorizationValues.set({});
|
||||
this.savingAuthorizations.set({});
|
||||
this.authorizationErrors.set({});
|
||||
|
|
@ -66,6 +68,23 @@ export class TaskExecutionViewerComponent implements OnDestroy {
|
|||
this.activeAsideTab.set('inputs');
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const status = String(this.execution()?.context.status ?? '').toUpperCase();
|
||||
if (!status) return;
|
||||
|
||||
const becameSuccessful =
|
||||
(status === 'SUCCESS' || status === 'COMPLETED') &&
|
||||
this.lastExecutionStatus !== status &&
|
||||
this.lastExecutionStatus !== 'SUCCESS' &&
|
||||
this.lastExecutionStatus !== 'COMPLETED';
|
||||
|
||||
if (becameSuccessful && this.executionOutputTabEnabled()) {
|
||||
this.activeAsideTab.set('output');
|
||||
}
|
||||
|
||||
this.lastExecutionStatus = status;
|
||||
});
|
||||
}
|
||||
|
||||
readonly stepsArray = computed(() =>
|
||||
|
|
|
|||
Loading…
Reference in New Issue