@if (flow()) {
-
+
-
-
} @else {
-
- Select a document
+
+
No flow selected
+
Open an existing flow from the sidebar or create a new one to start editing.
}
-
-
-
diff --git a/src/app/layouts/flow-editor/flow-editor.ts b/src/app/layouts/flow-editor/flow-editor.ts
index 6dc7df9..0a5d386 100644
--- a/src/app/layouts/flow-editor/flow-editor.ts
+++ b/src/app/layouts/flow-editor/flow-editor.ts
@@ -1,4 +1,5 @@
import { Component, inject} from '@angular/core';
+import { environment } from '@environment';
import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar";
import { EditorStateHolder } from '@stores/flow-editor';
import { TitleToolbar } from "@shared/title-toolbar/title-toolbar";
@@ -14,6 +15,7 @@ export class FlowEditor {
private editorState: EditorStateHolder = inject(EditorStateHolder);
+ assistantEnabled = environment.assistantEnabled;
flow = this.editorState.currentFlow;
}
diff --git a/src/app/models/assistant.ts b/src/app/models/assistant.ts
new file mode 100644
index 0000000..c0e3e59
--- /dev/null
+++ b/src/app/models/assistant.ts
@@ -0,0 +1,45 @@
+import { FlowData, FlowStatus } from '@models/flow';
+
+export type AssistantIntent = 'draft' | 'refine' | 'fix' | 'explain';
+
+export type AssistantDraftPayload = {
+ name: string;
+ description?: string;
+ flow: FlowData;
+};
+
+export type AssistantValidationIssue = {
+ message: string;
+ path?: string;
+};
+
+export type AssistantFlowResponse = {
+ flow: AssistantDraftPayload;
+ valid: boolean;
+ validationErrors: AssistantValidationIssue[];
+ warnings: string[];
+ assistantRationale?: string;
+ repairAttempts?: number;
+};
+
+export type AssistantExplainResponse = {
+ explanation: string;
+ warnings: string[];
+};
+
+export type AssistantChatMessage = {
+ id: string;
+ role: 'user' | 'assistant' | 'system';
+ content: string;
+ intent?: AssistantIntent | null;
+ warnings?: string[];
+ validationErrors?: AssistantValidationIssue[];
+};
+
+export type AssistantEditorDraft = {
+ id: string;
+ name: string;
+ description?: string;
+ data: FlowData;
+ status: FlowStatus;
+};
diff --git a/src/app/services/assistant/assistant-call.base.ts b/src/app/services/assistant/assistant-call.base.ts
new file mode 100644
index 0000000..ae77a65
--- /dev/null
+++ b/src/app/services/assistant/assistant-call.base.ts
@@ -0,0 +1,38 @@
+import {
+ AssistantDraftPayload,
+ AssistantExplainResponse,
+ AssistantFlowResponse,
+ AssistantValidationIssue
+} from '@models/assistant';
+import { Observable } from 'rxjs';
+
+export abstract class AssistantCallServiceBase {
+ abstract listModels(): Observable
;
+
+ abstract createDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ }): Observable;
+
+ abstract refineDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ }): Observable;
+
+ abstract fixDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ validationErrors?: AssistantValidationIssue[];
+ }): Observable;
+
+ abstract explainDraft(request: {
+ userPrompt: string;
+ model: string;
+ flow: AssistantDraftPayload;
+ }): Observable;
+}
diff --git a/src/app/services/assistant/assistant-call.fake.ts b/src/app/services/assistant/assistant-call.fake.ts
new file mode 100644
index 0000000..15e1979
--- /dev/null
+++ b/src/app/services/assistant/assistant-call.fake.ts
@@ -0,0 +1,238 @@
+import {
+ AssistantDraftPayload,
+ AssistantExplainResponse,
+ AssistantFlowResponse,
+ AssistantValidationIssue
+} from '@models/assistant';
+import { Observable, of } from 'rxjs';
+import { AssistantCallServiceBase } from './assistant-call.base';
+
+export class AssistantCallServiceFake extends AssistantCallServiceBase {
+ override listModels(): Observable {
+ return of([
+ 'llama3.1:8b',
+ 'qwen2.5:7b',
+ 'mistral:7b'
+ ]);
+ }
+
+ override createDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ }): Observable {
+ const invalid = /invalid|broken|errore|error/i.test(request.userPrompt);
+ const flow = buildTicketFlow(request.model);
+ return of({
+ flow: {
+ name: /classif/i.test(request.userPrompt) ? 'Ticket classification with urgent review' : 'Assistant generated flow',
+ description: `Draft generated from prompt: ${request.userPrompt}`,
+ flow
+ },
+ valid: !invalid,
+ validationErrors: invalid ? [{ message: 'The draft contains unresolved validation issues.' }] : [],
+ warnings: ['Fake assistant response'],
+ assistantRationale: 'Uses an input block, an LLM classifier, a conditional router, and a human review branch.',
+ repairAttempts: invalid ? 1 : 0
+ });
+ }
+
+ override refineDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ }): Observable {
+ const next = structuredClone(request.flow);
+ next.name = request.flow.name || 'Refined flow';
+ next.description = `${request.flow.description ?? 'Refined draft'}\n\nRefinement: ${request.userPrompt}`.trim();
+
+ if (/review|approval|human/i.test(request.userPrompt)) {
+ addHumanReviewTail(next.flow);
+ }
+
+ return of({
+ flow: next,
+ valid: true,
+ validationErrors: [],
+ warnings: ['Fake assistant response'],
+ assistantRationale: 'Applied the requested refinement incrementally without replacing the whole graph.',
+ repairAttempts: 0
+ });
+ }
+
+ override fixDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ validationErrors?: AssistantValidationIssue[];
+ }): Observable {
+ const next = structuredClone(request.flow);
+ return of({
+ flow: next,
+ valid: true,
+ validationErrors: [],
+ warnings: ['Fake assistant response'],
+ assistantRationale: 'Recomputed the draft and cleared the reported validation problems.',
+ repairAttempts: 1
+ });
+ }
+
+ override explainDraft(request: {
+ userPrompt: string;
+ model: string;
+ flow: AssistantDraftPayload;
+ }): Observable {
+ return of({
+ explanation: `This flow "${request.flow.name}" receives a ticket, classifies urgency with an LLM, routes urgent cases to a human reviewer, and auto-forwards non-urgent cases.`,
+ warnings: ['Fake assistant response']
+ });
+ }
+}
+
+function buildTicketFlow(model: string) {
+ return {
+ blocks: [
+ {
+ id: 'assistant-input',
+ name: 'Incoming Ticket',
+ position: { x: 80, y: 220 },
+ inputs: [],
+ outputs: [{ name: 'ticket', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'InputBlockConfiguration',
+ name: 'Incoming Ticket'
+ },
+ typeName: 'InputBlock'
+ },
+ {
+ id: 'assistant-llm',
+ name: 'Classify Urgency',
+ position: { x: 360, y: 220 },
+ inputs: [{ name: 'name', type: 'TEXT', multiple: false }],
+ outputs: [{ name: 'response', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'LLMBlockConfiguration',
+ name: 'Classify Urgency',
+ llmDescriptor: {
+ provider: 'InternalOllama',
+ model
+ },
+ prompt: 'Classify the ticket urgency as true for urgent, false otherwise. Ticket: ${{name}}'
+ },
+ typeName: 'LLMBlock'
+ },
+ {
+ id: 'assistant-condition',
+ name: 'Urgent?',
+ position: { x: 650, y: 220 },
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [
+ { name: 'true', type: 'TEXT', multiple: false },
+ { name: 'false', type: 'TEXT', multiple: false }
+ ],
+ specificConfiguration: {
+ type: 'ConditionalBlockConfiguration',
+ name: 'Urgent?',
+ condition: '${{input}} == true'
+ },
+ typeName: 'ConditionalBlock'
+ },
+ {
+ id: 'assistant-human',
+ name: 'Human Review',
+ position: { x: 950, y: 120 },
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'HumanInteractiveBlockConfiguration',
+ name: 'Human Review',
+ actionDescription: 'Review urgent ticket and decide priority',
+ simulateWith: {
+ provider: 'InternalOllama',
+ model
+ },
+ inputAsList: false,
+ outputAsList: false
+ },
+ typeName: 'HumanInteractionBlock'
+ },
+ {
+ id: 'assistant-output',
+ name: 'Resolved Route',
+ position: { x: 950, y: 330 },
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [],
+ specificConfiguration: {
+ type: 'OutputBlockConfiguration',
+ name: 'Resolved Route'
+ },
+ typeName: 'OutputBlock'
+ }
+ ],
+ connections: [
+ {
+ id: 'assistant-conn-1',
+ sourceId: 'assistant-input',
+ sourceName: 'ticket',
+ targetId: 'assistant-llm',
+ targetName: 'name'
+ },
+ {
+ id: 'assistant-conn-2',
+ sourceId: 'assistant-llm',
+ sourceName: 'response',
+ targetId: 'assistant-condition',
+ targetName: 'input'
+ },
+ {
+ id: 'assistant-conn-3',
+ sourceId: 'assistant-condition',
+ sourceName: 'true',
+ targetId: 'assistant-human',
+ targetName: 'input'
+ },
+ {
+ id: 'assistant-conn-4',
+ sourceId: 'assistant-condition',
+ sourceName: 'false',
+ targetId: 'assistant-output',
+ targetName: 'input'
+ }
+ ]
+ };
+}
+
+function addHumanReviewTail(flow: AssistantDraftPayload['flow']) {
+ const alreadyExists = flow.blocks.some((block) => block.id === 'assistant-extra-review');
+ if (alreadyExists) return;
+
+ flow.blocks.push({
+ id: 'assistant-extra-review',
+ name: 'Extra Review',
+ position: { x: 1240, y: 120 },
+ inputs: [{ name: 'input', type: 'TEXT', multiple: false }],
+ outputs: [{ name: 'output', type: 'TEXT', multiple: false }],
+ specificConfiguration: {
+ type: 'HumanInteractiveBlockConfiguration',
+ name: 'Extra Review',
+ actionDescription: 'Confirm the high-risk decision before completion',
+ simulateWith: {
+ provider: 'InternalOllama',
+ model: 'llama3.1:8b'
+ },
+ inputAsList: false,
+ outputAsList: false
+ },
+ typeName: 'HumanInteractionBlock'
+ });
+
+ flow.connections.push({
+ id: 'assistant-conn-extra',
+ sourceId: 'assistant-human',
+ sourceName: 'output',
+ targetId: 'assistant-extra-review',
+ targetName: 'input'
+ });
+}
diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts
new file mode 100644
index 0000000..5d39652
--- /dev/null
+++ b/src/app/services/assistant/assistant-call.ts
@@ -0,0 +1,122 @@
+import { HttpClient } from '@angular/common/http';
+import { inject } from '@angular/core';
+import {
+ AssistantDraftPayload,
+ AssistantExplainResponse,
+ AssistantFlowResponse,
+ AssistantValidationIssue
+} from '@models/assistant';
+import { environment } from '@environment';
+import { map, Observable } from 'rxjs';
+import { AssistantCallServiceBase } from './assistant-call.base';
+
+export class AssistantCallService extends AssistantCallServiceBase {
+ private readonly http = inject(HttpClient);
+
+ override listModels(): Observable {
+ return this.http
+ .get(`${environment.apiUrl}/retriever/LLM/models`, {
+ params: { provider: 'InternalOllama' }
+ })
+ .pipe(
+ map((raw) => (Array.isArray(raw) ? raw : []).map((item) => String(item)))
+ );
+ }
+
+ override createDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ }): Observable {
+ return this.http
+ .post(`${environment.apiUrl}/assistant/flows/draft`, request)
+ .pipe(map((raw) => mapAssistantFlowResponse(raw)));
+ }
+
+ override refineDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ }): Observable {
+ return this.http
+ .post(`${environment.apiUrl}/assistant/flows/refine`, request)
+ .pipe(map((raw) => mapAssistantFlowResponse(raw)));
+ }
+
+ override fixDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ validationErrors?: AssistantValidationIssue[];
+ }): Observable {
+ return this.http
+ .post(`${environment.apiUrl}/assistant/flows/fix`, request)
+ .pipe(map((raw) => mapAssistantFlowResponse(raw)));
+ }
+
+ override explainDraft(request: {
+ userPrompt: string;
+ model: string;
+ flow: AssistantDraftPayload;
+ }): Observable {
+ return this.http
+ .post(`${environment.apiUrl}/assistant/flows/explain`, request)
+ .pipe(map((raw) => mapAssistantExplainResponse(raw)));
+ }
+}
+
+function mapAssistantFlowResponse(raw: unknown): AssistantFlowResponse {
+ const value = (raw ?? {}) as Record;
+ const payload = mapAssistantDraftPayload(value['flow']);
+ return {
+ flow: payload,
+ valid: value['valid'] !== false,
+ validationErrors: mapValidationIssues(value['validationErrors']),
+ warnings: Array.isArray(value['warnings']) ? value['warnings'].map((item) => String(item)) : [],
+ assistantRationale: typeof value['assistantRationale'] === 'string' ? value['assistantRationale'] : undefined,
+ repairAttempts: typeof value['repairAttempts'] === 'number' ? value['repairAttempts'] : undefined
+ };
+}
+
+function mapAssistantExplainResponse(raw: unknown): AssistantExplainResponse {
+ const value = (raw ?? {}) as Record;
+ return {
+ explanation: typeof value['explanation'] === 'string'
+ ? value['explanation']
+ : typeof value['assistantRationale'] === 'string'
+ ? value['assistantRationale']
+ : '',
+ warnings: Array.isArray(value['warnings']) ? value['warnings'].map((item) => String(item)) : []
+ };
+}
+
+function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload {
+ const value = (raw ?? {}) as Record;
+ const flow = (value['flow'] ?? {}) as Record;
+ return {
+ name: String(value['name'] ?? 'Assistant Draft'),
+ description: typeof value['description'] === 'string' ? value['description'] : undefined,
+ flow: {
+ blocks: Array.isArray(flow['blocks']) ? (flow['blocks'] as any[]) : [],
+ connections: Array.isArray(flow['connections']) ? (flow['connections'] as any[]) : []
+ }
+ };
+}
+
+function mapValidationIssues(raw: unknown): AssistantValidationIssue[] {
+ if (!Array.isArray(raw)) return [];
+
+ return raw.map((item) => {
+ if (typeof item === 'string') {
+ return { message: item };
+ }
+
+ const value = (item ?? {}) as Record;
+ return {
+ message: String(value['message'] ?? value['error'] ?? 'Validation issue'),
+ path: typeof value['path'] === 'string' ? value['path'] : undefined
+ };
+ });
+}
diff --git a/src/app/services/assistant/assistant.ts b/src/app/services/assistant/assistant.ts
new file mode 100644
index 0000000..99e6540
--- /dev/null
+++ b/src/app/services/assistant/assistant.ts
@@ -0,0 +1,55 @@
+import { Injectable } from '@angular/core';
+import {
+ AssistantDraftPayload,
+ AssistantExplainResponse,
+ AssistantFlowResponse,
+ AssistantValidationIssue
+} from '@models/assistant';
+import { environment } from '@environment';
+import { AssistantCallServiceBase } from './assistant-call.base';
+
+@Injectable({
+ providedIn: 'root'
+})
+export class AssistantService {
+ private readonly assistantCall: AssistantCallServiceBase = new environment.assistantCallService();
+
+ listModels() {
+ return this.assistantCall.listModels();
+ }
+
+ createDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ }) {
+ return this.assistantCall.createDraft(request);
+ }
+
+ refineDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ }) {
+ return this.assistantCall.refineDraft(request);
+ }
+
+ fixDraft(request: {
+ userPrompt: string;
+ model: string;
+ maxRepairAttempts?: number;
+ flow: AssistantDraftPayload;
+ validationErrors?: AssistantValidationIssue[];
+ }) {
+ return this.assistantCall.fixDraft(request);
+ }
+
+ explainDraft(request: {
+ userPrompt: string;
+ model: string;
+ flow: AssistantDraftPayload;
+ }) {
+ return this.assistantCall.explainDraft(request);
+ }
+}
diff --git a/src/app/services/flows/flows-call.base.ts b/src/app/services/flows/flows-call.base.ts
index 01fcea2..4233796 100644
--- a/src/app/services/flows/flows-call.base.ts
+++ b/src/app/services/flows/flows-call.base.ts
@@ -9,6 +9,8 @@ export abstract class FlowsCallServiceBase {
abstract updateFlow(flow: Flow) : Observable;
+ abstract createFlow(flow: Pick) : Observable;
+
abstract createNewFlow(name?: string) : Observable;
abstract deleteFlow(flowId: string) : Observable;
diff --git a/src/app/services/flows/flows-call.fake.ts b/src/app/services/flows/flows-call.fake.ts
index 97369d4..c9d0f20 100644
--- a/src/app/services/flows/flows-call.fake.ts
+++ b/src/app/services/flows/flows-call.fake.ts
@@ -31,10 +31,30 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
return of(flow);
}
- override createNewFlow(name?: string): Observable {
+ override createFlow(flow: Pick): Observable {
const newId = (Object.keys(this.data).length + 1).toString();
- this.data[newId] = { id: newId, name: name || `New Flow`, data: { blocks: [], connections: [] }, visibility: 'PRIVATE', author: this.authorizationService.loggedInUser()!.username, createdAt: new Date(), status: 'DRAFT', updatedAt: new Date() };
- return of(this.data[newId]);
+ const created: Flow = {
+ id: newId,
+ name: flow.name,
+ description: flow.description,
+ data: flow.data,
+ visibility: 'PRIVATE',
+ author: this.authorizationService.loggedInUser()?.username ?? 'assistant',
+ createdAt: new Date(),
+ status: flow.status,
+ updatedAt: new Date()
+ };
+ this.data[newId] = created;
+ return of(created);
+ }
+
+ override createNewFlow(name?: string): Observable {
+ return this.createFlow({
+ name: name || 'New Flow',
+ description: undefined,
+ data: { blocks: [], connections: [] },
+ status: 'DRAFT'
+ });
}
override deleteFlow(flowId: string): Observable {
diff --git a/src/app/services/flows/flows-call.ts b/src/app/services/flows/flows-call.ts
index 4abec72..328844c 100644
--- a/src/app/services/flows/flows-call.ts
+++ b/src/app/services/flows/flows-call.ts
@@ -16,12 +16,27 @@ export class FlowsCallService extends FlowsCallServiceBase {
.pipe(map((raw) => flowFromApi(raw)));
}
- override createNewFlow(name?: string): Observable {
+ override createFlow(flow: Pick): Observable {
return this.http
- .post(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow', undefined, undefined, 'DRAFT'))
+ .post(
+ `${environment.apiUrl}/flows`,
+ toFlowCreateRequest(flow.name, flow.description, flow.data, flow.status)
+ )
.pipe(map((raw) => flowFromApi(raw)));
}
+ override createNewFlow(name?: string): Observable {
+ return this.createFlow({
+ name: name ?? 'New Flow',
+ description: undefined,
+ data: {
+ blocks: [],
+ connections: []
+ },
+ status: 'DRAFT'
+ });
+ }
+
override deleteFlow(flowId: string): Observable {
const encodedId = encodeURIComponent(flowId);
return this.http.delete(`${environment.apiUrl}/flows/${encodedId}`);
diff --git a/src/app/services/flows/flows.ts b/src/app/services/flows/flows.ts
index 8d20a0e..9bc29cd 100644
--- a/src/app/services/flows/flows.ts
+++ b/src/app/services/flows/flows.ts
@@ -53,6 +53,18 @@ export class FlowsService {
);
}
+ createFlow(flow: Pick) {
+ return this.flowsCallService.createFlow(flow).pipe(
+ tap((createdFlow) => {
+ this._flows.update((flows) => [createdFlow, ...flows]);
+ }),
+ catchError(err => {
+ console.error('Create flow failed', err);
+ return throwError(() => err);
+ })
+ );
+ }
+
deleteFlow(flowId: string) {
return this.flowsCallService.deleteFlow(flowId).pipe(
tap(() => this.refresh()),
@@ -85,7 +97,15 @@ export class FlowsService {
}
createNewFlow(name? : string) {
- return this.flowsCallService.createNewFlow(name || this.nextFileName('New Flow')).pipe(
+ return this.createFlow({
+ name: name || this.nextFileName('New Flow'),
+ description: undefined,
+ data: {
+ blocks: [],
+ connections: []
+ },
+ status: 'DRAFT'
+ }).pipe(
tap(() => this.refresh()),
catchError(err => {
console.error('Create new flow failed', err);
diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css
new file mode 100644
index 0000000..e285ab1
--- /dev/null
+++ b/src/app/shared/flow-assistant/flow-assistant.css
@@ -0,0 +1,283 @@
+.assistant-shell {
+ display: flex;
+ flex-direction: column;
+ height: 100%;
+ min-height: 0;
+ overflow: hidden;
+ background:
+ radial-gradient(circle at top right, rgba(251, 191, 36, 0.18), transparent 26%),
+ radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.14), transparent 30%),
+ linear-gradient(180deg, #fffdf7 0%, #f8fafc 100%);
+ border-left: 1px solid #dbe4ee;
+}
+
+.assistant-scroll {
+ display: flex;
+ flex: 1 1 auto;
+ min-height: 0;
+ flex-direction: column;
+ gap: 10px;
+ padding: 18px 18px 10px;
+ overflow-y: auto;
+ overflow-x: hidden;
+}
+
+.assistant-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+}
+
+.assistant-eyebrow {
+ margin: 0 0 4px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.12em;
+ text-transform: uppercase;
+ color: #b45309;
+}
+
+.assistant-title {
+ margin: 0;
+ font-size: 28px;
+ line-height: 1;
+ font-weight: 800;
+ color: #0f172a;
+}
+
+.assistant-intent {
+ padding: 6px 10px;
+ border-radius: 999px;
+ background: #0f172a;
+ color: #f8fafc;
+ font-size: 11px;
+ font-weight: 700;
+ text-transform: uppercase;
+}
+
+.assistant-card {
+ padding: 12px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.82);
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
+}
+
+.assistant-copy,
+.assistant-meta,
+.assistant-error,
+.assistant-label {
+ margin: 0;
+}
+
+.assistant-copy {
+ color: #334155;
+ font-size: 13px;
+ line-height: 1.45;
+ margin-bottom: 8px;
+}
+
+.assistant-label {
+ display: block;
+ margin-bottom: 8px;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.assistant-select,
+.assistant-input,
+.assistant-send,
+.assistant-chip {
+ font: inherit;
+}
+
+.assistant-select,
+.assistant-input {
+ width: 100%;
+ border: 1px solid #cbd5e1;
+ border-radius: 12px;
+ background: #fff;
+ color: #0f172a;
+}
+
+.assistant-select {
+ padding: 10px 12px;
+}
+
+.assistant-meta {
+ margin-top: 8px;
+ color: #475569;
+ font-size: 12px;
+}
+
+.assistant-error {
+ margin-top: 8px;
+ color: #b91c1c;
+ font-size: 12px;
+}
+
+.assistant-chip-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+}
+
+.assistant-section-head,
+.assistant-starters-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ gap: 12px;
+ margin-bottom: 8px;
+}
+
+.assistant-section-toggle {
+ width: 24px;
+ height: 24px;
+ border: 0;
+ border-radius: 999px;
+ background: transparent;
+ color: #64748b;
+ font-size: 16px;
+ line-height: 1;
+ font-weight: 700;
+}
+
+.assistant-chip {
+ padding: 9px 12px;
+ border: 1px solid #d6d3d1;
+ border-radius: 999px;
+ background: linear-gradient(180deg, #fff, #fff7ed);
+ color: #7c2d12;
+ font-size: 11px;
+ line-height: 1.35;
+ text-align: left;
+ cursor: pointer;
+ transition: transform 120ms ease, border-color 120ms ease, box-shadow 120ms ease;
+}
+
+.assistant-chip:hover {
+ transform: translateY(-1px);
+ border-color: #fb923c;
+ box-shadow: 0 8px 18px rgba(249, 115, 22, 0.16);
+}
+
+.assistant-thread {
+ min-height: 120px;
+ overflow: visible;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding-right: 2px;
+}
+
+.assistant-message {
+ padding: 12px 14px;
+ border-radius: 16px 16px 16px 6px;
+ background: #fff;
+ border: 1px solid rgba(226, 232, 240, 0.95);
+}
+
+.assistant-message-user {
+ align-self: flex-end;
+ max-width: 92%;
+ border-radius: 16px 16px 6px 16px;
+ background: linear-gradient(180deg, #fef3c7, #fffbeb);
+ border-color: #f59e0b;
+}
+
+.assistant-message-system {
+ background: #eff6ff;
+ border-color: #bfdbfe;
+}
+
+.assistant-message-role {
+ margin-bottom: 6px;
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: #64748b;
+}
+
+.assistant-message-body {
+ margin: 0;
+ white-space: pre-wrap;
+ color: #0f172a;
+ line-height: 1.45;
+ font-size: 13px;
+}
+
+.assistant-list {
+ margin-top: 10px;
+ display: grid;
+ gap: 6px;
+ font-size: 12px;
+}
+
+.assistant-list-warning {
+ color: #92400e;
+}
+
+.assistant-list-error {
+ color: #991b1b;
+}
+
+.assistant-composer {
+ display: grid;
+ gap: 8px;
+ padding: 12px;
+ margin: 0 18px 18px;
+ border: 1px solid rgba(148, 163, 184, 0.24);
+ border-radius: 16px;
+ background: rgba(255, 255, 255, 0.92);
+ box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
+ flex: 0 0 auto;
+}
+
+.assistant-input {
+ resize: vertical;
+ min-height: 88px;
+ max-height: 160px;
+ padding: 12px 14px;
+ line-height: 1.45;
+}
+
+.assistant-input:disabled,
+.assistant-send:disabled {
+ cursor: not-allowed;
+ opacity: 0.65;
+}
+
+.assistant-send {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ min-height: 44px;
+ padding: 10px 16px;
+ border: 0;
+ border-radius: 999px;
+ background: linear-gradient(135deg, #0f766e, #0f172a);
+ color: #fff;
+ box-shadow: 0 12px 22px rgba(15, 23, 42, 0.18);
+ font-weight: 700;
+ letter-spacing: 0.03em;
+ line-height: 1;
+ flex-shrink: 0;
+}
+
+.assistant-send:not(:disabled):hover {
+ filter: brightness(1.05);
+}
+
+@media (max-width: 1100px) {
+ .assistant-shell {
+ border-left: 0;
+ border-top: 1px solid #dbe4ee;
+ }
+}
diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html
new file mode 100644
index 0000000..2c73400
--- /dev/null
+++ b/src/app/shared/flow-assistant/flow-assistant.html
@@ -0,0 +1,125 @@
+
diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts
new file mode 100644
index 0000000..060216b
--- /dev/null
+++ b/src/app/shared/flow-assistant/flow-assistant.ts
@@ -0,0 +1,300 @@
+import { CommonModule } from '@angular/common';
+import { Component, computed, inject, OnInit, signal } from '@angular/core';
+import { FormsModule } from '@angular/forms';
+import {
+ AssistantChatMessage,
+ AssistantDraftPayload,
+ AssistantEditorDraft,
+ AssistantFlowResponse,
+ AssistantIntent,
+ AssistantValidationIssue
+} from '@models/assistant';
+import { Flow } from '@models/flow';
+import { AssistantService } from '@services/assistant/assistant';
+import { Authorization } from '@services/authorization/authorization';
+import { EditorStateHolder } from '@stores/flow-editor';
+import { finalize, take } from 'rxjs';
+
+@Component({
+ selector: 'app-flow-assistant',
+ imports: [CommonModule, FormsModule],
+ templateUrl: './flow-assistant.html',
+ styleUrl: './flow-assistant.css'
+})
+export class FlowAssistant implements OnInit {
+ private readonly assistant = inject(AssistantService);
+ private readonly editorState = inject(EditorStateHolder);
+ private readonly authorization = inject(Authorization);
+
+ readonly models = signal([]);
+ readonly modelsLoading = signal(false);
+ readonly modelsError = signal(null);
+ readonly selectedModel = signal('');
+ readonly modelPickerOpen = signal(true);
+ readonly assistantBusy = signal(false);
+ readonly currentIntent = signal(null);
+ readonly lastValidationErrors = signal([]);
+ readonly prompt = signal('');
+ readonly quickPromptsOpen = signal(true);
+ readonly messages = signal([
+ {
+ id: crypto.randomUUID(),
+ role: 'system',
+ content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
+ }
+ ]);
+
+ readonly currentFlow = this.editorState.currentFlow;
+ readonly draftDirty = this.editorState.isDirty;
+ readonly currentDraft = computed(() => this.toAssistantDraft(this.currentFlow()));
+ readonly starterPrompts = [
+ 'Create a flow that classifies incoming tickets and sends urgent ones to a human',
+ 'Modify the current flow to add a review step after the LLM block',
+ 'Fix the problems in this flow',
+ 'Explain why this flow is not valid'
+ ];
+
+ ngOnInit(): void {
+ this.loadModels();
+ }
+
+ selectModel(model: string) {
+ this.selectedModel.set(model);
+ }
+
+ useStarter(prompt: string) {
+ this.prompt.set(prompt);
+ }
+
+ toggleQuickPrompts() {
+ this.quickPromptsOpen.update((value) => !value);
+ }
+
+ toggleModelPicker() {
+ this.modelPickerOpen.update((value) => !value);
+ }
+
+ submitPrompt() {
+ const userPrompt = this.prompt().trim();
+ if (!userPrompt || this.assistantBusy() || !this.selectedModel()) return;
+
+ this.prompt.set('');
+ this.pushMessage({
+ role: 'user',
+ content: userPrompt
+ });
+
+ const clarification = this.maybeClarify(userPrompt);
+ if (clarification) {
+ this.pushMessage({
+ role: 'assistant',
+ content: clarification
+ });
+ return;
+ }
+
+ const intent = this.determineIntent(userPrompt);
+ this.currentIntent.set(intent);
+ this.assistantBusy.set(true);
+
+ if (intent === 'explain') {
+ const draft = this.currentDraft();
+ if (!draft) {
+ this.pushMessage({
+ role: 'assistant',
+ content: 'Open or generate a flow first, then I can explain it in detail.',
+ intent
+ });
+ this.assistantBusy.set(false);
+ return;
+ }
+
+ this.assistant.explainDraft({
+ userPrompt,
+ model: this.selectedModel(),
+ flow: draft
+ }).pipe(
+ take(1),
+ finalize(() => this.assistantBusy.set(false))
+ ).subscribe({
+ next: (response) => {
+ this.pushMessage({
+ role: 'assistant',
+ content: response.explanation || 'I analyzed the current flow.',
+ intent,
+ warnings: response.warnings
+ });
+ },
+ error: (err) => this.pushAssistantError(err, intent)
+ });
+ return;
+ }
+
+ const draft = this.currentDraft();
+ const maxRepairAttempts = intent === 'fix' ? 2 : 1;
+ const request$ = intent === 'draft' || !draft
+ ? this.assistant.createDraft({
+ userPrompt,
+ model: this.selectedModel(),
+ maxRepairAttempts
+ })
+ : intent === 'fix'
+ ? this.assistant.fixDraft({
+ userPrompt,
+ model: this.selectedModel(),
+ maxRepairAttempts,
+ flow: draft,
+ validationErrors: this.lastValidationErrors()
+ })
+ : this.assistant.refineDraft({
+ userPrompt,
+ model: this.selectedModel(),
+ maxRepairAttempts,
+ flow: draft
+ });
+
+ request$.pipe(
+ take(1),
+ finalize(() => this.assistantBusy.set(false))
+ ).subscribe({
+ next: (response) => void this.applyAssistantFlowResponse(response, intent),
+ error: (err) => this.pushAssistantError(err, intent)
+ });
+ }
+
+ private loadModels() {
+ this.modelsLoading.set(true);
+ this.modelsError.set(null);
+
+ this.assistant.listModels().pipe(
+ take(1),
+ finalize(() => this.modelsLoading.set(false))
+ ).subscribe({
+ next: (models) => {
+ this.models.set(models);
+ if (!this.selectedModel() && models.length) {
+ this.selectedModel.set(models[0]);
+ }
+ },
+ error: (err) => {
+ console.error('Assistant model loading failed', err);
+ this.modelsError.set('Unable to load internal assistant models.');
+ }
+ });
+ }
+
+ private determineIntent(prompt: string): AssistantIntent {
+ const normalized = prompt.toLowerCase();
+ const hasDraft = !!this.currentDraft();
+
+ if (/explain|what does|spiega|cosa fa|why/i.test(normalized)) return 'explain';
+ if (/fix|repair|problem|invalid|error|errore|bug/i.test(normalized)) return 'fix';
+ if (!hasDraft) return 'draft';
+ if (/create new|new flow|from scratch|nuovo flow/i.test(normalized)) return 'draft';
+ return 'refine';
+ }
+
+ private maybeClarify(prompt: string): string | null {
+ const normalized = prompt.trim();
+ if (normalized.split(/\s+/).length >= 4) return null;
+ if (this.currentDraft()) return null;
+ return 'The request is too short to generate a useful flow. Tell me in one sentence what the workflow should do.';
+ }
+
+ private async applyAssistantFlowResponse(response: AssistantFlowResponse, intent: AssistantIntent) {
+ this.lastValidationErrors.set(response.validationErrors);
+
+ const currentFlow = this.currentFlow();
+ const nextFlow = this.toEditorFlow(response, currentFlow, intent);
+ const isReplacingDocument = intent === 'draft' && currentFlow?.id !== nextFlow.id;
+ if (isReplacingDocument) {
+ const opened = await this.editorState.openDocument(nextFlow);
+ if (!opened) {
+ this.pushMessage({
+ role: 'assistant',
+ content: 'The new draft is ready, but I did not load it because the current flow has unsaved changes.',
+ intent
+ });
+ return;
+ }
+ }
+
+ this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
+
+ const summary = [
+ response.assistantRationale || this.defaultAssistantSummary(intent, response.valid),
+ response.valid ? 'The draft is valid.' : 'The draft still has validation errors.'
+ ].filter(Boolean).join(' ');
+
+ this.pushMessage({
+ role: 'assistant',
+ content: summary,
+ intent,
+ warnings: response.warnings,
+ validationErrors: response.validationErrors
+ });
+ }
+
+ private toEditorFlow(response: AssistantFlowResponse, currentFlow: Flow | null, intent: AssistantIntent): Flow {
+ const shouldReuseCurrentId = !!currentFlow && intent !== 'draft';
+ const nextId = shouldReuseCurrentId
+ ? currentFlow!.id
+ : `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`;
+
+ return {
+ id: nextId,
+ name: response.flow.name,
+ description: response.flow.description,
+ data: response.flow.flow,
+ status: response.valid ? 'EXECUTABLE' : '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 toAssistantDraft(flow: Flow | null): AssistantDraftPayload | null {
+ if (!flow) return null;
+ return {
+ name: flow.name,
+ description: flow.description,
+ flow: flow.data
+ };
+ }
+
+ private defaultAssistantSummary(intent: AssistantIntent, valid: boolean): string {
+ if (intent === 'draft') {
+ return valid
+ ? 'I created a new workflow draft.'
+ : 'I created an initial draft, but it still needs corrections.';
+ }
+ if (intent === 'fix') {
+ return valid
+ ? 'I fixed the current workflow.'
+ : 'I tried to fix the workflow, but there are still unresolved issues.';
+ }
+ return 'I updated the current workflow based on your request.';
+ }
+
+ private pushAssistantError(err: unknown, intent: AssistantIntent) {
+ console.error('Assistant request failed', err);
+ this.pushMessage({
+ role: 'assistant',
+ content: 'The assistant request failed.',
+ intent
+ });
+ }
+
+ private pushMessage(message: Omit) {
+ this.messages.update((messages) => [
+ ...messages,
+ {
+ ...message,
+ id: crypto.randomUUID()
+ }
+ ]);
+ }
+}
diff --git a/src/app/stores/flow-editor.ts b/src/app/stores/flow-editor.ts
index 5e4fc04..83f1dec 100644
--- a/src/app/stores/flow-editor.ts
+++ b/src/app/stores/flow-editor.ts
@@ -6,6 +6,7 @@ import { tap } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class EditorStateHolder {
+ static readonly ASSISTANT_DRAFT_PREFIX = 'assistant-draft:';
/** Stato */
readonly currentFlow = signal(null);
@@ -49,6 +50,11 @@ export class EditorStateHolder {
this.isDirty.set(false);
}
+ loadAssistantFlow(flow: Flow, options?: { markDirty?: boolean }) {
+ this.currentFlow.set(flow);
+ this.isDirty.set(options?.markDirty === true);
+ }
+
updateData(data: FlowData) {
const current = this.currentFlow();
if (!current) return;
@@ -78,7 +84,17 @@ export class EditorStateHolder {
}
save() {
- return this.flowsService.updateFlow(this.currentFlow()!).pipe(
+ const flow = this.currentFlow()!;
+ const save$ = flow.id.startsWith(EditorStateHolder.ASSISTANT_DRAFT_PREFIX)
+ ? this.flowsService.createFlow({
+ name: flow.name,
+ description: flow.description,
+ data: flow.data,
+ status: flow.status
+ })
+ : this.flowsService.updateFlow(flow);
+
+ return save$.pipe(
tap((savedFlow) => {
this.currentFlow.set(savedFlow);
this.markSaved();
diff --git a/src/environments/environment.development.ts b/src/environments/environment.development.ts
index d4a7ee0..681b192 100644
--- a/src/environments/environment.development.ts
+++ b/src/environments/environment.development.ts
@@ -1,3 +1,4 @@
+import { AssistantCallServiceFake } from "@services/assistant/assistant-call.fake";
import { AuthorizationCallFakeService } from "@services/authorization/authorization-call.fake";
import { BlocksCallServiceFake } from "@services/blocks/blocks-call.fake";
import { FlowsCallServiceFake } from "@services/flows/flows-call.fake";
@@ -7,7 +8,9 @@ import { TaskExecutionsCallServiceFake } from "@services/task-executions/task-ex
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
+ assistantEnabled: false,
authorizationCallService: AuthorizationCallFakeService, // Assign the appropriate service here
+ assistantCallService: AssistantCallServiceFake,
flowsCallService: FlowsCallServiceFake,
blocksCallService: BlocksCallServiceFake,
fieldRetrieverCallService: FieldRetrieverCallServiceFake,
diff --git a/src/environments/environment.staging.ts b/src/environments/environment.staging.ts
index a242617..9b8f5e9 100644
--- a/src/environments/environment.staging.ts
+++ b/src/environments/environment.staging.ts
@@ -1,3 +1,4 @@
+import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
import { BlocksCallService } from "@services/blocks/blocks-call";
import { FlowsCallService } from "@services/flows/flows-call";
@@ -7,6 +8,8 @@ import { TaskExecutionsCallService } from "@services/task-executions/task-execut
export const environment = {
production: false,
apiUrl: 'http://localhost:8080',
+ assistantEnabled: false,
+ assistantCallService: AssistantCallService,
authorizationCallService: AuthorizationCallService,
flowsCallService: FlowsCallService,
blocksCallService: BlocksCallService,
diff --git a/src/environments/environment.ts b/src/environments/environment.ts
index a8e10c3..470a800 100644
--- a/src/environments/environment.ts
+++ b/src/environments/environment.ts
@@ -1,3 +1,4 @@
+import { AssistantCallService } from "@services/assistant/assistant-call";
import { AuthorizationCallService } from "@services/authorization/authorization-call";
import { BlocksCallService } from "@services/blocks/blocks-call";
import { FlowsCallService } from "@services/flows/flows-call";
@@ -7,7 +8,9 @@ import { TaskExecutionsCallService } from "@services/task-executions/task-execut
export const environment = {
apiUrl: '/api',
production: true,
+ assistantEnabled: false,
authorizationCallService: AuthorizationCallService,
+ assistantCallService: AssistantCallService,
flowsCallService: FlowsCallService,
blocksCallService: BlocksCallService,
fieldRetrieverCallService: FieldRetrieverCallService,