Add assistant workflow scaffolding and disable it by default
This commit is contained in:
parent
d243395c15
commit
4c6a50f9a2
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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>;
|
||||
}
|
||||
|
|
@ -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'
|
||||
});
|
||||
}
|
||||
|
|
@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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>;
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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()
|
||||
}
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue