diff --git a/src/app/layouts/flow-editor/flow-editor.css b/src/app/layouts/flow-editor/flow-editor.css index e69de29..de950ea 100644 --- a/src/app/layouts/flow-editor/flow-editor.css +++ b/src/app/layouts/flow-editor/flow-editor.css @@ -0,0 +1,74 @@ +.flow-editor-main { + display: flex; + flex: 1 1 auto; + min-width: 0; + min-height: 0; + overflow: hidden; + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(241, 245, 249, 0.96)), + radial-gradient(circle at top left, rgba(14, 165, 233, 0.08), transparent 24%); +} + +.flow-editor-canvas-shell { + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + flex-direction: column; +} + +.flow-editor-canvas { + flex: 1 1 auto; + min-height: 0; +} + +.flow-editor-assistant { + display: flex; + flex: 0 0 380px; + width: 380px; + min-width: 320px; + max-width: 420px; + height: 100%; + min-height: 0; + background: #fff; + overflow: hidden; +} + +.flow-editor-empty { + display: flex; + flex: 1 1 auto; + align-items: center; + justify-content: center; + flex-direction: column; + gap: 12px; + padding: 32px; + text-align: center; + color: #475569; +} + +.flow-editor-empty h2 { + margin: 0; + font-size: 28px; + font-weight: 800; + color: #0f172a; +} + +.flow-editor-empty p { + margin: 0; + max-width: 420px; + line-height: 1.5; +} + +@media (max-width: 1100px) { + .flow-editor-main { + flex-direction: column; + } + + .flow-editor-assistant { + flex: 0 0 auto; + width: 100%; + max-width: none; + min-width: 0; + height: 42vh; + } +} diff --git a/src/app/layouts/flow-editor/flow-editor.html b/src/app/layouts/flow-editor/flow-editor.html index 9d37ba1..ead59d2 100644 --- a/src/app/layouts/flow-editor/flow-editor.html +++ b/src/app/layouts/flow-editor/flow-editor.html @@ -10,24 +10,21 @@ -
+
@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,