feat(execution): add intermediate inputs tab and preview modal

This commit is contained in:
Lucio Lelii 2026-05-13 12:18:26 +02:00
parent 4bcb9f5d3e
commit 877844fca0
7 changed files with 247 additions and 23 deletions

View File

@ -438,7 +438,7 @@
align-items: center;
justify-content: center;
padding: 0;
cursor: help;
cursor: default;
border-radius: 999px;
}
@ -502,16 +502,10 @@
pointer-events: none;
}
.llm-io-tooltip-input {
left: calc(100% + 8px);
}
.llm-io-tooltip-output {
right: calc(100% + 8px);
}
.llm-input-value-wrap:hover .llm-io-tooltip,
.llm-input-value-wrap:focus-within .llm-io-tooltip,
.llm-output-value-wrap:hover .llm-io-tooltip,
.llm-output-value-wrap:focus-within .llm-io-tooltip,
.llm-socket-wrap:hover .llm-io-tooltip,

View File

@ -81,21 +81,15 @@
}"
[emit]="emit">
</div>
@if (showInputTooltip() && executionInputTooltip(input.key); as inputTooltip) {
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputTooltip }}</span>
}
</span>
} @else {
<span class="llm-input-value-wrap" (pointerdown)="$event.stopPropagation()" (click)="$event.stopPropagation()">
<button
type="button"
class="llm-input-value-icon"
aria-label="Show input value">
aria-label="Input value available in Intermediate tab">
<i class="bi bi-box-arrow-in-right"></i>
</button>
@if (showInputTooltip()) {
<span class="llm-io-tooltip llm-io-tooltip-input">{{ inputValueTooltip(input.key) }}</span>
}
</span>
}
<span class="llm-pill llm-pill-input">

View File

@ -352,10 +352,6 @@ export class TaskStepNodeComponent {
return connectedInputs.includes(inputName);
}
inputValueTooltip(inputName: string): string {
return this.executionInputTooltip(inputName) ?? 'not ready yet';
}
showInputTooltip(): boolean {
const statusGroup = this.blockConfiguration?.['__executionStatusGroup'];
return statusGroup !== 'INIT';
@ -767,6 +763,10 @@ export class TaskStepNodeComponent {
).subscribe({
next: () => {
this.interactionSubmitting = false;
if (result.mode === 'complete') {
this.humanInteractionDialog.close(result);
return;
}
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: null

View File

@ -26,17 +26,35 @@ export type ExecutionOutputEntry = {
itemLabel: string | null;
};
export type ExecutionIntermediateInputEntry = {
key: string;
nodeTitle: string;
inputName: string;
inputType: string;
sourceLabel: string;
value: string;
preview: string;
isLong: boolean;
itemLabel: string | null;
};
export type ExecutionOutputGroup = {
nodeTitle: string;
outputs: ExecutionOutputEntry[];
};
export type ExecutionIntermediateInputGroup = {
nodeTitle: string;
inputs: ExecutionIntermediateInputEntry[];
};
export type ExecutionLogEntryView = ExecutionEventLogEntry & {
messageText: string;
levelText: string;
};
const OUTPUT_PREVIEW_LIMIT = 80;
const INTERMEDIATE_INPUT_PREVIEW_LIMIT = 120;
export function stepTitle(step: TaskExecutionStep | null | undefined): string {
return getTaskExecutionStepNode(step)?.name?.trim() || step?.id || 'Step';
@ -158,6 +176,52 @@ export function buildExecutionOutputGroups(outputs: ExecutionOutputEntry[]): Exe
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
}
export function buildExecutionIntermediateInputs(execution: TaskExecution | null): ExecutionIntermediateInputEntry[] {
if (!execution) return [];
const contextInputs = execution.context.inputs ?? {};
return Object.values(execution.context.steps ?? {})
.flatMap((step) => {
const inputValues = getExecutionInputValues(step, contextInputs);
return Object.entries(inputValues).flatMap(([inputName, rawValue]) => {
const input = (step.inputs ?? []).find((candidate) => candidate.descriptor?.name === inputName);
const values = Array.isArray(rawValue) ? rawValue : [rawValue];
const isArrayValue = Array.isArray(rawValue);
return values.map((item, index) => {
const value = stringifyOutputValue(item);
const isLong = value.length > INTERMEDIATE_INPUT_PREVIEW_LIMIT;
return {
key: isArrayValue ? `${step.id}:${inputName}:${index}` : `${step.id}:${inputName}`,
nodeTitle: stepTitle(step),
inputName,
inputType: String(input?.descriptor?.type ?? 'TEXT').toUpperCase(),
sourceLabel: input?.registered ? 'Connected input' : 'Prepared input',
value,
preview: isLong ? `${value.slice(0, INTERMEDIATE_INPUT_PREVIEW_LIMIT)}...` : value,
isLong,
itemLabel: isArrayValue ? `Item ${index + 1}` : null,
};
});
});
})
.filter((entry) => entry.value.trim().length > 0)
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle) || a.inputName.localeCompare(b.inputName));
}
export function buildExecutionIntermediateInputGroups(
inputs: ExecutionIntermediateInputEntry[]
): ExecutionIntermediateInputGroup[] {
const groups = new Map<string, ExecutionIntermediateInputEntry[]>();
for (const input of inputs) {
if (!groups.has(input.nodeTitle)) groups.set(input.nodeTitle, []);
groups.get(input.nodeTitle)!.push(input);
}
return Array.from(groups.entries())
.map(([nodeTitle, items]) => ({ nodeTitle, inputs: items }))
.sort((a, b) => a.nodeTitle.localeCompare(b.nodeTitle));
}
export function buildVisibleExecutionLogs(logs: ExecutionEventLogEntry[]): ExecutionLogEntryView[] {
return [...logs]
.sort((a, b) => a.timestamp - b.timestamp)

View File

@ -156,8 +156,9 @@
color: #64748b;
font-size: 12px;
font-weight: 700;
padding: 9px 12px;
padding: 9px 9px;
cursor: pointer;
white-space: nowrap;
}
.execution-aside-tab-active {
@ -184,9 +185,78 @@
word-break: break-word;
}
.execution-intermediate-group {
border: 1px solid #d9eadf;
border-radius: 8px;
background: #f7fbf8;
padding: 12px;
}
.execution-intermediate-group-title {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 14px;
font-weight: 800;
color: #0f172a;
}
.execution-intermediate-group-list {
margin-top: 10px;
display: flex;
flex-direction: column;
gap: 10px;
}
.execution-intermediate-item {
border: 1px solid #cfe6d5;
border-radius: 8px;
background: #ffffff;
padding: 10px 12px;
}
.execution-intermediate-item-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.execution-intermediate-item-name {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
color: #15803d;
}
.execution-intermediate-item-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 8px;
margin-top: 3px;
color: #64748b;
font-size: 10px;
font-weight: 700;
}
.execution-intermediate-preview {
margin-top: 8px;
white-space: pre-wrap;
word-break: break-word;
user-select: text;
color: #0f172a;
font-size: 12px;
line-height: 1.45;
}
.execution-output-group {
border: 1px solid #e2e8f0;
border-radius: 12px;
border-radius: 8px;
background: #f8fafc;
padding: 12px;
}
@ -206,7 +276,7 @@
.execution-output-item {
border: 1px solid #dbe7f5;
border-radius: 10px;
border-radius: 8px;
background: #ffffff;
padding: 10px 12px;
}

View File

@ -120,6 +120,13 @@
(click)="selectAsideTab('inputs')">
Inputs
</button>
<button
type="button"
class="execution-aside-tab"
[class.execution-aside-tab-active]="activeAsideTab() === 'intermediate'"
(click)="selectAsideTab('intermediate')">
Intermediate
</button>
<button
type="button"
class="execution-aside-tab"
@ -154,6 +161,46 @@
(textInputSubmit)="submitTextInput($event)"
(fileInputChange)="onFileInputChange($event.input, $event.files)">
</app-task-execution-inputs-panel>
} @else if (activeAsideTab() === 'intermediate') {
<div class="p-3 space-y-3">
@if (!executionIntermediateInputGroups().length) {
<div class="text-xs text-slate-500">No intermediate inputs available.</div>
} @else {
@for (group of executionIntermediateInputGroups(); track group.nodeTitle) {
<div class="execution-intermediate-group">
<div class="execution-intermediate-group-title">{{ group.nodeTitle }}</div>
<div class="execution-intermediate-group-list">
@for (input of group.inputs; track input.key) {
<div class="execution-intermediate-item">
<div class="execution-intermediate-item-head">
<div class="min-w-0">
<div class="execution-intermediate-item-name">{{ input.inputName }}</div>
<div class="execution-intermediate-item-meta">
<span>{{ input.sourceLabel }}</span>
<span>{{ input.inputType }}</span>
@if (input.itemLabel) {
<span>{{ input.itemLabel }}</span>
}
</div>
</div>
@if (input.isLong) {
<button
type="button"
class="execution-output-view-btn"
aria-label="View full intermediate input"
(click)="openIntermediateInputPreview(input, $event)">
<i class="bi bi-eye"></i>
</button>
}
</div>
<div class="execution-intermediate-preview">{{ input.preview }}</div>
</div>
}
</div>
</div>
}
}
</div>
} @else if (activeAsideTab() === 'logs') {
<div #logsScrollViewport class="execution-logs-scroll p-3 space-y-3">
@if (logsLoading()) {
@ -248,6 +295,23 @@
</div>
</div>
}
@if (intermediateInputPreviewModal(); as inputModal) {
<div class="fixed inset-0 z-9999 flex items-center justify-center p-4">
<div class="absolute inset-0 bg-black/45" (click)="closeIntermediateInputPreview($event)"></div>
<div class="relative z-10 flex max-h-[90vh] w-full max-w-4xl flex-col overflow-hidden rounded-xl border border-slate-200 bg-white shadow-2xl">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 px-5 py-4">
<div class="min-w-0">
<h3 class="text-lg font-semibold text-slate-900">{{ intermediateInputPreviewTitle(inputModal) }}</h3>
<p class="mt-1 break-all text-xs text-slate-500">{{ intermediateInputPreviewSubtitle(inputModal) }}</p>
</div>
<button type="button" mat-stroked-button (click)="closeIntermediateInputPreview($event)">Close</button>
</div>
<div class="min-h-0 flex-1 overflow-auto px-5 py-4">
<pre class="execution-output-modal">{{ inputModal.value }}</pre>
</div>
</div>
</div>
}
} @else {
<div class="h-full bg-white border border-slate-200 rounded-md text-slate-400 text-center flex items-center justify-center">
Select an execution from the left panel

View File

@ -39,6 +39,8 @@ import { firstValueFrom } from 'rxjs';
import {
ExecutionOutputEntry,
ExecutionOutputGroup,
ExecutionIntermediateInputEntry,
ExecutionIntermediateInputGroup,
ExecutionLogEntryView,
stepTitle,
stepNodeId,
@ -50,6 +52,8 @@ import {
formatExecutionOutputLabel,
buildExecutionOutputs,
buildExecutionOutputGroups,
buildExecutionIntermediateInputs,
buildExecutionIntermediateInputGroups,
buildVisibleExecutionLogs,
isInputSet,
normalizeEditableInputValue,
@ -81,7 +85,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly SIMULATOR_MODEL_RETRIEVER_URL = '/retriever/LLM/models';
readonly execution = input<TaskExecution | null>(null);
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'logs' | 'output'>('inputs');
readonly activeAsideTab = signal<'inputs' | 'intermediate' | 'logs' | 'output'>('inputs');
readonly startInProgress = signal(false);
readonly simulateInProgress = signal(false);
readonly cancelInProgress = signal(false);
@ -93,6 +97,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly savingAuthorizations = signal<Record<string, boolean>>({});
readonly authorizationErrors = signal<Record<string, string>>({});
readonly outputPreviewModal = signal<ExecutionOutputEntry | null>(null);
readonly intermediateInputPreviewModal = signal<ExecutionIntermediateInputEntry | null>(null);
readonly executionLogs = signal<ExecutionEventLogEntry[]>([]);
readonly logsLoading = signal(false);
readonly logsError = signal<string | null>(null);
@ -109,6 +114,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.authorizationErrors.set({});
this.activeAsideTab.set('inputs');
this.outputPreviewModal.set(null);
this.intermediateInputPreviewModal.set(null);
this.executionLogs.set([]);
this.logsError.set(null);
this.logsLoading.set(false);
@ -324,6 +330,14 @@ export class TaskExecutionViewerComponent implements OnDestroy {
buildExecutionOutputGroups(this.executionOutputs())
);
readonly executionIntermediateInputs = computed<ExecutionIntermediateInputEntry[]>(() =>
buildExecutionIntermediateInputs(this.execution())
);
readonly executionIntermediateInputGroups = computed<ExecutionIntermediateInputGroup[]>(() =>
buildExecutionIntermediateInputGroups(this.executionIntermediateInputs())
);
readonly inputsReadOnly = computed(() => {
const status = this.execution()?.context.status;
return getExecutionStatusGroup(status) !== 'INIT';
@ -468,7 +482,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.contextAsideOpen.update((open) => !open);
}
selectAsideTab(tab: 'inputs' | 'logs' | 'output') {
selectAsideTab(tab: 'inputs' | 'intermediate' | 'logs' | 'output') {
if (tab === 'output' && !this.executionOutputTabEnabled()) return;
this.activeAsideTab.set(tab);
}
@ -496,6 +510,30 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.outputPreviewModal.set(null);
}
openIntermediateInputPreview(input: ExecutionIntermediateInputEntry, event?: Event) {
event?.preventDefault();
event?.stopPropagation();
if (!input.isLong) return;
this.intermediateInputPreviewModal.set(input);
}
intermediateInputPreviewTitle(input: ExecutionIntermediateInputEntry | null): string {
if (!input) return 'Intermediate input';
return input.nodeTitle;
}
intermediateInputPreviewSubtitle(input: ExecutionIntermediateInputEntry | null): string {
if (!input) return '';
const itemLabel = input.itemLabel ? ` · ${input.itemLabel}` : '';
return `${input.inputName}${itemLabel}`;
}
closeIntermediateInputPreview(event?: Event) {
event?.preventDefault();
event?.stopPropagation();
this.intermediateInputPreviewModal.set(null);
}
startExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canStartExecution() || this.startInProgress()) return;