Refine execution chat modal and add cancel support

This commit is contained in:
Lucio Lelii 2026-03-20 12:54:05 +01:00
parent 19b0dadacc
commit 447b01786d
12 changed files with 494 additions and 60 deletions

View File

@ -1,6 +1,6 @@
import { FlowBlock, FlowBlockConnection, FlowContainer, FlowNode, FlowPort } from './flow';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUCCESS' | 'ERROR';
export type TaskExecutionStatus = 'CREATED' | 'READY' | 'RUNNING' | 'WAITING' | 'SUCCESS' | 'ERROR' | 'CANCELLED';
export type TaskExecutionStatusGroup = 'INIT' | 'RUNNING' | 'FINAL';
export type StepStatus = 'WAITING_FOR_INPUT' | 'FAILED' | 'COMPLETED' | 'RUNNING' | string;
@ -73,7 +73,7 @@ export function getExecutionStatusGroup(status: string | null | undefined): Task
) {
return 'RUNNING';
}
if (normalized === 'SUCCESS' || normalized === 'ERROR') {
if (normalized === 'SUCCESS' || normalized === 'ERROR' || normalized === 'CANCELLED') {
return 'FINAL';
}
@ -88,6 +88,7 @@ export function normalizeExecutionStatus(status: string | null | undefined): Tas
if (normalized === 'WAITING' || normalized === 'WAITING_FOR_INPUT' || normalized === 'WAITING_FOR_INTERACTION') {
return 'WAITING';
}
if (normalized === 'CANCELLED') return 'CANCELLED';
if (normalized === 'SUCCESS' || normalized === 'COMPLETED') return 'SUCCESS';
if (normalized === 'ERROR' || normalized === 'FAILED') return 'ERROR';
return 'CREATED';

View File

@ -8,14 +8,25 @@ export type HumanInteractionChatMessage = {
};
export type HumanInteractionDialogInput = {
executionId?: string | null;
nodeId?: string | null;
title?: string;
kind: BlockInteractionContractKind;
actionDescription?: string;
currentInput?: string;
history?: HumanInteractionChatMessage[];
latestResponse?: string;
historyField?: string | null;
responseField?: string | null;
messageField?: string | null;
completionField?: string | null;
pendingUserMessage?: string | null;
awaitingAssistantResponse?: boolean;
assistantResponseBaseline?: string;
isRunning?: boolean;
isSubmitting?: boolean;
submitError?: string | null;
onSubmit?: (value: HumanInteractionDialogResult) => void;
};
export type HumanInteractionDialogResult = {
@ -26,14 +37,25 @@ export type HumanInteractionDialogResult = {
@Injectable({ providedIn: 'root' })
export class HumanInteractionDialogService {
private _state = signal<{
executionId: string | null;
nodeId: string | null;
title: string;
kind: BlockInteractionContractKind;
actionDescription: string;
currentInput: string;
history: HumanInteractionChatMessage[];
latestResponse: string;
historyField: string | null;
responseField: string | null;
messageField: string | null;
completionField: string | null;
pendingUserMessage: string | null;
awaitingAssistantResponse: boolean;
assistantResponseBaseline: string;
isRunning: boolean;
isSubmitting: boolean;
submitError: string | null;
onSubmit: ((value: HumanInteractionDialogResult) => void) | null;
resolve: (value: HumanInteractionDialogResult | null) => void;
} | null>(null);
@ -42,19 +64,71 @@ export class HumanInteractionDialogService {
open(input: HumanInteractionDialogInput): Promise<HumanInteractionDialogResult | null> {
return new Promise((resolve) => {
this._state.set({
executionId: input.executionId ?? null,
nodeId: input.nodeId ?? null,
title: input.title ?? 'Human interaction',
kind: input.kind,
actionDescription: input.actionDescription ?? '',
currentInput: input.currentInput ?? '',
history: input.history ?? [],
latestResponse: input.latestResponse ?? '',
historyField: input.historyField ?? null,
responseField: input.responseField ?? null,
messageField: input.messageField ?? null,
completionField: input.completionField ?? null,
pendingUserMessage: input.pendingUserMessage ?? null,
awaitingAssistantResponse: input.awaitingAssistantResponse === true,
assistantResponseBaseline: input.assistantResponseBaseline ?? '',
isRunning: input.isRunning === true,
isSubmitting: input.isSubmitting === true,
submitError: input.submitError ?? null,
onSubmit: input.onSubmit ?? null,
resolve
});
});
}
update(input: Partial<Omit<HumanInteractionDialogInput, 'onSubmit'>> & {
onSubmit?: ((value: HumanInteractionDialogResult) => void) | null;
}) {
const state = this._state();
if (!state) return;
this._state.set({
...state,
executionId: input.executionId !== undefined ? input.executionId : state.executionId,
nodeId: input.nodeId !== undefined ? input.nodeId : state.nodeId,
title: input.title ?? state.title,
kind: input.kind ?? state.kind,
actionDescription: input.actionDescription ?? state.actionDescription,
currentInput: input.currentInput ?? state.currentInput,
history: input.history ?? state.history,
latestResponse: input.latestResponse ?? state.latestResponse,
historyField: input.historyField !== undefined ? input.historyField : state.historyField,
responseField: input.responseField !== undefined ? input.responseField : state.responseField,
messageField: input.messageField !== undefined ? input.messageField : state.messageField,
completionField: input.completionField !== undefined ? input.completionField : state.completionField,
pendingUserMessage: input.pendingUserMessage !== undefined ? input.pendingUserMessage : state.pendingUserMessage,
awaitingAssistantResponse: input.awaitingAssistantResponse ?? state.awaitingAssistantResponse,
assistantResponseBaseline: input.assistantResponseBaseline ?? state.assistantResponseBaseline,
isRunning: input.isRunning ?? state.isRunning,
isSubmitting: input.isSubmitting ?? state.isSubmitting,
submitError: input.submitError !== undefined ? input.submitError : state.submitError,
onSubmit: input.onSubmit !== undefined ? input.onSubmit : state.onSubmit
});
}
submit(value: HumanInteractionDialogResult) {
const state = this._state();
state?.onSubmit?.(value);
}
isOpenFor(executionId: string | null | undefined, nodeId: string | null | undefined): boolean {
const state = this._state();
if (!state) return false;
return state.executionId === (executionId ?? null) && state.nodeId === (nodeId ?? null);
}
close(value: HumanInteractionDialogResult | null) {
const state = this._state();
if (!state) return;

View File

@ -6,6 +6,7 @@ export abstract class TaskExecutionsCallServiceBase {
abstract createTaskExecution(flowId: string): Observable<TaskExecution>;
abstract deleteTaskExecution(executionId: string): Observable<void>;
abstract startTaskExecution(executionId: string): Observable<TaskExecution>;
abstract cancelTaskExecution(executionId: string): Observable<TaskExecution>;
abstract prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -494,6 +494,28 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase
return of(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.errors = {};
execution.context.warnings = {};
execution.context.waitingSteps = [];
for (const step of Object.values(execution.context.steps ?? {})) {
const status = String(step.status ?? '').toUpperCase();
if (status === 'RUNNING' || status === 'WAITING_FOR_INPUT' || status === 'WAITING_FOR_INTERACTION' || status === 'WAITING') {
step.status = 'CANCELLED';
}
}
return of(execution);
}
override prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -24,6 +24,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null);
}
override cancelTaskExecution(executionId: string): Observable<TaskExecution> {
return this.http.put<TaskExecution>(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null);
}
override prepareStringInput(
executionId: string,
nodeId: string,

View File

@ -67,6 +67,16 @@ export class TaskExecutionsService {
);
}
cancelExecution(executionId: string) {
return this.taskExecutionsCallService.cancelTaskExecution(executionId).pipe(
tap(() => this.refresh()),
catchError((err) => {
console.error('Cancel execution failed', err);
return throwError(() => err);
})
);
}
prepareStringInput(executionId: string, nodeId: string, inputName: string, value: string) {
return this.taskExecutionsCallService.prepareStringInput(executionId, nodeId, inputName, value).pipe(
tap(() => this.refresh()),

View File

@ -19,9 +19,28 @@
@if (currentState.kind === 'chat-session') {
<div class="flex min-h-0 flex-1 flex-col bg-slate-50">
<div class="flex-1 overflow-y-auto px-5 py-5">
<div #messagesContainer class="flex-1 overflow-y-auto px-5 py-5">
@if (currentState.isSubmitting || currentState.isRunning) {
<div class="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-900">
<div class="font-medium">
@if (currentState.isSubmitting) {
Sending message...
} @else {
Waiting for node response...
}
</div>
<div class="mt-1 text-amber-800/80">The window stays open until you close it.</div>
</div>
}
@if (currentState.submitError) {
<div class="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ currentState.submitError }}
</div>
}
<div class="flex flex-col gap-3">
@for (message of currentState.history; track $index) {
@for (message of displayMessages(); track $index) {
<div class="flex" [class.justify-end]="message.role === 'user'" [class.justify-start]="message.role !== 'user'">
<div
class="max-w-[85%] rounded-2xl px-4 py-3 shadow-sm"
@ -37,13 +56,27 @@
</div>
}
@if (!currentState.history.length) {
@if (!displayMessages().length) {
<div class="py-8 text-center text-sm text-slate-500">No messages yet.</div>
}
</div>
</div>
<div class="border-t border-slate-200 bg-white px-5 py-4">
@if (currentState.isSubmitting || currentState.isRunning) {
<div class="flex min-h-[5.5rem] items-center justify-center rounded-xl border border-slate-200 bg-slate-50 px-4 py-5 text-slate-600">
<div class="flex items-center gap-3">
<div class="h-5 w-5 animate-spin rounded-full border-2 border-slate-300 border-t-slate-700"></div>
<div class="text-sm font-medium">
@if (currentState.isSubmitting) {
Sending message...
} @else {
Waiting for node response...
}
</div>
</div>
</div>
} @else {
<mat-form-field appearance="outline" class="w-full">
<mat-label>Type a message</mat-label>
<textarea
@ -55,6 +88,7 @@
(ngModelChange)="setDraftValue($event)">
</textarea>
</mat-form-field>
}
</div>
</div>
} @else {
@ -79,6 +113,7 @@
class="min-h-40"
rows="7"
[ngModel]="draftValue"
[disabled]="currentState.isSubmitting || currentState.isRunning"
[attr.data-autofocus]="'true'"
(ngModelChange)="setDraftValue($event)">
</textarea>

View File

@ -1,4 +1,4 @@
import { Component, effect, ElementRef, inject, signal } from '@angular/core';
import { Component, computed, effect, ElementRef, inject, signal, viewChild } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
@ -17,22 +17,75 @@ import {
export class HumanInteractionDialogHostComponent {
private dialog = inject(HumanInteractionDialogService);
private host = inject(ElementRef<HTMLElement>);
private lastDialogKey: string | null = null;
private readonly messagesContainer = viewChild<ElementRef<HTMLElement>>('messagesContainer');
readonly state = this.dialog.state;
readonly editing = signal(false);
readonly displayMessages = computed(() => {
const state = this.state();
if (!state) return [];
const history = Array.isArray(state.history) ? state.history : [];
const baseMessages = [...history];
const pendingUserMessage = String(state.pendingUserMessage ?? '').trim();
if (pendingUserMessage) {
const lastHistoryMessage = history[history.length - 1];
if (!(lastHistoryMessage?.role === 'user' && String(lastHistoryMessage.content ?? '').trim() === pendingUserMessage)) {
baseMessages.push({ role: 'user', content: state.pendingUserMessage! });
}
}
const latestResponse = String(state.latestResponse ?? '').trim();
if (!latestResponse) return this.deduplicateMessages(baseMessages);
const shouldAppendAssistant =
!state.awaitingAssistantResponse ||
latestResponse !== String(state.assistantResponseBaseline ?? '').trim();
if (!shouldAppendAssistant) {
return this.deduplicateMessages(baseMessages);
}
const lastMessage = baseMessages[baseMessages.length - 1];
if (lastMessage?.role === 'assistant' && String(lastMessage.content ?? '').trim() === latestResponse) {
return this.deduplicateMessages(baseMessages);
}
return this.deduplicateMessages([
...baseMessages,
{ role: 'assistant' as const, content: state.latestResponse }
]);
});
draftValue = '';
constructor() {
effect(() => {
const state = this.state();
if (!state) return;
this.editing.set(state.kind !== 'chat-session');
this.draftValue = '';
if (!state) {
this.lastDialogKey = null;
return;
}
const dialogKey = `${state.executionId ?? ''}:${state.nodeId ?? ''}:${state.kind}`;
if (dialogKey !== this.lastDialogKey) {
this.lastDialogKey = dialogKey;
this.editing.set(state.kind !== 'chat-session');
this.draftValue = '';
}
queueMicrotask(() => {
const target = this.host.nativeElement.querySelector('[data-autofocus="true"]') as HTMLElement | null;
target?.focus();
});
});
effect(() => {
this.displayMessages();
this.state()?.isSubmitting;
this.state()?.isRunning;
queueMicrotask(() => {
const container = this.messagesContainer()?.nativeElement;
if (!container) return;
container.scrollTop = container.scrollHeight;
});
});
}
cancel(event?: Event) {
@ -80,7 +133,8 @@ export class HumanInteractionDialogHostComponent {
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'message', value: this.draftValue });
this.dialog.submit({ mode: 'message', value: this.draftValue });
this.draftValue = '';
}
completeChatSession(event?: Event) {
@ -88,13 +142,30 @@ export class HumanInteractionDialogHostComponent {
event?.stopPropagation();
const value = this.draftValue.trim();
if (!value) return;
this.closeWith({ mode: 'complete', value: this.draftValue });
this.dialog.submit({ mode: 'complete', value: this.draftValue });
this.draftValue = '';
}
canSendEditedOutput(): boolean {
const state = this.state();
if (state?.isSubmitting || state?.isRunning) return false;
return this.draftValue.trim().length > 0;
}
private deduplicateMessages(messages: Array<{ role: 'user' | 'assistant' | 'system'; content: string }>) {
const deduplicated: Array<{ role: 'user' | 'assistant' | 'system'; content: string }> = [];
for (const message of messages) {
const content = String(message.content ?? '').trim();
if (!content) continue;
const last = deduplicated[deduplicated.length - 1];
if (last?.role === message.role && String(last.content ?? '').trim() === content) {
continue;
}
deduplicated.push({ ...message, content: message.content });
}
return deduplicated;
}
private closeWith(value: HumanInteractionDialogResult) {
this.dialog.close(value);
}

View File

@ -372,39 +372,10 @@ export class TaskStepNodeComponent {
const contract = this.interactionContract();
if (!executionId || !executionNodeId || !contract) return;
const currentInput = this.currentInputValue();
const actionDescription = this.actionDescriptionValue();
const result = await this.humanInteractionDialog.open({
title: this.interactionDialogTitle(contract),
kind: contract.kind,
actionDescription,
currentInput,
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
messageField: contract.messageField,
completionField: contract.completionField
});
if (!result) return;
const interactionFieldName = result.mode === 'message'
? contract.messageField
: contract.completionField;
if (!interactionFieldName) return;
this.interactionSubmitting = true;
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
interactionFieldName,
result.value
).subscribe({
next: () => {
this.interactionSubmitting = false;
},
error: (error) => {
this.interactionSubmitting = false;
console.error('Submit interaction output failed', error);
this.humanInteractionDialog.open({
...this.buildInteractionDialogState(executionId, executionNodeId, contract),
onSubmit: (result) => {
this.submitInteractionResult(executionId, executionNodeId, contract, result);
}
});
}
@ -579,13 +550,20 @@ export class TaskStepNodeComponent {
}
private chatHistory(contract: BlockInteractionContract): Array<{ role: 'user' | 'assistant' | 'system'; content: string }> {
const historyField = contract.historyField;
if (!historyField) return [];
const historyField = this.resolveInteractionFieldName(contract, 'history');
const latestResponse = this.latestInteractionResponse(contract);
if (!historyField) {
return latestResponse
? [{ role: 'assistant', content: latestResponse }]
: [];
}
const rawHistory = this.interactionFieldValue(historyField, Boolean(contract.supportsPartialResult));
if (!Array.isArray(rawHistory)) return [];
return rawHistory
const rawHistory =
this.interactionFieldValue(historyField, true)
?? this.interactionFieldValue(historyField, false);
const normalizedHistory = !Array.isArray(rawHistory)
? []
: rawHistory
.map((entry) => {
if (typeof entry === 'string') {
return this.parseChatHistoryLine(entry);
@ -600,10 +578,22 @@ export class TaskStepNodeComponent {
return { role, content };
})
.filter((entry): entry is { role: 'user' | 'assistant' | 'system'; content: string } => entry != null);
if (!latestResponse) return normalizedHistory;
const lastMessage = normalizedHistory[normalizedHistory.length - 1];
if (lastMessage?.role === 'assistant' && lastMessage.content === latestResponse) {
return normalizedHistory;
}
return [
...normalizedHistory,
{ role: 'assistant', content: latestResponse }
];
}
private latestInteractionResponse(contract: BlockInteractionContract): string {
const fieldName = contract.responseField;
const fieldName = this.resolveInteractionFieldName(contract, 'response');
if (!fieldName) return '';
const value =
this.interactionFieldValue(fieldName, true)
@ -611,6 +601,85 @@ export class TaskStepNodeComponent {
return typeof value === 'string' ? value : '';
}
private resolveInteractionFieldName(
contract: BlockInteractionContract,
target: 'history' | 'response'
): string | null {
const explicit = target === 'history'
? contract.historyField
: contract.responseField;
if (explicit) return explicit;
if (contract.kind !== 'chat-session') return null;
return target;
}
private buildInteractionDialogState(executionId: string, executionNodeId: string, contract: BlockInteractionContract) {
return {
executionId,
nodeId: executionNodeId,
title: this.interactionDialogTitle(contract),
kind: contract.kind,
actionDescription: this.actionDescriptionValue(),
currentInput: this.currentInputValue(),
history: this.chatHistory(contract),
latestResponse: this.latestInteractionResponse(contract),
historyField: contract.historyField,
responseField: contract.responseField,
messageField: contract.messageField,
completionField: contract.completionField,
pendingUserMessage: null,
awaitingAssistantResponse: false,
assistantResponseBaseline: this.latestInteractionResponse(contract),
isRunning: this.isRunning(),
isSubmitting: this.interactionSubmitting,
submitError: null
};
}
private submitInteractionResult(
executionId: string,
executionNodeId: string,
contract: BlockInteractionContract,
result: { mode: 'message' | 'complete'; value: string }
) {
const interactionFieldName = result.mode === 'message'
? contract.messageField
: contract.completionField;
if (!interactionFieldName) return;
this.interactionSubmitting = true;
this.humanInteractionDialog.update({
pendingUserMessage: result.value,
awaitingAssistantResponse: true,
assistantResponseBaseline: this.latestInteractionResponse(contract),
isSubmitting: true,
submitError: null
});
this.taskExecutionsService.submitInteractionText(
executionId,
executionNodeId,
interactionFieldName,
result.value
).subscribe({
next: () => {
this.interactionSubmitting = false;
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: null
});
},
error: (error) => {
this.interactionSubmitting = false;
this.humanInteractionDialog.update({
isSubmitting: false,
submitError: 'Failed to send the interaction response.'
});
console.error('Submit interaction output failed', error);
}
});
}
private parseChatHistoryLine(rawLine: string): { role: 'user' | 'assistant' | 'system'; content: string } | null {
const line = rawLine.trim();
if (!line) return null;

View File

@ -7,15 +7,26 @@
<h2 class="text-base font-semibold text-slate-900">{{ execution()!.name }}</h2>
<p class="text-sm text-slate-500">Execution ID: {{ execution()!.id }}</p>
</div>
<button
type="button"
class="self-stretch h-full w-10 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Start execution"
aria-label="Start execution"
[disabled]="!canStartExecution() || startInProgress()"
(click)="startExecution()">
<mat-icon fontIcon="play_arrow"></mat-icon>
</button>
<div class="flex items-stretch gap-2">
<button
type="button"
class="self-stretch h-full w-10 rounded-md border border-rose-300 bg-rose-50 text-rose-600 hover:bg-rose-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Cancel execution"
aria-label="Cancel execution"
[disabled]="!canCancelExecution()"
(click)="cancelExecution()">
<mat-icon fontIcon="stop"></mat-icon>
</button>
<button
type="button"
class="self-stretch h-full w-10 rounded-md border border-emerald-300 bg-emerald-50 text-emerald-600 hover:bg-emerald-100 disabled:opacity-40 disabled:cursor-not-allowed"
matTooltip="Start execution"
aria-label="Start execution"
[disabled]="!canStartExecution() || startInProgress()"
(click)="startExecution()">
<mat-icon fontIcon="play_arrow"></mat-icon>
</button>
</div>
</div>
</div>
@ -42,6 +53,12 @@
</div>
</div>
@if (execution()!.context.status === 'CANCELLED') {
<div class="mx-1 mt-1 rounded-md border border-slate-300 bg-slate-50 px-4 py-3 text-sm text-slate-700">
Execution cancelled. This execution is closed. Start a new 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>

View File

@ -24,6 +24,10 @@ import {
TaskExecutionInputsPanelComponent
} from '@shared/task-execution-inputs-panel/task-execution-inputs-panel';
import { ReteEditor } from '@shared/rete-editor/rete-editor';
import {
HumanInteractionChatMessage,
HumanInteractionDialogService
} from '@services/dialogs/human-interaction-dialog';
import { TaskExecutionsService } from '@services/task-executions/task-executions';
type ExecutionOutputEntry = {
@ -44,6 +48,7 @@ type ExecutionOutputEntry = {
export class TaskExecutionViewerComponent implements OnDestroy {
private static readonly TEXT_INPUT_DEBOUNCE_MS = 1200;
private taskExecutionsService = inject(TaskExecutionsService);
private humanInteractionDialog = inject(HumanInteractionDialogService);
private readonly textInputDebounceTimers = new Map<string, ReturnType<typeof setTimeout>>();
private lastExecutionId: string | null = null;
private lastExecutionStatus: string | null = null;
@ -51,6 +56,7 @@ export class TaskExecutionViewerComponent implements OnDestroy {
readonly contextAsideOpen = signal(true);
readonly activeAsideTab = signal<'inputs' | 'output'>('inputs');
readonly startInProgress = signal(false);
readonly cancelInProgress = signal(false);
readonly savingInputs = signal<Record<string, boolean>>({});
readonly savingErrors = signal<Record<string, string>>({});
readonly pendingTextInputs = signal<Record<string, string | string[]>>({});
@ -94,6 +100,71 @@ export class TaskExecutionViewerComponent implements OnDestroy {
this.lastExecutionStatus = status;
});
effect(() => {
const dialogState = this.humanInteractionDialog.state();
const execution = this.execution();
if (!dialogState || !execution) return;
if (dialogState.executionId !== execution.id || !dialogState.nodeId) return;
const executionStatus = String(execution.context.status ?? '').toUpperCase();
if (executionStatus === 'CANCELLED') {
this.humanInteractionDialog.close(null);
return;
}
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 historyField = dialogState.historyField || (dialogState.kind === 'chat-session' ? 'history' : null);
const responseField = dialogState.responseField || (dialogState.kind === 'chat-session' ? 'response' : null);
const historyKey = historyField ? `${dialogState.nodeId}:${historyField}` : null;
const responseKey = responseField ? `${dialogState.nodeId}:${responseField}` : null;
const rawHistory = historyField
? partialResult[historyKey ?? '']
?? finalResult[historyKey ?? '']
?? executionResult[historyKey ?? '']
?? stepResult[historyField]
: undefined;
const rawResponse = responseField
? partialResult[responseKey ?? '']
?? finalResult[responseKey ?? '']
?? executionResult[responseKey ?? '']
?? stepResult[responseField]
: undefined;
const nextHistory = this.toDialogHistory(rawHistory);
const nextLatestResponse = typeof rawResponse === 'string' ? rawResponse : '';
const nextIsRunning = String(step?.status ?? '').toUpperCase() === 'RUNNING';
const historyHasPendingUser = !!dialogState.pendingUserMessage
&& nextHistory.some((message) =>
message.role === 'user' && String(message.content ?? '').trim() === String(dialogState.pendingUserMessage ?? '').trim()
);
const nextPendingUserMessage = historyHasPendingUser ? null : dialogState.pendingUserMessage;
const nextAwaitingAssistantResponse = dialogState.awaitingAssistantResponse
&& nextLatestResponse.trim().length > 0
&& nextLatestResponse.trim() !== String(dialogState.assistantResponseBaseline ?? '').trim()
? false
: dialogState.awaitingAssistantResponse;
const sameHistory = JSON.stringify(dialogState.history) === JSON.stringify(nextHistory);
const sameLatestResponse = dialogState.latestResponse === nextLatestResponse;
const sameIsRunning = dialogState.isRunning === nextIsRunning;
const samePendingUserMessage = dialogState.pendingUserMessage === nextPendingUserMessage;
const sameAwaitingAssistantResponse = dialogState.awaitingAssistantResponse === nextAwaitingAssistantResponse;
if (sameHistory && sameLatestResponse && sameIsRunning && samePendingUserMessage && sameAwaitingAssistantResponse) return;
this.humanInteractionDialog.update({
history: nextHistory,
latestResponse: nextLatestResponse,
pendingUserMessage: nextPendingUserMessage,
awaitingAssistantResponse: nextAwaitingAssistantResponse,
isRunning: nextIsRunning
});
});
}
readonly stepsArray = computed(() =>
@ -211,6 +282,11 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return getExecutionStatusGroup(status) !== 'INIT';
});
readonly canCancelExecution = computed(() => {
const status = String(this.execution()?.context.status ?? '').toUpperCase();
return !this.cancelInProgress() && (status === 'RUNNING' || status === 'WAITING');
});
readonly canStartExecution = computed(() => {
const execution = this.execution();
if (!execution) return false;
@ -310,6 +386,20 @@ export class TaskExecutionViewerComponent implements OnDestroy {
});
}
cancelExecution() {
const executionId = this.execution()?.id;
if (!executionId || !this.canCancelExecution()) return;
this.cancelInProgress.set(true);
this.taskExecutionsService.cancelExecution(executionId).subscribe({
next: () => {
this.cancelInProgress.set(false);
this.humanInteractionDialog.close(null);
},
error: () => this.cancelInProgress.set(false)
});
}
onTextInputChange(input: EditableExecutionInput, value: string | string[]) {
if (this.inputsReadOnly()) return;
const executionId = this.execution()?.id;
@ -626,6 +716,45 @@ export class TaskExecutionViewerComponent implements OnDestroy {
return result;
}
private toDialogHistory(rawHistory: unknown): HumanInteractionChatMessage[] {
if (!Array.isArray(rawHistory)) return [];
return rawHistory
.map((entry) => {
if (typeof entry === 'string') {
return this.parseChatHistoryLine(entry);
}
if (!entry || typeof entry !== 'object') return null;
const record = entry as Record<string, unknown>;
const role = record['role'];
const content = record['content'] ?? record['message'] ?? record['text'];
if ((role !== 'user' && role !== 'assistant' && role !== 'system') || typeof content !== 'string') {
return null;
}
return { role, content };
})
.filter((entry): entry is HumanInteractionChatMessage => entry != null);
}
private parseChatHistoryLine(rawLine: string): HumanInteractionChatMessage | null {
const line = rawLine.trim();
if (!line) return null;
const prefixed = line.match(/^\[(USER|ASSISTANT|SYSTEM)\]\s*([\s\S]*)$/i);
if (prefixed) {
const role = prefixed[1].toLowerCase() as 'user' | 'assistant' | 'system';
return {
role,
content: prefixed[2] ?? ''
};
}
return {
role: 'assistant',
content: line
};
}
private getConnectedInputs(step: TaskExecutionStep): string[] {
return (step.inputs ?? [])
.filter((input) => input.registered)

View File

@ -108,6 +108,7 @@ export class TasksExecutionsListComponent {
const normalized = String(status).toUpperCase();
if (normalized === 'SUCCESS') return 'bg-emerald-100 text-emerald-700 border-emerald-200';
if (normalized === 'ERROR') return 'bg-rose-100 text-rose-700 border-rose-200';
if (normalized === 'CANCELLED') return 'bg-slate-200 text-slate-700 border-slate-300';
if (normalized === 'WAITING') return 'bg-amber-100 text-amber-700 border-amber-200';
if (normalized === 'RUNNING') return 'bg-blue-100 text-blue-700 border-blue-200';
if (normalized === 'CREATED' || normalized === 'READY') return 'bg-slate-100 text-slate-700 border-slate-200';