Refine execution viewer and schema-driven node handling

This commit is contained in:
Lucio Lelii 2026-03-24 13:45:32 +01:00
parent 5672ce4336
commit 55418d2226
34 changed files with 2124 additions and 323 deletions

View File

@ -6,6 +6,7 @@ import { MatToolbarModule } from '@angular/material/toolbar';
import { Router, RouterLink, RouterLinkActive, RouterOutlet } from '@angular/router';
import { Authorization } from '@services/authorization/authorization';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
@Component({
selector: 'app-app-layout',
@ -19,6 +20,7 @@ export class AppLayout {
private authService = inject(Authorization);
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
loggedUser = this.authService.loggedInUser;
@ -27,6 +29,9 @@ export class AppLayout {
void this.blocksService.getAllBlocksTypes().catch((err) => {
console.error('Blocks preload failed', err);
});
void this.containersService.getAllContainerTypes().catch((err) => {
console.error('Containers preload failed', err);
});
});
}

View File

@ -245,8 +245,9 @@ export class FlowEditor {
}
private async buildDemoFlowData(): Promise<FlowData> {
const firstBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock'));
const secondBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock'));
const flowId = this.editorState.currentFlow()?.id ?? null;
const firstBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock', { flowId }));
const secondBlock = await firstValueFrom(this.blocksService.createEmptyBlock('LLMBlock', { flowId }));
const left = this.decorateDemoBlock(firstBlock, 'Collect Prompt', { x: 140, y: 180 });
const right = this.decorateDemoBlock(secondBlock, 'Generate Answer', { x: 520, y: 180 });

View File

@ -1,11 +1,15 @@
import { Component, computed, effect, inject, signal } from '@angular/core';
import { MatCardModule } from '@angular/material/card';
import { normalizeExecutionStatus, TaskExecution } from '@models/task-execution';
import { ActivatedRoute, Router } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import {
TaskExecutionListItem,
TasksExecutionsListComponent
} from '@shared/tasks-executions-list/tasks-executions-list';
import { TaskExecutionViewerComponent } from '@shared/task-execution-viewer/task-execution-viewer';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { ConfirmDialogService } from '@services/dialogs/confirm-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@ -18,6 +22,14 @@ import { TaskExecutionsService } from '@services/task-executions/task-executions
export class TasksExecutor {
private taskExecutionsService = inject(TaskExecutionsService);
private confirm = inject(ConfirmDialogService);
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
private route = inject(ActivatedRoute);
private router = inject(Router);
private routeExecutionId = toSignal(
this.route.queryParamMap,
{ initialValue: this.route.snapshot.queryParamMap }
);
readonly executionDetails = this.taskExecutionsService.taskExecutions;
@ -28,23 +40,47 @@ export class TasksExecutor {
flowName: execution.name,
status: normalizeExecutionStatus(execution.context.status),
startedAt: this.formatDateTime(execution.creationTime),
duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null)
duration: this.formatDuration(execution.context.startTime ?? null, execution.context.endTime ?? null),
simulated: execution.interactionSimulationEnabled === true
}))
);
readonly selectedExecutionId = signal<string | null>(null);
readonly requestedExecutionId = signal<string | null>(null);
readonly selectedExecution = computed<TaskExecution | null>(() => {
const selectedId = this.selectedExecutionId();
const details = this.executionDetails();
if (!details.length) return null;
if (!selectedId) return details[0];
return details.find((execution) => execution.id === selectedId) ?? details[0];
return details.find((execution) => execution.id === selectedId) ?? null;
});
constructor() {
void this.blocksService.getAllBlocksTypes().catch((err) => {
console.error('Error preloading block types for task executor', err);
});
void this.containersService.getAllContainerTypes().catch((err) => {
console.error('Error preloading container types for task executor', err);
});
this.taskExecutionsService.init();
effect(() => {
const routeSelectedId = this.routeExecutionId().get('executionId');
this.requestedExecutionId.set(routeSelectedId);
if (routeSelectedId) {
this.selectedExecutionId.set(routeSelectedId);
}
});
effect(() => {
const requestedId = this.requestedExecutionId();
if (!requestedId) return;
const exists = this.executionDetails().some((execution) => execution.id === requestedId);
if (exists) {
this.selectedExecutionId.set(requestedId);
}
});
effect(() => {
if (this.requestedExecutionId()) return;
if (this.selectedExecutionId()) return;
const first = this.executions()[0];
if (first) this.selectedExecutionId.set(first.id);
@ -52,7 +88,14 @@ export class TasksExecutor {
}
selectExecution(id: string) {
this.requestedExecutionId.set(null);
this.selectedExecutionId.set(id);
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { executionId: id },
queryParamsHandling: 'merge',
replaceUrl: true
});
}
async removeExecution(id: string) {

View File

@ -1,15 +1,30 @@
import { FlowBlock, FlowBlockConnection, FlowContainer, FlowNode, FlowPort } from './flow';
import { FlowBlockConnection, FlowNode, FlowPort, LLMDescriptor } from './flow';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUSPENDED' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'PAUSED' | 'FINAL';
export type StepStatus = 'WAITING_FOR_INPUT' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string;
export type ExecutionEventLogEntry = {
id: string;
timestamp: number;
stepId?: string | null;
nodeId?: string | null;
nodeName?: string | null;
level?: string | null;
type?: string | null;
message?: string | null;
details?: unknown;
};
export type TaskExecution = {
id: string;
name: string;
creationTime: number;
context: TaskExecutionContext;
interactionSimulationEnabled?: boolean;
simulationAvailable?: boolean;
interactionSimulationDescriptor?: LLMDescriptor;
stepConnections?: FlowBlockConnection[];
requiredAuthorizations?: Record<string, TaskExecutionAuthorizationRequirement>;
providedAuthorizations?: Record<string, unknown>;
@ -27,6 +42,7 @@ export type TaskExecutionAuthorizationRequirement = {
export type TaskExecutionContext = {
inputs: Record<string, unknown>;
result: Record<string, unknown>;
partialResult?: Record<string, unknown>;
startTime?: number | null;
endTime?: number | null;
errors: Record<string, string>;
@ -34,13 +50,10 @@ export type TaskExecutionContext = {
steps: Record<string, TaskExecutionStep>;
status: TaskExecutionStatus;
waitingSteps: string[];
executionResult: Record<string, unknown>;
};
export type TaskExecutionStep = {
node?: FlowNode;
block?: FlowBlock;
container?: FlowContainer;
id: string;
inputs: TaskExecutionStepInput[];
outputs: TaskExecutionStepOutput[];
@ -105,11 +118,5 @@ export function getTaskExecutionStepNode(step: TaskExecutionStep | null | undefi
? { ...step.node, nodeFamily: 'container' }
: { ...step.node, nodeFamily: 'block' };
}
if (step.container) {
return { ...step.container, nodeFamily: 'container' };
}
if (step.block) {
return { ...step.block, nodeFamily: 'block' };
}
return null;
}

View File

@ -1,12 +1,17 @@
import { BlockType, BlockTypeName, FlowBlock } from "@models/flow";
import { Observable } from "rxjs";
export type BlockDraftContext = {
flowId?: string | null;
replacesBlockId?: string | null;
};
export abstract class BlocksCallServiceBase {
abstract retrieveAllBlocksTypes() : Observable<BlockType[]>;
abstract createEmptyBlock(blockType: BlockTypeName) : Observable<FlowBlock>;
abstract createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) : Observable<FlowBlock>;
abstract updateBlock(blockId : string, configuration : any) : Observable<FlowBlock>;
abstract updateBlock(blockId : string, configuration : any, context?: BlockDraftContext) : Observable<FlowBlock>;
}

View File

@ -1,6 +1,6 @@
import { BlockType, FlowBlock } from "@models/flow";
import { Observable, of } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallServiceFake extends BlocksCallServiceBase {
private readonly blockTypes: BlockType[] =[
@ -147,7 +147,7 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(this.blockTypes);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
override createEmptyBlock(blockType: string, _context?: BlockDraftContext): Observable<FlowBlock> {
const descriptor = this.blockTypes.find((b) => b.type === blockType);
const typeName = descriptor?.type ?? blockType ?? "LLMBlock";
const schema = descriptor?.schema as Record<string, any> | null;
@ -177,22 +177,87 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase {
return of(block);
}
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
override updateBlock(blockId: string, configuration: any, _context?: BlockDraftContext): Observable<FlowBlock> {
const typeName = configuration?.typeName ?? "LLMBlock";
const io = this.defaultIOForBlockType(typeName);
const descriptor = this.blockTypes.find((item) => item.type === typeName);
const specificConfiguration = this.sanitizeConfigurationBySchema(
configuration?.specificConfiguration ?? configuration ?? {},
(descriptor?.schema as Record<string, unknown> | null) ?? null,
(descriptor?.schema as Record<string, unknown> | null) ?? null
);
const block: FlowBlock = {
id: blockId,
name: configuration?.name ?? typeName,
name: specificConfiguration?.['name'] ?? configuration?.name ?? typeName,
position: configuration?.position,
inputs: configuration?.inputs ?? io.inputs,
outputs: configuration?.outputs ?? io.outputs,
specificConfiguration: configuration?.specificConfiguration ?? {},
specificConfiguration,
typeName,
nodeFamily: 'block'
};
return of(block);
}
private sanitizeConfigurationBySchema(
configuration: any,
schemaNode: Record<string, unknown> | null,
schemaRoot: Record<string, unknown> | null
) {
if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) return {};
if (!schemaNode || !schemaRoot) return { ...(configuration as Record<string, unknown>) };
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = resolved && typeof resolved === 'object' && !Array.isArray(resolved)
? resolved as Record<string, unknown>
: {};
const properties = schemaRecord['properties'] && typeof schemaRecord['properties'] === 'object' && !Array.isArray(schemaRecord['properties'])
? schemaRecord['properties'] as Record<string, unknown>
: {};
if (!Object.keys(properties).length) {
return { ...(configuration as Record<string, unknown>) };
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(configuration as Record<string, unknown>)) {
if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
const propertySchema = properties[key] && typeof properties[key] === 'object' && !Array.isArray(properties[key])
? properties[key] as Record<string, unknown>
: null;
sanitized[key] = this.sanitizeSchemaValue(value, propertySchema, schemaRoot);
}
return sanitized;
}
private sanitizeSchemaValue(
value: unknown,
schemaNode: Record<string, unknown> | null,
schemaRoot: Record<string, unknown> | null
): unknown {
if (!schemaNode || !schemaRoot || value == null) return value;
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = resolved && typeof resolved === 'object' && !Array.isArray(resolved)
? resolved as Record<string, unknown>
: {};
const type = schemaRecord['type'];
if ((type === 'object' || schemaRecord['properties']) && value && typeof value === 'object' && !Array.isArray(value)) {
return this.sanitizeConfigurationBySchema(value as Record<string, unknown>, schemaRecord, schemaRoot);
}
if (type === 'array' && Array.isArray(value)) {
const itemSchema = schemaRecord['items'] && typeof schemaRecord['items'] === 'object' && !Array.isArray(schemaRecord['items'])
? schemaRecord['items'] as Record<string, unknown>
: null;
return value.map((item) => this.sanitizeSchemaValue(item, itemSchema, schemaRoot));
}
return value;
}
private defaultIOForBlockType(typeName: string) {
if (typeName === "SourceBlock") {
return {

View File

@ -1,9 +1,9 @@
import { BlockType, FlowBlock } from "@models/flow";
import { HttpClient } from "@angular/common/http";
import { HttpClient, HttpParams } from "@angular/common/http";
import { inject } from "@angular/core";
import { environment } from "@environment";
import { catchError, map, Observable, of, switchMap, take, throwError } from "rxjs";
import { BlocksCallServiceBase } from "./block-call.base";
import { BlockDraftContext, BlocksCallServiceBase } from "./block-call.base";
export class BlocksCallService extends BlocksCallServiceBase {
private readonly http = inject(HttpClient);
@ -21,7 +21,7 @@ export class BlocksCallService extends BlocksCallServiceBase {
);
}
override createEmptyBlock(blockType: string): Observable<FlowBlock> {
override createEmptyBlock(blockType: string, context?: BlockDraftContext): Observable<FlowBlock> {
return this.getBlockTypesForCreate().pipe(
take(1),
switchMap((types) => {
@ -40,41 +40,51 @@ export class BlocksCallService extends BlocksCallServiceBase {
const payload = this.buildBlockConfigurationPayload(blockType, configuration);
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.post<unknown>(`${environment.apiUrl}/blocks`, payload, {
params: this.toDraftContextParams(context)
})
.pipe(map((raw) => this.flowBlockFromApi(raw, descriptor?.type ?? blockType, payload)));
})
);
}
override updateBlock(blockId: string, configuration: any): Observable<FlowBlock> {
override updateBlock(blockId: string, configuration: any, context?: BlockDraftContext): Observable<FlowBlock> {
const blockType = String(configuration?.typeName ?? configuration?.type ?? "LLMBlock");
const payload = this.buildBlockConfigurationPayload(
blockType,
this.toRecord(configuration?.specificConfiguration ?? configuration)
);
return this.getBlockTypesForCreate().pipe(
take(1),
switchMap((types) => {
const descriptor = types.find((type) => type.type === blockType);
const payload = this.buildBlockConfigurationPayload(
blockType,
this.toRecord(configuration?.specificConfiguration ?? configuration),
descriptor?.schema ?? null
);
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload)
.pipe(
map((raw) => {
if (!raw || typeof raw !== "object") {
throw new Error(`Invalid updateBlock response for ${blockType}`);
}
return raw;
}),
map((raw) =>
this.flowBlockFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? blockId
},
blockType,
payload
)
),
catchError((error) => throwError(() => this.toUpdateBlockError(error, blockType)))
);
return this.http
.post<unknown>(`${environment.apiUrl}/blocks`, payload, {
params: this.toDraftContextParams(context, blockId)
})
.pipe(
map((raw) => {
if (!raw || typeof raw !== "object") {
throw new Error(`Invalid updateBlock response for ${blockType}`);
}
return raw;
}),
map((raw) =>
this.flowBlockFromApi(
{
...(this.toRecord(raw)),
id: this.toRecord(raw)["id"] ?? blockId
},
blockType,
payload
)
)
);
}),
catchError((error) => throwError(() => this.toUpdateBlockError(error, blockType)))
);
}
private getBlockTypesForCreate(): Observable<BlockType[]> {
@ -203,8 +213,13 @@ export class BlocksCallService extends BlocksCallServiceBase {
return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`;
}
private buildBlockConfigurationPayload(blockType: string, configuration: Record<string, unknown>) {
const { typeName: _ignoreTypeName, ...sanitized } = configuration;
private buildBlockConfigurationPayload(
blockType: string,
configuration: Record<string, unknown>,
schema?: Record<string, unknown> | null
) {
const { typeName: _ignoreTypeName, ...rawConfiguration } = configuration;
const sanitized = this.sanitizeConfigurationBySchema(rawConfiguration, schema ?? null, schema ?? null);
return {
...sanitized,
name: typeof sanitized["name"] === "string" && sanitized["name"].length > 0
@ -213,6 +228,25 @@ export class BlocksCallService extends BlocksCallServiceBase {
};
}
private toDraftContextParams(context?: BlockDraftContext, blockId?: string) {
let params = new HttpParams();
const flowId = typeof context?.flowId === 'string' && context.flowId.trim().length > 0
? context.flowId.trim()
: null;
const replacesBlockId = typeof context?.replacesBlockId === 'string' && context.replacesBlockId.trim().length > 0
? context.replacesBlockId.trim()
: (typeof blockId === 'string' && blockId.trim().length > 0 ? blockId.trim() : null);
if (flowId) {
params = params.set('flowId', flowId);
}
if (replacesBlockId) {
params = params.set('replacesBlockId', replacesBlockId);
}
return params;
}
private resolveExampleEndpoint(typeName: string, descriptor?: BlockType): string {
if (descriptor?.hasExampleBlock && descriptor.exampleBlockEndpoint) {
return descriptor.exampleBlockEndpoint;
@ -278,6 +312,53 @@ export class BlocksCallService extends BlocksCallServiceBase {
return null;
}
private sanitizeConfigurationBySchema(
configuration: Record<string, unknown>,
schemaNode: Record<string, unknown> | null,
schemaRoot: Record<string, unknown> | null
): Record<string, unknown> {
if (!schemaNode || !schemaRoot) return { ...configuration };
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = this.toRecord(resolved);
const properties = this.toRecord(schemaRecord["properties"]);
if (!Object.keys(properties).length) {
return { ...configuration };
}
const sanitized: Record<string, unknown> = {};
for (const [key, value] of Object.entries(configuration)) {
if (!Object.prototype.hasOwnProperty.call(properties, key)) continue;
const propertySchema = this.toRecord(properties[key]);
sanitized[key] = this.sanitizeSchemaValue(value, propertySchema, schemaRoot);
}
return sanitized;
}
private sanitizeSchemaValue(
value: unknown,
schemaNode: Record<string, unknown> | null,
schemaRoot: Record<string, unknown> | null
): unknown {
if (!schemaNode || !schemaRoot || value == null) return value;
const resolved = this.resolveRef(schemaNode, schemaRoot);
const schemaRecord = this.toRecord(resolved);
const type = schemaRecord["type"];
if ((type === "object" || schemaRecord["properties"]) && value && typeof value === "object" && !Array.isArray(value)) {
return this.sanitizeConfigurationBySchema(this.toRecord(value), schemaRecord, schemaRoot);
}
if (type === "array" && Array.isArray(value)) {
const itemSchema = this.toRecord(schemaRecord["items"]);
return value.map((item) => this.sanitizeSchemaValue(item, itemSchema, schemaRoot));
}
return value;
}
private resolveRef(node: unknown, root: unknown): unknown {
const value = this.toRecord(node);
const ref = value["$ref"];

View File

@ -1,7 +1,7 @@
import { computed, Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { BlockType, BlockTypeName, FlowBlock } from '@models/flow';
import { BlocksCallServiceBase } from './block-call.base';
import { BlockDraftContext, BlocksCallServiceBase } from './block-call.base';
import { catchError, finalize, firstValueFrom, map, Observable, of, shareReplay, throwError } from 'rxjs';
@Injectable({
@ -64,8 +64,9 @@ export class BlocksService {
return blockTypes.find((blockType) => blockType.type === typeName);
}
createEmptyBlock(blockType: BlockTypeName) {
const cacheKey = String(blockType);
createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) {
const flowId = typeof context?.flowId === 'string' && context.flowId.trim().length > 0 ? context.flowId.trim() : '';
const cacheKey = `${String(blockType)}::${flowId}`;
const cached = this.emptyBlockCache.get(cacheKey);
if (cached) {
return of(this.cloneEmptyBlock(cached));
@ -76,7 +77,7 @@ export class BlocksService {
return pending.pipe(map((block) => this.cloneEmptyBlock(block)));
}
const request = this.blocksCallService.createEmptyBlock(blockType).pipe(
const request = this.blocksCallService.createEmptyBlock(blockType, context).pipe(
map((block) => {
this.emptyBlockCache.set(cacheKey, this.cloneEmptyBlock(block));
return block;
@ -98,9 +99,9 @@ export class BlocksService {
);
}
updateBlock(blockId: string, configuration: any) {
updateBlock(blockId: string, configuration: any, context?: BlockDraftContext) {
this.pendingServerSyncCount.update((count) => count + 1);
return this.blocksCallService.updateBlock(blockId, configuration).pipe(
return this.blocksCallService.updateBlock(blockId, configuration, context).pipe(
finalize(() => {
this.pendingServerSyncCount.update((count) => Math.max(0, count - 1));
}),

View File

@ -58,6 +58,39 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
}
];
private readonly sharedExecutionVariableItems = [
{
descriptor: {
label: 'Primary MCP Session',
description: 'Shared by MCPAgent Producer',
meta: {
kind: 'MCP_SESSION',
producerBlockId: 'mcp-producer-1',
producerBlockName: 'MCPAgent Producer'
}
},
data: 'primary-mcp-session',
structuredData: true,
valid: true,
validationErrors: []
},
{
descriptor: {
label: 'Support MCP Session',
description: 'Shared by MCPAgentChat Support',
meta: {
kind: 'MCP_SESSION',
producerBlockId: 'mcp-chat-1',
producerBlockName: 'MCPAgentChat Support'
}
},
data: 'support-mcp-session',
structuredData: true,
valid: true,
validationErrors: []
}
];
override retrieveValues(
blockType: string,
key: string,
@ -79,12 +112,21 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
override retrieveItems<T = unknown>(
_blockType: string,
key: string,
_context?: Record<string, string>,
_retrieverUrl?: string | null
context?: Record<string, string>,
retrieverUrl?: string | null
): Observable<RetrieverStructuredItem<T>[]> {
if (key === 'subFlow') {
return of(this.subFlowItems as unknown as RetrieverStructuredItem<T>[]);
}
if (
key === 'shared'
|| retrieverUrl?.includes('/ExecutionVariables/shared/items')
) {
const kind = context?.['kind'] ?? this.readQueryParam(retrieverUrl, 'kind');
if (kind === 'MCP_SESSION') {
return of(this.sharedExecutionVariableItems as unknown as RetrieverStructuredItem<T>[]);
}
}
return of([]);
}
@ -128,4 +170,13 @@ export class FieldRetrieverCallServiceFake extends FieldRetrieverCallServiceBase
}
});
}
private readQueryParam(url: string | null | undefined, key: string): string | null {
if (typeof url !== 'string' || url.trim().length === 0) return null;
const queryString = url.split('?', 2)[1];
if (!queryString) return null;
const params = new URLSearchParams(queryString);
const value = params.get(key);
return value && value.trim().length > 0 ? value.trim() : null;
}
}

View File

@ -118,7 +118,7 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
private normalizeStringList(raw: unknown): string[] {
if (Array.isArray(raw)) {
return raw.filter((item): item is string => typeof item === 'string');
return this.normalizeStringArray(raw);
}
if (!raw || typeof raw !== 'object') {
@ -137,7 +137,7 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
return [];
}
return candidate.filter((item): item is string => typeof item === 'string');
return this.normalizeStringArray(candidate);
}
private normalizeStructuredItems<T>(raw: unknown): RetrieverStructuredItem<T>[] {
@ -176,6 +176,22 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
return Array.isArray(candidate) ? candidate : [];
}
private normalizeStringArray(candidate: unknown[]): string[] {
return candidate
.map((item) => {
if (typeof item === 'string') return item;
if (!item || typeof item !== 'object' || Array.isArray(item)) return null;
const record = item as Record<string, unknown>;
if (typeof record['data'] === 'string') {
return record['data'];
}
return null;
})
.filter((item): item is string => typeof item === 'string' && item.length > 0);
}
private toRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? value as Record<string, unknown>

View File

@ -1,11 +1,14 @@
import { TaskExecution } from '@models/task-execution';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
import { Observable } from 'rxjs';
export abstract class TaskExecutionsCallServiceBase {
abstract retrieveAllTaskExecutions(): Observable<TaskExecution[]>;
abstract retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]>;
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution>;
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;
abstract resumeTaskExecution(executionId: string): Observable<TaskExecution>;
abstract prepareStringInput(

View File

@ -1,4 +1,5 @@
import { TaskExecution } from '@models/task-execution';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
import { Observable, of } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -34,7 +35,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
},
steps: {
'b2540579-ca7b-4beb-8ed3-65136e7f03d6': {
block: {
node: {
id: 'b2540579-ca7b-4beb-8ed3-65136e7f03d6',
position: { x: 120, y: 160 },
name: 'first',
@ -68,7 +69,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
simulated: false
},
'5ceb9b7b-88a0-41bb-afef-76fcb1f57918': {
block: {
node: {
id: '5ceb9b7b-88a0-41bb-afef-76fcb1f57918',
position: { x: 500, y: 160 },
name: 'second',
@ -104,7 +105,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
},
status: 'ERROR',
waitingSteps: [],
executionResult: {}
}
},
{
@ -128,7 +128,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
warnings: {},
steps: {
'95ebb03f-80e0-412d-87ee-2d4b7ddef240': {
block: {
node: {
id: '95ebb03f-80e0-412d-87ee-2d4b7ddef240',
position: { x: 120, y: 140 },
name: 'first',
@ -173,7 +173,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
simulated: false
},
'68c5949c-1c74-400e-a1aa-b5f7739e5bb2': {
block: {
node: {
id: '68c5949c-1c74-400e-a1aa-b5f7739e5bb2',
position: { x: 500, y: 140 },
name: 'interactive',
@ -221,10 +221,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
},
status: 'SUCCESS',
waitingSteps: [],
executionResult: {
'68c5949c-1c74-400e-a1aa-b5f7739e5bb2:output':
'Based on the provided context, Marie Curie is a remarkable scientist and explorer who made significant contributions to the field of science and exploration. She is often considered a pioneer in the field of radioactivity.\n'
}
}
},
{
@ -245,7 +241,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
warnings: {},
steps: {
'ab7e0b08-c653-4d11-b808-e0e51c89d989': {
block: {
node: {
id: 'ab7e0b08-c653-4d11-b808-e0e51c89d989',
position: { x: 500, y: 140 },
name: 'interactive',
@ -290,7 +286,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
simulated: false
},
'f91ec0f7-03e8-4208-89ac-bd9db46dca8c': {
block: {
node: {
id: 'f91ec0f7-03e8-4208-89ac-bd9db46dca8c',
position: { x: 120, y: 140 },
name: 'first',
@ -337,7 +333,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
},
status: 'READY',
waitingSteps: [],
executionResult: {}
}
},
{
@ -355,7 +350,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
warnings: {},
steps: {
'82844256-d9c1-4f81-a415-49b18c371a13': {
block: {
node: {
id: '82844256-d9c1-4f81-a415-49b18c371a13',
position: { x: 500, y: 140 },
name: 'interactive',
@ -401,7 +396,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
simulated: false
},
'f80bce81-f1e4-4e03-9982-d35a042b1276': {
block: {
node: {
id: 'f80bce81-f1e4-4e03-9982-d35a042b1276',
position: { x: 120, y: 140 },
name: 'first',
@ -448,13 +443,17 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
},
status: 'WAITING',
waitingSteps: ['82844256-d9c1-4f81-a415-49b18c371a13'],
executionResult: {}
}
}
];
override retrieveAllTaskExecutions(): Observable<TaskExecution[]> {
return of(this.data);
return of(this.data.map((execution) => this.withSimulationAvailability(execution)));
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
const execution = this.findExecution(executionId);
return of(this.buildExecutionEvents(execution));
}
override createTaskExecution(flowId: string): Observable<TaskExecution> {
@ -462,6 +461,7 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
id: crypto.randomUUID(),
name: flowId || 'Execution',
creationTime: Date.now(),
simulationAvailable: false,
context: {
inputs: {},
result: {},
@ -472,11 +472,10 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
steps: {},
status: 'CREATED',
waitingSteps: [],
executionResult: {}
}
};
this.data.unshift(execution);
return of(execution);
return of(this.withSimulationAvailability(execution));
}
override deleteTaskExecution(executionId: string): Observable<void> {
@ -489,19 +488,48 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
override startTaskExecution(executionId: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.interactionSimulationEnabled = false;
execution.context.status = 'RUNNING';
execution.context.startTime = execution.context.startTime ?? Date.now();
return of(execution);
}
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
if (execution.simulationAvailable !== true) {
throw new Error('Simulation is not available for this execution.');
}
if (!simulator?.provider?.trim() || !simulator?.model?.trim()) {
throw new Error('A simulator descriptor is required to start simulation.');
}
execution.interactionSimulationEnabled = true;
execution.interactionSimulationDescriptor = {
provider: simulator.provider.trim(),
model: simulator.model.trim()
};
execution.context.startTime = execution.context.startTime ?? Date.now();
execution.context.endTime = Date.now();
execution.context.status = 'SUCCESS';
for (const step of Object.values(execution.context.steps ?? {})) {
const status = String(step.status ?? '').toUpperCase();
if (status === 'WAITING_FOR_INPUT' || status === 'WAITING_FOR_INTERACTION' || status === 'WAITING' || status === 'RUNNING') {
step.status = 'COMPLETED';
step.simulated = true;
}
}
execution.context.waitingSteps = [];
return of(this.withSimulationAvailability(execution));
}
override cancelTaskExecution(executionId: string): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.status = 'CANCELLED';
execution.context.endTime = Date.now();
execution.context.inputs = {};
execution.context.result = {};
execution.context.executionResult = {};
(execution.context as Record<string, unknown>)['partialResult'] = {};
execution.context.partialResult = {};
execution.context.errors = {};
execution.context.warnings = {};
execution.context.waitingSteps = [];
@ -589,7 +617,6 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
value: string
): Observable<TaskExecution> {
const execution = this.findExecution(executionId);
execution.context.executionResult[`${nodeId}:${fieldName}`] = value;
execution.context.result[`${nodeId}:${fieldName}`] = value;
execution.context.waitingSteps = execution.context.waitingSteps.filter((stepId) => stepId !== nodeId);
@ -623,4 +650,117 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
}
return execution;
}
private withSimulationAvailability(execution: TaskExecution): TaskExecution {
execution.simulationAvailable = Object.values(execution.context.steps ?? {}).some((step) => {
const typeName = String(step.node?.typeName ?? '').trim();
return typeName === 'HumanInteractionBlock' || typeName === 'ChatInteraction' || typeName === 'MCPAgentChat';
});
return execution;
}
private buildExecutionEvents(execution: TaskExecution): ExecutionEventLogEntry[] {
const events: ExecutionEventLogEntry[] = [
{
id: `${execution.id}:created`,
timestamp: execution.creationTime,
level: 'INFO',
type: 'EXECUTION_CREATED',
message: 'Execution created'
}
];
if (execution.context.startTime) {
events.push({
id: `${execution.id}:started`,
timestamp: execution.context.startTime,
level: 'INFO',
type: execution.interactionSimulationEnabled ? 'EXECUTION_STARTED' : 'EXECUTION_STARTED',
message: execution.interactionSimulationEnabled
? 'Execution started in simulation mode'
: 'Execution started'
});
}
for (const step of Object.values(execution.context.steps ?? {})) {
const nodeName = step.node?.name ?? step.id;
const normalizedStatus = String(step.status ?? '').toUpperCase();
events.push({
id: `${execution.id}:${step.id}:step`,
timestamp: execution.context.startTime ?? execution.creationTime,
stepId: step.id,
nodeId: step.id,
nodeName,
level: normalizedStatus === 'FAILED' ? 'ERROR' : normalizedStatus.includes('WAITING') ? 'WARN' : 'INFO',
type: this.toStepEventType(normalizedStatus),
message: this.toStepEventMessage(nodeName, normalizedStatus)
});
}
const executionStatus = String(execution.context.status ?? '').toUpperCase();
if (executionStatus === 'WAITING') {
events.push({
id: `${execution.id}:waiting`,
timestamp: execution.context.startTime ?? execution.creationTime,
level: 'WARN',
type: 'EXECUTION_WAITING',
message: 'Execution waiting for interaction'
});
}
if (executionStatus === 'SUSPENDED') {
events.push({
id: `${execution.id}:suspended`,
timestamp: execution.context.startTime ?? execution.creationTime,
level: 'WARN',
type: 'EXECUTION_WAITING',
message: 'Execution suspended after restart'
});
}
if (executionStatus === 'SUCCESS') {
events.push({
id: `${execution.id}:completed`,
timestamp: execution.context.endTime ?? execution.context.startTime ?? execution.creationTime,
level: 'INFO',
type: 'EXECUTION_COMPLETED',
message: 'Execution completed'
});
}
if (executionStatus === 'ERROR') {
events.push({
id: `${execution.id}:failed`,
timestamp: execution.context.endTime ?? execution.context.startTime ?? execution.creationTime,
level: 'ERROR',
type: 'EXECUTION_FAILED',
message: 'Execution failed'
});
}
if (executionStatus === 'CANCELLED') {
events.push({
id: `${execution.id}:cancelled`,
timestamp: execution.context.endTime ?? execution.context.startTime ?? execution.creationTime,
level: 'WARN',
type: 'EXECUTION_CANCELLED',
message: 'Execution cancelled'
});
}
return [...events].sort((a, b) => a.timestamp - b.timestamp);
}
private toStepEventType(status: string): string {
if (status === 'FAILED') return 'STEP_FAILED';
if (status === 'COMPLETED') return 'STEP_COMPLETED';
if (status.includes('WAITING')) return 'STEP_WAITING_FOR_INTERACTION';
if (status === 'RUNNING') return 'STEP_STARTED';
return 'STEP_STARTED';
}
private toStepEventMessage(nodeName: string, status: string): string {
if (status === 'FAILED') return `Step ${nodeName} failed`;
if (status === 'COMPLETED') return `Completed step ${nodeName}`;
if (status.includes('WAITING')) return `Waiting for interaction on ${nodeName}`;
if (status === 'RUNNING') return `Started step ${nodeName}`;
return `Started step ${nodeName}`;
}
}

View File

@ -1,7 +1,8 @@
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import { environment } from '@environment';
import { TaskExecution } from '@models/task-execution';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, TaskExecution } from '@models/task-execution';
import { Observable } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -12,6 +13,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.get<TaskExecution[]>(`${environment.apiUrl}/executions`);
}
override retrieveExecutionEvents(executionId: string): Observable<ExecutionEventLogEntry[]> {
return this.http.get<ExecutionEventLogEntry[]>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`);
}
override createTaskExecution(flowId: string): Observable<TaskExecution> {
return this.http.post<TaskExecution>(`${environment.apiUrl}/executions`, flowId);
}
@ -24,6 +29,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null);
}
override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator });
}
override cancelTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null);
}

View File

@ -1,6 +1,7 @@
import { Injectable, signal } from '@angular/core';
import { environment } from '@environment';
import { getExecutionStatusGroup, TaskExecution } from '@models/task-execution';
import { LLMDescriptor } from '@models/flow';
import { ExecutionEventLogEntry, getExecutionStatusGroup, TaskExecution } from '@models/task-execution';
import { catchError, finalize, tap, throwError } from 'rxjs';
import { TaskExecutionsCallServiceBase } from './task-executions-call.base';
@ -37,6 +38,15 @@ export class TaskExecutionsService {
});
}
retrieveExecutionEvents(executionId: string) {
return this.taskExecutionsCallService.retrieveExecutionEvents(executionId).pipe(
catchError((err) => {
console.error('Retrieve execution events failed', err);
return throwError(() => err);
})
);
}
createExecution(flowId: string) {
return this.taskExecutionsCallService.createTaskExecution(flowId).pipe(
tap(() => this.refresh()),
@ -67,6 +77,24 @@ export class TaskExecutionsService {
);
}
simulateExecution(executionId: string, simulator: LLMDescriptor) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.simulationAvailable !== true) {
return throwError(() => new Error('Simulation is not available for this execution.'));
}
if (!simulator?.provider?.trim() || !simulator?.model?.trim()) {
return throwError(() => new Error('A simulator descriptor is required to start simulation.'));
}
return this.taskExecutionsCallService.simulateTaskExecution(executionId, simulator).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Simulate execution failed', err);
return throwError(() => err);
})
);
}
cancelExecution(executionId: string) {
return this.taskExecutionsCallService.cancelTaskExecution(executionId).pipe(
tap(() => this.refresh()),
@ -134,6 +162,11 @@ export class TaskExecutionsService {
}
submitInteractionText(executionId: string, nodeId: string, fieldName: string, value: string) {
const execution = this._taskExecutions().find((item) => item.id === executionId);
if (execution?.interactionSimulationEnabled === true) {
return throwError(() => new Error('Manual interaction is disabled for simulated executions.'));
}
return this.taskExecutionsCallService.submitInteractionText(executionId, nodeId, fieldName, value).pipe(
tap(() => this.refresh()),
catchError((err) => {

View File

@ -101,9 +101,14 @@ export class NodeSettingsDialogHostComponent {
this.fields = next.fields;
const rebuiltDraft = this.buildDraft(next.fields, next.initial ?? {});
for (const field of next.fields) {
if (Object.prototype.hasOwnProperty.call(currentDraft, field.key)) {
rebuiltDraft[field.key] = currentDraft[field.key];
if (!Object.prototype.hasOwnProperty.call(currentDraft, field.key)) continue;
const currentValue = currentDraft[field.key];
if (field.type === 'select' && Array.isArray(field.options) && field.options.length > 0) {
const currentStringValue = typeof currentValue === 'string' ? currentValue : String(currentValue ?? '');
const optionStillAvailable = field.options.some((option) => option.value === currentStringValue);
if (!optionStillAvailable) continue;
}
rebuiltDraft[field.key] = currentValue;
}
this.draft = rebuiltDraft;
}

View File

@ -20,6 +20,87 @@
backdrop-filter: grayscale(1) saturate(0.2);
}
.container-node__params-shell {
position: relative;
min-height: 110px;
overflow: hidden;
border-radius: 0 0 18px 18px;
}
.container-node__skeleton-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: 14px;
border-radius: 0 0 18px 18px;
background: rgba(248, 250, 252, 0.82);
backdrop-filter: blur(1px);
}
.container-node__skeleton-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.container-node__skeleton-line,
.container-node__skeleton-pill {
position: relative;
overflow: hidden;
background: #dbe4ee;
}
.container-node__skeleton-line::after,
.container-node__skeleton-pill::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.72), transparent);
animation: container-node__skeleton-shimmer 1.1s ease-in-out infinite;
}
.container-node__skeleton-line {
height: 12px;
border-radius: 999px;
}
.container-node__skeleton-line-title {
width: 62%;
margin-top: 6px;
}
.container-node__skeleton-line-short {
width: 38%;
}
.container-node__skeleton-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.container-node__skeleton-pill {
height: 54px;
border-radius: 12px;
}
.container-node__skeleton-pill-wide {
grid-column: 1 / -1;
height: 76px;
}
@keyframes container-node__skeleton-shimmer {
to {
transform: translateX(100%);
}
}
:host.selected .container-node {
border-color: #0f766e;
box-shadow: 0 0 0 3px rgba(15, 118, 110, 0.18), 0 16px 36px rgba(15, 23, 42, 0.18);
@ -296,6 +377,42 @@
letter-spacing: 0.03em;
}
.container-node__bool-toggle {
position: relative;
width: 34px;
height: 20px;
border: 1px solid #99f6e4;
border-radius: 999px;
background: #ccfbf1;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.container-node__bool-toggle--on {
background: #0f766e;
border-color: #0f766e;
}
.container-node__bool-toggle:disabled {
opacity: 0.45;
cursor: default;
}
.container-node__bool-toggle-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
transition: transform 0.15s ease;
}
.container-node__bool-toggle--on .container-node__bool-toggle-thumb {
transform: translateX(14px);
}
.container-node__param-value {
display: block;
min-width: 0;

View File

@ -97,13 +97,38 @@
</div>
</div>
@if (hasParameterFields()) {
<div class="container-node__params">
@if (hasParameterFields() || hasMainContent()) {
<div class="container-node__params container-node__params-shell">
@if (!schemaReady) {
<div class="container-node__skeleton-overlay" aria-hidden="true">
<div class="container-node__skeleton-stack">
<div class="container-node__skeleton-line container-node__skeleton-line-title"></div>
<div class="container-node__skeleton-line container-node__skeleton-line-short"></div>
<div class="container-node__skeleton-grid">
<div class="container-node__skeleton-pill"></div>
<div class="container-node__skeleton-pill"></div>
<div class="container-node__skeleton-pill container-node__skeleton-pill-wide"></div>
</div>
</div>
</div>
}
@for (field of parameterFields; track field.path) {
<div class="container-node__param-chip" [class.container-node__param-chip--wide]="field.wide" [class.container-node__param-chip--disabled]="!field.enabled">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ field.label }}</span>
@if (!isReadonly) {
@if (field.type === 'boolean' && !isReadonly) {
<button
type="button"
class="container-node__bool-toggle"
[class.container-node__bool-toggle--on]="field.booleanValue"
[disabled]="!field.enabled"
[attr.aria-pressed]="field.booleanValue"
title="Toggle field"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="container-node__bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="container-node__param-edit"
@ -115,7 +140,23 @@
</button>
}
</div>
<span class="container-node__param-value">{{ field.value }}</span>
<span class="container-node__param-value">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
@for (contentField of richContentFields; track contentField.path) {
<div class="container-node__param-chip container-node__param-chip--wide">
<div class="container-node__param-head">
<span class="container-node__param-key">{{ contentField.label }}</span>
</div>
<div class="container-node__param-value">
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>
} @else {
<span>{{ part.text }}</span>
}
}
</div>
</div>
}
</div>

View File

@ -11,7 +11,7 @@ import { EditorStateHolder } from '@stores/flow-editor';
import { CONTAINER_SUBFLOW_DRAG_MIME } from './container-node-drag';
import { firstValueFrom } from 'rxjs';
import { extractSchemaRequirements, SchemaRequirements } from '../schema-requirements';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, readUiConditionRule, resolveSchemaPath, resolveSchemaRef, schemaFieldLabel, shouldSkipSchemaField, valueToDisplayString } from '../node-utility';
import { evaluateUiConditionRule, getValueByPath, parentPath, pathToLabel, readEffectiveUiVisibleConditionRule, readUiConditionRule, resolveSchemaPath, resolveSchemaRef, schemaFieldLabel, shouldSkipSchemaField, splitTemplatedTextParts, valueToDisplayString } from '../node-utility';
type ContainerFieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown';
@ -38,6 +38,14 @@ type ContainerFieldView = {
value: string;
wide: boolean;
enabled: boolean;
type: ContainerFieldType;
booleanValue: boolean;
};
type RichContentView = {
path: string;
label: string;
parts: { text: string; isDynamicInput: boolean }[];
};
type StructuredRetrieverConfig = {
@ -72,6 +80,8 @@ export class ContainerNodeComponent {
importLoading = false;
private importErrorMessage: string | null = null;
parameterFields: ContainerFieldView[] = [];
richContentFields: RichContentView[] = [];
schemaReady = false;
@Input() data!: any;
@Input() emit!: (data: any) => void;
@ -197,6 +207,15 @@ export class ContainerNodeComponent {
return this.parameterFields.length > 0;
}
hasMainContent() {
return this.richContentFields.length > 0;
}
formatDynamicInputToken(token: string): string {
const match = token.match(/^\$\{\{\s*([^}]+?)\s*\}\}$/);
return match ? match[1] : token;
}
inputDisplayLabel(inputKey: string) {
return this.resolvePortName('input', inputKey);
}
@ -330,6 +349,18 @@ export class ContainerNodeComponent {
await this.applyFieldValue(definition, result[definition.path]);
}
async toggleBooleanParameter(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.containerFieldDefinitions.find((field) => field.path === path);
if (!definition || definition.type !== 'boolean' || !this.isFieldEnabled(definition.path)) return;
const currentValue = getValueByPath(this.configuration ?? {}, definition.path);
await this.applyFieldValue(definition, currentValue !== true);
}
onDropZoneDragOver(event: DragEvent) {
if (this.isReadonly) return;
if (!this.canAcceptSelectionDrop()) return;
@ -537,18 +568,22 @@ export class ContainerNodeComponent {
}
private async loadSchemaContext() {
const containerType = await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while schema was loading.
}
});
try {
const containerType = await this.containersService.getContainerType(this.typeName);
this.containerSchema = (containerType?.schema ?? null) as Record<string, any> | null;
this.schemaRequirements = extractSchemaRequirements(this.containerSchema);
this.containerFieldDefinitions = this.buildContainerFieldDefinitions(this.containerSchema);
this.refreshParameterFields();
} finally {
this.schemaReady = true;
queueMicrotask(() => {
try {
this.cdr.detectChanges();
} catch {
// Node may have been removed while schema was loading.
}
});
}
}
private isMissingValue(value: unknown): boolean {
@ -564,14 +599,27 @@ export class ContainerNodeComponent {
private refreshParameterFields() {
const config = this.configuration ?? {};
const richContentPaths = new Set(this.richContentPaths());
this.richContentFields = this.richContentPaths()
.filter((path) => this.isFieldVisible(path))
.map((path) => ({
path,
label: this.containerFieldDefinitions.find((field) => field.path === path)?.label ?? pathToLabel(path),
parts: this.toRichContentParts(path)
}))
.filter((field) => field.parts.length > 0);
this.parameterFields = this.containerFieldDefinitions
.filter((field) => this.isFieldVisible(field.path))
.filter((field) => !richContentPaths.has(field.path))
.map((field) => ({
path: field.path,
label: field.label,
value: valueToDisplayString(getValueByPath(config, field.path)),
wide: field.widget === 'textarea' || field.label.length >= 18,
enabled: this.isFieldEnabled(field.path)
enabled: this.isFieldEnabled(field.path),
type: field.type,
booleanValue: getValueByPath(config, field.path) === true
}));
}
@ -619,17 +667,32 @@ export class ContainerNodeComponent {
return definitions;
}
private richContentPaths(): string[] {
return this.containerFieldDefinitions
.filter((field) => field.widget === 'textarea')
.map((field) => field.path);
}
private toRichContentParts(path: string): { text: string; isDynamicInput: boolean }[] {
const content = String(getValueByPath(this.configuration ?? {}, path) ?? '').trim();
if (!content) return [];
const schema = this.resolveFieldSchema(path);
if (schema?.['x-ui-accept-variable-as-placeholder'] === true) {
return splitTemplatedTextParts(content);
}
return [{ text: content, isDynamicInput: false }];
}
private isFieldVisible(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
const schema = this.resolveFieldSchema(path);
const rule = readUiConditionRule(schema?.['x-ui-visible-when']);
const rule = readEffectiveUiVisibleConditionRule(schema);
if (!rule) return true;
const parent = typeof rule.field === 'string' ? rule.field : null;
if (parent && !this.isFieldVisible(parent, visited)) return false;
return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath));
}
@ -644,8 +707,6 @@ export class ContainerNodeComponent {
const rules = [readUiConditionRule(schema?.['x-ui-enabled-when'])].filter((rule) => !!rule);
return rules.every((rule) => {
if (!rule) return true;
const dependency = typeof rule.field === 'string' ? rule.field : null;
if (dependency && !this.isFieldVisible(dependency, visited)) return false;
return evaluateUiConditionRule(rule, this.configuration ?? {}, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}

View File

@ -56,6 +56,87 @@
box-shadow: 0 8px 20px rgba(37, 99, 235, 0.16);
}
.llm-params-shell {
position: relative;
min-height: 110px;
overflow: hidden;
border-radius: 0 0 16px 16px;
}
.llm-skeleton-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: 14px;
border-radius: 16px;
background: rgba(248, 250, 252, 0.82);
backdrop-filter: blur(1px);
}
.llm-skeleton-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.llm-skeleton-line,
.llm-skeleton-pill {
position: relative;
overflow: hidden;
background: #e2e8f0;
}
.llm-skeleton-line::after,
.llm-skeleton-pill::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent);
animation: llm-skeleton-shimmer 1.1s ease-in-out infinite;
}
.llm-skeleton-line {
height: 12px;
border-radius: 999px;
}
.llm-skeleton-line-title {
width: 62%;
margin-top: 6px;
}
.llm-skeleton-line-short {
width: 38%;
}
.llm-skeleton-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.llm-skeleton-pill {
height: 54px;
border-radius: 12px;
}
.llm-skeleton-pill-wide {
grid-column: 1 / -1;
height: 76px;
}
@keyframes llm-skeleton-shimmer {
to {
transform: translateX(100%);
}
}
.llm-spinner {
width: 14px;
height: 14px;
@ -764,6 +845,42 @@
pointer-events: none;
}
.llm-bool-toggle {
position: relative;
width: 34px;
height: 20px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #dbeafe;
transition: background-color 0.15s ease, border-color 0.15s ease;
}
.llm-bool-toggle-on {
background: #2563eb;
border-color: #2563eb;
}
.llm-bool-toggle:disabled {
opacity: 0.45;
cursor: default;
}
.llm-bool-toggle-thumb {
position: absolute;
top: 1px;
left: 1px;
width: 16px;
height: 16px;
border-radius: 999px;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
transition: transform 0.15s ease;
}
.llm-bool-toggle-on .llm-bool-toggle-thumb {
transform: translateX(14px);
}
.llm-param-block {
border: 1px solid #dbe2ea;
background: #ffffff;

View File

@ -155,7 +155,20 @@
</div>
</div>
<div class="llm-params">
<div class="llm-params llm-params-shell">
@if (!schemaReady) {
<div class="llm-skeleton-overlay" aria-hidden="true">
<div class="llm-skeleton-stack">
<div class="llm-skeleton-line llm-skeleton-line-title"></div>
<div class="llm-skeleton-line llm-skeleton-line-short"></div>
<div class="llm-skeleton-grid">
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill llm-skeleton-pill-wide"></div>
</div>
</div>
</div>
}
@if (parameterFieldGroups.length) {
<div class="llm-param-groups">
@for (group of parameterFieldGroups; track group.key) {
@ -166,7 +179,19 @@
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
@if (field.type === 'boolean' && !isReadonly) {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="!field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -178,7 +203,7 @@
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
<span class="llm-param-value">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
</div>
@ -193,7 +218,19 @@
<div class="llm-param-chip" [class.llm-param-chip-wide]="field.wide" [class.llm-param-chip-disabled]="!field.enabled">
<div class="llm-param-row-head">
<span class="llm-param-key">{{ field.label }}</span>
@if (!isReadonly) {
@if (field.type === 'boolean' && !isReadonly) {
<button
type="button"
class="llm-bool-toggle"
[class.llm-bool-toggle-on]="field.booleanValue"
[disabled]="!field.enabled"
[attr.aria-pressed]="field.booleanValue"
[attr.title]="'Toggle ' + field.label.toLowerCase()"
(pointerdown)="$event.stopPropagation()"
(click)="toggleBooleanParameter(field.path, $event)">
<span class="llm-bool-toggle-thumb"></span>
</button>
} @else if (!isReadonly) {
<button
type="button"
class="llm-edit-btn"
@ -205,7 +242,7 @@
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
<span class="llm-param-value">{{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}</span>
</div>
}
</div>
@ -339,7 +376,7 @@
</button>
} @else if (localEditorWidget === 'textarea') {
<textarea
rows="6"
[rows]="localEditorRows ?? 6"
[(ngModel)]="localEditorValue"
[attr.maxlength]="localEditorMaxLength"
[placeholder]="localEditorMaxLength ? 'Max ' + localEditorMaxLength + ' characters' : 'Value...'"

View File

@ -23,6 +23,7 @@ import {
parentPath,
pathToLabel,
readUiConditionRule,
readEffectiveUiVisibleConditionRule,
readUiLabel,
readUiGroup,
resolveSchemaRef,
@ -41,6 +42,7 @@ type FieldType = 'string' | 'number' | 'integer' | 'boolean' | 'unknown';
type RetrieverDependency = {
key: string;
path: string;
source: 'field' | 'context';
};
type NodeOptionsSource = {
@ -58,6 +60,7 @@ type EditableFieldDefinition = {
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
retrieverStructuredData: boolean;
retrieverDependsOn: RetrieverDependency[];
ui: {
widget: 'textarea' | null;
@ -84,6 +87,8 @@ type EditableFieldView = {
value: string;
wide: boolean;
enabled: boolean;
type: FieldType;
booleanValue: boolean;
};
type ArrayFieldDefinition = {
@ -173,10 +178,12 @@ export class GenericNodeComponent {
localEditorType: FieldType = 'string';
localEditorMaxLength: number | null = null;
localEditorWidget: 'textarea' | null = null;
localEditorRows: number | null = null;
localEditorBindableAsInput = false;
localEditorUseInput = false;
localEditorBindableInputName: string | null = null;
deleteConfirmOpen = false;
schemaReady = false;
missingRequiredParams: string[] = [];
private blockSchema: Record<string, any> | null = null;
@ -239,6 +246,7 @@ export class GenericNodeComponent {
this.localEditorHasRetriever = false;
this.localEditorOpen = true;
this.localEditorWidget = null;
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorBindableInputName = null;
@ -258,6 +266,7 @@ export class GenericNodeComponent {
this.localEditorType = definition.type;
this.localEditorMaxLength = null;
this.localEditorWidget = definition.ui.widget;
this.localEditorRows = definition.ui.rows ?? null;
this.localEditorValue = this.valueToEditorString(this.getByPath(this.blockConfiguration ?? {}, definition.path), definition.type);
this.localEditorOptions = this.resolveSelectableOptions(definition);
this.localEditorBindableAsInput = definition.ui.bindableAsInput;
@ -270,12 +279,18 @@ export class GenericNodeComponent {
if (definition.retrieverKey && !this.localEditorUseInput) {
const missingDependencies = definition.retrieverDependsOn
.filter((dep) => {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
const value = this.resolveRetrieverDependencyValue(this.blockConfiguration ?? {}, dep);
return this.isMissingValue(value);
})
.map((dep) => pathToLabel(dep.path));
.map((dep) => dep.source === 'context' ? pathToLabel(dep.key) : pathToLabel(dep.path));
if (missingDependencies.length > 0) {
const hasMissingFieldDependencies = definition.retrieverDependsOn.some((dep) => {
if (dep.source !== 'field') return false;
const value = this.resolveRetrieverDependencyValue(this.blockConfiguration ?? {}, dep);
return this.isMissingValue(value);
});
if (hasMissingFieldDependencies) {
this.localEditorLoading = false;
return;
}
@ -297,6 +312,7 @@ export class GenericNodeComponent {
this.localEditorType = 'string';
this.localEditorMaxLength = null;
this.localEditorWidget = null;
this.localEditorRows = null;
this.localEditorBindableAsInput = false;
this.localEditorUseInput = false;
this.localEditorBindableInputName = null;
@ -329,6 +345,7 @@ export class GenericNodeComponent {
}
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
@ -522,19 +539,27 @@ export class GenericNodeComponent {
private async loadSchemaContext() {
const type = this.blockType;
if (!type) return;
if (!type) {
this.schemaReady = true;
return;
}
const blockType = await this.blocksService.getBlockType(type);
this.blockDescriptor = blockType ?? null;
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);
try {
const blockType = await this.blocksService.getBlockType(type);
this.blockDescriptor = blockType ?? null;
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);
this.pruneInactiveConfiguration(this.ensureBlockConfiguration());
await this.refreshConditionalRequirements();
this.refreshParameterFields();
this.refreshValidationState();
this.maybeCreateBlockOnServer();
await this.refreshConditionalRequirements();
this.refreshParameterFields();
this.refreshValidationState();
this.maybeCreateBlockOnServer();
} finally {
this.schemaReady = true;
}
}
private async openTextareaEditor(
@ -569,6 +594,7 @@ export class GenericNodeComponent {
this.markBlockForServerRecreate();
}
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
@ -623,6 +649,7 @@ export class GenericNodeComponent {
retrieverBlockType: this.toRetrieverBlockType(childResolved),
retrieverKey: this.toRetrieverKey(childResolved),
retrieverUrl: this.toRetrieverUrl(childResolved),
retrieverStructuredData: childResolved?.['x-retriever-structured-data'] === true,
retrieverDependsOn: this.toRetrieverDependsOn(childResolved, pathPrefix),
ui: childUi
});
@ -724,10 +751,14 @@ export class GenericNodeComponent {
const structuralReason = typeof schema?.['x-ui-structural-reason'] === 'string'
? String(schema['x-ui-structural-reason'])
: undefined;
const visibleWhen = readUiConditionRule(schema?.['x-ui-visible-when']);
const visibleWhen = readEffectiveUiVisibleConditionRule(schema);
const enabledWhen = readUiConditionRule(schema?.['x-ui-enabled-when']);
const group = readUiGroup(schema?.['x-ui-group']) ?? inheritedUi?.group ?? null;
const label = readUiLabel(schema?.['x-ui-label']) ?? undefined;
const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties'];
const group = readUiGroup(schema?.['x-ui-group'])
?? (isObjectLike ? label ?? null : null)
?? inheritedUi?.group
?? null;
return {
widget: normalizedWidget,
@ -812,7 +843,10 @@ export class GenericNodeComponent {
private toRetrieverBlockType(schema: Record<string, any> | null | undefined): string | null {
if (!schema || typeof schema !== 'object') return null;
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType ?? null;
return this.parseRetrieverUrl(schema['x-retriever-url'])?.blockType
?? (typeof schema['x-retriever-owner'] === 'string' && String(schema['x-retriever-owner']).trim().length > 0
? String(schema['x-retriever-owner']).trim()
: null);
}
private toEnumOptions(schema: Record<string, any> | null | undefined): string[] {
@ -855,7 +889,7 @@ export class GenericNodeComponent {
const path = rawUrl.split('?')[0];
const parts = path.split('/').filter(Boolean);
const retrieverIndex = parts.findIndex((part) => part === 'retriever');
const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever');
if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null;
const blockType = parts[retrieverIndex + 1];
@ -971,10 +1005,25 @@ export class GenericNodeComponent {
return raw
.filter((dep): dep is string => typeof dep === 'string' && dep.length > 0)
.map((dep) => ({
key: dep,
path: pathPrefix ? `${pathPrefix}.${dep}` : dep
}));
.map((dep) => this.toRetrieverDependency(dep, pathPrefix));
}
private toRetrieverDependency(dependency: string, pathPrefix: string): RetrieverDependency {
const normalized = dependency.trim();
if (normalized.startsWith('$context.')) {
const contextKey = normalized.slice('$context.'.length).trim();
return {
key: contextKey,
path: normalized,
source: 'context'
};
}
return {
key: normalized,
path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized,
source: 'field'
};
}
private async loadLocalEditorOptions(definition: EditableFieldDefinition) {
@ -984,22 +1033,19 @@ export class GenericNodeComponent {
return;
}
const context: Record<string, string> = {};
for (const dep of definition.retrieverDependsOn) {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
context[dep.key] = value == null ? '' : String(value);
}
const context = this.buildRetrieverContext(
this.blockConfiguration ?? {},
definition.retrieverDependsOn
);
try {
const options = await firstValueFrom(
this.fieldRetriever.retrieveValues(
blockType,
definition.retrieverKey,
definition.retrieverDependsOn.length ? context : undefined,
definition.retrieverUrl
)
this.localEditorOptions = await this.fetchRetrieverOptions(
blockType,
definition.retrieverKey,
definition.retrieverUrl,
definition.retrieverStructuredData,
context
);
this.localEditorOptions = (options ?? []).map((option) => ({ label: option, value: option }));
} catch {
this.localEditorOptions = [];
} finally {
@ -1062,7 +1108,9 @@ export class GenericNodeComponent {
label: definition.label,
value: this.fieldDisplayValue(definition, value),
wide: this.shouldRenderWideField(definition.label, definition.ui.widget === 'textarea'),
enabled: this.isPathEnabled(definition.path)
enabled: this.isPathEnabled(definition.path),
type: definition.type,
booleanValue: value === true
};
}).filter((field) => !richContentPaths.has(field.path))
.filter((field) => this.isPathVisible(field.path));
@ -1097,7 +1145,9 @@ export class GenericNodeComponent {
label: this.fieldDisplayLabel(entry.path),
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(this.fieldDisplayLabel(entry.path), false),
enabled: this.isPathEnabled(entry.path)
enabled: this.isPathEnabled(entry.path),
type: (typeof entry.value === 'boolean' ? 'boolean' : 'unknown') as FieldType,
booleanValue: entry.value === true
}));
for (const field of fallbackFields) {
@ -1151,6 +1201,7 @@ export class GenericNodeComponent {
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
@ -1188,6 +1239,7 @@ export class GenericNodeComponent {
if (this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
@ -1529,22 +1581,16 @@ export class GenericNodeComponent {
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);
}
const retrieverContext = this.buildRetrieverContext(item as Record<string, any>, retrieverDependsOn);
try {
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues(
retrieverBlockType,
retrieverKey,
retrieverDependsOn.length ? retrieverContext : undefined,
this.toRetrieverUrl(propertySchema)
)
return await this.fetchRetrieverOptions(
retrieverBlockType,
retrieverKey,
this.toRetrieverUrl(propertySchema),
propertySchema['x-retriever-structured-data'] === true,
retrieverContext
);
return values.map((value) => ({ label: value, value }));
} catch {
return [];
}
@ -1645,6 +1691,47 @@ export class GenericNodeComponent {
current[keys[keys.length - 1]] = value;
}
private deleteByPath(target: Record<string, any>, path: string) {
const keys = path.split('.').filter(Boolean);
if (!keys.length) return;
let current: Record<string, any> | undefined = target;
const parents: Array<{ owner: Record<string, any>; key: string }> = [];
for (let i = 0; i < keys.length - 1; i++) {
const key = keys[i];
const next = current?.[key];
if (!next || typeof next !== 'object' || Array.isArray(next)) {
return;
}
parents.push({ owner: current!, key });
current = next as Record<string, any>;
}
if (!current) return;
delete current[keys[keys.length - 1]];
for (let i = parents.length - 1; i >= 0; i--) {
const { owner, key } = parents[i];
const value = owner[key];
if (!value || typeof value !== 'object' || Array.isArray(value)) break;
if (Object.keys(value).length > 0) break;
delete owner[key];
}
}
private pruneInactiveConfiguration(config: Record<string, any>) {
const candidatePaths = [
...this.editableFieldDefinitions.map((field) => field.path),
...this.arrayFieldDefinitions.map((field) => field.path)
].sort((left, right) => right.length - left.length);
for (const path of candidatePaths) {
if (this.isPathVisible(path) && this.isPathEnabled(path)) continue;
this.deleteByPath(config, path);
}
}
private resetDependentRetrieverFields(
config: Record<string, any>,
changedPath: string,
@ -1668,11 +1755,11 @@ export class GenericNodeComponent {
}
private richContentPaths(): string[] {
const preferredPaths = this.editableFieldDefinitions
.filter((field) => field.ui.widget === 'textarea' && field.ui.acceptVariableAsPlaceholder)
const textareaPaths = this.editableFieldDefinitions
.filter((field) => field.ui.widget === 'textarea')
.map((field) => field.path);
if (preferredPaths.length) {
return preferredPaths;
if (textareaPaths.length) {
return textareaPaths;
}
return this.findMainContentCandidatePaths(this.blockSchema);
@ -1717,8 +1804,7 @@ export class GenericNodeComponent {
? 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 || seen.has(path)) continue;
if (!isTextarea || seen.has(path)) continue;
seen.add(path);
paths.push(path);
@ -1814,11 +1900,7 @@ export class GenericNodeComponent {
if (!field.retrieverKey) return false;
const retrieverBlockType = field.retrieverBlockType ?? blockType;
const context: Record<string, string> = {};
for (const dep of field.dependsOn) {
const value = this.getByPath(this.blockConfiguration ?? {}, dep.path);
context[dep.key] = typeof value === 'string' ? value : '';
}
const context = this.buildRetrieverContext(this.blockConfiguration ?? {}, field.dependsOn);
try {
return await firstValueFrom(
@ -1894,6 +1976,9 @@ export class GenericNodeComponent {
this.blocksService.updateBlock(String(nodeData['id'] ?? ''), {
...configuration,
typeName: blockType
}, {
flowId: this.editorFlowId(),
replacesBlockId: String(nodeData['id'] ?? '')
}).pipe(
take(1)
).subscribe({
@ -1943,6 +2028,100 @@ export class GenericNodeComponent {
return this.isFieldConditionSatisfied(path);
}
private editorFlowId(): string | null {
const flowId = this.editorState.currentFlow()?.id;
return typeof flowId === 'string' && flowId.trim().length > 0 ? flowId.trim() : null;
}
private buildRetrieverContext(
source: Record<string, unknown>,
dependencies: RetrieverDependency[]
) {
const context = this.withEditorFlowContext({});
for (const dep of dependencies) {
const value = this.resolveRetrieverDependencyValue(source, dep);
context[dep.key] = value == null ? '' : String(value);
}
return context;
}
private resolveRetrieverDependencyValue(source: Record<string, unknown>, dependency: RetrieverDependency): unknown {
if (dependency.source === 'context') {
return this.resolveEditorContextDependencyValue(dependency.key);
}
return getValueByPath(source as Record<string, any>, dependency.path);
}
private resolveEditorContextDependencyValue(contextKey: string): unknown {
if (contextKey === 'flowId') {
return this.editorFlowId();
}
if (contextKey === 'blockId') {
const blockId = this.blockId;
return typeof blockId === 'string' && blockId.trim().length > 0 ? blockId.trim() : null;
}
if (contextKey === 'inputNames') {
return this.resolvePorts('input').map((port) => port.name).join(',');
}
if (contextKey === 'outputNames') {
return this.resolvePorts('output').map((port) => port.name).join(',');
}
return null;
}
private async fetchRetrieverOptions(
blockType: string,
retrieverKey: string,
retrieverUrl: string | null,
structuredData: boolean,
context?: Record<string, string>
): Promise<NodeSettingOption[]> {
if (structuredData) {
const items = await firstValueFrom(
this.fieldRetriever.retrieveItems<unknown>(
blockType,
retrieverKey,
context,
retrieverUrl
)
);
return this.toStructuredRetrieverOptions(items ?? []);
}
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues(
blockType,
retrieverKey,
context,
retrieverUrl
)
);
return (values ?? []).map((value) => ({ label: value, value }));
}
private toStructuredRetrieverOptions(items: Array<{ descriptor?: { label?: string; description?: string }; data?: unknown }>) {
return items
.map((item, index) => {
const value = item?.data == null ? '' : String(item.data);
const label = item.descriptor?.label?.trim() || value || `Item ${index + 1}`;
const description = item.descriptor?.description?.trim();
return {
label: description ? `${label} - ${description}` : label,
value
};
})
.filter((option) => option.value.trim().length > 0);
}
private withEditorFlowContext(context?: Record<string, string>) {
const nextContext = { ...(context ?? {}) };
const flowId = this.editorFlowId();
if (flowId) {
nextContext['flowId'] = flowId;
}
return nextContext;
}
private isPathEnabled(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
@ -1950,7 +2129,6 @@ export class GenericNodeComponent {
const ui = this.getFieldUiMeta(path);
return ui.enabledWhen.every((rule) => {
if (!rule) return true;
if (!this.isFieldConditionSatisfied(rule.field, visited)) return false;
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
@ -1959,6 +2137,31 @@ export class GenericNodeComponent {
return this.isPathVisible(field.path);
}
toggleBooleanParameter(path: string, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (this.isReadonly) return;
const definition = this.editableFieldDefinitions.find((field) => field.path === path);
if (!definition || definition.type !== 'boolean') return;
if (!this.isFieldVisible(definition) || !this.isPathEnabled(definition.path)) return;
const config = this.ensureBlockConfiguration();
const previousValue = this.getByPath(config, path);
const nextValue = previousValue !== true;
this.setByPath(config, path, nextValue);
this.resetDependentRetrieverFields(config, path);
if (!this.areValuesEqual(previousValue, nextValue) && this.isStructuralField(path)) {
this.markBlockForServerRecreate();
}
this.pruneInactiveConfiguration(config);
this.refreshParameterFields();
this.refreshValidationState();
this.markFlowDirty();
this.maybeCreateBlockOnServer();
}
private isFieldConditionSatisfied(path: string, visited = new Set<string>()): boolean {
if (visited.has(path)) return true;
visited.add(path);
@ -1966,7 +2169,6 @@ export class GenericNodeComponent {
const ui = this.getFieldUiMeta(path);
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));
});
}

View File

@ -152,6 +152,11 @@ export function readUiConditionRule(value: unknown): UiConditionRule | null {
return null;
}
export function readEffectiveUiVisibleConditionRule(schema: Record<string, any> | null | undefined): UiConditionRule | null {
return readUiConditionRule(schema?.['x-ui-visible-when'])
?? readUiConditionRule(schema?.['x-ui-enabled-when']);
}
export function readUiGroup(value: unknown): string | null {
if (typeof value !== 'string') return null;
const normalized = value.trim();
@ -226,10 +231,11 @@ export function evaluateUiConditionRule(
}
if (type === 'boolean' || typeof actualValue === 'boolean') {
const normalizedActual = typeof actualValue === 'boolean' ? actualValue : false;
if (expectedValues) {
return expectedValues.some((value) => actualValue === parseBooleanCondition(value));
return expectedValues.some((value) => normalizedActual === parseBooleanCondition(value));
}
return expectedValue != null && actualValue === parseBooleanCondition(expectedValue);
return expectedValue != null && normalizedActual === parseBooleanCondition(expectedValue);
}
if (type === 'number' || type === 'integer' || typeof actualValue === 'number') {

View File

@ -11,7 +11,7 @@ export type ConditionalRequiredField = {
retrieverBlockType: string | null;
retrieverKey: string | null;
retrieverUrl: string | null;
dependsOn: Array<{ key: string; path: string }>;
dependsOn: Array<{ key: string; path: string; source: 'field' | 'context' }>;
requiredWhen: UiConditionRule | null;
};
@ -89,10 +89,7 @@ function walkSchema(
const dependsOn = rawDepends
.filter((dep): dep is string => typeof dep === 'string' && dep.length > 0)
.map((dep) => ({
key: dep,
path: pathPrefix ? `${pathPrefix}.${dep}` : dep
}));
.map((dep) => toRetrieverDependency(dep, pathPrefix));
const signature = `${propertyPath}|${retrieverKey ?? 'local'}|${dependsOn.map((d) => d.path).join(',')}|${JSON.stringify(requiredWhen ?? null)}`;
if (!seenConditional.has(signature)) {
@ -134,7 +131,7 @@ function parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string }
const path = rawUrl.split('?')[0];
const normalized = path.endsWith('/required') ? path.slice(0, -'/required'.length) : path;
const parts = normalized.split('/').filter(Boolean);
const retrieverIndex = parts.findIndex((part) => part === 'retriever');
const retrieverIndex = parts.findIndex((part) => part === 'retriever' || part === 'secure-retriever');
if (retrieverIndex < 0 || parts.length < retrieverIndex + 3) return null;
const blockType = parts[retrieverIndex + 1];
@ -143,3 +140,21 @@ function parseRetrieverUrl(rawUrl: unknown): { blockType: string; key: string }
return { blockType, key };
}
function toRetrieverDependency(dependency: string, pathPrefix: string) {
const normalized = dependency.trim();
if (normalized.startsWith('$context.')) {
const contextKey = normalized.slice('$context.'.length).trim();
return {
key: contextKey,
path: normalized,
source: 'context' as const
};
}
return {
key: normalized,
path: pathPrefix ? `${pathPrefix}.${normalized}` : normalized,
source: 'field' as const
};
}

View File

@ -61,6 +61,88 @@
border-bottom-color: #cbd5e1;
}
.llm-skeleton-overlay {
position: absolute;
inset: 0;
z-index: 10;
display: flex;
align-items: stretch;
justify-content: stretch;
padding: 14px;
border-radius: 16px;
background: rgba(248, 250, 252, 0.82);
backdrop-filter: blur(1px);
}
.llm-params-shell {
position: relative;
min-height: 110px;
overflow: hidden;
border-bottom-left-radius: 16px;
border-bottom-right-radius: 16px;
}
.llm-skeleton-stack {
width: 100%;
display: flex;
flex-direction: column;
gap: 10px;
}
.llm-skeleton-line,
.llm-skeleton-pill {
position: relative;
overflow: hidden;
background: #e2e8f0;
}
.llm-skeleton-line::after,
.llm-skeleton-pill::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent);
animation: llm-skeleton-shimmer 1.1s ease-in-out infinite;
}
.llm-skeleton-line {
height: 12px;
border-radius: 999px;
}
.llm-skeleton-line-title {
width: 62%;
margin-top: 6px;
}
.llm-skeleton-line-short {
width: 38%;
}
.llm-skeleton-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 8px;
}
.llm-skeleton-pill {
height: 54px;
border-radius: 12px;
}
.llm-skeleton-pill-wide {
grid-column: 1 / -1;
height: 76px;
}
@keyframes llm-skeleton-shimmer {
to {
transform: translateX(100%);
}
}
.llm-error-alert-wrap,
.llm-warning-alert-wrap {
position: absolute;
@ -587,6 +669,8 @@
.llm-param-row-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 0;
}
@ -605,6 +689,23 @@
white-space: nowrap;
}
.llm-param-value-wide {
overflow: visible;
text-overflow: unset;
white-space: pre-wrap;
word-break: break-word;
}
.llm-param-value-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
white-space: pre-wrap;
word-break: break-word;
}
.llm-param-block {
border: 1px solid #dbe2ea;
background: #ffffff;
@ -621,6 +722,37 @@
line-height: 1.45;
}
.llm-param-text-clamped {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
overflow: hidden;
text-overflow: ellipsis;
}
.llm-param-view-btn {
flex: 0 0 auto;
width: 20px;
height: 20px;
border: 1px solid #bfdbfe;
border-radius: 999px;
background: #eff6ff;
color: #1d4ed8;
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.llm-param-view-btn:hover {
background: #dbeafe;
}
.llm-param-view-btn i {
font-size: 10px;
}
.llm-inline-token {
display: inline-flex;
align-items: center;

View File

@ -161,7 +161,20 @@
</div>
</div>
<div class="llm-params">
<div class="llm-params llm-params-shell">
@if (!schemaReady) {
<div class="llm-skeleton-overlay" aria-hidden="true">
<div class="llm-skeleton-stack">
<div class="llm-skeleton-line llm-skeleton-line-title"></div>
<div class="llm-skeleton-line llm-skeleton-line-short"></div>
<div class="llm-skeleton-grid">
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill"></div>
<div class="llm-skeleton-pill llm-skeleton-pill-wide"></div>
</div>
</div>
</div>
}
@if (parameterFieldGroups.length) {
<div class="llm-param-groups">
@for (group of parameterFieldGroups; track group.key) {
@ -172,8 +185,23 @@
<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>
@if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
<span
class="llm-param-value"
[class.llm-param-value-wide]="field.wide"
[class.llm-param-value-clamped]="field.expandable">
{{ field.value }}
</span>
</div>
}
</div>
@ -188,8 +216,23 @@
<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>
@if (field.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full value"
(pointerdown)="$event.stopPropagation()"
(click)="openFieldPreview(field, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<span class="llm-param-value">{{ field.value }}</span>
<span
class="llm-param-value"
[class.llm-param-value-wide]="field.wide"
[class.llm-param-value-clamped]="field.expandable">
{{ field.value }}
</span>
</div>
}
</div>
@ -200,8 +243,18 @@
<div class="llm-param-block">
<div class="llm-param-row-head">
<div class="llm-param-key">{{ contentField.label }}</div>
@if (contentField.expandable) {
<button
type="button"
class="llm-param-view-btn"
aria-label="View full content"
(pointerdown)="$event.stopPropagation()"
(click)="openMainContentPreview(contentField, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="llm-param-text">
<div class="llm-param-text" [class.llm-param-text-clamped]="contentField.expandable">
@for (part of contentField.parts; track $index) {
@if (part.isDynamicInput) {
<span class="llm-inline-token">{{ formatDynamicInputToken(part.text) }}</span>

View File

@ -5,6 +5,7 @@ import { ReteModule } from 'rete-angular-plugin/21';
import { BlockInteractionContract, BlockType, FlowBlock, FlowContainer, FlowData, FlowPort } from '@models/flow';
import { BlocksService } from '@services/blocks/blocks';
import { ContainersService } from '@services/containers/containers';
import { NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { SubflowPreviewDialogService } from '@services/dialogs/subflow-preview-dialog';
import { HumanInteractionDialogService } from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
@ -15,7 +16,9 @@ import {
parentPath,
pathToLabel,
readUiConditionRule,
readEffectiveUiVisibleConditionRule,
readUiGroup,
readUiLabel,
resolveSchemaRef,
resolveSchemaPath,
schemaFieldLabel,
@ -30,6 +33,7 @@ type DisplayField = {
label: string;
value: string;
wide: boolean;
expandable: boolean;
};
type ArrayFieldDefinition = {
@ -58,6 +62,8 @@ type DisplayFieldGroup = {
type MainContentView = {
path: string;
label: string;
rawValue: string;
expandable: boolean;
parts: { text: string; isDynamicInput: boolean }[];
};
@ -66,6 +72,13 @@ type PortLabelParts = {
name: string;
};
type FieldUiMeta = {
visibleWhen: UiConditionRule[];
group: string | null;
widget: 'textarea' | null;
acceptVariableAsPlaceholder: boolean;
};
@Component({
selector: 'app-task-step-node',
imports: [CommonModule, ReteModule],
@ -76,8 +89,13 @@ type PortLabelParts = {
}
})
export class TaskStepNodeComponent {
private static readonly globalFieldSchemaCache = new Map<string, Map<string, Record<string, any> | null>>();
private static readonly globalFieldUiMetaCache = new Map<string, Map<string, FieldUiMeta>>();
private static readonly globalFieldLabelCache = new Map<string, Map<string, string>>();
private blocksService = inject(BlocksService);
private containersService = inject(ContainersService);
private settingsDialog = inject(NodeSettingsDialogService);
private subflowPreview = inject(SubflowPreviewDialogService);
private cdr = inject(ChangeDetectorRef);
private humanInteractionDialog = inject(HumanInteractionDialogService);
@ -102,12 +120,11 @@ export class TaskStepNodeComponent {
name = 'Step';
mainContentFields: MainContentView[] = [];
interactionSubmitting = false;
schemaReady = false;
private blockSchema: Record<string, any> | null = null;
private blockDescriptor: BlockType | null = null;
private variablePlaceholderPaths = new Set<string>();
private arrayFieldDefinitions: ArrayFieldDefinition[] = [];
private mainContentPaths = new Set<string>();
ngOnInit() {
this.outputs = [];
@ -136,13 +153,25 @@ export class TaskStepNodeComponent {
.filter((entry) => !this.shouldHideConfigPath(entry.path))
.filter((entry) => !arrayFieldPaths.has(entry.path));
const visibleEntries = primitiveEntries.filter((entry) => this.isPathVisible(entry.path));
const fieldMetaByPath = new Map<string, { label: string; ui: FieldUiMeta; value: string }>();
for (const entry of visibleEntries) {
const ui = this.getFieldUiMeta(entry.path);
const label = this.displayLabelForPath(entry.path);
fieldMetaByPath.set(entry.path, {
label,
ui,
value: valueToDisplayString(entry.value)
});
}
const mainContentEntries = visibleEntries
.filter((entry) => this.mainContentPaths.has(entry.path))
.filter((entry) => fieldMetaByPath.get(entry.path)?.ui.widget === 'textarea')
.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: this.displayLabelForPath(entry.path),
label: fieldMetaByPath.get(entry.path)?.label ?? this.displayLabelForPath(entry.path),
rawValue: String(entry.value),
expandable: this.isLongTextValue(String(entry.value)),
parts: this.toMainContentParts(entry.path, String(entry.value))
}));
this.arrayFields = this.arrayFieldDefinitions
@ -159,12 +188,18 @@ export class TaskStepNodeComponent {
.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: this.displayLabelForPath(entry.path),
value: valueToDisplayString(entry.value),
wide: this.shouldRenderWideField(this.displayLabelForPath(entry.path), this.mainContentPaths.has(entry.path))
}));
.map((entry) => {
const meta = fieldMetaByPath.get(entry.path);
const label = meta?.label ?? this.displayLabelForPath(entry.path);
const value = meta?.value ?? valueToDisplayString(entry.value);
return {
path: entry.path,
label,
value,
wide: this.shouldRenderWideField(label, meta?.ui.widget === 'textarea'),
expandable: this.isLongTextValue(value)
};
});
for (const field of orderedFields) {
const groupLabel = this.groupLabelForPath(field.path);
@ -367,6 +402,7 @@ export class TaskStepNodeComponent {
event?.stopPropagation();
if (this.interactionSubmitting) return;
if (this.executionStatus() === 'SUSPENDED') return;
if (this.isInteractionSimulationEnabled()) return;
const executionId = this.executionId();
const executionNodeId = this.executionNodeId();
@ -389,6 +425,20 @@ export class TaskStepNodeComponent {
this.subflowPreview.open(subFlow, `${this.name || this.nodeTitle()} subflow`);
}
openFieldPreview(field: DisplayField, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, field.value);
}
openMainContentPreview(field: MainContentView, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!field.expandable) return;
void this.openReadonlyTextDialog(field.label, field.rawValue);
}
private get blockConfiguration(): Record<string, any> | null {
return this.data?.data?.specificConfiguration ?? null;
}
@ -424,6 +474,10 @@ export class TaskStepNodeComponent {
return typeof value === 'string' ? value.toUpperCase() : '';
}
private isInteractionSimulationEnabled(): boolean {
return this.blockConfiguration?.['__interactionSimulationEnabled'] === true;
}
private getExecutionMessages(key: '__executionErrors' | '__executionWarnings'): string[] {
const values = this.blockConfiguration?.[key];
if (!Array.isArray(values)) return [];
@ -476,7 +530,7 @@ export class TaskStepNodeComponent {
}
private toMainContentParts(path: string, value: string): { text: string; isDynamicInput: boolean }[] {
if (this.variablePlaceholderPaths.has(path)) {
if (this.getFieldUiMeta(path).acceptVariableAsPlaceholder) {
return splitTemplatedTextParts(value);
}
return [{ text: value, isDynamicInput: false }];
@ -494,10 +548,15 @@ export class TaskStepNodeComponent {
: await this.blocksService.getBlockType(type);
this.blockDescriptor = (typeDescriptor ?? null) as BlockType | null;
this.blockSchema = (typeDescriptor?.schema ?? null) as Record<string, any> | null;
this.variablePlaceholderPaths = this.extractVariablePlaceholderPaths(this.blockSchema);
const typeKey = this.nodeTypeCacheKey();
if (typeKey) {
TaskStepNodeComponent.globalFieldSchemaCache.delete(typeKey);
TaskStepNodeComponent.globalFieldUiMetaCache.delete(typeKey);
TaskStepNodeComponent.globalFieldLabelCache.delete(typeKey);
}
this.arrayFieldDefinitions = this.extractArrayFieldDefinitions(this.blockSchema);
this.mainContentPaths = this.extractMainContentPaths(this.blockSchema);
this.rebuildDisplayState();
this.schemaReady = true;
}
private interactionContract(): BlockInteractionContract | null {
@ -516,11 +575,6 @@ export class TaskStepNodeComponent {
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
private executionResultData(): Record<string, unknown> | null {
const value = this.blockConfiguration?.['__executionResultData'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
}
private stepResultData(): Record<string, unknown> | null {
const value = this.blockConfiguration?.['__stepResultData'];
return value && typeof value === 'object' && !Array.isArray(value) ? value as Record<string, unknown> : null;
@ -542,7 +596,7 @@ export class TaskStepNodeComponent {
}
const scopedFieldName = this.executionScopedFieldName(fieldName);
const executionSource = preferPartial ? this.executionPartialResult() : this.executionResultData();
const executionSource = preferPartial ? this.executionPartialResult() : null;
if (executionSource) {
if (Object.prototype.hasOwnProperty.call(executionSource, fieldName)) {
return executionSource[fieldName];
@ -649,6 +703,8 @@ export class TaskStepNodeComponent {
contract: BlockInteractionContract,
result: { mode: 'message' | 'complete'; value: string }
) {
if (this.isInteractionSimulationEnabled()) return;
const interactionFieldName = result.mode === 'message'
? contract.messageField
: contract.completionField;
@ -705,41 +761,6 @@ export class TaskStepNodeComponent {
};
}
private extractVariablePlaceholderPaths(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 extractArrayFieldDefinitions(schema: Record<string, any> | null): ArrayFieldDefinition[] {
if (!schema) return [];
@ -837,42 +858,6 @@ export class TaskStepNodeComponent {
return this.isContainerNode() && (path === 'subFlow' || path.startsWith('subFlow.'));
}
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 [];
@ -923,7 +908,17 @@ export class TaskStepNodeComponent {
}
private displayLabelForPath(path: string): string {
return schemaFieldLabel(path, this.resolveFieldSchema(path));
const typeKey = this.nodeTypeCacheKey();
if (!typeKey || !this.blockSchema) return schemaFieldLabel(path, this.resolveFieldSchema(path));
const cache = this.getGlobalCache(TaskStepNodeComponent.globalFieldLabelCache, typeKey);
if (cache.has(path)) {
return cache.get(path) ?? path;
}
const label = schemaFieldLabel(path, this.resolveFieldSchema(path));
cache.set(path, label);
return label;
}
private shouldRenderWideField(label: string, isTextarea: boolean) {
@ -954,7 +949,6 @@ export class TaskStepNodeComponent {
const ui = this.getFieldUiMeta(path);
return ui.visibleWhen.every((rule) => {
if (!rule) return true;
if (!this.isPathVisible(rule.field, visited)) return false;
return evaluateUiConditionRule(rule, this.blockConfiguration, (fieldPath) => this.resolveFieldSchema(fieldPath));
});
}
@ -964,26 +958,61 @@ export class TaskStepNodeComponent {
}
private resolveFieldSchema(path: string): Record<string, any> | null {
return resolveSchemaPath(this.blockSchema, path);
const typeKey = this.nodeTypeCacheKey();
if (!typeKey || !this.blockSchema) {
return resolveSchemaPath(this.blockSchema, path);
}
const cache = this.getGlobalCache(TaskStepNodeComponent.globalFieldSchemaCache, typeKey);
if (cache.has(path)) {
return cache.get(path) ?? null;
}
const resolved = resolveSchemaPath(this.blockSchema, path);
cache.set(path, resolved);
return resolved;
}
private getFieldUiMeta(path: string) {
private getFieldUiMeta(path: string): FieldUiMeta {
const typeKey = this.nodeTypeCacheKey();
const globalCache = typeKey && this.blockSchema
? this.getGlobalCache(TaskStepNodeComponent.globalFieldUiMetaCache, typeKey)
: null;
const cached = globalCache?.get(path);
if (cached) return cached;
const root = this.blockSchema;
if (!root) return this.toFieldUiMeta(null);
if (!root) {
const empty = this.toFieldUiMeta(null);
globalCache?.set(path, empty);
return empty;
}
let current: Record<string, any> | null = root;
let inheritedUi = { visibleWhen: [] as UiConditionRule[], group: null as string | null };
for (const segment of path.split('.')) {
if (!current) return this.toFieldUiMeta(null, inheritedUi);
if (!current) {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
const resolved = resolveSchemaRef(current, root);
if (/^\d+$/.test(segment)) {
const items = resolved?.items;
if (!items || typeof items !== 'object') return this.toFieldUiMeta(null, inheritedUi);
if (!items || typeof items !== 'object') {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
current = resolveSchemaRef(items as Record<string, any>, root);
} else {
const properties = resolved?.properties as Record<string, unknown> | undefined;
if (!properties || !properties[segment]) return this.toFieldUiMeta(null, inheritedUi);
if (!properties || !properties[segment]) {
const empty = this.toFieldUiMeta(null, inheritedUi);
globalCache?.set(path, empty);
return empty;
}
current = resolveSchemaRef(properties[segment] as Record<string, any>, root);
}
const nextUi = this.toFieldUiMeta(current, inheritedUi);
@ -993,23 +1022,79 @@ export class TaskStepNodeComponent {
};
}
return this.toFieldUiMeta(current, inheritedUi);
const meta = this.toFieldUiMeta(current, inheritedUi);
globalCache?.set(path, meta);
return meta;
}
private nodeTypeCacheKey(): string | null {
const type = this.blockType;
if (!type) return null;
const family = this.isContainerNode() ? 'container' : 'block';
return `${family}:${type}`;
}
private getGlobalCache<T>(store: Map<string, Map<string, T>>, typeKey: string): Map<string, T> {
let cache = store.get(typeKey);
if (!cache) {
cache = new Map<string, T>();
store.set(typeKey, cache);
}
return cache;
}
private toFieldUiMeta(
schema: Record<string, any> | null | undefined,
inheritedUi?: { visibleWhen: UiConditionRule[]; group: string | null }
) {
const visibleWhen = readUiConditionRule(schema?.['x-ui-visible-when']);
const group = readUiGroup(schema?.['x-ui-group']) ?? inheritedUi?.group ?? null;
): FieldUiMeta {
const visibleWhen = readEffectiveUiVisibleConditionRule(schema);
const label = readUiLabel(schema?.['x-ui-label']);
const isObjectLike = schema?.['type'] === 'object' || !!schema?.['properties'];
const group = readUiGroup(schema?.['x-ui-group'])
?? (isObjectLike ? label ?? null : null)
?? inheritedUi?.group
?? null;
const rawWidget = typeof schema?.['x-ui-widget'] === 'string'
? String(schema['x-ui-widget']).toLowerCase().trim()
: '';
const widget: 'textarea' | null =
rawWidget === 'textarea' || rawWidget === 'text-area' ? 'textarea' : null;
const acceptVariableAsPlaceholder = schema?.['x-ui-accept-variable-as-placeholder'] === true;
return {
visibleWhen: [
...(inheritedUi?.visibleWhen ?? []),
...(visibleWhen ? [visibleWhen] : [])
],
group
group,
widget,
acceptVariableAsPlaceholder
};
}
private isLongTextValue(value: string): boolean {
const normalized = String(value ?? '');
if (!normalized.trim()) return false;
const lineCount = normalized.split(/\r?\n/).length;
return lineCount > 2 || normalized.length > 160;
}
private async openReadonlyTextDialog(label: string, value: string) {
await this.settingsDialog.open({
title: label,
fields: [
{
key: 'value',
label,
type: 'textarea',
readonly: true,
rows: 18
}
],
initial: {
value
}
});
}
}

View File

@ -58,8 +58,12 @@ export class ReteEditor implements OnChanges, OnDestroy {
ngOnChanges(changes: SimpleChanges): void {
if (!this.viewReady) return;
if (changes['flowId'] || (this.readonly() && changes['flowData'])) {
if (changes['flowId']) {
void this.reloadEditor();
return;
}
if (this.readonly() && changes['flowData']) {
void this.syncReadonlyFlowData();
}
}
@ -105,7 +109,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
try {
newBlock = blockType.family === 'container'
? await firstValueFrom(this.containersService.createEmptyContainer(blockType.type))
: await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type));
: await firstValueFrom(this.blocksService.createEmptyBlock(blockType.type, {
flowId: this.flowId()
}));
} catch (error) {
console.error('Failed to create empty block', error);
return;
@ -266,6 +272,96 @@ export class ReteEditor implements OnChanges, OnDestroy {
});
}
private async syncReadonlyFlowData() {
if (!this.readonly()) return;
const rete = this.rete;
if (!rete) {
await this.reloadEditor();
return;
}
const nextFlowData = this.flowData();
if (!this.canPatchReadonlyFlowData(rete, nextFlowData)) {
await this.reloadEditor();
return;
}
await this.patchReadonlyNodes(rete, nextFlowData);
}
private canPatchReadonlyFlowData(rete: ReteEditorInstance, nextFlowData: FlowData) {
const currentNodes = rete.editor.getNodes() as any[];
const nextNodes = [...(nextFlowData.blocks ?? []), ...(nextFlowData.containers ?? [])];
if (currentNodes.length !== nextNodes.length) return false;
const currentByBlockId = new Map(
currentNodes.map((node: any) => [String(node.data?.id ?? ''), node])
);
for (const nextNode of nextNodes) {
const currentNode = currentByBlockId.get(String(nextNode.id));
if (!currentNode?.data) return false;
if (!this.hasSameNodeStructure(currentNode.data as FlowNode, nextNode)) return false;
}
return this.hasSameConnectionStructure(exportGraph(rete.editor), nextFlowData);
}
private hasSameNodeStructure(currentNode: FlowNode, nextNode: FlowNode) {
return currentNode.id === nextNode.id
&& currentNode.nodeFamily === nextNode.nodeFamily
&& currentNode.typeName === nextNode.typeName
&& this.hasSamePortStructure(currentNode.inputs, nextNode.inputs)
&& this.hasSamePortStructure(currentNode.outputs, nextNode.outputs);
}
private hasSamePortStructure(
currentPorts: FlowNode['inputs'] | FlowNode['outputs'],
nextPorts: FlowNode['inputs'] | FlowNode['outputs']
) {
const current = currentPorts ?? [];
const next = nextPorts ?? [];
if (current.length !== next.length) return false;
return current.every((port, index) => {
const candidate = next[index];
return port.name === candidate?.name && port.type === candidate?.type;
});
}
private hasSameConnectionStructure(currentFlowData: FlowData, nextFlowData: FlowData) {
const currentConnections = [...(currentFlowData.connections ?? [])]
.map((connection) => `${connection.sourceId}:${connection.sourceName}->${connection.targetId}:${connection.targetName}`)
.sort();
const nextConnections = [...(nextFlowData.connections ?? [])]
.map((connection) => `${connection.sourceId}:${connection.sourceName}->${connection.targetId}:${connection.targetName}`)
.sort();
if (currentConnections.length !== nextConnections.length) return false;
return currentConnections.every((connection, index) => connection === nextConnections[index]);
}
private async patchReadonlyNodes(rete: ReteEditorInstance, nextFlowData: FlowData) {
const nextNodes = [...(nextFlowData.blocks ?? []), ...(nextFlowData.containers ?? [])];
const currentNodes = new Map(
(rete.editor.getNodes() as any[]).map((node) => [String(node.data?.id ?? ''), node])
);
for (const nextNode of nextNodes) {
const currentNode = currentNodes.get(String(nextNode.id));
if (!currentNode?.data) continue;
currentNode.data = {
...currentNode.data,
...nextNode,
position: nextNode.position ?? currentNode.data.position,
__readonly: true
};
await rete.area.update('node', currentNode.id);
}
}
private getDropPosition(event: DragEvent) {
const host = this.container.nativeElement as HTMLElement;
const rect = host.getBoundingClientRect();

View File

@ -126,3 +126,92 @@
font-size: 12px;
line-height: 1.45;
}
.execution-log-card {
border: 1px solid #e2e8f0;
border-radius: 12px;
background: #f8fafc;
padding: 12px;
}
.execution-log-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.execution-log-title-wrap {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
}
.execution-log-icon {
width: 18px;
height: 18px;
font-size: 18px;
color: #475569;
margin-top: 1px;
}
.execution-log-title {
color: #0f172a;
font-size: 13px;
font-weight: 700;
line-height: 1.35;
}
.execution-log-meta {
display: flex;
flex-wrap: wrap;
gap: 6px 10px;
margin-top: 4px;
color: #64748b;
font-size: 11px;
}
.execution-log-level {
display: inline-flex;
align-items: center;
border-radius: 999px;
border: 1px solid transparent;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
padding: 3px 8px;
text-transform: uppercase;
}
.execution-log-level-info {
background: #e0f2fe;
border-color: #bae6fd;
color: #0c4a6e;
}
.execution-log-level-warn {
background: #fef3c7;
border-color: #fde68a;
color: #92400e;
}
.execution-log-level-error {
background: #fee2e2;
border-color: #fecaca;
color: #991b1b;
}
.execution-log-details {
margin: 10px 0 0;
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #ffffff;
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 11px;
line-height: 1.45;
padding: 10px;
white-space: pre-wrap;
word-break: break-word;
}

View File

@ -6,8 +6,25 @@
<div>
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
@if (isSimulatedExecution()) {
<p class="text-sm text-sky-700">Simulated interactive execution</p>
@if (simulationDescriptorLabel(); as simulationDescriptor) {
<p class="text-xs text-sky-900">Simulator: {{ simulationDescriptor }}</p>
}
}
</div>
<div class="flex items-stretch gap-2">
@if (execution()!.simulationAvailable === true) {
<button
type="button"
class="self-stretch h-full rounded-md border border-sky-300 bg-sky-50 px-3 text-sky-700 hover:bg-sky-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Simulate execution"
aria-label="Simulate execution"
[disabled]="!canSimulateExecution()"
(click)="simulateExecution()">
Simulate
</button>
}
<button
type="button"
class="self-stretch h-full rounded-md border border-violet-300 bg-violet-50 px-3 text-violet-700 hover:bg-violet-100 disabled:opacity-40 disabled:cursor-not-allowed"
@ -72,7 +89,6 @@
Execution suspended after service restart. Resume the execution to continue.
</div>
}
<div class="flex-1 min-h-0 flex gap-2 p-1">
<div class="flex-1 border border-slate-200 rounded-md bg-slate-50 min-h-0 overflow-hidden">
<div class="px-3 py-2 text-xs font-semibold text-slate-600 border-b border-slate-200 bg-white">Execution Graph (read-only)</div>
@ -102,7 +118,14 @@
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'inputs'"
(click)="selectAsideTab('inputs')">
Execution Inputs
Inputs
</button>
<button
type="button"
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'logs'"
(click)="selectAsideTab('logs')">
Logs
</button>
<button
type="button"
@ -110,7 +133,7 @@
[class.execution-aside-tab-active]="activeAsideTab() === 'output'"
[disabled]="!executionOutputTabEnabled()"
(click)="selectAsideTab('output')">
Execution Output
Output
</button>
</div>
@ -130,6 +153,44 @@
(textInputChange)="onTextInputChange($event.input, $event.value)"
(fileInputChange)="onFileInputChange($event.input, $event.files)">
</app-task-execution-inputs-panel>
} @else if (activeAsideTab() === 'logs') {
<div class="p-3 space-y-3">
@if (logsLoading()) {
<div class="text-xs text-slate-500">Loading execution log...</div>
} @else if (logsError()) {
<div class="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-xs text-rose-700">{{ logsError() }}</div>
} @else if (!visibleExecutionLogs().length) {
<div class="text-xs text-slate-500">No execution log available.</div>
} @else {
@for (event of visibleExecutionLogs(); track event.id) {
<div class="execution-log-card">
<div class="execution-log-header">
<div class="execution-log-title-wrap">
<mat-icon class="execution-log-icon" [fontIcon]="logTypeIcon(event.type)"></mat-icon>
<div>
<div class="execution-log-title">{{ event.messageText }}</div>
<div class="execution-log-meta">
<span>{{ event.timestamp | date:'dd/MM/yyyy HH:mm:ss' }}</span>
@if (event.nodeName) {
<span>{{ event.nodeName }}</span>
}
@if (event.type) {
<span>{{ event.type }}</span>
}
</div>
</div>
</div>
<span class="execution-log-level" [ngClass]="logLevelClass(event.level)">
{{ event.levelText }}
</span>
</div>
@if (event.details) {
<pre class="execution-log-details">{{ event.details | json }}</pre>
}
</div>
}
}
</div>
} @else {
<div class="p-3 space-y-3">
@if (!executionOutputs().length) {

View File

@ -9,10 +9,12 @@ import {
FlowBlockConnection,
FlowContainer,
FlowData,
LLMDescriptor,
FlowNode,
normalizeFlowPortValueKinds
} from '@models/flow';
import {
ExecutionEventLogEntry,
getTaskExecutionStepNode,
getExecutionStatusGroup,
TaskExecution,
@ -28,7 +30,10 @@ import {
HumanInteractionChatMessage,
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { FieldRetriever } from '@services/retriever/field-retriever';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
import { firstValueFrom } from 'rxjs';
type ExecutionOutputEntry = {
key: string;
@ -39,6 +44,11 @@ type ExecutionOutputEntry = {
isLong: boolean;
};
type ExecutionLogEntryView = ExecutionEventLogEntry & {
messageText: string;
levelText: string;
};
@Component({
selector: 'app-task-execution-viewer',
imports: [CommonModule, ReteEditor, TaskExecutionInputsPanelComponent, MatButtonModule, MatIconModule, MatTooltipModule],
@ -47,15 +57,21 @@ type ExecutionOutputEntry = {
})
export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200;
private static readonly EVENTS_POLL_INTERVAL_MS = 5000;
private taskExecutionsService = inject(TaskExecutionsService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private settingsDialog = inject(NodeSettingsDialogService);
private fieldRetriever = inject(FieldRetriever);
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private lastExecutionId: string | null = null;
private lastExecutionStatus: string | null = null;
private static readonly SIMULATOR_PROVIDER_RETRIEVER_URL = '/retriever/LLM/providers';
private static readonly SIMULATOR_MODEL_RETRIEVER_URL = '/retriever/LLM/models';
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
readonly activeAsideTab = signal<'inputs' | 'logs' | 'output'>('inputs');
readonly startInProgress = signal(false);
readonly simulateInProgress = signal(false);
readonly cancelInProgress = signal(false);
readonly resumeInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
@ -65,6 +81,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(null);
readonly executionLogs = signal<ExecutionEventLogEntry[]>([]);
readonly logsLoading = signal(false);
readonly logsError = signal<string | null>(null);
constructor() {
effect(() => {
@ -77,6 +96,9 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.authorizationErrors.set({});
this.activeAsideTab.set('inputs');
this.outputPreviewModal.set(null);
this.executionLogs.set([]);
this.logsError.set(null);
this.logsLoading.set(false);
});
effect(() => {
@ -102,6 +124,23 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.lastExecutionStatus = status;
});
effect((onCleanup) => {
const execution = this.execution();
const executionId = execution?.id ?? null;
if (!executionId || !execution) return;
this.fetchExecutionLogs(executionId);
const statusGroup = getExecutionStatusGroup(execution.context.status);
if (statusGroup !== 'RUNNING' && statusGroup !== 'PAUSED') return;
const timer = setInterval(() => {
this.fetchExecutionLogs(executionId);
}, TaskExecutionViewerComponent.EVENTS_POLL_INTERVAL_MS);
onCleanup(() => clearInterval(timer));
});
effect(() => {
const dialogState = this.humanInteractionDialog.state();
const execution = this.execution();
@ -109,7 +148,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
if (dialogState.executionId !== execution.id || !dialogState.nodeId) return;
const executionStatus = String(execution.context.status ?? '').toUpperCase();
if (executionStatus === 'CANCELLED' || executionStatus === 'SUSPENDED') {
if (executionStatus === 'CANCELLED' || executionStatus === 'SUSPENDED' || execution.interactionSimulationEnabled === true) {
this.humanInteractionDialog.close(null);
return;
}
@ -117,8 +156,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const step = execution.context.steps?.[dialogState.nodeId];
const stepResult = step?.result && typeof step.result === 'object' ? step.result as Record<string, unknown> : {};
const finalResult = execution.context.result ?? {};
const executionResult = execution.context.executionResult ?? {};
const partialResult = ((execution.context as Record<string, unknown>)['partialResult'] as Record<string, unknown> | undefined) ?? {};
const partialResult = execution.context.partialResult ?? {};
const historyField = dialogState.historyField || (dialogState.kind === 'chat-session' ? 'history' : null);
const responseField = dialogState.responseField || (dialogState.kind === 'chat-session' ? 'response' : null);
@ -128,13 +166,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
const rawHistory = historyField
? partialResult[historyKey ?? '']
?? finalResult[historyKey ?? '']
?? executionResult[historyKey ?? '']
?? stepResult[historyField]
: undefined;
const rawResponse = responseField
? partialResult[responseKey ?? '']
?? finalResult[responseKey ?? '']
?? executionResult[responseKey ?? '']
?? stepResult[responseField]
: undefined;
@ -191,10 +227,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly executionFlowData = computed<FlowData>(() => {
const executionStatusGroup = getExecutionStatusGroup(this.execution()?.context.status);
const contextInputs = this.execution()?.context.inputs ?? {};
const contextResults = {
...(this.execution()?.context.result ?? {}),
...(this.execution()?.context.executionResult ?? {})
};
const contextResults = this.execution()?.context.result ?? {};
const contextErrors = this.execution()?.context.errors ?? {};
const contextWarnings = this.execution()?.context.warnings ?? {};
const waitingSteps = this.execution()?.context.waitingSteps ?? [];
@ -214,6 +247,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionId: this.execution()?.id ?? null,
__executionNodeId: step.id,
__executionStatus: this.execution()?.context.status ?? null,
__interactionSimulationEnabled: this.execution()?.interactionSimulationEnabled === true,
__stepStatus: step.status,
__executionStatusGroup: executionStatusGroup,
__isWaitingStep: waitingSteps.includes(step.id),
@ -224,8 +258,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
__executionErrors: this.getExecutionErrors(step.id, contextErrors),
__executionWarnings: this.getExecutionWarnings(step.id, contextWarnings),
__stepResultData: step.result ?? null,
__executionPartialResult: (this.execution()?.context as Record<string, unknown> | undefined)?.['partialResult'] ?? null,
__executionResultData: this.execution()?.context.result ?? {}
__executionPartialResult: this.execution()?.context.partialResult ?? null
},
position: stepNode.position ?? {
x: 120 + (index % 3) * 340,
@ -257,10 +290,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly executionOutputs = computed<ExecutionOutputEntry[]>(() => {
const steps = this.execution()?.context.steps ?? {};
const resultMap = {
...(this.execution()?.context.result ?? {}),
...(this.execution()?.context.executionResult ?? {})
};
const resultMap = this.execution()?.context.result ?? {};
return Object.entries(resultMap)
.map(([key, rawValue]) => {
@ -284,6 +314,16 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return getExecutionStatusGroup(status) !== 'INIT';
});
readonly visibleExecutionLogs = computed<ExecutionLogEntryView[]>(() =>
[...this.executionLogs()]
.sort((a, b) => a.timestamp - b.timestamp)
.map((entry) => ({
...entry,
messageText: String(entry.message ?? '').trim() || this.fallbackExecutionLogMessage(entry),
levelText: String(entry.level ?? 'INFO').toUpperCase()
}))
);
readonly canCancelExecution = computed(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING');
@ -324,6 +364,23 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return true;
});
readonly canSimulateExecution = computed(() => {
return this.execution()?.simulationAvailable === true && this.canStartExecution() && !this.simulateInProgress();
});
readonly isSimulatedExecution = computed(() => this.execution()?.interactionSimulationEnabled === true);
readonly simulationDescriptorLabel = computed(() => {
const descriptor = this.execution()?.interactionSimulationDescriptor;
if (!descriptor) return null;
const provider = String(descriptor.provider ?? '').trim();
const model = String(descriptor.model ?? '').trim();
if (!provider && !model) return null;
if (!provider) return model;
if (!model) return provider;
return `${provider} / ${model}`;
});
readonly editableInputs = computed<EditableExecutionInput[]>(() => {
const execution = this.execution();
if (!execution) return [];
@ -364,7 +421,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.contextAsideOpen.update((open) => !open);
}
selectAsideTab(tab: 'inputs' | 'output') {
selectAsideTab(tab: 'inputs' | 'logs' | 'output') {
if (tab === 'output' && !this.executionOutputTabEnabled()) return;
this.activeAsideTab.set(tab);
}
@ -393,6 +450,20 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
async simulateExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canSimulateExecution()) return;
const simulator = await this.openSimulationSettings();
if (!simulator) return;
this.simulateInProgress.set(true);
this.taskExecutionsService.simulateExecution(executionId, simulator).subscribe({
next: () => this.simulateInProgress.set(false),
error: () => this.simulateInProgress.set(false)
});
}
cancelExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canCancelExecution()) return;
@ -571,6 +642,111 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
private async openSimulationSettings(): Promise<LLMDescriptor | null> {
const providerOptions = await this.loadSimulationOptions(
'providers',
{},
TaskExecutionViewerComponent.SIMULATOR_PROVIDER_RETRIEVER_URL
);
if (!providerOptions.length) {
return null;
}
const defaultProvider = providerOptions[0].value;
const initialModelOptions = await this.loadSimulationOptions(
'models',
{ provider: defaultProvider },
TaskExecutionViewerComponent.SIMULATOR_MODEL_RETRIEVER_URL
);
const buildFields = (providers: { label: string; value: string; }[], models: { label: string; value: string; }[]): NodeSettingField[] => [
{
key: 'provider',
label: 'Provider',
type: 'select',
options: providers,
required: true,
autofocus: true
},
{
key: 'model',
label: 'Model',
type: 'select',
options: models,
required: true
}
];
const result = await this.settingsDialog.open({
title: 'Simulation Settings',
fields: buildFields(providerOptions, initialModelOptions),
initial: {
provider: defaultProvider,
model: initialModelOptions[0]?.value ?? ''
},
onValuesChange: async (draft) => {
const provider = String(draft['provider'] ?? '').trim();
const modelOptions = provider
? await this.loadSimulationOptions(
'models',
{ provider },
TaskExecutionViewerComponent.SIMULATOR_MODEL_RETRIEVER_URL
)
: [];
return {
fields: buildFields(providerOptions, modelOptions),
initial: {
provider,
model: modelOptions[0]?.value ?? ''
}
};
}
});
if (!result) return null;
const provider = String(result['provider'] ?? '').trim();
const model = String(result['model'] ?? '').trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
private fetchExecutionLogs(executionId: string) {
this.logsLoading.set(true);
this.logsError.set(null);
this.taskExecutionsService.retrieveExecutionEvents(executionId).subscribe({
next: (events) => {
if (this.execution()?.id !== executionId) return;
this.executionLogs.set(Array.isArray(events) ? events : []);
this.logsLoading.set(false);
},
error: () => {
if (this.execution()?.id !== executionId) return;
this.logsError.set('Unable to load execution logs.');
this.logsLoading.set(false);
}
});
}
private async loadSimulationOptions(
key: string,
context: Record<string, string>,
retrieverUrl: string
): Promise<Array<{ label: string; value: string }>> {
const values = await firstValueFrom(
this.fieldRetriever.retrieveValues('LLM', key, context, retrieverUrl)
);
return values.map((value) => ({
label: value,
value
}));
}
private stringifyOutputValue(value: unknown): string {
if (value == null) return '';
if (typeof value === 'string') return value;
@ -582,6 +758,32 @@ export class TaskExecutionViewerComponent implements OnDestroy {
}
}
private fallbackExecutionLogMessage(entry: ExecutionEventLogEntry): string {
const type = String(entry.type ?? '').trim();
if (type) {
return type.replaceAll('_', ' ').toLowerCase().replace(/^\w/, (letter) => letter.toUpperCase());
}
return 'Execution event';
}
logLevelClass(level: string | null | undefined): string {
const normalized = String(level ?? '').toUpperCase();
if (normalized === 'ERROR') return 'execution-log-level-error';
if (normalized === 'WARN' || normalized === 'WARNING') return 'execution-log-level-warn';
return 'execution-log-level-info';
}
logTypeIcon(type: string | null | undefined): string {
const normalized = String(type ?? '').toUpperCase();
if (normalized.includes('FAILED') || normalized.includes('ERROR')) return 'error';
if (normalized.includes('WAITING') || normalized.includes('PAUSED')) return 'pause_circle';
if (normalized.includes('COMPLETED') || normalized.includes('SUCCESS')) return 'check_circle';
if (normalized.includes('HTTP')) return 'language';
if (normalized.includes('LLM')) return 'smart_toy';
if (normalized.includes('MCP_SESSION')) return 'hub';
return 'schedule';
}
private formatExecutionOutputLabel(
key: string,
steps: Record<string, TaskExecutionStep>

View File

@ -110,6 +110,20 @@
color: #64748b;
}
.tasks-list-simulated-badge {
display: inline-flex;
align-items: center;
border: 1px solid #bae6fd;
border-radius: 999px;
background: #e0f2fe;
color: #0c4a6e;
font-size: 10px;
font-weight: 700;
letter-spacing: 0.03em;
padding: 2px 8px;
text-transform: uppercase;
}
.tasks-list-status {
padding: 3px 9px;
border-radius: 999px;

View File

@ -44,6 +44,11 @@
<div class="tasks-list-item-meta">
<div class="tasks-list-item-title">{{ execution.title }}</div>
<div class="tasks-list-item-subtitle">{{ execution.flowName }}</div>
@if (execution.simulated) {
<div class="mt-1">
<span class="tasks-list-simulated-badge">Simulated</span>
</div>
}
</div>
<span class="tasks-list-status"
[ngClass]="statusBadgeClass(execution.status)">

View File

@ -22,6 +22,7 @@ export type TaskExecutionListItem = {
status: TaskExecutionStatus;
startedAt: string;
duration?: string;
simulated?: boolean;
};
@Component({

View File

@ -106,9 +106,11 @@ export class TitleToolbar {
this.taskExecutionsService.createExecution(flow.id).pipe(
take(1)
).subscribe({
next: () => {
next: (execution) => {
this.executeLoading.set(false);
this.router.navigate(['/tasks']);
this.router.navigate(['/tasks'], {
queryParams: { executionId: execution.id }
});
},
error: (err) => {
this.executeLoading.set(false);