Refine execution viewer and schema-driven node handling
This commit is contained in:
parent
5672ce4336
commit
55418d2226
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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...'"
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)">
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ export type TaskExecutionListItem = {
|
|||
status: TaskExecutionStatus;
|
||||
startedAt: string;
|
||||
duration?: string;
|
||||
simulated?: boolean;
|
||||
};
|
||||
|
||||
@Component({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue