diff --git a/src/app/models/task-execution.ts b/src/app/models/task-execution.ts index 7c17e22..9473449 100644 --- a/src/app/models/task-execution.ts +++ b/src/app/models/task-execution.ts @@ -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'; diff --git a/src/app/services/dialogs/human-interaction-dialog.ts b/src/app/services/dialogs/human-interaction-dialog.ts index 1f3bf4a..89284fd 100644 --- a/src/app/services/dialogs/human-interaction-dialog.ts +++ b/src/app/services/dialogs/human-interaction-dialog.ts @@ -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 { 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> & { + 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; diff --git a/src/app/services/task-executions/task-executions-call.base.ts b/src/app/services/task-executions/task-executions-call.base.ts index 8a98da8..d348b30 100644 --- a/src/app/services/task-executions/task-executions-call.base.ts +++ b/src/app/services/task-executions/task-executions-call.base.ts @@ -6,6 +6,7 @@ export abstract class TaskExecutionsCallServiceBase { abstract createTaskExecution(flowId: string): Observable; abstract deleteTaskExecution(executionId: string): Observable; abstract startTaskExecution(executionId: string): Observable; + abstract cancelTaskExecution(executionId: string): Observable; abstract prepareStringInput( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions-call.fake.ts b/src/app/services/task-executions/task-executions-call.fake.ts index a0dcb93..46a6825 100644 --- a/src/app/services/task-executions/task-executions-call.fake.ts +++ b/src/app/services/task-executions/task-executions-call.fake.ts @@ -494,6 +494,28 @@ export class TaskExecutionsCallServiceFake extends TaskExecutionsCallServiceBase return of(execution); } + override cancelTaskExecution(executionId: string): Observable { + const execution = this.findExecution(executionId); + execution.context.status = 'CANCELLED'; + execution.context.endTime = Date.now(); + execution.context.inputs = {}; + execution.context.result = {}; + execution.context.executionResult = {}; + (execution.context as Record)['partialResult'] = {}; + execution.context.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, diff --git a/src/app/services/task-executions/task-executions-call.ts b/src/app/services/task-executions/task-executions-call.ts index dd235e4..9f0c0de 100644 --- a/src/app/services/task-executions/task-executions-call.ts +++ b/src/app/services/task-executions/task-executions-call.ts @@ -24,6 +24,10 @@ export class TaskExecutionsCallService extends TaskExecutionsCallServiceBase { return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/start`, null); } + override cancelTaskExecution(executionId: string): Observable { + return this.http.put(`${environment.apiUrl}/executions/${encodeURIComponent(executionId)}/cancel`, null); + } + override prepareStringInput( executionId: string, nodeId: string, diff --git a/src/app/services/task-executions/task-executions.ts b/src/app/services/task-executions/task-executions.ts index 2911b31..ddcba96 100644 --- a/src/app/services/task-executions/task-executions.ts +++ b/src/app/services/task-executions/task-executions.ts @@ -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()), diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.html b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html index 2357aa9..4a54c0e 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.html +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.html @@ -19,9 +19,28 @@ @if (currentState.kind === 'chat-session') {
-
+
+ @if (currentState.isSubmitting || currentState.isRunning) { +
+
+ @if (currentState.isSubmitting) { + Sending message... + } @else { + Waiting for node response... + } +
+
The window stays open until you close it.
+
+ } + + @if (currentState.submitError) { +
+ {{ currentState.submitError }} +
+ } +
- @for (message of currentState.history; track $index) { + @for (message of displayMessages(); track $index) {
} - @if (!currentState.history.length) { + @if (!displayMessages().length) {
No messages yet.
}
+ @if (currentState.isSubmitting || currentState.isRunning) { +
+
+
+
+ @if (currentState.isSubmitting) { + Sending message... + } @else { + Waiting for node response... + } +
+
+
+ } @else { Type a message + }
} @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)"> diff --git a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts index e4470de..5b778f6 100644 --- a/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts +++ b/src/app/shared/human-interaction-dialog/human-interaction-dialog.ts @@ -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); + private lastDialogKey: string | null = null; + private readonly messagesContainer = viewChild>('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); } diff --git a/src/app/shared/nodes/task-step-node/task-step-node.ts b/src/app/shared/nodes/task-step-node/task-step-node.ts index b94db23..1ca960a 100644 --- a/src/app/shared/nodes/task-step-node/task-step-node.ts +++ b/src/app/shared/nodes/task-step-node/task-step-node.ts @@ -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; diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.html b/src/app/shared/task-execution-viewer/task-execution-viewer.html index 0fc1fd8..956d029 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.html +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.html @@ -7,15 +7,26 @@

{{ execution()!.name }}

Execution ID: {{ execution()!.id }}

- +
+ + +
@@ -42,6 +53,12 @@ + @if (execution()!.context.status === 'CANCELLED') { +
+ Execution cancelled. This execution is closed. Start a new execution to continue. +
+ } +
Execution Graph (read-only)
diff --git a/src/app/shared/task-execution-viewer/task-execution-viewer.ts b/src/app/shared/task-execution-viewer/task-execution-viewer.ts index 4c9610e..db916f0 100644 --- a/src/app/shared/task-execution-viewer/task-execution-viewer.ts +++ b/src/app/shared/task-execution-viewer/task-execution-viewer.ts @@ -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>(); 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>({}); readonly savingErrors = signal>({}); readonly pendingTextInputs = signal>({}); @@ -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 : {}; + const finalResult = execution.context.result ?? {}; + const executionResult = execution.context.executionResult ?? {}; + const partialResult = ((execution.context as Record)['partialResult'] as Record | 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; + 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) diff --git a/src/app/shared/tasks-executions-list/tasks-executions-list.ts b/src/app/shared/tasks-executions-list/tasks-executions-list.ts index b7d4eb4..540ad19 100644 --- a/src/app/shared/tasks-executions-list/tasks-executions-list.ts +++ b/src/app/shared/tasks-executions-list/tasks-executions-list.ts @@ -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';