Refine execution chat modal and add cancel support
This commit is contained in:
parent
19b0dadacc
commit
447b01786d
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in New Issue