584 lines
20 KiB
TypeScript
584 lines
20 KiB
TypeScript
import { CommonModule } from '@angular/common';
|
|
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, OnDestroy, OnInit, signal, ViewChild } from '@angular/core';
|
|
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 { environment } from '@environment';
|
|
import {
|
|
AssistantCallPhase,
|
|
AssistantCallState,
|
|
AssistantChatMessage,
|
|
AssistantConfig,
|
|
AssistantDraftPayload,
|
|
AssistantSessionState
|
|
} from '@models/assistant';
|
|
import { Flow } from '@models/flow';
|
|
import { AssistantService } from '@services/assistant/assistant';
|
|
import { Authorization } from '@services/authorization/authorization';
|
|
import { AssistantSessionStore } from '@stores/assistant-session-store';
|
|
import { EditorStateHolder } from '@stores/flow-editor';
|
|
import { finalize, interval, Subscription, switchMap, take } from 'rxjs';
|
|
|
|
@Component({
|
|
selector: 'app-flow-assistant',
|
|
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
|
|
templateUrl: './flow-assistant.html',
|
|
styleUrl: './flow-assistant.css',
|
|
changeDetection: ChangeDetectionStrategy.OnPush
|
|
})
|
|
export class FlowAssistant implements OnInit, OnDestroy {
|
|
@ViewChild('assistantScroll') assistantScrollElement?: ElementRef<HTMLDivElement>;
|
|
|
|
private readonly assistant = inject(AssistantService);
|
|
private readonly editorState = inject(EditorStateHolder);
|
|
private readonly authorization = inject(Authorization);
|
|
private readonly sessionStore = inject(AssistantSessionStore);
|
|
private pollSubscription: Subscription | null = null;
|
|
private initialized = false;
|
|
private activeFlowKey: string | null = null;
|
|
private lastAutoScrollKey = '';
|
|
|
|
readonly assistantConfig = signal<AssistantConfig | null>(null);
|
|
readonly sessionState = signal<AssistantSessionState | null>(null);
|
|
readonly currentCall = signal<AssistantCallState | null>(null);
|
|
readonly localMessages = signal<AssistantChatMessage[]>([]);
|
|
readonly models = signal<string[]>([]);
|
|
readonly modelsLoading = signal(false);
|
|
readonly modelsError = signal<string | null>(null);
|
|
readonly sessionLoading = signal(false);
|
|
readonly prompt = signal('');
|
|
readonly selectedModel = signal('');
|
|
readonly modelPickerOpen = signal(false);
|
|
readonly quickPromptsOpen = signal(true);
|
|
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 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()];
|
|
});
|
|
readonly assistantBusy = computed(() => {
|
|
const status = this.currentCall()?.status;
|
|
return this.sessionLoading() || 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 (!phase) {
|
|
return this.sessionLoading() ? 'Preparing assistant session...' : '';
|
|
}
|
|
return this.phaseText(phase);
|
|
});
|
|
readonly busyDetail = computed(() => {
|
|
if (this.sessionLoading()) {
|
|
return 'Loading assistant configuration, models, and chat session...';
|
|
}
|
|
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);
|
|
|
|
constructor() {
|
|
effect(() => {
|
|
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 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();
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
this.bootstrapAssistant();
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
if (this.activeFlowKey) {
|
|
this.persistSnapshot(this.activeFlowKey);
|
|
}
|
|
this.stopPolling();
|
|
}
|
|
|
|
selectModel(model: string) {
|
|
if (!model || model === this.selectedModel()) return;
|
|
this.selectedModel.set(model);
|
|
void this.openSession(model);
|
|
}
|
|
|
|
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();
|
|
const sessionId = this.sessionState()?.id;
|
|
if (!content || this.assistantBusy() || !sessionId) return;
|
|
|
|
this.prompt.set('');
|
|
this.localMessages.set([
|
|
{
|
|
id: crypto.randomUUID(),
|
|
role: 'user',
|
|
content
|
|
}
|
|
]);
|
|
this.persistSnapshot();
|
|
|
|
this.assistant.sendMessage(sessionId, { message: content }).pipe(
|
|
take(1)
|
|
).subscribe({
|
|
next: ({ callId }) => {
|
|
this.currentCall.set({
|
|
id: callId,
|
|
sessionId,
|
|
status: 'QUEUED',
|
|
phase: 'queued'
|
|
});
|
|
this.persistSnapshot();
|
|
this.startPolling(callId, sessionId);
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant send message failed', err);
|
|
this.pushLocalAssistantMessage('The assistant request failed.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private bootstrapAssistant() {
|
|
this.sessionLoading.set(true);
|
|
this.modelsError.set(null);
|
|
this.assistant.getConfig().pipe(
|
|
take(1)
|
|
).subscribe({
|
|
next: (config) => {
|
|
this.assistantConfig.set(config);
|
|
this.loadModelsAndSession(config);
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant config loading failed', err);
|
|
this.sessionLoading.set(false);
|
|
this.modelsError.set('Unable to load assistant configuration.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private loadModelsAndSession(config: AssistantConfig) {
|
|
this.modelsLoading.set(true);
|
|
const resolvedUrl = this.resolveAssistantModelsUrl(config.availableModelsRetrieverUrl);
|
|
this.assistant.listModels(config.availableModelsRetrieverUrl).pipe(
|
|
take(1),
|
|
finalize(() => this.modelsLoading.set(false))
|
|
).subscribe({
|
|
next: (models) => {
|
|
this.models.set(models);
|
|
const selectedModel = config.defaultModel || models[0] || '';
|
|
this.selectedModel.set(selectedModel);
|
|
if (!selectedModel) {
|
|
this.sessionLoading.set(false);
|
|
this.modelsError.set('No assistant model is available.');
|
|
return;
|
|
}
|
|
this.activeFlowKey = this.resolveFlowKey(this.currentFlow()?.id ?? null);
|
|
this.initialized = true;
|
|
void this.restoreSessionForFlow(this.activeFlowKey);
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant model loading failed', err);
|
|
this.sessionLoading.set(false);
|
|
this.modelsError.set(
|
|
`Unable to load assistant models from ${resolvedUrl}.`
|
|
);
|
|
}
|
|
});
|
|
}
|
|
|
|
private async openSession(model: 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({ model }).pipe(
|
|
take(1),
|
|
finalize(() => this.sessionLoading.set(false))
|
|
).subscribe({
|
|
next: (session) => {
|
|
this.applySessionState(session);
|
|
this.persistSnapshot(flowKey);
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant session creation failed', err);
|
|
this.pushLocalAssistantMessage('Unable to create an assistant session.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private startPolling(callId: string, sessionId: string) {
|
|
this.stopPolling();
|
|
this.pollSubscription = interval(500).pipe(
|
|
switchMap(() => this.assistant.getCall(callId))
|
|
).subscribe({
|
|
next: (callState) => {
|
|
this.currentCall.set(callState);
|
|
this.persistSnapshot();
|
|
if (callState.status === 'COMPLETED' || callState.status === 'FAILED') {
|
|
this.stopPolling();
|
|
void this.reloadSession(sessionId, callState.status === 'FAILED' ? callState.errorMessage : undefined);
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant call polling failed', err);
|
|
this.stopPolling();
|
|
this.pushLocalAssistantMessage('Polling the assistant call failed.');
|
|
}
|
|
});
|
|
}
|
|
|
|
private async reloadSession(sessionId: string, failureMessage?: string) {
|
|
this.assistant.getSession(sessionId).pipe(
|
|
take(1)
|
|
).subscribe({
|
|
next: (session) => {
|
|
this.applySessionState(session);
|
|
this.currentCall.set(null);
|
|
this.persistSnapshot();
|
|
if (failureMessage) {
|
|
this.pushLocalAssistantMessage(failureMessage);
|
|
}
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant session refresh failed', err);
|
|
this.currentCall.set(null);
|
|
this.pushLocalAssistantMessage('Unable to refresh the assistant session.');
|
|
}
|
|
});
|
|
}
|
|
|
|
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.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);
|
|
|
|
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 });
|
|
});
|
|
}
|
|
|
|
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 filtered = this.localMessages().filter((message) => message.role !== 'assistant');
|
|
this.localMessages.set([
|
|
...filtered,
|
|
{
|
|
id: crypto.randomUUID(),
|
|
role: 'assistant',
|
|
content
|
|
}
|
|
]);
|
|
this.persistSnapshot();
|
|
}
|
|
|
|
private systemWelcomeMessage(): AssistantChatMessage {
|
|
return {
|
|
id: 'assistant-system-welcome',
|
|
role: 'system',
|
|
content: this.canOfferCreate()
|
|
? 'Select a model, then ask me to create a new workflow.'
|
|
: 'Select a 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(true);
|
|
this.sessionState.set(null);
|
|
this.localMessages.set([]);
|
|
const model = this.selectedModel();
|
|
if (!model) {
|
|
this.sessionLoading.set(false);
|
|
return;
|
|
}
|
|
void this.openSession(model, flowKey);
|
|
return;
|
|
}
|
|
|
|
this.prompt.set(snapshot.prompt);
|
|
this.modelPickerOpen.set(snapshot.modelPickerOpen);
|
|
this.quickPromptsOpen.set(snapshot.quickPromptsOpen);
|
|
this.localMessages.set(snapshot.localMessages);
|
|
this.currentCall.set(null);
|
|
if (snapshot.selectedModel) {
|
|
this.selectedModel.set(snapshot.selectedModel);
|
|
}
|
|
if (snapshot.sessionState) {
|
|
this.sessionState.set(snapshot.sessionState);
|
|
} else {
|
|
this.sessionState.set(null);
|
|
}
|
|
|
|
if (!snapshot.sessionId) {
|
|
const model = this.selectedModel();
|
|
if (!model) {
|
|
this.sessionLoading.set(false);
|
|
return;
|
|
}
|
|
void this.openSession(model, flowKey);
|
|
return;
|
|
}
|
|
|
|
this.sessionLoading.set(true);
|
|
this.assistant.getSession(snapshot.sessionId).pipe(
|
|
take(1),
|
|
finalize(() => this.sessionLoading.set(false))
|
|
).subscribe({
|
|
next: (session) => {
|
|
this.applySessionState(session, {
|
|
clearLocalMessages: false,
|
|
syncDraftToEditor: !this.currentFlow()?.id
|
|
});
|
|
},
|
|
error: (err) => {
|
|
console.error('Assistant session restore failed', err);
|
|
const model = this.selectedModel();
|
|
if (!model) return;
|
|
void this.openSession(model, flowKey);
|
|
}
|
|
});
|
|
}
|
|
|
|
private resolveFlowKey(flowId: string | null | undefined): string {
|
|
return this.sessionStore.flowKey(flowId);
|
|
}
|
|
|
|
private persistSnapshot(flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) {
|
|
this.sessionStore.setSnapshot(flowKey, {
|
|
sessionId: this.sessionState()?.id ?? null,
|
|
selectedModel: this.selectedModel(),
|
|
prompt: this.prompt(),
|
|
modelPickerOpen: this.modelPickerOpen(),
|
|
quickPromptsOpen: this.quickPromptsOpen(),
|
|
localMessages: this.localMessages(),
|
|
currentCall: this.currentCall(),
|
|
sessionState: this.sessionState()
|
|
});
|
|
}
|
|
|
|
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.';
|
|
}
|
|
}
|
|
|
|
private resolveAssistantModelsUrl(url: string): string {
|
|
if (!url) return url;
|
|
if (/^https?:\/\//i.test(url)) return url;
|
|
|
|
const apiBase = environment.apiUrl;
|
|
if (/^https?:\/\//i.test(apiBase)) {
|
|
return new URL(url, `${apiBase.replace(/\/+$/, '')}/`).toString();
|
|
}
|
|
|
|
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
|
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
|
|
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|