diff --git a/src/app/layouts/app-layout/app-layout.ts b/src/app/layouts/app-layout/app-layout.ts index 9b90784..b6967a6 100644 --- a/src/app/layouts/app-layout/app-layout.ts +++ b/src/app/layouts/app-layout/app-layout.ts @@ -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); + }); }); } diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts index 1c31669..622751a 100644 --- a/src/app/layouts/flow-editor/flow-editor.ts +++ b/src/app/layouts/flow-editor/flow-editor.ts @@ -245,8 +245,9 @@ export class FlowEditor { } private async buildDemoFlowData(): Promise { - 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 }); diff --git a/src/app/layouts/tasks-executor/tasks-executor.ts b/src/app/layouts/tasks-executor/tasks-executor.ts index f4e7e83..39e01ea 100644 --- a/src/app/layouts/tasks-executor/tasks-executor.ts +++ b/src/app/layouts/tasks-executor/tasks-executor.ts @@ -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(null); + readonly requestedExecutionId = signal(null); readonly selectedExecution = computed(() => { 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) { diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 2281c56..7b3c165 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -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; providedAuthorizations?: Record; @@ -27,6 +42,7 @@ export type TaskExecutionAuthorizationRequirement = { export type TaskExecutionContext = { inputs: Record; result: Record; + partialResult?: Record; startTime?: number | null; endTime?: number | null; errors: Record; @@ -34,13 +50,10 @@ export type TaskExecutionContext = { steps: Record; status: TaskExecutionStatus; waitingSteps: string[]; - executionResult: Record; }; 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; } diff --git a/src/app/services/blocks/block-call.base.ts b/src/app/services/blocks/block-call.base.ts index e27b137..90a0335 100644 --- a/src/app/services/blocks/block-call.base.ts +++ b/src/app/services/blocks/block-call.base.ts @@ -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; - abstract createEmptyBlock(blockType: BlockTypeName) : Observable; + abstract createEmptyBlock(blockType: BlockTypeName, context?: BlockDraftContext) : Observable; - abstract updateBlock(blockId : string, configuration : any) : Observable; + abstract updateBlock(blockId : string, configuration : any, context?: BlockDraftContext) : Observable; } diff --git a/src/app/services/blocks/blocks-call.fake.ts b/src/app/services/blocks/blocks-call.fake.ts index 72b7cbb..464a4f8 100644 --- a/src/app/services/blocks/blocks-call.fake.ts +++ b/src/app/services/blocks/blocks-call.fake.ts @@ -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 { + override createEmptyBlock(blockType: string, _context?: BlockDraftContext): Observable { const descriptor = this.blockTypes.find((b) => b.type === blockType); const typeName = descriptor?.type ?? blockType ?? "LLMBlock"; const schema = descriptor?.schema as Record | null; @@ -177,22 +177,87 @@ export class BlocksCallServiceFake extends BlocksCallServiceBase { return of(block); } - override updateBlock(blockId: string, configuration: any): Observable { + override updateBlock(blockId: string, configuration: any, _context?: BlockDraftContext): Observable { 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 | null) ?? null, + (descriptor?.schema as Record | 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 | null, + schemaRoot: Record | null + ) { + if (!configuration || typeof configuration !== 'object' || Array.isArray(configuration)) return {}; + if (!schemaNode || !schemaRoot) return { ...(configuration as Record) }; + + const resolved = this.resolveRef(schemaNode, schemaRoot); + const schemaRecord = resolved && typeof resolved === 'object' && !Array.isArray(resolved) + ? resolved as Record + : {}; + const properties = schemaRecord['properties'] && typeof schemaRecord['properties'] === 'object' && !Array.isArray(schemaRecord['properties']) + ? schemaRecord['properties'] as Record + : {}; + + if (!Object.keys(properties).length) { + return { ...(configuration as Record) }; + } + + const sanitized: Record = {}; + for (const [key, value] of Object.entries(configuration as Record)) { + if (!Object.prototype.hasOwnProperty.call(properties, key)) continue; + const propertySchema = properties[key] && typeof properties[key] === 'object' && !Array.isArray(properties[key]) + ? properties[key] as Record + : null; + sanitized[key] = this.sanitizeSchemaValue(value, propertySchema, schemaRoot); + } + + return sanitized; + } + + private sanitizeSchemaValue( + value: unknown, + schemaNode: Record | null, + schemaRoot: Record | 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 + : {}; + const type = schemaRecord['type']; + + if ((type === 'object' || schemaRecord['properties']) && value && typeof value === 'object' && !Array.isArray(value)) { + return this.sanitizeConfigurationBySchema(value as Record, schemaRecord, schemaRoot); + } + + if (type === 'array' && Array.isArray(value)) { + const itemSchema = schemaRecord['items'] && typeof schemaRecord['items'] === 'object' && !Array.isArray(schemaRecord['items']) + ? schemaRecord['items'] as Record + : null; + return value.map((item) => this.sanitizeSchemaValue(item, itemSchema, schemaRoot)); + } + + return value; + } + private defaultIOForBlockType(typeName: string) { if (typeName === "SourceBlock") { return { diff --git a/src/app/services/blocks/blocks-call.ts b/src/app/services/blocks/blocks-call.ts index 4723ccb..d30f121 100644 --- a/src/app/services/blocks/blocks-call.ts +++ b/src/app/services/blocks/blocks-call.ts @@ -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 { + override createEmptyBlock(blockType: string, context?: BlockDraftContext): Observable { 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(`${environment.apiUrl}/blocks`, payload) + .post(`${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 { + override updateBlock(blockId: string, configuration: any, context?: BlockDraftContext): Observable { 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(`${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(`${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 { @@ -203,8 +213,13 @@ export class BlocksCallService extends BlocksCallServiceBase { return `${environment.apiUrl}${value.startsWith("/") ? value : `/${value}`}`; } - private buildBlockConfigurationPayload(blockType: string, configuration: Record) { - const { typeName: _ignoreTypeName, ...sanitized } = configuration; + private buildBlockConfigurationPayload( + blockType: string, + configuration: Record, + schema?: Record | 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, + schemaNode: Record | null, + schemaRoot: Record | null + ): Record { + 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 = {}; + 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 | null, + schemaRoot: Record | 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"]; diff --git a/src/app/services/blocks/blocks.ts b/src/app/services/blocks/blocks.ts index a39409a..1a82608 100644 --- a/src/app/services/blocks/blocks.ts +++ b/src/app/services/blocks/blocks.ts @@ -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)); }), diff --git a/src/app/services/retriever/field-retriever-call.fake.ts b/src/app/services/retriever/field-retriever-call.fake.ts index 92add94..b140380 100644 --- a/src/app/services/retriever/field-retriever-call.fake.ts +++ b/src/app/services/retriever/field-retriever-call.fake.ts @@ -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( _blockType: string, key: string, - _context?: Record, - _retrieverUrl?: string | null + context?: Record, + retrieverUrl?: string | null ): Observable[]> { if (key === 'subFlow') { return of(this.subFlowItems as unknown as RetrieverStructuredItem[]); } + 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[]); + } + } 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; + } } diff --git a/src/app/services/retriever/field-retriever-call.ts b/src/app/services/retriever/field-retriever-call.ts index 3e7a837..c07b0f5 100644 --- a/src/app/services/retriever/field-retriever-call.ts +++ b/src/app/services/retriever/field-retriever-call.ts @@ -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(raw: unknown): RetrieverStructuredItem[] { @@ -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; + 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 { return value && typeof value === 'object' && !Array.isArray(value) ? value as Record diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 11000fd..8d7ca71 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -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; + abstract retrieveExecutionEvents(executionId: string): Observable; abstract createTaskExecution(flowId: string): Observable; abstract deleteTaskExecution(executionId: string): Observable; abstract startTaskExecution(executionId: string): Observable; + abstract simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable; abstract cancelTaskExecution(executionId: string): Observable; abstract resumeTaskExecution(executionId: string): Observable; abstract prepareStringInput( diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index 90af944..e787d19 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -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 { - return of(this.data); + return of(this.data.map((execution) => this.withSimulationAvailability(execution))); + } + + override retrieveExecutionEvents(executionId: string): Observable { + const execution = this.findExecution(executionId); + return of(this.buildExecutionEvents(execution)); } override createTaskExecution(flowId: string): Observable { @@ -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 { @@ -489,19 +488,48 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase override startTaskExecution(executionId: string): Observable { 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 { + 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 { 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)['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 { 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}`; + } } diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index df59f31..e8c1bbb 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -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(`${environment.apiUrl}/executions`); } + override retrieveExecutionEvents(executionId: string): Observable { + return this.http.get(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/events`); + } + override createTaskExecution(flowId: string): Observable { return this.http.post(`${environment.apiUrl}/executions`, flowId); } @@ -24,6 +29,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null); } + override simulateTaskExecution(executionId: string, simulator: LLMDescriptor): Observable { + return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/simulate`, { simulator }); + } + override cancelTaskExecution(executionId: string): Observable { return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null); } diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index f0faefa..b5e5485 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -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) => { diff --git a/src/app/shared/node-settings-dialog/node-settings-dialog.ts b/src/app/shared/node-settings-dialog/node-settings-dialog.ts index 74b2e05..fa2e17f 100644 --- a/src/app/shared/node-settings-dialog/node-settings-dialog.ts +++ b/src/app/shared/node-settings-dialog/node-settings-dialog.ts @@ -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; } diff --git a/src/app/shared/nodes/container-node/container-node.css b/src/app/shared/nodes/container-node/container-node.css index 0338048..0427e18 100644 --- a/src/app/shared/nodes/container-node/container-node.css +++ b/src/app/shared/nodes/container-node/container-node.css @@ -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; diff --git a/src/app/shared/nodes/container-node/container-node.html b/src/app/shared/nodes/container-node/container-node.html index 15e93e4..0e59983 100644 --- a/src/app/shared/nodes/container-node/container-node.html +++ b/src/app/shared/nodes/container-node/container-node.html @@ -97,13 +97,38 @@ - @if (hasParameterFields()) { -
+ @if (hasParameterFields() || hasMainContent()) { +
+ @if (!schemaReady) { + + } @for (field of parameterFields; track field.path) {
{{ field.label }} - @if (!isReadonly) { + @if (field.type === 'boolean' && !isReadonly) { + + } @else if (!isReadonly) {
- {{ field.value }} + {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }} +
+ } + @for (contentField of richContentFields; track contentField.path) { +
+
+ {{ contentField.label }} +
+
+ @for (part of contentField.parts; track $index) { + @if (part.isDynamicInput) { + {{ formatDynamicInputToken(part.text) }} + } @else { + {{ part.text }} + } + } +
}
diff --git a/src/app/shared/nodes/container-node/container-node.ts b/src/app/shared/nodes/container-node/container-node.ts index 807015c..a616901 100644 --- a/src/app/shared/nodes/container-node/container-node.ts +++ b/src/app/shared/nodes/container-node/container-node.ts @@ -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 | 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 | 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()): 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)); }); } diff --git a/src/app/shared/nodes/generic-node/generic-node.css b/src/app/shared/nodes/generic-node/generic-node.css index 825bad2..d07854d 100644 --- a/src/app/shared/nodes/generic-node/generic-node.css +++ b/src/app/shared/nodes/generic-node/generic-node.css @@ -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; diff --git a/src/app/shared/nodes/generic-node/generic-node.html b/src/app/shared/nodes/generic-node/generic-node.html index ff83d64..0481618 100644 --- a/src/app/shared/nodes/generic-node/generic-node.html +++ b/src/app/shared/nodes/generic-node/generic-node.html @@ -155,7 +155,20 @@
-
+
+ @if (!schemaReady) { + + } @if (parameterFieldGroups.length) {
@for (group of parameterFieldGroups; track group.key) { @@ -166,7 +179,19 @@
{{ field.label }} - @if (!isReadonly) { + @if (field.type === 'boolean' && !isReadonly) { + + } @else if (!isReadonly) {
- {{ field.value }} + {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}
}
@@ -193,7 +218,19 @@
{{ field.label }} - @if (!isReadonly) { + @if (field.type === 'boolean' && !isReadonly) { + + } @else if (!isReadonly) {
- {{ field.value }} + {{ field.type === 'boolean' ? (field.booleanValue ? 'Enabled' : 'Disabled') : field.value }}
}
@@ -339,7 +376,7 @@ } @else if (localEditorWidget === 'textarea') {