Update assistant session UI and fix structural node reconnection
This commit is contained in:
parent
4c6a50f9a2
commit
78d8527a85
|
|
@ -24,6 +24,7 @@
|
|||
|
||||
.flow-editor-assistant {
|
||||
display: flex;
|
||||
position: relative;
|
||||
flex: 0 0 380px;
|
||||
width: 380px;
|
||||
min-width: 320px;
|
||||
|
|
@ -32,6 +33,71 @@
|
|||
min-height: 0;
|
||||
background: #fff;
|
||||
overflow: hidden;
|
||||
transition: width 180ms ease, flex-basis 180ms ease, min-width 180ms ease;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-collapsed {
|
||||
flex: 0 0 72px;
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-toggle {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 4;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
color: #0f172a;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
box-shadow: 0 10px 18px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.flow-editor-assistant-collapsed .flow-editor-assistant-toggle {
|
||||
right: 50%;
|
||||
transform: translateX(50%);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-collapsed .flow-editor-assistant-toggle span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-rail {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(251, 191, 36, 0.22), transparent 28%),
|
||||
linear-gradient(180deg, #fffdf7 0%, #f8fafc 100%);
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-rail-icon,
|
||||
.flow-editor-assistant-rail-wand {
|
||||
font-size: 18px;
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-rail-text {
|
||||
writing-mode: vertical-rl;
|
||||
transform: rotate(180deg);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.flow-editor-empty {
|
||||
|
|
@ -71,4 +137,10 @@
|
|||
min-width: 0;
|
||||
height: 42vh;
|
||||
}
|
||||
|
||||
.flow-editor-assistant-collapsed {
|
||||
width: 72px;
|
||||
min-width: 72px;
|
||||
height: 42vh;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,5 +26,28 @@
|
|||
}
|
||||
</main>
|
||||
|
||||
@if (assistantEnabled) {
|
||||
<section class="flow-editor-assistant" [class.flow-editor-assistant-collapsed]="!assistantOpen()">
|
||||
<button
|
||||
type="button"
|
||||
class="flow-editor-assistant-toggle"
|
||||
(click)="toggleAssistant()"
|
||||
[attr.aria-label]="assistantOpen() ? 'Collapse assistant' : 'Expand assistant'">
|
||||
<i class="bi" [class.bi-layout-sidebar-inset-reverse]="assistantOpen()" [class.bi-stars]="!assistantOpen()"></i>
|
||||
<span>{{ assistantOpen() ? 'Hide' : 'AI' }}</span>
|
||||
</button>
|
||||
|
||||
@if (assistantOpen()) {
|
||||
<app-flow-assistant></app-flow-assistant>
|
||||
} @else {
|
||||
<div class="flow-editor-assistant-rail">
|
||||
<i class="bi bi-stars flow-editor-assistant-rail-icon" aria-hidden="true"></i>
|
||||
<div class="flow-editor-assistant-rail-text">AI</div>
|
||||
<i class="bi bi-magic flow-editor-assistant-rail-wand" aria-hidden="true"></i>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { Component, inject} from '@angular/core';
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { environment } from '@environment';
|
||||
import { EditorSidebar } from "@pages/main/editor-sidebar/editor-sidebar";
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { FlowAssistant } from '@shared/flow-assistant/flow-assistant';
|
||||
import { TitleToolbar } from "@shared/title-toolbar/title-toolbar";
|
||||
import { ReteEditor } from "@shared/rete-editor/rete-editor";
|
||||
|
||||
@Component({
|
||||
selector: 'app-flow-editor',
|
||||
imports: [EditorSidebar, TitleToolbar, ReteEditor],
|
||||
imports: [EditorSidebar, TitleToolbar, ReteEditor, FlowAssistant],
|
||||
templateUrl: './flow-editor.html',
|
||||
styleUrl: './flow-editor.css',
|
||||
})
|
||||
|
|
@ -16,6 +17,11 @@ export class FlowEditor {
|
|||
private editorState: EditorStateHolder = inject(EditorStateHolder);
|
||||
|
||||
assistantEnabled = environment.assistantEnabled;
|
||||
assistantOpen = signal(true);
|
||||
flow = this.editorState.currentFlow;
|
||||
|
||||
toggleAssistant() {
|
||||
this.assistantOpen.update((value) => !value);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,6 @@ export type AssistantValidationIssue = {
|
|||
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';
|
||||
|
|
@ -43,3 +29,43 @@ export type AssistantEditorDraft = {
|
|||
data: FlowData;
|
||||
status: FlowStatus;
|
||||
};
|
||||
|
||||
export type AssistantConfig = {
|
||||
provider?: string;
|
||||
defaultModel: string;
|
||||
availableModelsRetrieverUrl: string;
|
||||
};
|
||||
|
||||
export type AssistantCallStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED';
|
||||
|
||||
export type AssistantCallPhase =
|
||||
| 'queued'
|
||||
| 'routing'
|
||||
| 'planning'
|
||||
| 'configuring_blocks'
|
||||
| 'connecting_blocks'
|
||||
| 'validating'
|
||||
| 'fixing'
|
||||
| 'explaining'
|
||||
| 'completed'
|
||||
| 'failed';
|
||||
|
||||
export type AssistantCallState = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
status: AssistantCallStatus;
|
||||
phase: AssistantCallPhase;
|
||||
progressMessage?: string;
|
||||
intent?: AssistantIntent | null;
|
||||
errorMessage?: string;
|
||||
};
|
||||
|
||||
export type AssistantSessionState = {
|
||||
id: string;
|
||||
owner?: string;
|
||||
selectedModel: string;
|
||||
messages: AssistantChatMessage[];
|
||||
currentDraftFlow: AssistantDraftPayload | null;
|
||||
lastValidationErrors: AssistantValidationIssue[];
|
||||
lastCallId: string | null;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,38 +1,24 @@
|
|||
import {
|
||||
AssistantDraftPayload,
|
||||
AssistantExplainResponse,
|
||||
AssistantFlowResponse,
|
||||
AssistantValidationIssue
|
||||
AssistantCallState,
|
||||
AssistantConfig,
|
||||
AssistantSessionState
|
||||
} from '@models/assistant';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
export abstract class AssistantCallServiceBase {
|
||||
abstract listModels(): Observable<string[]>;
|
||||
abstract getConfig(): Observable<AssistantConfig>;
|
||||
|
||||
abstract createDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
}): Observable<AssistantFlowResponse>;
|
||||
abstract listModels(retrieverUrl: string): Observable<string[]>;
|
||||
|
||||
abstract refineDraft(request: {
|
||||
userPrompt: string;
|
||||
abstract createSession(request: {
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
}): Observable<AssistantFlowResponse>;
|
||||
}): Observable<AssistantSessionState>;
|
||||
|
||||
abstract fixDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
validationErrors?: AssistantValidationIssue[];
|
||||
}): Observable<AssistantFlowResponse>;
|
||||
abstract sendMessage(sessionId: string, request: {
|
||||
message: string;
|
||||
}): Observable<{ callId: string }>;
|
||||
|
||||
abstract explainDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
flow: AssistantDraftPayload;
|
||||
}): Observable<AssistantExplainResponse>;
|
||||
abstract getCall(callId: string): Observable<AssistantCallState>;
|
||||
|
||||
abstract getSession(sessionId: string): Observable<AssistantSessionState>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,93 +1,208 @@
|
|||
import {
|
||||
AssistantCallPhase,
|
||||
AssistantCallState,
|
||||
AssistantChatMessage,
|
||||
AssistantConfig,
|
||||
AssistantDraftPayload,
|
||||
AssistantExplainResponse,
|
||||
AssistantFlowResponse,
|
||||
AssistantSessionState,
|
||||
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'
|
||||
]);
|
||||
}
|
||||
type FakeCallRecord = {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
content: string;
|
||||
phaseIndex: number;
|
||||
phases: AssistantCallPhase[];
|
||||
completed: boolean;
|
||||
failed: boolean;
|
||||
};
|
||||
|
||||
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);
|
||||
export class AssistantCallServiceFake extends AssistantCallServiceBase {
|
||||
private readonly models = ['llama3.1:8b', 'qwen2.5:7b', 'mistral:7b'];
|
||||
private readonly sessions = new Map<string, AssistantSessionState>();
|
||||
private readonly calls = new Map<string, FakeCallRecord>();
|
||||
|
||||
override getConfig(): Observable<AssistantConfig> {
|
||||
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
|
||||
defaultModel: this.models[0],
|
||||
availableModelsRetrieverUrl: '/fake/assistant/models'
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
override listModels(_retrieverUrl: string): Observable<string[]> {
|
||||
return of(this.models);
|
||||
}
|
||||
|
||||
if (/review|approval|human/i.test(request.userPrompt)) {
|
||||
addHumanReviewTail(next.flow);
|
||||
override createSession(request: {
|
||||
model: string;
|
||||
}): Observable<AssistantSessionState> {
|
||||
const session: AssistantSessionState = {
|
||||
id: crypto.randomUUID(),
|
||||
selectedModel: request.model,
|
||||
messages: [
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'system',
|
||||
content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
|
||||
}
|
||||
],
|
||||
currentDraftFlow: null,
|
||||
lastValidationErrors: [],
|
||||
lastCallId: null
|
||||
};
|
||||
this.sessions.set(session.id, session);
|
||||
return of(structuredClone(session));
|
||||
}
|
||||
|
||||
override sendMessage(sessionId: string, request: {
|
||||
message: string;
|
||||
}): Observable<{ callId: string }> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Assistant session ${sessionId} not found`);
|
||||
}
|
||||
|
||||
session.messages = [
|
||||
...session.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: request.message
|
||||
}
|
||||
];
|
||||
|
||||
const callId = crypto.randomUUID();
|
||||
const phases = this.buildPhases(request.message);
|
||||
this.calls.set(callId, {
|
||||
id: callId,
|
||||
sessionId,
|
||||
content: request.message,
|
||||
phaseIndex: 0,
|
||||
phases,
|
||||
completed: false,
|
||||
failed: false
|
||||
});
|
||||
session.lastCallId = callId;
|
||||
|
||||
return of({ callId });
|
||||
}
|
||||
|
||||
override getCall(callId: string): Observable<AssistantCallState> {
|
||||
const call = this.calls.get(callId);
|
||||
if (!call) {
|
||||
throw new Error(`Assistant call ${callId} not found`);
|
||||
}
|
||||
|
||||
if (!call.completed && !call.failed) {
|
||||
if (call.phaseIndex < call.phases.length - 1) {
|
||||
call.phaseIndex += 1;
|
||||
} else {
|
||||
call.completed = true;
|
||||
this.applyCallResult(call);
|
||||
}
|
||||
}
|
||||
|
||||
const phase = call.completed
|
||||
? 'completed'
|
||||
: call.failed
|
||||
? 'failed'
|
||||
: call.phases[call.phaseIndex];
|
||||
|
||||
return of({
|
||||
flow: next,
|
||||
valid: true,
|
||||
validationErrors: [],
|
||||
warnings: ['Fake assistant response'],
|
||||
assistantRationale: 'Applied the requested refinement incrementally without replacing the whole graph.',
|
||||
repairAttempts: 0
|
||||
id: call.id,
|
||||
sessionId: call.sessionId,
|
||||
status: call.failed
|
||||
? 'FAILED'
|
||||
: call.completed
|
||||
? 'COMPLETED'
|
||||
: call.phaseIndex === 0
|
||||
? 'QUEUED'
|
||||
: 'RUNNING',
|
||||
phase,
|
||||
errorMessage: call.failed ? 'Fake assistant call failed.' : undefined
|
||||
});
|
||||
}
|
||||
|
||||
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 getSession(sessionId: string): Observable<AssistantSessionState> {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) {
|
||||
throw new Error(`Assistant session ${sessionId} not found`);
|
||||
}
|
||||
return of(structuredClone(session));
|
||||
}
|
||||
|
||||
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']
|
||||
});
|
||||
private buildPhases(content: string): AssistantCallPhase[] {
|
||||
const normalized = content.toLowerCase();
|
||||
if (/explain|what does|why/.test(normalized)) {
|
||||
return ['routing', 'explaining'];
|
||||
}
|
||||
if (/fix|repair|invalid|error|bug/.test(normalized)) {
|
||||
return ['routing', 'planning', 'configuring_blocks', 'connecting_blocks', 'validating', 'fixing'];
|
||||
}
|
||||
return ['routing', 'planning', 'configuring_blocks', 'connecting_blocks', 'validating'];
|
||||
}
|
||||
|
||||
private applyCallResult(call: FakeCallRecord) {
|
||||
const session = this.sessions.get(call.sessionId);
|
||||
if (!session) return;
|
||||
|
||||
const content = call.content.toLowerCase();
|
||||
|
||||
if (/explain|what does|why/.test(content)) {
|
||||
session.messages = [
|
||||
...session.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: session.currentDraftFlow
|
||||
? `This flow "${session.currentDraftFlow.name}" receives input, classifies urgency, routes urgent cases to a human, and completes the non-urgent path automatically.`
|
||||
: 'There is no draft flow in the session yet.'
|
||||
}
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
let draft = session.currentDraftFlow;
|
||||
let validationErrors: AssistantValidationIssue[] = [];
|
||||
let assistantMessage = 'I updated the workflow based on your request.';
|
||||
|
||||
if (!draft || /create|new flow|from scratch/.test(content)) {
|
||||
draft = {
|
||||
name: 'Ticket classification with urgent review',
|
||||
description: `Draft generated from prompt: ${call.content}`,
|
||||
flow: buildTicketFlow(session.selectedModel)
|
||||
};
|
||||
assistantMessage = 'I created a new workflow draft.';
|
||||
} else if (/review|approval|human/.test(content)) {
|
||||
draft = structuredClone(draft);
|
||||
addHumanReviewTail(draft.flow);
|
||||
assistantMessage = 'I updated the current workflow based on your request.';
|
||||
} else if (/fix|repair|invalid|error|bug/.test(content)) {
|
||||
draft = structuredClone(draft);
|
||||
validationErrors = [];
|
||||
assistantMessage = 'I fixed the current workflow.';
|
||||
}
|
||||
|
||||
if (/invalid|broken/.test(content)) {
|
||||
validationErrors = [{ message: 'The draft contains unresolved validation issues.' }];
|
||||
assistantMessage = 'I created an initial draft, but it still needs corrections.';
|
||||
}
|
||||
|
||||
session.currentDraftFlow = draft;
|
||||
session.lastValidationErrors = validationErrors;
|
||||
session.messages = [
|
||||
...session.messages,
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content: `${assistantMessage} ${validationErrors.length ? 'The draft still has validation errors.' : 'The draft is valid.'}`.trim(),
|
||||
warnings: ['Fake assistant response'],
|
||||
validationErrors
|
||||
}
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject } from '@angular/core';
|
||||
import {
|
||||
AssistantCallPhase,
|
||||
AssistantCallState,
|
||||
AssistantCallStatus,
|
||||
AssistantChatMessage,
|
||||
AssistantConfig,
|
||||
AssistantDraftPayload,
|
||||
AssistantExplainResponse,
|
||||
AssistantFlowResponse,
|
||||
AssistantIntent,
|
||||
AssistantSessionState,
|
||||
AssistantValidationIssue
|
||||
} from '@models/assistant';
|
||||
import { environment } from '@environment';
|
||||
|
|
@ -13,87 +18,142 @@ import { AssistantCallServiceBase } from './assistant-call.base';
|
|||
export class AssistantCallService extends AssistantCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
||||
override listModels(): Observable<string[]> {
|
||||
override getConfig(): Observable<AssistantConfig> {
|
||||
return this.http
|
||||
.get<unknown[]>(`${environment.apiUrl}/retriever/LLM/models`, {
|
||||
params: { provider: 'InternalOllama' }
|
||||
})
|
||||
.pipe(
|
||||
map((raw) => (Array.isArray(raw) ? raw : []).map((item) => String(item)))
|
||||
);
|
||||
.get<unknown>(`${environment.apiUrl}/assistant/config`)
|
||||
.pipe(map((raw) => mapAssistantConfig(raw)));
|
||||
}
|
||||
|
||||
override createDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
}): Observable<AssistantFlowResponse> {
|
||||
override listModels(retrieverUrl: string): Observable<string[]> {
|
||||
const resolvedUrl = resolveAssistantUrl(retrieverUrl);
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/flows/draft`, request)
|
||||
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
|
||||
.get<unknown>(resolvedUrl)
|
||||
.pipe(map((raw) => mapModelList(raw)));
|
||||
}
|
||||
|
||||
override refineDraft(request: {
|
||||
userPrompt: string;
|
||||
override createSession(request: {
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
}): Observable<AssistantFlowResponse> {
|
||||
}): Observable<AssistantSessionState> {
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/flows/refine`, request)
|
||||
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/sessions`, request)
|
||||
.pipe(map((raw) => mapAssistantSessionState(raw)));
|
||||
}
|
||||
|
||||
override fixDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
validationErrors?: AssistantValidationIssue[];
|
||||
}): Observable<AssistantFlowResponse> {
|
||||
override sendMessage(sessionId: string, request: {
|
||||
message: string;
|
||||
}): Observable<{ callId: string }> {
|
||||
const encodedId = encodeURIComponent(sessionId);
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/flows/fix`, request)
|
||||
.pipe(map((raw) => mapAssistantFlowResponse(raw)));
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}/messages`, request)
|
||||
.pipe(map((raw) => mapSendMessageResponse(raw)));
|
||||
}
|
||||
|
||||
override explainDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
flow: AssistantDraftPayload;
|
||||
}): Observable<AssistantExplainResponse> {
|
||||
override getCall(callId: string): Observable<AssistantCallState> {
|
||||
const encodedId = encodeURIComponent(callId);
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/flows/explain`, request)
|
||||
.pipe(map((raw) => mapAssistantExplainResponse(raw)));
|
||||
.get<unknown>(`${environment.apiUrl}/assistant/calls/${encodedId}`)
|
||||
.pipe(map((raw) => mapAssistantCallState(raw)));
|
||||
}
|
||||
|
||||
override getSession(sessionId: string): Observable<AssistantSessionState> {
|
||||
const encodedId = encodeURIComponent(sessionId);
|
||||
return this.http
|
||||
.get<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}`)
|
||||
.pipe(map((raw) => mapAssistantSessionState(raw)));
|
||||
}
|
||||
}
|
||||
|
||||
function mapAssistantFlowResponse(raw: unknown): AssistantFlowResponse {
|
||||
function mapAssistantConfig(raw: unknown): AssistantConfig {
|
||||
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
|
||||
provider: typeof value['provider'] === 'string' ? value['provider'] : undefined,
|
||||
defaultModel: String(value['defaultModel'] ?? ''),
|
||||
availableModelsRetrieverUrl: String(value['availableModelsRetrieverUrl'] ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function mapAssistantExplainResponse(raw: unknown): AssistantExplainResponse {
|
||||
function mapModelList(raw: unknown): string[] {
|
||||
const candidate = Array.isArray(raw)
|
||||
? raw
|
||||
: raw && typeof raw === 'object'
|
||||
? (((raw as Record<string, unknown>)['values']
|
||||
?? (raw as Record<string, unknown>)['items']
|
||||
?? (raw as Record<string, unknown>)['data']
|
||||
?? (raw as Record<string, unknown>)['result']
|
||||
?? []) as unknown[])
|
||||
: [];
|
||||
|
||||
if (!Array.isArray(candidate)) return [];
|
||||
|
||||
return candidate.map((item) => {
|
||||
if (typeof item === 'string') return item;
|
||||
const value = (item ?? {}) as Record<string, unknown>;
|
||||
return String(value['name'] ?? value['model'] ?? value['value'] ?? '');
|
||||
}).filter((item) => item.length > 0);
|
||||
}
|
||||
|
||||
function mapSendMessageResponse(raw: unknown): { callId: string } {
|
||||
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)) : []
|
||||
callId: String(value['callId'] ?? value['id'] ?? '')
|
||||
};
|
||||
}
|
||||
|
||||
function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload {
|
||||
function mapAssistantCallState(raw: unknown): AssistantCallState {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
const status = mapCallStatus(value['status']);
|
||||
return {
|
||||
id: String(value['id'] ?? value['callId'] ?? ''),
|
||||
sessionId: String(value['sessionId'] ?? ''),
|
||||
status,
|
||||
phase: mapCallPhase(value['phase']),
|
||||
progressMessage: typeof value['progressMessage'] === 'string' ? value['progressMessage'] : undefined,
|
||||
intent: mapIntent(value['intent']),
|
||||
errorMessage: typeof value['errorMessage'] === 'string'
|
||||
? value['errorMessage']
|
||||
: typeof value['message'] === 'string' && status === 'FAILED'
|
||||
? value['message']
|
||||
: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function mapAssistantSessionState(raw: unknown): AssistantSessionState {
|
||||
const value = (raw ?? {}) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
id: String(value['id'] ?? value['sessionId'] ?? ''),
|
||||
owner: typeof value['owner'] === 'string' ? value['owner'] : undefined,
|
||||
selectedModel: String(value['selectedModel'] ?? value['model'] ?? ''),
|
||||
messages: mapAssistantMessages(value['messages']),
|
||||
currentDraftFlow: mapAssistantDraftPayload(
|
||||
value['currentDraftFlow'] ?? value['currentFlow'] ?? value['currentDraft'] ?? value['flow']
|
||||
),
|
||||
lastValidationErrors: mapValidationIssues(value['lastValidationErrors'] ?? value['validationErrors']),
|
||||
lastCallId: value['lastCallId'] == null ? null : String(value['lastCallId'])
|
||||
};
|
||||
}
|
||||
|
||||
function mapAssistantMessages(raw: unknown): AssistantChatMessage[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
|
||||
return raw.map((item) => {
|
||||
const value = (item ?? {}) as Record<string, unknown>;
|
||||
const normalizedRole = typeof value['role'] === 'string' ? value['role'].toLowerCase() : 'user';
|
||||
const role = normalizedRole === 'assistant' || normalizedRole === 'system' ? normalizedRole : 'user';
|
||||
return {
|
||||
id: String(value['id'] ?? crypto.randomUUID()),
|
||||
role,
|
||||
content: String(value['content'] ?? value['message'] ?? ''),
|
||||
warnings: Array.isArray(value['warnings']) ? value['warnings'].map((warning) => String(warning)) : [],
|
||||
validationErrors: mapValidationIssues(value['validationErrors'])
|
||||
} as AssistantChatMessage;
|
||||
});
|
||||
}
|
||||
|
||||
function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
|
||||
const value = raw as Record<string, unknown>;
|
||||
const flow = (value['flow'] ?? {}) as Record<string, unknown>;
|
||||
return {
|
||||
name: String(value['name'] ?? 'Assistant Draft'),
|
||||
|
|
@ -120,3 +180,55 @@ function mapValidationIssues(raw: unknown): AssistantValidationIssue[] {
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mapCallStatus(raw: unknown): AssistantCallStatus {
|
||||
const normalized = typeof raw === 'string' ? raw.toUpperCase() : '';
|
||||
if (normalized === 'QUEUED' || normalized === 'RUNNING' || normalized === 'FAILED') return normalized;
|
||||
return 'COMPLETED';
|
||||
}
|
||||
|
||||
function mapCallPhase(raw: unknown): AssistantCallPhase {
|
||||
const normalized = typeof raw === 'string' ? raw.toLowerCase() : '';
|
||||
switch (normalized) {
|
||||
case 'queued':
|
||||
case 'routing':
|
||||
case 'planning':
|
||||
case 'configuring_blocks':
|
||||
case 'connecting_blocks':
|
||||
case 'validating':
|
||||
case 'fixing':
|
||||
case 'explaining':
|
||||
case 'completed':
|
||||
case 'failed':
|
||||
return normalized;
|
||||
default:
|
||||
return 'queued';
|
||||
}
|
||||
}
|
||||
|
||||
function mapIntent(raw: unknown): AssistantIntent | null {
|
||||
const normalized = typeof raw === 'string' ? raw.toLowerCase() : '';
|
||||
switch (normalized) {
|
||||
case 'draft':
|
||||
case 'refine':
|
||||
case 'fix':
|
||||
case 'explain':
|
||||
return normalized;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveAssistantUrl(url: string): string {
|
||||
if (!url) return url;
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
|
||||
const apiBase = environment.apiUrl;
|
||||
if (/^https?:\/\//i.test(apiBase)) {
|
||||
return new URL(url, `${apiBase.replace(/\/+$/, '')}/`).toString();
|
||||
}
|
||||
|
||||
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,4 @@
|
|||
import { Injectable } from '@angular/core';
|
||||
import {
|
||||
AssistantDraftPayload,
|
||||
AssistantExplainResponse,
|
||||
AssistantFlowResponse,
|
||||
AssistantValidationIssue
|
||||
} from '@models/assistant';
|
||||
import { environment } from '@environment';
|
||||
import { AssistantCallServiceBase } from './assistant-call.base';
|
||||
|
||||
|
|
@ -14,42 +8,27 @@ import { AssistantCallServiceBase } from './assistant-call.base';
|
|||
export class AssistantService {
|
||||
private readonly assistantCall: AssistantCallServiceBase = new environment.assistantCallService();
|
||||
|
||||
listModels() {
|
||||
return this.assistantCall.listModels();
|
||||
getConfig() {
|
||||
return this.assistantCall.getConfig();
|
||||
}
|
||||
|
||||
createDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
}) {
|
||||
return this.assistantCall.createDraft(request);
|
||||
listModels(retrieverUrl: string) {
|
||||
return this.assistantCall.listModels(retrieverUrl);
|
||||
}
|
||||
|
||||
refineDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
}) {
|
||||
return this.assistantCall.refineDraft(request);
|
||||
createSession(request: { model: string }) {
|
||||
return this.assistantCall.createSession(request);
|
||||
}
|
||||
|
||||
fixDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
maxRepairAttempts?: number;
|
||||
flow: AssistantDraftPayload;
|
||||
validationErrors?: AssistantValidationIssue[];
|
||||
}) {
|
||||
return this.assistantCall.fixDraft(request);
|
||||
sendMessage(sessionId: string, request: { message: string }) {
|
||||
return this.assistantCall.sendMessage(sessionId, request);
|
||||
}
|
||||
|
||||
explainDraft(request: {
|
||||
userPrompt: string;
|
||||
model: string;
|
||||
flow: AssistantDraftPayload;
|
||||
}) {
|
||||
return this.assistantCall.explainDraft(request);
|
||||
getCall(callId: string) {
|
||||
return this.assistantCall.getCall(callId);
|
||||
}
|
||||
|
||||
getSession(sessionId: string) {
|
||||
return this.assistantCall.getSession(sessionId);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,8 +25,7 @@
|
|||
.assistant-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.assistant-eyebrow {
|
||||
|
|
@ -56,6 +55,11 @@
|
|||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.assistant-intent-row {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.assistant-card {
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(148, 163, 184, 0.24);
|
||||
|
|
@ -64,6 +68,77 @@
|
|||
box-shadow: 0 12px 30px rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.assistant-busy-card {
|
||||
border-color: rgba(15, 118, 110, 0.28);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(240, 253, 250, 0.96), rgba(248, 250, 252, 0.96));
|
||||
}
|
||||
|
||||
.assistant-busy-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.assistant-busy-spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: 0 0 auto;
|
||||
margin-top: 2px;
|
||||
border-radius: 999px;
|
||||
border: 2px solid rgba(15, 118, 110, 0.18);
|
||||
border-top-color: #0f766e;
|
||||
animation: assistant-spin 0.85s linear infinite;
|
||||
}
|
||||
|
||||
.assistant-busy-title {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
}
|
||||
|
||||
.assistant-busy-copy {
|
||||
margin: 4px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: #475569;
|
||||
}
|
||||
|
||||
.assistant-progress {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.assistant-progress-step {
|
||||
position: relative;
|
||||
display: block;
|
||||
padding-left: 18px;
|
||||
font-size: 12px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.assistant-progress-step::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 6px;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
}
|
||||
|
||||
.assistant-progress-step-active {
|
||||
color: #0f172a;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.assistant-progress-step-active::before {
|
||||
background: #0f766e;
|
||||
box-shadow: 0 0 0 4px rgba(15, 118, 110, 0.12);
|
||||
}
|
||||
|
||||
.assistant-copy,
|
||||
.assistant-meta,
|
||||
.assistant-error,
|
||||
|
|
@ -275,6 +350,12 @@
|
|||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
@keyframes assistant-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.assistant-shell {
|
||||
border-left: 0;
|
||||
|
|
|
|||
|
|
@ -5,11 +5,35 @@
|
|||
<p class="assistant-eyebrow">Workflow Copilot</p>
|
||||
<h2 class="assistant-title">Assistant</h2>
|
||||
</div>
|
||||
@if (currentIntent()) {
|
||||
<span class="assistant-intent">{{ currentIntent() }}</span>
|
||||
}
|
||||
</header>
|
||||
|
||||
@if (activePhaseLabel()) {
|
||||
<div class="assistant-intent-row">
|
||||
<span class="assistant-intent">{{ activePhaseLabel() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (assistantBusy()) {
|
||||
<section class="assistant-card assistant-busy-card">
|
||||
<div class="assistant-busy-row">
|
||||
<span class="assistant-busy-spinner" aria-hidden="true"></span>
|
||||
<div>
|
||||
<div class="assistant-busy-title">{{ busyHeadline() }}</div>
|
||||
<p class="assistant-busy-copy">{{ busyDetail() }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@if (activePhase() !== 'explaining') {
|
||||
<div class="assistant-progress">
|
||||
@for (step of progressSteps; track step.phase; let index = $index) {
|
||||
<span class="assistant-progress-step" [class.assistant-progress-step-active]="index <= activeProgressIndex()">
|
||||
{{ step.label }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="assistant-card assistant-model-card">
|
||||
<div class="assistant-section-head">
|
||||
<div>
|
||||
|
|
@ -72,7 +96,7 @@
|
|||
</section>
|
||||
|
||||
<section class="assistant-thread">
|
||||
@for (message of messages(); track message.id) {
|
||||
@for (message of displayedMessages(); 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>
|
||||
|
|
@ -98,7 +122,7 @@
|
|||
@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>
|
||||
<p class="assistant-message-body">{{ busyHeadline() }}</p>
|
||||
</article>
|
||||
}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { CommonModule } from '@angular/common';
|
||||
import { Component, computed, inject, OnInit, signal } from '@angular/core';
|
||||
import { Component, computed, inject, OnDestroy, OnInit, signal } from '@angular/core';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { environment } from '@environment';
|
||||
import {
|
||||
AssistantCallPhase,
|
||||
AssistantCallState,
|
||||
AssistantChatMessage,
|
||||
AssistantConfig,
|
||||
AssistantDraftPayload,
|
||||
AssistantEditorDraft,
|
||||
AssistantFlowResponse,
|
||||
AssistantIntent,
|
||||
AssistantValidationIssue
|
||||
AssistantSessionState
|
||||
} from '@models/assistant';
|
||||
import { Flow } from '@models/flow';
|
||||
import { AssistantService } from '@services/assistant/assistant';
|
||||
|
|
@ -21,32 +22,81 @@ import { finalize, take } from 'rxjs';
|
|||
templateUrl: './flow-assistant.html',
|
||||
styleUrl: './flow-assistant.css'
|
||||
})
|
||||
export class FlowAssistant implements OnInit {
|
||||
export class FlowAssistant implements OnInit, OnDestroy {
|
||||
private readonly assistant = inject(AssistantService);
|
||||
private readonly editorState = inject(EditorStateHolder);
|
||||
private readonly authorization = inject(Authorization);
|
||||
private pollTick: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
readonly assistantConfig = signal<AssistantConfig | null>(null);
|
||||
readonly sessionState = signal<AssistantSessionState | null>(null);
|
||||
readonly currentCall = signal<AssistantCallState | null>(null);
|
||||
readonly localMessages = signal<AssistantChatMessage[]>([]);
|
||||
readonly models = signal<string[]>([]);
|
||||
readonly modelsLoading = signal(false);
|
||||
readonly modelsError = signal<string | null>(null);
|
||||
readonly selectedModel = signal('');
|
||||
readonly modelPickerOpen = signal(true);
|
||||
readonly assistantBusy = signal(false);
|
||||
readonly currentIntent = signal<AssistantIntent | null>(null);
|
||||
readonly lastValidationErrors = signal<AssistantValidationIssue[]>([]);
|
||||
readonly sessionLoading = signal(false);
|
||||
readonly prompt = signal('');
|
||||
readonly selectedModel = signal('');
|
||||
readonly modelPickerOpen = signal(false);
|
||||
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 initialSystemMessage: AssistantChatMessage = {
|
||||
id: 'assistant-system-welcome',
|
||||
role: 'system',
|
||||
content: 'Select a model, then ask me to create, refine, fix, or explain a workflow.'
|
||||
};
|
||||
|
||||
readonly displayedMessages = computed(() => {
|
||||
const baseMessages = this.sessionState()?.messages?.length
|
||||
? this.sessionState()!.messages
|
||||
: [this.initialSystemMessage];
|
||||
return [...baseMessages, ...this.localMessages()];
|
||||
});
|
||||
readonly assistantBusy = computed(() => {
|
||||
const status = this.currentCall()?.status;
|
||||
return this.sessionLoading() || status === 'QUEUED' || status === 'RUNNING';
|
||||
});
|
||||
readonly activePhase = computed<AssistantCallPhase | null>(() => {
|
||||
const call = this.currentCall();
|
||||
return call ? call.phase : null;
|
||||
});
|
||||
readonly activePhaseLabel = computed(() => {
|
||||
const phase = this.activePhase();
|
||||
return phase ? this.phaseText(phase) : null;
|
||||
});
|
||||
readonly callProgressMessage = computed(() => this.currentCall()?.progressMessage ?? '');
|
||||
readonly busyHeadline = computed(() => {
|
||||
const phase = this.activePhase();
|
||||
if (!phase) {
|
||||
return this.sessionLoading() ? 'Preparing assistant session...' : '';
|
||||
}
|
||||
return this.phaseText(phase);
|
||||
});
|
||||
readonly busyDetail = computed(() => {
|
||||
if (this.sessionLoading()) {
|
||||
return 'Loading assistant configuration, models, and chat session...';
|
||||
}
|
||||
if (!this.currentCall()) return '';
|
||||
return this.callProgressMessage() || 'The backend may perform multiple internal steps before returning the updated conversation and flow.';
|
||||
});
|
||||
readonly progressSteps = [
|
||||
{ phase: 'queued', label: 'Queued...' },
|
||||
{ phase: 'routing', label: 'Routing your request...' },
|
||||
{ phase: 'planning', label: 'Planning workflow blocks...' },
|
||||
{ phase: 'configuring_blocks', label: 'Configuring blocks...' },
|
||||
{ phase: 'connecting_blocks', label: 'Connecting blocks...' },
|
||||
{ phase: 'validating', label: 'Validating flow...' },
|
||||
{ phase: 'fixing', label: 'Repairing invalid flow...' },
|
||||
{ phase: 'explaining', label: 'Explaining current flow...' }
|
||||
] as const;
|
||||
readonly activeProgressIndex = computed(() => {
|
||||
const phase = this.activePhase();
|
||||
if (!phase) return -1;
|
||||
return this.progressSteps.findIndex((step) => step.phase === phase);
|
||||
});
|
||||
readonly currentFlow = this.editorState.currentFlow;
|
||||
readonly draftDirty = this.editorState.isDirty;
|
||||
readonly currentDraft = computed(() => this.toAssistantDraft(this.currentFlow()));
|
||||
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
|
||||
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',
|
||||
|
|
@ -55,11 +105,17 @@ export class FlowAssistant implements OnInit {
|
|||
];
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadModels();
|
||||
this.bootstrapAssistant();
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.stopPolling();
|
||||
}
|
||||
|
||||
selectModel(model: string) {
|
||||
if (!model || model === this.selectedModel()) return;
|
||||
this.selectedModel.set(model);
|
||||
void this.openSession(model);
|
||||
}
|
||||
|
||||
useStarter(prompt: string) {
|
||||
|
|
@ -75,178 +131,188 @@ export class FlowAssistant implements OnInit {
|
|||
}
|
||||
|
||||
submitPrompt() {
|
||||
const userPrompt = this.prompt().trim();
|
||||
if (!userPrompt || this.assistantBusy() || !this.selectedModel()) return;
|
||||
const content = this.prompt().trim();
|
||||
const sessionId = this.sessionState()?.id;
|
||||
if (!content || this.assistantBusy() || !sessionId) 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.localMessages.set([
|
||||
{
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content
|
||||
}
|
||||
]);
|
||||
|
||||
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))
|
||||
this.assistant.sendMessage(sessionId, { message: content }).pipe(
|
||||
take(1)
|
||||
).subscribe({
|
||||
next: (response) => void this.applyAssistantFlowResponse(response, intent),
|
||||
error: (err) => this.pushAssistantError(err, intent)
|
||||
next: ({ callId }) => {
|
||||
this.currentCall.set({
|
||||
id: callId,
|
||||
sessionId,
|
||||
status: 'QUEUED',
|
||||
phase: 'queued'
|
||||
});
|
||||
this.startPolling(callId, sessionId);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant send message failed', err);
|
||||
this.pushLocalAssistantMessage('The assistant request failed.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private loadModels() {
|
||||
this.modelsLoading.set(true);
|
||||
private bootstrapAssistant() {
|
||||
this.sessionLoading.set(true);
|
||||
this.modelsError.set(null);
|
||||
this.assistant.getConfig().pipe(
|
||||
take(1)
|
||||
).subscribe({
|
||||
next: (config) => {
|
||||
this.assistantConfig.set(config);
|
||||
this.loadModelsAndSession(config);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant config loading failed', err);
|
||||
this.sessionLoading.set(false);
|
||||
this.modelsError.set('Unable to load assistant configuration.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.assistant.listModels().pipe(
|
||||
private loadModelsAndSession(config: AssistantConfig) {
|
||||
this.modelsLoading.set(true);
|
||||
const resolvedUrl = this.resolveAssistantModelsUrl(config.availableModelsRetrieverUrl);
|
||||
this.assistant.listModels(config.availableModelsRetrieverUrl).pipe(
|
||||
take(1),
|
||||
finalize(() => this.modelsLoading.set(false))
|
||||
).subscribe({
|
||||
next: (models) => {
|
||||
this.models.set(models);
|
||||
if (!this.selectedModel() && models.length) {
|
||||
this.selectedModel.set(models[0]);
|
||||
const selectedModel = config.defaultModel || models[0] || '';
|
||||
this.selectedModel.set(selectedModel);
|
||||
if (!selectedModel) {
|
||||
this.sessionLoading.set(false);
|
||||
this.modelsError.set('No assistant model is available.');
|
||||
return;
|
||||
}
|
||||
void this.openSession(selectedModel);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant model loading failed', err);
|
||||
this.modelsError.set('Unable to load internal assistant models.');
|
||||
this.sessionLoading.set(false);
|
||||
this.modelsError.set(
|
||||
`Unable to load assistant models from ${resolvedUrl}.`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private determineIntent(prompt: string): AssistantIntent {
|
||||
const normalized = prompt.toLowerCase();
|
||||
const hasDraft = !!this.currentDraft();
|
||||
private async openSession(model: string) {
|
||||
this.stopPolling();
|
||||
this.currentCall.set(null);
|
||||
this.localMessages.set([]);
|
||||
this.sessionLoading.set(true);
|
||||
|
||||
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';
|
||||
this.assistant.createSession({ model }).pipe(
|
||||
take(1),
|
||||
finalize(() => this.sessionLoading.set(false))
|
||||
).subscribe({
|
||||
next: (session) => {
|
||||
this.applySessionState(session);
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant session creation failed', err);
|
||||
this.pushLocalAssistantMessage('Unable to create an assistant session.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 startPolling(callId: string, sessionId: string) {
|
||||
this.stopPolling();
|
||||
this.pollTick = setInterval(() => {
|
||||
this.assistant.getCall(callId).pipe(
|
||||
take(1)
|
||||
).subscribe({
|
||||
next: (callState) => {
|
||||
this.currentCall.set(callState);
|
||||
if (callState.status === 'COMPLETED' || callState.status === 'FAILED') {
|
||||
this.stopPolling();
|
||||
void this.reloadSession(sessionId, callState.status === 'FAILED' ? callState.errorMessage : undefined);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant call polling failed', err);
|
||||
this.stopPolling();
|
||||
this.pushLocalAssistantMessage('Polling the assistant call failed.');
|
||||
}
|
||||
});
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private async applyAssistantFlowResponse(response: AssistantFlowResponse, intent: AssistantIntent) {
|
||||
this.lastValidationErrors.set(response.validationErrors);
|
||||
private async reloadSession(sessionId: string, failureMessage?: string) {
|
||||
this.assistant.getSession(sessionId).pipe(
|
||||
take(1)
|
||||
).subscribe({
|
||||
next: (session) => {
|
||||
this.applySessionState(session);
|
||||
this.currentCall.set(null);
|
||||
if (failureMessage) {
|
||||
this.pushLocalAssistantMessage(failureMessage);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Assistant session refresh failed', err);
|
||||
this.currentCall.set(null);
|
||||
this.pushLocalAssistantMessage('Unable to refresh the assistant session.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private applySessionState(session: AssistantSessionState) {
|
||||
const normalizedSession = session.messages.length
|
||||
? session
|
||||
: {
|
||||
...session,
|
||||
messages: [this.initialSystemMessage]
|
||||
};
|
||||
|
||||
this.sessionState.set(normalizedSession);
|
||||
this.selectedModel.set(normalizedSession.selectedModel || this.selectedModel());
|
||||
this.localMessages.set([]);
|
||||
this.syncDraftToEditor(normalizedSession.currentDraftFlow);
|
||||
}
|
||||
|
||||
private syncDraftToEditor(draft: AssistantDraftPayload | null) {
|
||||
if (!draft) return;
|
||||
|
||||
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;
|
||||
}
|
||||
const nextFlow = this.toEditorFlow(draft, currentFlow);
|
||||
|
||||
if (currentFlow?.id) {
|
||||
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
|
||||
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
|
||||
void this.editorState.openDocument(nextFlow, { skipDirtyCheck: false }).then((opened) => {
|
||||
if (!opened) {
|
||||
this.pushLocalAssistantMessage('The draft is ready, but I did not load it because the current flow has unsaved changes.');
|
||||
return;
|
||||
}
|
||||
this.editorState.loadAssistantFlow(nextFlow, { markDirty: true });
|
||||
});
|
||||
}
|
||||
|
||||
private toEditorFlow(response: AssistantFlowResponse, currentFlow: Flow | null, intent: AssistantIntent): Flow {
|
||||
const shouldReuseCurrentId = !!currentFlow && intent !== 'draft';
|
||||
const nextId = shouldReuseCurrentId
|
||||
? currentFlow!.id
|
||||
: `${EditorStateHolder.ASSISTANT_DRAFT_PREFIX}${crypto.randomUUID()}`;
|
||||
private toEditorFlow(draft: AssistantDraftPayload, currentFlow: Flow | null): Flow {
|
||||
const nextId = 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',
|
||||
name: draft.name,
|
||||
description: draft.description,
|
||||
data: draft.flow,
|
||||
status: 'DRAFT',
|
||||
visibility: currentFlow?.visibility ?? 'PRIVATE',
|
||||
author: currentFlow?.author ?? this.authorization.loggedInUser()?.username ?? 'assistant',
|
||||
createdAt: currentFlow?.createdAt ?? new Date(),
|
||||
|
|
@ -256,45 +322,61 @@ export class FlowAssistant implements OnInit {
|
|||
};
|
||||
}
|
||||
|
||||
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,
|
||||
private pushLocalAssistantMessage(content: string) {
|
||||
const filtered = this.localMessages().filter((message) => message.role !== 'assistant');
|
||||
this.localMessages.set([
|
||||
...filtered,
|
||||
{
|
||||
...message,
|
||||
id: crypto.randomUUID()
|
||||
id: crypto.randomUUID(),
|
||||
role: 'assistant',
|
||||
content
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
private stopPolling() {
|
||||
if (this.pollTick) {
|
||||
clearInterval(this.pollTick);
|
||||
this.pollTick = null;
|
||||
}
|
||||
}
|
||||
|
||||
private phaseText(phase: AssistantCallPhase): string {
|
||||
switch (phase) {
|
||||
case 'queued':
|
||||
return 'Queued...';
|
||||
case 'routing':
|
||||
return 'Routing your request...';
|
||||
case 'planning':
|
||||
return 'Planning workflow blocks...';
|
||||
case 'configuring_blocks':
|
||||
return 'Configuring blocks...';
|
||||
case 'connecting_blocks':
|
||||
return 'Connecting blocks...';
|
||||
case 'validating':
|
||||
return 'Validating flow...';
|
||||
case 'fixing':
|
||||
return 'Repairing invalid flow...';
|
||||
case 'explaining':
|
||||
return 'Explaining current flow...';
|
||||
case 'completed':
|
||||
return 'Finalizing flow...';
|
||||
case 'failed':
|
||||
return 'Assistant request failed.';
|
||||
}
|
||||
}
|
||||
|
||||
private resolveAssistantModelsUrl(url: string): string {
|
||||
if (!url) return url;
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
|
||||
const apiBase = environment.apiUrl;
|
||||
if (/^https?:\/\//i.test(apiBase)) {
|
||||
return new URL(url, `${apiBase.replace(/\/+$/, '')}/`).toString();
|
||||
}
|
||||
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
const normalizedBase = apiBase.startsWith('/') ? apiBase : `/${apiBase}`;
|
||||
return new URL(url, `${origin}${normalizedBase.replace(/\/+$/, '')}/`).toString();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -901,6 +901,13 @@ export class GenericNodeComponent {
|
|||
queueMicrotask(() => {
|
||||
try {
|
||||
this.cdr.detectChanges();
|
||||
requestAnimationFrame(() => {
|
||||
try {
|
||||
this.rendered();
|
||||
} catch {
|
||||
// Node may have been removed while async refresh was running.
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
// Node may have been removed while async validation was running.
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,6 +166,9 @@ export class ReteEditor implements OnChanges, OnDestroy {
|
|||
...movedNode.data,
|
||||
position: { x: pos.x, y: pos.y }
|
||||
};
|
||||
|
||||
// Keep socket anchors and connection paths visually in sync while dragging.
|
||||
void rete.area.update('node', movedNode.id);
|
||||
}
|
||||
|
||||
private markFlowChanged(rete: ReteEditorInstance, context: any, loadedFlowId: string, loadedVersion: number) {
|
||||
|
|
|
|||
|
|
@ -135,9 +135,56 @@ export async function addBlockToEditor(
|
|||
};
|
||||
const replaceWithCreatedBlock = async (createdBlock: FlowBlock) => {
|
||||
if (!editor.getNode(node.id)) return;
|
||||
const previousConnections = editor.getConnections()
|
||||
.filter((connection) => connection.source === node.id || connection.target === node.id)
|
||||
.map((connection) => ({
|
||||
id: connection.id,
|
||||
source: connection.source,
|
||||
sourceOutput: connection.sourceOutput,
|
||||
target: connection.target,
|
||||
targetInput: connection.targetInput
|
||||
}));
|
||||
const currentPosition = (node.data?.position ?? position ?? createdBlock.position) as { x: number; y: number } | undefined;
|
||||
for (const connection of previousConnections) {
|
||||
await editor.removeConnection(connection.id);
|
||||
}
|
||||
await editor.removeNode(node.id);
|
||||
await addBlockToEditor(editor, area, { ...createdBlock, position: currentPosition }, currentPosition);
|
||||
const replacementNode = await addBlockToEditor(
|
||||
editor,
|
||||
area,
|
||||
{ ...createdBlock, position: currentPosition },
|
||||
currentPosition
|
||||
);
|
||||
|
||||
if (!replacementNode) return;
|
||||
|
||||
const replacementOutputNames = new Set(Object.keys(replacementNode.outputs));
|
||||
const replacementInputNames = new Set(Object.keys(replacementNode.inputs));
|
||||
|
||||
for (const connection of previousConnections) {
|
||||
const sourceNode = connection.source === node.id
|
||||
? replacementNode
|
||||
: editor.getNode(connection.source);
|
||||
const targetNode = connection.target === node.id
|
||||
? replacementNode
|
||||
: editor.getNode(connection.target);
|
||||
|
||||
if (!sourceNode || !targetNode) continue;
|
||||
|
||||
const sourceOutput = connection.source === node.id
|
||||
? connection.sourceOutput
|
||||
: connection.sourceOutput;
|
||||
const targetInput = connection.target === node.id
|
||||
? connection.targetInput
|
||||
: connection.targetInput;
|
||||
|
||||
if (connection.source === node.id && !replacementOutputNames.has(sourceOutput)) continue;
|
||||
if (connection.target === node.id && !replacementInputNames.has(targetInput)) continue;
|
||||
|
||||
await editor.addConnection(
|
||||
new ClassicPreset.Connection(sourceNode as HFNode, sourceOutput, targetNode as HFNode, targetInput)
|
||||
);
|
||||
}
|
||||
};
|
||||
node.data = {
|
||||
...cloneValue(block),
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { TaskExecutionsCallService } from "@services/task-executions/task-execut
|
|||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'http://localhost:8080',
|
||||
assistantEnabled: false,
|
||||
assistantEnabled: true,
|
||||
assistantCallService: AssistantCallService,
|
||||
authorizationCallService: AuthorizationCallService,
|
||||
flowsCallService: FlowsCallService,
|
||||
|
|
|
|||
Loading…
Reference in New Issue