Add assistant workflow scaffolding and disable it by default

This commit is contained in:
Lucio Lelii 2026-03-10 22:19:14 +01:00
parent d243395c15
commit 4c6a50f9a2
19 changed files with 1377 additions and 16 deletions

View File

@ -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;
}
}

View File

@ -10,24 +10,21 @@
<app-editor-sidebar></app-editor-sidebar>
<!-- CENTER EDITOR -->
<main class="flex flex-col w-full bg-gray-100 overflow-hidden">
<main class="flow-editor-main">
@if (flow()) {
<div class="flex-1 overflow-hidden">
<div class="flow-editor-canvas-shell">
<app-title-toolbar></app-title-toolbar>
<!-- editor canvas -->
<div class="flex-1 h-full">
<div class="flow-editor-canvas">
<app-rete-editor [flowId]="flow()!.id" [flowData]="flow()!.data"></app-rete-editor>
</div>
</div>
} @else {
<div class="text-gray-400 text-center mt-10 flex flex-col flex-1">
Select a document
<div class="flow-editor-empty">
<h2>No flow selected</h2>
<p>Open an existing flow from the sidebar or create a new one to start editing.</p>
</div>
}
</main>
<!-- RIGHT PANEL -->
<!-- <app-right-panel></app-right-panel> -->
</div>
</div>

View File

@ -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;
}

View File

@ -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;
};

View File

@ -0,0 +1,38 @@
import {
AssistantDraftPayload,
AssistantExplainResponse,
AssistantFlowResponse,
AssistantValidationIssue
} from '@models/assistant';
import { Observable } from 'rxjs';
export abstract class AssistantCallServiceBase {
abstract listModels(): Observable<string[]>;
abstract createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}): Observable<AssistantFlowResponse>;
abstract refineDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}): Observable<AssistantFlowResponse>;
abstract fixDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
}): Observable<AssistantFlowResponse>;
abstract explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}): Observable<AssistantExplainResponse>;
}

View File

@ -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<string[]> {
return of([
'llama3.1:8b',
'qwen2.5:7b',
'mistral:7b'
]);
}
override createDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
}): Observable<AssistantFlowResponse> {
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<AssistantFlowResponse> {
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<AssistantFlowResponse> {
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<AssistantExplainResponse> {
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'
});
}

View File

@ -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<string[]> {
return this.http
.get<unknown[]>(`${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<AssistantFlowResponse> {
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/draft`, request)
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
}
override refineDraft(request: {
userPrompt: string;
model: string;
maxRepairAttempts?: number;
flow: AssistantDraftPayload;
}): Observable<AssistantFlowResponse> {
return this.http
.post<unknown>(`${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<AssistantFlowResponse> {
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/fix`, request)
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
}
override explainDraft(request: {
userPrompt: string;
model: string;
flow: AssistantDraftPayload;
}): Observable<AssistantExplainResponse> {
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/flows/explain`, request)
.pipe(map((raw) => mapAssistantExplainResponse(raw)));
}
}
function mapAssistantFlowResponse(raw: unknown): AssistantFlowResponse {
const value = (raw ?? {}) as Record<string, unknown>;
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<string, unknown>;
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<string, unknown>;
const flow = (value['flow'] ?? {}) as Record<string, unknown>;
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<string, unknown>;
return {
message: String(value['message'] ?? value['error'] ?? 'Validation issue'),
path: typeof value['path'] === 'string' ? value['path'] : undefined
};
});
}

View File

@ -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);
}
}

View File

@ -9,6 +9,8 @@ export abstract class FlowsCallServiceBase {
abstract updateFlow(flow: Flow) : Observable<Flow>;
abstract createFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>) : Observable<Flow>;
abstract createNewFlow(name?: string) : Observable<Flow>;
abstract deleteFlow(flowId: string) : Observable<void>;

View File

@ -31,10 +31,30 @@ export class FlowsCallServiceFake extends FlowsCallServiceBase {
return of(flow);
}
override createNewFlow(name?: string): Observable<Flow> {
override createFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
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<Flow> {
return this.createFlow({
name: name || 'New Flow',
description: undefined,
data: { blocks: [], connections: [] },
status: 'DRAFT'
});
}
override deleteFlow(flowId: string): Observable<void> {

View File

@ -16,12 +16,27 @@ export class FlowsCallService extends FlowsCallServiceBase {
.pipe(map((raw) => flowFromApi(raw)));
}
override createNewFlow(name?: string): Observable<Flow> {
override createFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>): Observable<Flow> {
return this.http
.post<unknown>(`${environment.apiUrl}/flows`, toFlowCreateRequest(name ?? 'New Flow', undefined, undefined, 'DRAFT'))
.post<unknown>(
`${environment.apiUrl}/flows`,
toFlowCreateRequest(flow.name, flow.description, flow.data, flow.status)
)
.pipe(map((raw) => flowFromApi(raw)));
}
override createNewFlow(name?: string): Observable<Flow> {
return this.createFlow({
name: name ?? 'New Flow',
description: undefined,
data: {
blocks: [],
connections: []
},
status: 'DRAFT'
});
}
override deleteFlow(flowId: string): Observable<void> {
const encodedId = encodeURIComponent(flowId);
return this.http.delete<void>(`${environment.apiUrl}/flows/${encodedId}`);

View File

@ -53,6 +53,18 @@ export class FlowsService {
);
}
createFlow(flow: Pick<Flow, 'name' | 'description' | 'data' | 'status'>) {
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);

View File

@ -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;
}
}

View File

@ -0,0 +1,125 @@
<aside class="assistant-shell">
<div class="assistant-scroll">
<header class="assistant-header">
<div>
<p class="assistant-eyebrow">Workflow Copilot</p>
<h2 class="assistant-title">Assistant</h2>
</div>
@if (currentIntent()) {
<span class="assistant-intent">{{ currentIntent() }}</span>
}
</header>
<section class="assistant-card assistant-model-card">
<div class="assistant-section-head">
<div>
<p class="assistant-copy">
Create, refine, fix, and explain flows in chat. The backend remains the source of truth.
</p>
<p class="assistant-label">Model</p>
</div>
<button
type="button"
class="assistant-section-toggle"
(click)="toggleModelPicker()"
[attr.aria-label]="modelPickerOpen() ? 'Collapse model selector' : 'Expand model selector'">
{{ modelPickerOpen() ? '−' : '+' }}
</button>
</div>
@if (modelPickerOpen()) {
<select
id="assistant-model"
class="assistant-select"
[ngModel]="selectedModel()"
(ngModelChange)="selectModel($event)"
[disabled]="modelsLoading() || !models().length">
@for (model of models(); track model) {
<option [value]="model">{{ model }}</option>
}
</select>
}
@if (modelsLoading()) {
<p class="assistant-meta">Loading internal models...</p>
} @else if (modelsError()) {
<p class="assistant-error">{{ modelsError() }}</p>
} @else if (selectedModel()) {
<p class="assistant-meta">Using <strong>{{ selectedModel() }}</strong></p>
}
</section>
<section class="assistant-card assistant-starters">
<div class="assistant-starters-head">
<p class="assistant-label">Quick prompts</p>
<button
type="button"
class="assistant-section-toggle"
(click)="toggleQuickPrompts()"
[attr.aria-label]="quickPromptsOpen() ? 'Collapse quick prompts' : 'Expand quick prompts'">
{{ quickPromptsOpen() ? '−' : '+' }}
</button>
</div>
@if (quickPromptsOpen()) {
<div class="assistant-chip-grid">
@for (starter of starterPrompts; track starter) {
<button type="button" class="assistant-chip" (click)="useStarter(starter)">
{{ starter }}
</button>
}
</div>
}
</section>
<section class="assistant-thread">
@for (message of messages(); track message.id) {
<article class="assistant-message" [class.assistant-message-user]="message.role === 'user'" [class.assistant-message-system]="message.role === 'system'">
<div class="assistant-message-role">{{ message.role }}</div>
<p class="assistant-message-body">{{ message.content }}</p>
@if (message.warnings?.length) {
<div class="assistant-list assistant-list-warning">
@for (warning of message.warnings ?? []; track warning) {
<div>{{ warning }}</div>
}
</div>
}
@if (message.validationErrors?.length) {
<div class="assistant-list assistant-list-error">
@for (issue of message.validationErrors ?? []; track issue.message + (issue.path ?? '')) {
<div>{{ issue.path ? issue.path + ': ' : '' }}{{ issue.message }}</div>
}
</div>
}
</article>
}
@if (assistantBusy()) {
<article class="assistant-message assistant-message-system">
<div class="assistant-message-role">assistant</div>
<p class="assistant-message-body">Working on the workflow...</p>
</article>
}
</section>
</div>
<form class="assistant-composer" (ngSubmit)="submitPrompt()">
<textarea
class="assistant-input"
[ngModel]="prompt()"
(ngModelChange)="prompt.set($event)"
[disabled]="!selectedModel() || assistantBusy()"
name="assistantPrompt"
rows="4"
placeholder="Ask the assistant to create or refine a workflow"></textarea>
<button
type="submit"
class="assistant-send"
[disabled]="!selectedModel() || assistantBusy() || !prompt().trim()">
Send
</button>
</form>
</aside>

View File

@ -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<string[]>([]);
readonly modelsLoading = signal(false);
readonly modelsError = signal<string | null>(null);
readonly selectedModel = signal('');
readonly modelPickerOpen = signal(true);
readonly assistantBusy = signal(false);
readonly currentIntent = signal<AssistantIntent | null>(null);
readonly lastValidationErrors = signal<AssistantValidationIssue[]>([]);
readonly prompt = signal('');
readonly quickPromptsOpen = signal(true);
readonly messages = signal<AssistantChatMessage[]>([
{
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<AssistantChatMessage, 'id'>) {
this.messages.update((messages) => [
...messages,
{
...message,
id: crypto.randomUUID()
}
]);
}
}

View File

@ -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<Flow | null>(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();

View File

@ -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,

View File

@ -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,

View File

@ -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,