humainflow-web/src/app/shared/flow-assistant/flow-assistant.ts

1081 lines
39 KiB
TypeScript

import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, EventEmitter, inject, input, OnDestroy, OnInit, Output, signal, ViewChild } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import {
AssistantCallPhase,
AssistantCallState,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantFlowActionResult,
AssistantLlmSelection,
AssistantSessionMessageRequest,
AssistantSessionState,
VaultSecret
} from '@models/assistant';
import { Flow } from '@models/flow';
import { AssistantService } from '@services/assistant/assistant';
import { CREDENTIAL_ERROR_MESSAGES, VaultService } from '@services/vault/vault';
import { Authorization } from '@services/authorization/authorization';
import { NodeSettingField, NodeSettingsDialogService } from '@services/dialogs/node-settings-dialog';
import { AssistantSessionStore } from '@stores/assistant-session-store';
import { EditorStateHolder } from '@stores/flow-editor';
import { finalize, firstValueFrom, interval, Subscription, switchMap, take } from 'rxjs';
@Component({
selector: 'app-flow-assistant',
imports: [CommonModule, FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './flow-assistant.html',
styleUrl: './flow-assistant.css',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FlowAssistant implements OnInit, OnDestroy {
@Output() cancellableCallChange = new EventEmitter<boolean>();
@ViewChild('assistantScroll') assistantScrollElement?: ElementRef<HTMLDivElement>;
private readonly assistant = inject(AssistantService);
private readonly vault = inject(VaultService);
private readonly editorState = inject(EditorStateHolder);
private readonly authorization = inject(Authorization);
private readonly sessionStore = inject(AssistantSessionStore);
private readonly settingsDialog = inject(NodeSettingsDialogService);
private pollSubscription: Subscription | null = null;
private initialized = false;
private activeFlowKey: string | null = null;
private lastAutoScrollKey = '';
private lastCancellableCallState = false;
private skipDestroySnapshot = false;
private readonly createModalFlowKey = AssistantSessionStore.CREATE_MODAL_FLOW_KEY;
private static readonly STANDARD_ASSISTANT_ERROR = 'Something went wrong while processing your workflow request. Please try again.';
readonly assistantConfig = signal<AssistantConfig | null>(null);
readonly variant = input<'aside' | 'create-modal'>('aside');
readonly sessionState = signal<AssistantSessionState | null>(null);
readonly currentCall = signal<AssistantCallState | null>(null);
readonly localMessages = signal<AssistantChatMessage[]>([]);
readonly models = signal<string[]>([]);
readonly providers = signal<string[]>([]);
readonly providersLoading = signal(false);
readonly providersError = signal<string | null>(null);
readonly modelsLoading = signal(false);
readonly modelsError = signal<string | null>(null);
/**
* The provider cannot be asked what it offers, so the model is typed rather than picked. An
* empty list is not enough to tell: for our own Ollama it means the provider is unreachable.
*/
readonly modelsOpen = signal(false);
readonly sessionLoading = signal(false);
readonly requestPending = signal(false);
readonly prompt = signal('');
readonly createPromptSubmitted = signal(false);
readonly selectedModel = signal('');
readonly useDefaultConfiguration = signal(true);
readonly selectedProvider = signal('');
readonly phaseModels = signal<AssistantLlmSelection['phaseModels']>(undefined);
readonly credentials = signal<VaultSecret[]>([]);
readonly credentialsLoading = signal(false);
readonly credentialsError = signal<string | null>(null);
readonly selectedCredentialId = signal('');
readonly credentialSaving = signal(false);
readonly advancedModelsOpen = signal(false);
readonly modelPickerOpen = signal(false);
/** Closed on arrival, like the other collapsible sections: the prompt box is what you came for. */
readonly quickPromptsOpen = signal(false);
readonly assistantErrorMessage = signal<string | null>(null);
readonly lastFailedPrompt = signal<string | null>(null);
readonly lastSubmittedPrompt = signal('');
readonly editorHasOpenFlow = computed(() => !!this.currentFlow());
readonly editorHasNonEmptyFlow = computed(() => this.hasMeaningfulFlow(this.currentFlow()));
readonly sessionHasDraft = computed(() => !!this.currentDraft());
readonly canOfferCreate = computed(() => !this.editorHasNonEmptyFlow() && !this.sessionHasDraft());
readonly canOfferFix = computed(() => !this.canOfferCreate() && (this.sessionState()?.lastValidationErrors?.length ?? 0) > 0);
readonly isCreateModal = computed(() => this.variant() === 'create-modal');
readonly createModalProgressOnly = computed(() =>
this.isCreateModal() && this.createPromptSubmitted()
);
readonly refineProgressOnly = computed(() =>
!this.isCreateModal() && !this.canOfferCreate() && this.assistantBusy()
);
readonly progressOnlyMode = computed(() =>
this.createModalProgressOnly() || this.refineProgressOnly()
);
readonly canRetryLastPrompt = computed(() =>
!this.assistantBusy() && !!this.lastFailedPrompt() && !!this.sessionState()?.id
);
readonly assistantModeLabel = computed(() => this.canOfferCreate() ? 'Create with assistant' : 'Refine with assistant');
readonly assistantModeDescription = computed(() => {
if (this.canOfferCreate()) {
return 'No flow is open, so the assistant is in create mode and can draft a new workflow.';
}
if (this.canOfferFix()) {
return 'A flow is already attached, so use the assistant to refine, fix, or explain it.';
}
return 'A flow is already open or attached to this session, so create mode is not offered here.';
});
readonly promptPlaceholder = computed(() =>
this.canOfferCreate()
? 'Ask the assistant to create a new workflow'
: 'Ask the assistant to refine, fix, or explain the current workflow'
);
readonly starterPrompts = computed(() => {
if (this.canOfferCreate()) {
return [
'Create a flow that classifies incoming tickets and sends urgent ones to a human',
'Create a flow that downloads a file, indexes it, and then queries it',
'Create a recruiter workflow that reviews a CV and produces a final assessment'
];
}
const prompts = [
'Modify the current flow to add a review step after the LLM block',
'Explain this flow and describe what each branch does'
];
if (this.canOfferFix()) {
prompts.unshift('Fix the problems in this flow');
}
return prompts;
});
readonly displayedMessages = computed(() => {
const baseMessages = this.sessionState()?.messages?.length
? this.sessionState()!.messages
: [this.systemWelcomeMessage()];
return [...baseMessages, ...this.localMessages()]
.filter((message) => !this.isTechnicalAssistantFailureMessage(message));
});
readonly assistantBusy = computed(() => {
const status = this.currentCall()?.status;
return this.sessionLoading() || this.requestPending() || status === 'QUEUED' || status === 'RUNNING';
});
readonly activePhase = computed<AssistantCallPhase | null>(() => {
const call = this.currentCall();
return call ? call.phase : null;
});
readonly activePhaseLabel = computed(() => {
const phase = this.activePhase();
return phase ? this.phaseText(phase) : null;
});
readonly callProgressMessage = computed(() => this.currentCall()?.progressMessage ?? '');
readonly busyHeadline = computed(() => {
const phase = this.activePhase();
if (this.requestPending()) {
return 'Sending request...';
}
if (!phase) {
if (this.sessionLoading()) return 'Preparing assistant session...';
if (this.createModalProgressOnly()) return 'Finalizing flow...';
return '';
}
return this.phaseText(phase);
});
readonly busyDetail = computed(() => {
if (this.requestPending()) {
return 'Preparing the assistant run.';
}
if (this.sessionLoading()) {
return 'Loading assistant configuration, models, and chat session...';
}
if (this.createModalProgressOnly() && !this.currentCall()) {
return 'The draft is almost ready.';
}
if (!this.currentCall()) return '';
return this.callProgressMessage() || 'The backend may perform multiple internal steps before returning the updated conversation and flow.';
});
readonly progressSteps = [
{ phase: 'queued', label: 'Queued...' },
{ phase: 'routing', label: 'Routing your request...' },
{ phase: 'planning', label: 'Planning workflow blocks...' },
{ phase: 'configuring_blocks', label: 'Configuring blocks...' },
{ phase: 'connecting_blocks', label: 'Connecting blocks...' },
{ phase: 'validating', label: 'Validating flow...' },
{ phase: 'fixing', label: 'Repairing invalid flow...' },
{ phase: 'explaining', label: 'Explaining current flow...' }
] as const;
readonly activeProgressIndex = computed(() => {
const phase = this.activePhase();
if (!phase) return -1;
return this.progressSteps.findIndex((step) => step.phase === phase);
});
readonly currentFlow = this.editorState.currentFlow;
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
readonly configurationLocked = computed(() => !!this.sessionState()?.id);
/**
* What the backend says it will use when nothing is chosen here. It has always been in the
* config payload alongside the retriever URLs; the panel just never showed it, so ticking Use
* default configuration told you a default applied without ever saying which.
*/
readonly defaultConfigurationSummary = computed(() => {
const config = this.assistantConfig();
const parts = [config?.defaultProvider?.trim(), config?.defaultModel?.trim()].filter((part) => !!part);
return parts.length ? parts.join(' \u00b7 ') : null;
});
/** The phases the backend overrides, if any. Silence here means they all use the main model. */
readonly defaultPhaseModelSummary = computed(() => {
const phases = this.assistantConfig()?.defaultPhaseModels;
return [
{ label: 'Planning', model: phases?.planningModel?.trim() },
{ label: 'JSON', model: phases?.jsonModel?.trim() },
{ label: 'Repair', model: phases?.repairModel?.trim() }
].filter((entry): entry is { label: string; model: string } => !!entry.model);
});
/** What the run will use, readable with the panel collapsed. */
readonly effectiveModelSummary = computed(() => {
if (this.useDefaultConfiguration()) {
const summary = this.defaultConfigurationSummary();
return summary ? `Default: ${summary}` : null;
}
const parts = [this.selectedProvider().trim(), this.selectedModel().trim()].filter((part) => !!part);
return parts.length ? parts.join(' \u00b7 ') : null;
});
readonly providerNeedsCredential = computed(() =>
!!this.selectedProvider().trim() && !this.isInternalProvider(this.selectedProvider())
);
readonly compatibleCredentials = computed(() => {
const provider = this.selectedProvider().trim().toLowerCase();
if (!provider) return [];
return this.credentials().filter((credential) =>
credential.active && credential.provider.trim().toLowerCase() === provider
);
});
readonly customConfigurationValid = computed(() =>
!!this.selectedProvider().trim()
&& !!this.selectedModel().trim()
&& (!this.providerNeedsCredential() || this.compatibleCredentials().some(
(credential) => credential.id === this.selectedCredentialId()
))
);
readonly configurationValid = computed(() =>
this.useDefaultConfiguration() || this.customConfigurationValid()
);
constructor() {
effect(() => {
const messages = this.displayedMessages();
const lastMessageId = messages[messages.length - 1]?.id ?? '';
const busy = this.assistantBusy();
const nextKey = `${messages.length}:${lastMessageId}:${busy ? '1' : '0'}`;
if (nextKey === this.lastAutoScrollKey) return;
this.lastAutoScrollKey = nextKey;
this.scheduleScrollToLatestMessage();
});
effect(() => {
if (this.isCreateModal()) return;
const flowKey = this.resolveFlowKey(this.currentFlow()?.id ?? null);
if (!this.initialized || this.activeFlowKey === flowKey) return;
const previousFlowKey = this.activeFlowKey;
if (previousFlowKey) {
this.persistSnapshot(previousFlowKey);
}
this.activeFlowKey = flowKey;
void this.restoreSessionForFlow(flowKey);
});
effect(() => {
const nextState = this.hasCancellableCall();
if (this.lastCancellableCallState === nextState) return;
this.lastCancellableCallState = nextState;
this.cancellableCallChange.emit(nextState);
});
}
ngOnInit(): void {
this.bootstrapAssistant();
}
ngOnDestroy(): void {
if (this.activeFlowKey && !this.skipDestroySnapshot) {
this.persistSnapshot(this.activeFlowKey);
}
this.stopPolling();
}
selectModel(model: string) {
if (this.configurationLocked()) return;
this.selectedModel.set(model);
this.persistSnapshot();
}
setUseDefaultConfiguration(useDefault: boolean) {
if (this.configurationLocked()) return;
this.useDefaultConfiguration.set(useDefault);
this.modelsError.set(null);
if (!useDefault) {
void this.loadProviders();
}
this.persistSnapshot();
}
selectProvider(provider: string) {
if (this.configurationLocked()) return;
this.selectedProvider.set(provider);
this.selectedModel.set('');
this.selectedCredentialId.set('');
this.models.set([]);
this.modelsError.set(null);
this.modelsOpen.set(false);
if (provider) void this.loadModels(provider);
if (provider) void this.loadCredentials();
this.persistSnapshot();
}
selectCredential(credentialId: string) {
if (this.configurationLocked()) return;
this.selectedCredentialId.set(credentialId);
}
/**
* A modal, from the credential picker beside the model - the one place the assistant needs a key
* at all. Creation only: the assistant no longer manages the vault, it answers the requirement
* the chosen provider has, so there is nothing here to rotate or disable.
*/
async openCredentialForm(provider = this.selectedProvider()) {
if (this.configurationLocked()) return;
this.credentialsError.set(null);
// The current provider is offered even when the list has not loaded: it is the one the user
// is adding a key for, and a select with nothing in it would be a dead end.
const providerOptions = [...new Set([...this.providers(), provider].filter((name) => !!name))]
.map((name) => ({ label: name, value: name }));
const fields: NodeSettingField[] = [
{ key: 'label', label: 'Label', type: 'text', required: true, autofocus: true },
{ key: 'provider', label: 'Provider', type: 'select', required: true, options: providerOptions },
{ key: 'description', label: 'Description (optional)', type: 'text' },
{
key: 'value',
label: 'API key',
type: 'password',
required: true,
tip: 'The value will not be shown again after saving.'
}
];
const result = await this.settingsDialog.open({
title: 'Add credential',
fields,
initial: { label: '', provider, description: '', value: '' }
});
if (!result) return;
this.saveCredential(
String(result['label'] ?? '').trim(),
String(result['provider'] ?? '').trim(),
String(result['description'] ?? '').trim(),
String(result['value'] ?? '')
);
}
private saveCredential(label: string, provider: string, description: string, value: string) {
if (!label || !provider || !value.trim() || this.credentialSaving()) return;
this.credentialSaving.set(true);
this.credentialsError.set(null);
this.vault.createSecret({ label, provider, description: description || undefined, value })
.pipe(finalize(() => this.credentialSaving.set(false)))
.subscribe({
next: (credential) => {
if (credential.active && this.sameProvider(credential.provider, this.selectedProvider())) {
this.selectedCredentialId.set(credential.id);
}
void this.loadCredentials();
},
error: (err) => this.credentialsError.set(this.backendErrorMessage(err))
});
}
setPhaseModel(phase: keyof NonNullable<AssistantLlmSelection['phaseModels']>, model: string) {
if (this.configurationLocked()) return;
this.phaseModels.update((current) => ({ ...current, [phase]: model || undefined }));
this.persistSnapshot();
}
toggleAdvancedModels() {
this.advancedModelsOpen.update((value) => !value);
this.persistSnapshot();
}
useStarter(prompt: string) {
this.prompt.set(prompt);
this.persistSnapshot();
}
toggleQuickPrompts() {
this.quickPromptsOpen.update((value) => !value);
this.persistSnapshot();
}
toggleModelPicker() {
this.modelPickerOpen.update((value) => !value);
this.persistSnapshot();
}
submitPrompt() {
const content = this.prompt().trim();
this.sendPrompt(content);
}
retryLastPrompt() {
const failedPrompt = this.lastFailedPrompt()?.trim() ?? '';
if (!failedPrompt) return;
this.sendPrompt(failedPrompt);
}
canRetryFromMessage(message: AssistantChatMessage): boolean {
return message.role === 'assistant'
&& this.canRetryLastPrompt()
&& this.canonicalAssistantErrorContent(message.content)
=== this.canonicalAssistantErrorContent(FlowAssistant.STANDARD_ASSISTANT_ERROR);
}
hasCancellableCall(): boolean {
return this.isActiveCall(this.currentCall());
}
async cancelActiveCall(): Promise<boolean> {
const call = this.currentCall();
if (!this.isActiveCall(call)) {
this.requestPending.set(false);
this.createPromptSubmitted.set(false);
return true;
}
this.stopPolling();
this.requestPending.set(false);
try {
const cancelledCall = await firstValueFrom(this.assistant.cancelCall(call.id).pipe(take(1)));
this.currentCall.set(cancelledCall);
this.createPromptSubmitted.set(false);
this.persistSnapshot();
const session = await firstValueFrom(this.assistant.getSession(cancelledCall.sessionId || call.sessionId).pipe(take(1)));
this.applySessionState(session, { syncDraftToEditor: false });
this.currentCall.set(null);
this.persistSnapshot();
return true;
} catch (err) {
console.error('Assistant call cancel failed', err);
this.currentCall.set(call);
this.beginPolling(call.id);
this.assistantErrorMessage.set('Unable to cancel the assistant request.');
this.persistSnapshot();
return false;
}
}
clearActiveSnapshot() {
const flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null);
this.clearSnapshot(flowKey);
}
private sendPrompt(content: string) {
const normalizedContent = content.trim();
if (!normalizedContent || this.assistantBusy() || !this.configurationValid()) return;
if (!this.sessionState()?.id) {
void this.openSession(normalizedContent);
return;
}
this.submitAssistantMessage(normalizedContent);
}
private submitAssistantMessage(normalizedContent: string) {
const sessionId = this.sessionState()?.id;
if (!sessionId) return;
if (this.isCreateModal()) this.createPromptSubmitted.set(true);
this.requestPending.set(true);
this.assistantErrorMessage.set(null);
this.lastSubmittedPrompt.set(normalizedContent);
this.prompt.set('');
this.localMessages.set([
{
id: crypto.randomUUID(),
role: 'user',
content: normalizedContent
}
]);
this.persistSnapshot();
const flow = this.assistantFlowForRequest();
const request: AssistantSessionMessageRequest = {
message: normalizedContent,
...(flow ? { flow } : {})
};
this.assistant.submitMessage(sessionId, request).pipe(take(1)).subscribe({
next: (accepted) => {
this.requestPending.set(false);
this.beginPolling(accepted.callId);
},
error: (err) => {
console.error('Assistant message submission failed', err);
this.requestPending.set(false);
this.discardFailedSession();
this.handleAssistantErrorWithRetry(normalizedContent, this.backendErrorMessage(err));
}
});
}
private beginPolling(callId: string) {
this.stopPolling();
this.pollSubscription = interval(1000).pipe(
switchMap(() => this.assistant.getCall(callId))
).subscribe({
next: (call) => {
this.currentCall.set(call);
this.persistSnapshot();
if (call.status === 'COMPLETED') {
this.stopPolling();
this.applyFlowActionResult(call.actionResult ?? {
flow: null,
validationErrors: [],
warnings: [],
message: 'The assistant completed the request.'
});
this.createPromptSubmitted.set(false);
this.persistSnapshot();
return;
}
if (call.status === 'FAILED') {
this.stopPolling();
this.discardFailedSession();
this.handleAssistantErrorWithRetry(this.lastSubmittedPrompt(), call.errorMessage || undefined);
return;
}
if (call.status === 'CANCELLED') {
this.stopPolling();
this.createPromptSubmitted.set(false);
}
},
error: (err) => {
console.error('Assistant call polling failed', err);
this.stopPolling();
this.discardFailedSession();
this.handleAssistantErrorWithRetry(this.lastSubmittedPrompt(), this.backendErrorMessage(err));
}
});
}
private bootstrapAssistant() {
this.sessionLoading.set(true);
this.modelsError.set(null);
this.assistant.getConfig().pipe(
take(1)
).subscribe({
next: (config) => {
this.assistantConfig.set(config);
this.initializeConfiguration(config);
void this.loadCredentials();
},
error: (err) => {
console.error('Assistant config loading failed', err);
this.sessionLoading.set(false);
this.modelsError.set('Unable to load assistant configuration.');
}
});
}
private initializeConfiguration(_config: AssistantConfig) {
this.activeFlowKey = this.isCreateModal()
? this.createModalFlowKey
: this.resolveFlowKey(this.currentFlow()?.id ?? null);
this.initialized = true;
void this.restoreSessionForFlow(this.activeFlowKey);
}
private loadProviders() {
const config = this.assistantConfig();
if (!config?.availableProvidersRetrieverUrl) return;
this.providersLoading.set(true);
this.providersError.set(null);
this.assistant.listProviders(config.availableProvidersRetrieverUrl).pipe(
take(1),
finalize(() => this.providersLoading.set(false))
).subscribe({
next: (providers) => this.providers.set(providers),
error: (err) => this.providersError.set(this.backendErrorMessage(err))
});
}
private loadModels(provider: string) {
const config = this.assistantConfig();
if (!config?.availableModelsRetrieverUrl) return;
this.modelsLoading.set(true);
this.modelsError.set(null);
// Asked alongside the list and not derived from it: an open provider is exactly the one whose
// list comes back empty, so "nothing to show" and "nothing to offer" must not be the same
// answer. Closed on failure, which keeps a select the user can see is broken.
this.assistant.areModelsOpen(config.availableModelsRetrieverUrl, provider).pipe(take(1)).subscribe({
next: (open) => {
this.modelsOpen.set(open);
if (open) this.modelsError.set(null);
},
error: () => this.modelsOpen.set(false)
});
this.assistant.listModels(config.availableModelsRetrieverUrl, provider).pipe(
take(1),
finalize(() => this.modelsLoading.set(false))
).subscribe({
next: (models) => {
this.models.set(models);
if (!models.length && !this.modelsOpen()) {
this.modelsError.set('No models are available for the selected provider.');
}
},
error: (err) => this.modelsError.set(this.backendErrorMessage(err))
});
}
private loadCredentials() {
this.credentialsLoading.set(true);
this.credentialsError.set(null);
this.vault.listSecrets().pipe(
take(1),
finalize(() => this.credentialsLoading.set(false))
).subscribe({
next: (credentials) => {
this.credentials.set(credentials);
const selectedId = this.selectedCredentialId();
if (selectedId && !credentials.some((credential) => credential.id === selectedId && credential.active)) {
this.selectedCredentialId.set('');
}
},
error: (err) => this.credentialsError.set(this.backendErrorMessage(err))
});
}
private llmSelection(): AssistantLlmSelection | undefined {
if (this.useDefaultConfiguration()) return undefined;
const provider = this.selectedProvider().trim();
const model = this.selectedModel().trim();
if (!provider || !model) return undefined;
const phaseModels = this.phaseModels();
const populatedPhases = phaseModels && Object.values(phaseModels).some(Boolean)
? phaseModels
: undefined;
const credentialId = this.selectedCredentialId();
return {
provider,
model,
...(this.providerNeedsCredential() && credentialId ? { credentialId } : {}),
...(populatedPhases ? { phaseModels: populatedPhases } : {})
};
}
private sessionRequest() {
const llmSelection = this.llmSelection();
return llmSelection ? { llmSelection } : {};
}
private assistantFlowForRequest(): AssistantDraftPayload | undefined {
const draft = this.currentDraft();
if (draft) return draft;
const flow = this.currentFlow();
if (!flow) return undefined;
return { name: flow.name, description: flow.description, flow: flow.data };
}
private applyFlowActionResult(result: AssistantFlowActionResult) {
const message: AssistantChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: result.message,
warnings: result.warnings,
validationErrors: result.validationErrors
};
this.localMessages.update((messages) => [...messages, message]);
if (!result.flow) return;
const session = this.sessionState();
if (session) {
this.sessionState.set({
...session,
currentFlow: result.flow,
currentDraftFlow: result.flow,
lastValidationErrors: result.validationErrors
});
}
this.syncDraftToEditor(result.flow);
}
private discardFailedSession() {
this.stopPolling();
this.currentCall.set(null);
this.sessionState.set(null);
}
private backendErrorMessage(error: unknown): string {
const value = error as { error?: unknown; message?: unknown; status?: unknown };
const payload = value?.error;
if (typeof payload === 'string' && payload.trim()) return payload;
if (payload && typeof payload === 'object') {
const body = payload as Record<string, unknown>;
for (const key of ['message', 'error', 'detail', 'title']) {
if (typeof body[key] === 'string' && body[key].trim()) return body[key] as string;
}
}
const statusMessage = CREDENTIAL_ERROR_MESSAGES[Number(value?.status)];
if (statusMessage) return statusMessage;
if (typeof value?.message === 'string' && value.message.trim()) return value.message;
return FlowAssistant.STANDARD_ASSISTANT_ERROR;
}
private isInternalProvider(provider: string): boolean {
return provider.trim().toLowerCase() === 'internalollama';
}
private sameProvider(left: string, right: string): boolean {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
private async openSession(
promptToSend?: string,
flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)
) {
this.stopPolling();
this.currentCall.set(null);
this.localMessages.set([]);
this.sessionLoading.set(true);
this.assistant.createSession(this.sessionRequest()).pipe(
take(1),
finalize(() => this.sessionLoading.set(false))
).subscribe({
next: (session) => {
this.applySessionState(session);
this.persistSnapshot(flowKey);
if (promptToSend) this.submitAssistantMessage(promptToSend);
},
error: (err) => {
console.error('Assistant session creation failed', err);
const message = this.backendErrorMessage(err);
this.assistantErrorMessage.set(message);
this.pushLocalAssistantMessage(message);
}
});
}
private handleAssistantErrorWithRetry(
promptForRetry: string,
message = FlowAssistant.STANDARD_ASSISTANT_ERROR
) {
const normalizedPrompt = String(promptForRetry ?? '').trim();
this.createPromptSubmitted.set(false);
this.assistantErrorMessage.set(message);
this.lastFailedPrompt.set(normalizedPrompt || null);
this.pushLocalAssistantMessage(message);
this.persistSnapshot();
}
private applySessionState(session: AssistantSessionState, options?: { clearLocalMessages?: boolean; syncDraftToEditor?: boolean }) {
const normalizedSession = session.messages.length
? session
: {
...session,
messages: [this.systemWelcomeMessage()]
};
this.sessionState.set(normalizedSession);
this.selectedModel.set(normalizedSession.selectedModel || this.selectedModel());
if (options?.clearLocalMessages !== false) {
this.localMessages.set([]);
}
if (options?.syncDraftToEditor !== false) {
this.syncDraftToEditor(normalizedSession.currentFlow ?? normalizedSession.currentDraftFlow);
}
this.persistSnapshot();
}
private syncDraftToEditor(draft: AssistantDraftPayload | null) {
if (!draft) return;
const currentFlow = this.currentFlow();
const nextFlow = this.toEditorFlow(draft, currentFlow);
if (currentFlow?.id) {
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
return;
}
const currentFlowKey = this.activeFlowKey ?? this.resolveFlowKey(currentFlow?.id ?? null);
const nextFlowKey = this.resolveFlowKey(nextFlow.id);
this.persistSnapshot(currentFlowKey);
this.sessionStore.cloneSnapshot(currentFlowKey, nextFlowKey);
const shouldClearCreateModalSnapshot = currentFlowKey === this.createModalFlowKey;
void this.editorState.openDocument(nextFlow, { skipDirtyCheck: false }).then((opened) => {
if (!opened) {
this.pushLocalAssistantMessage('The draft is ready, but I did not load it because the current flow has unsaved changes.');
return;
}
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
if (shouldClearCreateModalSnapshot) {
this.clearSnapshot(currentFlowKey);
}
});
}
private toEditorFlow(draft: AssistantDraftPayload, currentFlow: Flow | null): Flow {
const nextId = currentFlow?.id ?? `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`;
return {
id: nextId,
name: draft.name,
description: draft.description,
data: draft.flow,
status: 'DRAFT',
visibility: currentFlow?.visibility ?? 'PRIVATE',
author: currentFlow?.author ?? this.authorization.loggedInUser()?.username ?? 'assistant',
createdAt: currentFlow?.createdAt ?? new Date(),
updatedAt: new Date(),
published: currentFlow?.published,
finalized: currentFlow?.finalized
};
}
private pushLocalAssistantMessage(content: string) {
const normalizedContent = this.normalizeAssistantMessageContent(content);
const filtered = this.localMessages().filter((message) => message.role !== 'assistant');
if (this.sessionHasAssistantMessage(normalizedContent)) {
this.localMessages.set(filtered);
this.persistSnapshot();
return;
}
this.localMessages.set([
...filtered,
{
id: crypto.randomUUID(),
role: 'assistant',
content: normalizedContent
}
]);
this.persistSnapshot();
}
private sessionHasAssistantMessage(content: string): boolean {
if (!content) return false;
const canonicalContent = this.canonicalAssistantErrorContent(content);
return (this.sessionState()?.messages ?? []).some((message) =>
message.role === 'assistant' && this.assistantMessagesOverlap(
canonicalContent,
this.canonicalAssistantErrorContent(message.content)
)
);
}
private assistantMessagesOverlap(left: string, right: string): boolean {
if (!left || !right) return false;
return left === right || left.includes(right) || right.includes(left);
}
private normalizeAssistantMessageContent(content: string): string {
return String(content ?? '').trim().replace(/\s+/g, ' ');
}
private isTechnicalAssistantFailureMessage(message: AssistantChatMessage): boolean {
return message.role === 'assistant'
&& /^the assistant request failed:/i.test(this.normalizeAssistantMessageContent(message.content));
}
private canonicalAssistantErrorContent(content: string): string {
return this.normalizeAssistantMessageContent(content)
.replace(/^the assistant request failed:\s*/i, '')
.replace(/\s+"/g, ' "')
.replace(/"\s+/g, '" ')
.toLowerCase();
}
private systemWelcomeMessage(): AssistantChatMessage {
return {
id: 'assistant-system-welcome',
role: 'system',
content: this.canOfferCreate()
? (this.isCreateModal() ? 'Describe the workflow you want to create.' : 'Use the default configuration or choose a provider and model, then ask me to create a new workflow.')
: 'Use the default configuration or choose a provider and model, then ask me to refine, fix, or explain the current workflow.'
};
}
private async restoreSessionForFlow(flowKey: string) {
this.stopPolling();
this.currentCall.set(null);
const snapshot = this.sessionStore.getSnapshot(flowKey);
if (!snapshot) {
this.prompt.set('');
this.modelPickerOpen.set(false);
this.quickPromptsOpen.set(false);
this.sessionState.set(null);
this.localMessages.set([]);
this.assistantErrorMessage.set(null);
this.lastFailedPrompt.set(null);
this.lastSubmittedPrompt.set('');
this.useDefaultConfiguration.set(true);
this.selectedProvider.set('');
this.selectedModel.set('');
this.phaseModels.set(undefined);
this.advancedModelsOpen.set(false);
this.sessionLoading.set(false);
return;
}
this.prompt.set(snapshot.prompt);
this.modelPickerOpen.set(snapshot.modelPickerOpen);
this.quickPromptsOpen.set(snapshot.quickPromptsOpen);
this.localMessages.set(snapshot.localMessages);
this.currentCall.set(snapshot.currentCall);
this.assistantErrorMessage.set(snapshot.assistantErrorMessage);
this.lastFailedPrompt.set(snapshot.lastFailedPrompt);
this.lastSubmittedPrompt.set(snapshot.lastSubmittedPrompt);
this.createPromptSubmitted.set(this.isCreateModal() && this.isActiveCall(snapshot.currentCall));
this.useDefaultConfiguration.set(snapshot.useDefaultConfiguration);
this.selectedProvider.set(snapshot.selectedProvider);
this.selectedModel.set(snapshot.selectedModel);
this.phaseModels.set(snapshot.phaseModels);
this.advancedModelsOpen.set(snapshot.advancedModelsOpen);
this.sessionState.set(snapshot.sessionState ?? null);
if (!this.useDefaultConfiguration() && this.selectedProvider()) {
void this.loadProviders();
void this.loadModels(this.selectedProvider());
}
if (this.isActiveCall(snapshot.currentCall)) {
this.sessionLoading.set(false);
this.beginPolling(snapshot.currentCall!.id);
return;
}
const sessionId = snapshot.sessionId ?? snapshot.sessionState?.id ?? null;
if (!sessionId) {
this.sessionLoading.set(false);
return;
}
this.sessionLoading.set(true);
this.assistant.getSession(sessionId).pipe(
take(1),
finalize(() => this.sessionLoading.set(false))
).subscribe({
next: (session) => {
this.applySessionState(session, { clearLocalMessages: false, syncDraftToEditor: false });
},
error: (err) => {
console.error('Assistant session refresh failed', err);
}
});
}
private resolveFlowKey(flowId: string | null | undefined): string {
return this.sessionStore.flowKey(flowId);
}
private clearSnapshot(flowKey: string) {
if (flowKey === this.activeFlowKey) {
this.skipDestroySnapshot = true;
}
this.sessionStore.clearSnapshot(flowKey);
}
private persistSnapshot(flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) {
this.sessionStore.setSnapshot(flowKey, {
sessionId: this.sessionState()?.id ?? null,
selectedModel: this.selectedModel(),
useDefaultConfiguration: this.useDefaultConfiguration(),
selectedProvider: this.selectedProvider(),
phaseModels: this.phaseModels(),
advancedModelsOpen: this.advancedModelsOpen(),
prompt: this.prompt(),
modelPickerOpen: this.modelPickerOpen(),
quickPromptsOpen: this.quickPromptsOpen(),
localMessages: this.localMessages(),
currentCall: this.currentCall(),
sessionState: this.sessionState(),
assistantErrorMessage: this.assistantErrorMessage(),
lastFailedPrompt: this.lastFailedPrompt(),
lastSubmittedPrompt: this.lastSubmittedPrompt()
});
}
private scheduleScrollToLatestMessage() {
queueMicrotask(() => {
if (typeof requestAnimationFrame === 'function') {
requestAnimationFrame(() => this.scrollToLatestMessage());
return;
}
setTimeout(() => this.scrollToLatestMessage(), 0);
});
}
private scrollToLatestMessage() {
const container = this.assistantScrollElement?.nativeElement;
if (!container) return;
container.scrollTop = container.scrollHeight;
}
private stopPolling() {
this.pollSubscription?.unsubscribe();
this.pollSubscription = null;
}
private phaseText(phase: AssistantCallPhase): string {
switch (phase) {
case 'queued':
return 'Queued...';
case 'routing':
return 'Routing your request...';
case 'planning':
return 'Planning workflow blocks...';
case 'configuring_blocks':
return 'Configuring blocks...';
case 'connecting_blocks':
return 'Connecting blocks...';
case 'validating':
return 'Validating flow...';
case 'fixing':
return 'Repairing invalid flow...';
case 'explaining':
return 'Explaining current flow...';
case 'completed':
return 'Finalizing flow...';
case 'failed':
return 'Assistant request failed.';
case 'cancelled':
return 'Assistant request cancelled.';
}
}
private isActiveCall(call: AssistantCallState | null): call is AssistantCallState {
return !!call && (call.status === 'QUEUED' || call.status === 'RUNNING');
}
private hasMeaningfulFlow(flow: Flow | null): boolean {
if (!flow) return false;
const data = flow.data;
return (data.blocks?.length ?? 0) > 0
|| (data.containers?.length ?? 0) > 0
|| (data.connections?.length ?? 0) > 0
|| (data.dependencies?.length ?? 0) > 0;
}
}