feat(assistant): add provider and model selection

This commit is contained in:
Lucio Lelii 2026-09-01 15:53:28 +02:00
parent b6474def51
commit cf6b25bd1d
11 changed files with 635 additions and 574 deletions

View File

@ -41,9 +41,43 @@ export type AssistantEditorDraft = {
};
export type AssistantConfig = {
provider?: string;
defaultProvider: string;
defaultModel: string;
availableProvidersRetrieverUrl: string;
availableModelsRetrieverUrl: string;
defaultPhaseModels: AssistantPhaseModels;
};
export type AssistantPhaseModels = {
planningModel?: string;
jsonModel?: string;
repairModel?: string;
};
export type AssistantLlmSelection = {
provider: string;
model: string;
phaseModels?: AssistantPhaseModels;
};
export type AssistantSessionRequest = {
llmSelection?: AssistantLlmSelection;
};
export type AssistantFlowRequest = {
userPrompt: string;
llmSelection?: AssistantLlmSelection;
flow?: AssistantDraftPayload;
validationErrors?: AssistantValidationIssue[];
maxRepairAttempts?: number;
};
export type AssistantFlowActionResult = {
flow: AssistantDraftPayload | null;
valid?: boolean;
validationErrors: AssistantValidationIssue[];
warnings: string[];
message: string;
};
export type AssistantCallStatus = 'QUEUED' | 'RUNNING' | 'COMPLETED' | 'FAILED' | 'CANCELLED';

View File

@ -1,7 +1,8 @@
import {
AssistantCallState,
AssistantConfig,
AssistantSendMessageRequest,
AssistantFlowActionResult,
AssistantFlowRequest,
AssistantSessionRequest,
AssistantSessionState
} from '@models/assistant';
import { Observable } from 'rxjs';
@ -9,17 +10,18 @@ import { Observable } from 'rxjs';
export abstract class AssistantCallServiceBase {
abstract getConfig(): Observable<AssistantConfig>;
abstract listModels(retrieverUrl: string): Observable<string[]>;
abstract listProviders(retrieverUrl: string): Observable<string[]>;
abstract createSession(request: {
model: string;
}): Observable<AssistantSessionState>;
abstract listModels(retrieverUrlTemplate: string, provider: string): Observable<string[]>;
abstract sendMessage(sessionId: string, request: AssistantSendMessageRequest): Observable<{ callId: string }>;
abstract createSession(request: AssistantSessionRequest): Observable<AssistantSessionState>;
abstract getCall(callId: string): Observable<AssistantCallState>;
abstract draft(request: AssistantFlowRequest): Observable<AssistantFlowActionResult>;
abstract cancelCall(callId: string): Observable<AssistantCallState>;
abstract refine(request: AssistantFlowRequest): Observable<AssistantFlowActionResult>;
abstract fix(request: AssistantFlowRequest): Observable<AssistantFlowActionResult>;
abstract explain(request: AssistantFlowRequest): Observable<AssistantFlowActionResult>;
abstract getSession(sessionId: string): Observable<AssistantSessionState>;
}

View File

@ -1,4 +1,3 @@
import { catchError, of } from 'rxjs';
import { AssistantCallServiceFake } from './assistant-call.fake';
describe('AssistantCallServiceFake', () => {
@ -8,37 +7,17 @@ describe('AssistantCallServiceFake', () => {
service = new AssistantCallServiceFake();
});
it('surfaces "call not found" as an observable error catchError can intercept, not a synchronous throw', async () => {
expect(() => service.getCall('missing-call')).not.toThrow();
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.getCall('missing-call').pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
it('uses the selected model only when a custom selection is supplied', async () => {
let selectedModel = '';
service.draft({
userPrompt: 'Create a flow',
llmSelection: { provider: 'OpenAI', model: 'custom-model' }
}).subscribe((result) => {
selectedModel = String(
(result.flow?.flow.blocks[1]?.specificConfiguration as Record<string, any>)['llmDescriptor']?.model
);
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('missing-call');
});
it('surfaces "session not found" as an observable error catchError can intercept, not a synchronous throw', async () => {
expect(() => service.getSession('missing-session')).not.toThrow();
let caught: unknown = null;
await new Promise<void>((resolve) => {
service.getSession('missing-session').pipe(
catchError((error) => {
caught = error;
return of(null);
})
).subscribe(() => resolve());
});
expect(caught).toBeInstanceOf(Error);
expect((caught as Error).message).toContain('missing-session');
expect(selectedModel).toBe('custom-model');
});
});

View File

@ -1,50 +1,41 @@
import {
AssistantCallPhase,
AssistantCallState,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantSendMessageRequest,
AssistantSessionState,
AssistantValidationIssue
AssistantFlowActionResult,
AssistantFlowRequest,
AssistantSessionRequest,
AssistantSessionState
} from '@models/assistant';
import { FlowData } from '@models/flow';
import { defer, Observable, of } from 'rxjs';
import { Observable, of } from 'rxjs';
import { AssistantCallServiceBase } from './assistant-call.base';
type FakeCallRecord = {
id: string;
sessionId: string;
content: string;
phaseIndex: number;
phases: AssistantCallPhase[];
completed: boolean;
failed: boolean;
cancelled: boolean;
};
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>();
private readonly providers = ['InternalOllama', 'OpenAI'];
override getConfig(): Observable<AssistantConfig> {
return of({
defaultProvider: this.providers[0],
defaultModel: this.models[0],
availableModelsRetrieverUrl: '/fake/assistant/models'
availableProvidersRetrieverUrl: '/fake/assistant/providers',
availableModelsRetrieverUrl: '/fake/assistant/models?provider={provider}',
defaultPhaseModels: {}
});
}
override listModels(_retrieverUrl: string): Observable<string[]> {
override listProviders(_retrieverUrl: string): Observable<string[]> {
return of(this.providers);
}
override listModels(_retrieverUrlTemplate: string, _provider: string): Observable<string[]> {
return of(this.models);
}
override createSession(request: {
model: string;
}): Observable<AssistantSessionState> {
override createSession(request: AssistantSessionRequest): Observable<AssistantSessionState> {
const session: AssistantSessionState = {
id: crypto.randomUUID(),
selectedModel: request.model,
selectedModel: request.llmSelection?.model ?? this.models[0],
messages: [
{
id: crypto.randomUUID(),
@ -57,187 +48,55 @@ export class AssistantCallServiceFake extends AssistantCallServiceBase {
lastValidationErrors: [],
lastCallId: null
};
this.sessions.set(session.id, session);
return of(structuredClone(session));
}
override sendMessage(sessionId: string, request: AssistantSendMessageRequest): Observable<{ callId: string }> {
return defer(() => {
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,
cancelled: false
});
session.lastCallId = callId;
return of({ callId });
});
}
override getCall(callId: string): Observable<AssistantCallState> {
return defer(() => {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
if (!call.completed && !call.failed && !call.cancelled) {
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.cancelled
? 'cancelled'
: call.phases[call.phaseIndex];
const result: AssistantCallState = {
id: call.id,
sessionId: call.sessionId,
status: call.failed
? 'FAILED'
: call.cancelled
? 'CANCELLED'
: call.completed
? 'COMPLETED'
: call.phaseIndex === 0
? 'QUEUED'
: 'RUNNING',
phase,
progressMessage: call.cancelled ? 'Assistant request cancelled' : undefined,
errorMessage: call.failed ? 'Fake assistant call failed.' : undefined
};
return of(result);
});
}
override cancelCall(callId: string): Observable<AssistantCallState> {
return defer(() => {
const call = this.calls.get(callId);
if (!call) {
throw new Error(`Assistant call ${callId} not found`);
}
call.cancelled = true;
const result: AssistantCallState = {
id: call.id,
sessionId: call.sessionId,
status: 'CANCELLED',
phase: 'cancelled',
progressMessage: 'Assistant request cancelled'
};
return of(result);
});
}
override getSession(sessionId: string): Observable<AssistantSessionState> {
return defer(() => {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Assistant session ${sessionId} not found`);
}
return of(structuredClone(session));
});
}
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 = {
override draft(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
const model = request.llmSelection?.model ?? this.models[0];
return of({
flow: {
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.currentFlow = draft;
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
}
];
description: `Draft generated from prompt: ${request.userPrompt}`,
flow: buildTicketFlow(model)
},
valid: true,
validationErrors: [],
warnings: ['Fake assistant response'],
message: 'I created a new workflow draft.'
});
}
override refine(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
const flow = request.flow ? structuredClone(request.flow) : null;
if (flow) addHumanReviewTail(flow.flow);
return of({
flow,
valid: true,
validationErrors: [],
warnings: ['Fake assistant response'],
message: 'I updated the current workflow based on your request.'
});
}
override fix(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return of({
flow: request.flow ? structuredClone(request.flow) : null,
valid: true,
validationErrors: [],
warnings: ['Fake assistant response'],
message: 'I fixed the current workflow.'
});
}
override explain(_request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return of({
flow: null,
validationErrors: [],
warnings: [],
message: 'This workflow classifies incoming tickets and routes urgent cases to a human reviewer.'
});
}
}
function buildTicketFlow(model: string): FlowData {

View File

@ -28,13 +28,10 @@ describe('AssistantCallService', () => {
});
it('normalizes node families in assistant drafts and nested container subflows', async () => {
const request = firstValueFrom(service.getSession('session-1'));
const request = firstValueFrom(service.draft({ userPrompt: 'Create a flow' }));
httpMock.expectOne(`${environment.apiUrl}/assistant/sessions/session-1`).flush({
id: 'session-1',
selectedModel: 'model-1',
messages: [],
currentDraftFlow: {
httpMock.expectOne(`${environment.apiUrl}/assistant/flows/draft`).flush({
flow: {
name: 'Loop draft',
flow: {
blocks: [{ id: 'root-block', typeName: 'LLMBlock', specificConfiguration: {} }],
@ -68,8 +65,8 @@ describe('AssistantCallService', () => {
}
});
const session = await request;
const flow = session.currentDraftFlow!.flow;
const result = await request;
const flow = result.flow!.flow;
const loopConfiguration = flow.containers[0].specificConfiguration as Record<string, any>;
expect(flow.blocks[0].nodeFamily).toBe('block');
@ -86,4 +83,46 @@ describe('AssistantCallService', () => {
});
expect(loopConfiguration['guardSubFlow'].blocks[0].nodeFamily).toBe('block');
});
it('loads providers and provider-specific models from the configured retrievers', async () => {
const providers = firstValueFrom(service.listProviders('/retriever/LLM/providers'));
httpMock.expectOne(`${environment.apiUrl}/retriever/LLM/providers`).flush(['InternalOllama', 'OpenAI']);
await expect(providers).resolves.toEqual(['InternalOllama', 'OpenAI']);
const models = firstValueFrom(service.listModels('/retriever/LLM/models?provider={provider}', 'Open AI'));
httpMock.expectOne(`${environment.apiUrl}/retriever/LLM/models?provider=Open%20AI`).flush(['gpt-oss:20b']);
await expect(models).resolves.toEqual(['gpt-oss:20b']);
});
it('sends llmSelection only when supplied, for sessions and flow actions', async () => {
const defaultSession = firstValueFrom(service.createSession({}));
const defaultSessionRequest = httpMock.expectOne(`${environment.apiUrl}/assistant/sessions`);
expect(defaultSessionRequest.request.body).toEqual({});
defaultSessionRequest.flush({ id: 'session-default', messages: [] });
await defaultSession;
const selection = {
provider: 'OpenAI',
model: 'gpt-oss:20b',
phaseModels: { planningModel: 'planning-model' }
};
const actionRequests = [
['draft', service.draft({ userPrompt: 'Create a flow', llmSelection: selection })],
['refine', service.refine({ userPrompt: 'Refine it', flow: { name: 'Flow', flow: emptyFlow() }, llmSelection: selection })],
['fix', service.fix({ userPrompt: 'Fix it', flow: { name: 'Flow', flow: emptyFlow() }, llmSelection: selection })],
['explain', service.explain({ userPrompt: 'Explain it', flow: { name: 'Flow', flow: emptyFlow() }, llmSelection: selection })]
] as const;
for (const [action, observable] of actionRequests) {
const result = firstValueFrom(observable);
const request = httpMock.expectOne(`${environment.apiUrl}/assistant/flows/${action}`);
expect(request.request.body.llmSelection).toEqual(selection);
request.flush({ message: 'Done' });
await result;
}
});
});
function emptyFlow() {
return { blocks: [], containers: [], connections: [], dependencies: [], globalInputs: [], lanes: [] };
}

View File

@ -1,15 +1,12 @@
import { HttpClient } from '@angular/common/http';
import { inject } from '@angular/core';
import {
AssistantCallPhase,
AssistantCallState,
AssistantCallStatus,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantFlowResult,
AssistantIntent,
AssistantSendMessageRequest,
AssistantFlowActionResult,
AssistantFlowRequest,
AssistantSessionRequest,
AssistantSessionState,
AssistantValidationIssue
} from '@models/assistant';
@ -26,56 +23,69 @@ export class AssistantCallService extends AssistantCallServiceBase {
.pipe(map((raw) => mapAssistantConfig(raw)));
}
override listModels(retrieverUrl: string): Observable<string[]> {
override listProviders(retrieverUrl: string): Observable<string[]> {
return this.http
.get<unknown>(resolveAssistantUrl(retrieverUrl))
.pipe(map((raw) => mapModelList(raw)));
}
override listModels(retrieverUrlTemplate: string, provider: string): Observable<string[]> {
const retrieverUrl = retrieverUrlTemplate.replace('{provider}', encodeURIComponent(provider));
const resolvedUrl = resolveAssistantUrl(retrieverUrl);
return this.http
.get<unknown>(resolvedUrl)
.pipe(map((raw) => mapModelList(raw)));
}
override createSession(request: {
model: string;
}): Observable<AssistantSessionState> {
override createSession(request: AssistantSessionRequest): Observable<AssistantSessionState> {
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/sessions`, request)
.pipe(map((raw) => mapAssistantSessionState(raw)));
}
override sendMessage(sessionId: string, request: AssistantSendMessageRequest): Observable<{ callId: string }> {
const encodedId = encodeURIComponent(sessionId);
return this.http
.post<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}/messages`, request)
.pipe(map((raw) => mapSendMessageResponse(raw)));
override draft(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return this.runFlowAction('draft', request);
}
override getCall(callId: string): Observable<AssistantCallState> {
const encodedId = encodeURIComponent(callId);
return this.http
.get<unknown>(`${environment.apiUrl}/assistant/calls/${encodedId}`)
.pipe(map((raw) => mapAssistantCallState(raw)));
override refine(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return this.runFlowAction('refine', request);
}
override cancelCall(callId: string): Observable<AssistantCallState> {
const encodedId = encodeURIComponent(callId);
return this.http
.put<unknown>(`${environment.apiUrl}/assistant/calls/${encodedId}/cancel`, {})
.pipe(map((raw) => mapAssistantCallState(raw)));
override fix(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return this.runFlowAction('fix', request);
}
override getSession(sessionId: string): Observable<AssistantSessionState> {
const encodedId = encodeURIComponent(sessionId);
override explain(request: AssistantFlowRequest): Observable<AssistantFlowActionResult> {
return this.runFlowAction('explain', request);
}
private runFlowAction(
action: 'draft' | 'refine' | 'fix' | 'explain',
request: AssistantFlowRequest
): Observable<AssistantFlowActionResult> {
return this.http
.get<unknown>(`${environment.apiUrl}/assistant/sessions/${encodedId}`)
.pipe(map((raw) => mapAssistantSessionState(raw)));
.post<unknown>(`${environment.apiUrl}/assistant/flows/${action}`, request)
.pipe(map((raw) => mapAssistantFlowActionResult(raw)));
}
}
function mapAssistantConfig(raw: unknown): AssistantConfig {
const value = (raw ?? {}) as Record<string, unknown>;
return {
provider: typeof value['provider'] === 'string' ? value['provider'] : undefined,
defaultProvider: String(value['defaultProvider'] ?? value['provider'] ?? ''),
defaultModel: String(value['defaultModel'] ?? ''),
availableModelsRetrieverUrl: String(value['availableModelsRetrieverUrl'] ?? '')
availableProvidersRetrieverUrl: String(value['availableProvidersRetrieverUrl'] ?? ''),
availableModelsRetrieverUrl: String(value['availableModelsRetrieverUrlTemplate'] ?? value['availableModelsRetrieverUrl'] ?? ''),
defaultPhaseModels: mapPhaseModels(value['defaultPhaseModels'])
};
}
function mapPhaseModels(raw: unknown) {
const value = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
return {
planningModel: typeof value['planningModel'] === 'string' ? value['planningModel'] : undefined,
jsonModel: typeof value['jsonModel'] === 'string' ? value['jsonModel'] : undefined,
repairModel: typeof value['repairModel'] === 'string' ? value['repairModel'] : undefined
};
}
@ -99,29 +109,25 @@ function mapModelList(raw: unknown): string[] {
}).filter((item) => item.length > 0);
}
function mapSendMessageResponse(raw: unknown): { callId: string } {
const value = (raw ?? {}) as Record<string, unknown>;
return {
callId: String(value['callId'] ?? value['id'] ?? '')
};
}
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'
function mapAssistantFlowActionResult(raw: unknown): AssistantFlowActionResult {
const value = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
const flow = mapAssistantDraftPayload(value['flow'] ?? value['result']);
const message = typeof value['assistantRationale'] === 'string'
? value['assistantRationale']
: typeof value['explanation'] === 'string'
? value['explanation']
: typeof value['message'] === 'string'
? value['message']
: undefined,
flowResult: mapAssistantFlowResult(value['flowResult'])
: flow
? 'Workflow updated.'
: 'The assistant completed the request.';
return {
flow,
valid: typeof value['valid'] === 'boolean' ? value['valid'] : undefined,
validationErrors: mapValidationIssues(value['validationErrors']),
warnings: Array.isArray(value['warnings']) ? value['warnings'].map((warning) => String(warning)) : [],
message
};
}
@ -159,18 +165,6 @@ function mapAssistantMessages(raw: unknown): AssistantChatMessage[] {
});
}
function mapAssistantFlowResult(raw: unknown): AssistantFlowResult | null {
if (!raw || typeof raw !== 'object') return null;
const value = raw as Record<string, unknown>;
const flow = mapAssistantFlowData(value['flow']);
if (!hasAssistantFlowData(flow)) return null;
return {
name: typeof value['name'] === 'string' ? value['name'] : undefined,
description: typeof value['description'] === 'string' ? value['description'] : undefined,
flow
};
}
function mapAssistantDraftPayload(raw: unknown): AssistantDraftPayload | null {
if (!raw || typeof raw !== 'object') return null;
@ -276,45 +270,6 @@ function mapValidationIssues(raw: unknown): AssistantValidationIssue[] {
});
}
function mapCallStatus(raw: unknown): AssistantCallStatus {
const normalized = typeof raw === 'string' ? raw.toUpperCase() : '';
if (normalized === 'QUEUED' || normalized === 'RUNNING' || normalized === 'FAILED' || normalized === 'CANCELLED') 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':
case 'cancelled':
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;

View File

@ -1,6 +1,9 @@
import { Injectable } from '@angular/core';
import { environment } from '@environment';
import { AssistantSendMessageRequest } from '@models/assistant';
import {
AssistantFlowRequest,
AssistantSessionRequest
} from '@models/assistant';
import { AssistantCallServiceBase } from './assistant-call.base';
@Injectable({
@ -13,27 +16,32 @@ export class AssistantService {
return this.assistantCall.getConfig();
}
listModels(retrieverUrl: string) {
return this.assistantCall.listModels(retrieverUrl);
listProviders(retrieverUrl: string) {
return this.assistantCall.listProviders(retrieverUrl);
}
createSession(request: { model: string }) {
listModels(retrieverUrlTemplate: string, provider: string) {
return this.assistantCall.listModels(retrieverUrlTemplate, provider);
}
createSession(request: AssistantSessionRequest) {
return this.assistantCall.createSession(request);
}
sendMessage(sessionId: string, request: AssistantSendMessageRequest) {
return this.assistantCall.sendMessage(sessionId, request);
draft(request: AssistantFlowRequest) {
return this.assistantCall.draft(request);
}
getCall(callId: string) {
return this.assistantCall.getCall(callId);
refine(request: AssistantFlowRequest) {
return this.assistantCall.refine(request);
}
cancelCall(callId: string) {
return this.assistantCall.cancelCall(callId);
fix(request: AssistantFlowRequest) {
return this.assistantCall.fix(request);
}
getSession(sessionId: string) {
return this.assistantCall.getSession(sessionId);
explain(request: AssistantFlowRequest) {
return this.assistantCall.explain(request);
}
}

View File

@ -232,6 +232,42 @@
margin-bottom: 8px;
}
.assistant-llm-settings {
display: grid;
gap: 10px;
}
.assistant-llm-settings .assistant-copy {
margin-bottom: 0;
}
.assistant-settings-fields {
display: grid;
gap: 4px;
}
.assistant-phase-fields {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.assistant-advanced-toggle,
.assistant-new-session {
justify-self: start;
border: 0;
padding: 0;
background: transparent;
color: #0f766e;
font: inherit;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.assistant-advanced-toggle:disabled {
color: #94a3b8;
cursor: default;
}
.assistant-label {
display: block;
margin-bottom: 8px;
@ -441,3 +477,9 @@
border-top: 1px solid #dbe4ee;
}
}
@media (max-width: 520px) {
.assistant-phase-fields {
grid-template-columns: 1fr;
}
}

View File

@ -39,6 +39,99 @@
}
@if (!progressOnlyMode()) {
<section class="assistant-card assistant-llm-settings">
<div class="assistant-starters-head">
<div>
<p class="assistant-label">Configurazione assistente</p>
<p class="assistant-copy">Le credenziali dei provider sono gestite dal backend.</p>
</div>
@if (configurationLocked()) {
<button type="button" mat-stroked-button class="assistant-new-session" [disabled]="assistantBusy()" (click)="createNewSession()">
Nuova sessione
</button>
}
</div>
<mat-checkbox
[checked]="useDefaultConfiguration()"
[disabled]="configurationLocked() || assistantBusy()"
(change)="setUseDefaultConfiguration($event.checked)">
Usa configurazione predefinita
</mat-checkbox>
@if (configurationLocked()) {
<p class="assistant-meta">La configurazione LLM è fissata per questa sessione. Crea una nuova sessione per modificarla.</p>
}
@if (!useDefaultConfiguration()) {
<div class="assistant-settings-fields">
<mat-form-field appearance="outline">
<mat-label>Provider</mat-label>
<mat-select
[value]="selectedProvider()"
[disabled]="configurationLocked() || assistantBusy() || providersLoading()"
(selectionChange)="selectProvider($event.value)">
@for (provider of providers(); track provider) {
<mat-option [value]="provider">{{ provider }}</mat-option>
}
</mat-select>
@if (providersLoading()) { <mat-hint>Caricamento provider...</mat-hint> }
@if (providersError()) { <mat-error>{{ providersError() }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Modello</mat-label>
<mat-select
[value]="selectedModel()"
[disabled]="configurationLocked() || assistantBusy() || !selectedProvider() || modelsLoading()"
(selectionChange)="selectModel($event.value)">
@for (model of models(); track model) {
<mat-option [value]="model">{{ model }}</mat-option>
}
</mat-select>
@if (modelsLoading()) { <mat-hint>Caricamento modelli...</mat-hint> }
@if (modelsError()) { <mat-error>{{ modelsError() }}</mat-error> }
</mat-form-field>
</div>
<button type="button" class="assistant-advanced-toggle" [disabled]="configurationLocked() || assistantBusy()" (click)="toggleAdvancedModels()">
{{ advancedModelsOpen() ? 'Nascondi' : 'Mostra' }} modelli per fase (opzionale)
</button>
@if (advancedModelsOpen()) {
<div class="assistant-settings-fields assistant-phase-fields">
<mat-form-field appearance="outline">
<mat-label>Planning</mat-label>
<mat-select [value]="phaseModels()?.planningModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('planningModel', $event.value)">
<mat-option value="">Modello principale</mat-option>
@for (model of models(); track model) { <mat-option [value]="model">{{ model }}</mat-option> }
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>JSON</mat-label>
<mat-select [value]="phaseModels()?.jsonModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('jsonModel', $event.value)">
<mat-option value="">Modello principale</mat-option>
@for (model of models(); track model) { <mat-option [value]="model">{{ model }}</mat-option> }
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Repair</mat-label>
<mat-select [value]="phaseModels()?.repairModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('repairModel', $event.value)">
<mat-option value="">Modello principale</mat-option>
@for (model of models(); track model) { <mat-option [value]="model">{{ model }}</mat-option> }
</mat-select>
</mat-form-field>
</div>
}
}
</section>
@if (assistantErrorMessage()) {
<section class="assistant-card assistant-error-card">
<p class="assistant-error-message">{{ assistantErrorMessage() }}</p>
</section>
}
<section class="assistant-card assistant-starters">
<div class="assistant-starters-head">
<p class="assistant-label">Quick prompts</p>
@ -115,7 +208,7 @@
class="assistant-input"
[ngModel]="prompt()"
(ngModelChange)="prompt.set($event)"
[disabled]="!selectedModel() || assistantBusy()"
[disabled]="!configurationValid() || assistantBusy()"
name="assistantPrompt"
rows="4"
[placeholder]="promptPlaceholder()"></textarea>
@ -125,7 +218,7 @@
type="submit"
mat-flat-button
class="assistant-send"
[disabled]="!selectedModel() || assistantBusy() || !prompt().trim()">
[disabled]="!configurationValid() || assistantBusy() || !prompt().trim()">
Send
</button>
</form>

View File

@ -1,18 +1,19 @@
import { CommonModule } from '@angular/common';
import { ChangeDetectionStrategy, Component, computed, effect, ElementRef, EventEmitter, inject, input, OnDestroy, OnInit, Output, signal, ViewChild } from '@angular/core';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { FormsModule } from '@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatInputModule } from '@angular/material/input';
import { MatSelectModule } from '@angular/material/select';
import { environment } from '@environment';
import {
AssistantCallPhase,
AssistantCallState,
AssistantChatMessage,
AssistantConfig,
AssistantDraftPayload,
AssistantFlowResult,
AssistantFlowActionResult,
AssistantLlmSelection,
AssistantSessionState
} from '@models/assistant';
import { Flow } from '@models/flow';
@ -24,7 +25,7 @@ import { finalize, firstValueFrom, interval, Subscription, switchMap, take } fro
@Component({
selector: 'app-flow-assistant',
imports: [CommonModule, FormsModule, MatButtonModule, MatFormFieldModule, MatInputModule, MatSelectModule],
imports: [CommonModule, FormsModule, MatButtonModule, MatCheckboxModule, MatFormFieldModule, MatInputModule, MatSelectModule],
templateUrl: './flow-assistant.html',
styleUrl: './flow-assistant.css',
changeDetection: ChangeDetectionStrategy.OnPush
@ -52,6 +53,9 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly currentCall = signal<AssistantCallState | null>(null);
readonly localMessages = signal<AssistantChatMessage[]>([]);
readonly models = signal<string[]>([]);
readonly providers = signal<string[]>([]);
readonly providersLoading = signal(false);
readonly providersError = signal<string | null>(null);
readonly modelsLoading = signal(false);
readonly modelsError = signal<string | null>(null);
readonly sessionLoading = signal(false);
@ -59,6 +63,10 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly prompt = signal('');
readonly createPromptSubmitted = signal(false);
readonly selectedModel = signal('');
readonly useDefaultConfiguration = signal(true);
readonly selectedProvider = signal('');
readonly phaseModels = signal<AssistantLlmSelection['phaseModels']>(undefined);
readonly advancedModelsOpen = signal(false);
readonly modelPickerOpen = signal(false);
readonly quickPromptsOpen = signal(true);
readonly assistantErrorMessage = signal<string | null>(null);
@ -180,6 +188,13 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
readonly currentFlow = this.editorState.currentFlow;
readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null);
readonly configurationLocked = computed(() => !!this.sessionState()?.id);
readonly customConfigurationValid = computed(() =>
!!this.selectedProvider().trim() && !!this.selectedModel().trim()
);
readonly configurationValid = computed(() =>
this.useDefaultConfiguration() || this.customConfigurationValid()
);
constructor() {
effect(() => {
@ -226,9 +241,52 @@ export class FlowAssistant implements OnInit, OnDestroy {
}
selectModel(model: string) {
if (!model || model === this.selectedModel()) return;
if (this.configurationLocked()) return;
this.selectedModel.set(model);
void this.openSession(model);
this.persistSnapshot();
}
setUseDefaultConfiguration(useDefault: boolean) {
if (this.configurationLocked()) return;
this.useDefaultConfiguration.set(useDefault);
this.modelsError.set(null);
if (!useDefault) {
void this.loadProviders();
}
this.persistSnapshot();
}
selectProvider(provider: string) {
if (this.configurationLocked()) return;
this.selectedProvider.set(provider);
this.selectedModel.set('');
this.models.set([]);
this.modelsError.set(null);
if (provider) void this.loadModels(provider);
this.persistSnapshot();
}
setPhaseModel(phase: keyof NonNullable<AssistantLlmSelection['phaseModels']>, model: string) {
if (this.configurationLocked()) return;
this.phaseModels.update((current) => ({ ...current, [phase]: model || undefined }));
this.persistSnapshot();
}
toggleAdvancedModels() {
this.advancedModelsOpen.update((value) => !value);
this.persistSnapshot();
}
createNewSession() {
if (this.assistantBusy() || !this.configurationValid()) return;
this.stopPolling();
this.currentCall.set(null);
this.sessionState.set(null);
this.localMessages.set([]);
this.assistantErrorMessage.set(null);
this.lastFailedPrompt.set(null);
this.lastSubmittedPrompt.set('');
void this.openSession();
}
useStarter(prompt: string) {
@ -264,40 +322,12 @@ export class FlowAssistant implements OnInit, OnDestroy {
=== this.canonicalAssistantErrorContent(FlowAssistant.STANDARD_ASSISTANT_ERROR);
}
hasCancellableCall(): boolean {
return this.isActiveCall(this.currentCall());
}
hasCancellableCall(): boolean { return false; }
async cancelActiveCall(): Promise<boolean> {
const call = this.currentCall();
if (!this.isActiveCall(call)) {
this.requestPending.set(false);
this.createPromptSubmitted.set(false);
return true;
}
this.stopPolling();
this.requestPending.set(false);
try {
const cancelledCall = await firstValueFrom(this.assistant.cancelCall(call.id).pipe(take(1)));
this.currentCall.set(cancelledCall);
this.createPromptSubmitted.set(false);
this.persistSnapshot();
const session = await firstValueFrom(this.assistant.getSession(cancelledCall.sessionId || call.sessionId).pipe(take(1)));
this.applySessionState(session, { syncDraftToEditor: false });
this.currentCall.set(null);
this.persistSnapshot();
return true;
} catch (err) {
console.error('Assistant call cancel failed', err);
this.currentCall.set(call);
this.startPolling(call.id, call.sessionId);
this.assistantErrorMessage.set('Unable to cancel the assistant request.');
this.persistSnapshot();
return false;
}
this.createPromptSubmitted.set(false);
return true;
}
clearActiveSnapshot() {
@ -307,8 +337,18 @@ export class FlowAssistant implements OnInit, OnDestroy {
private sendPrompt(content: string) {
const normalizedContent = content.trim();
const sessionId = this.sessionState()?.id;
if (!normalizedContent || this.assistantBusy() || !sessionId) return;
if (!normalizedContent || this.assistantBusy() || !this.configurationValid()) return;
if (!this.sessionState()?.id) {
void this.openSession(normalizedContent);
return;
}
this.runFlowAction(normalizedContent);
}
private runFlowAction(normalizedContent: string) {
const intent = this.resolveIntent(normalizedContent);
if (this.isCreateModal()) this.createPromptSubmitted.set(true);
this.requestPending.set(true);
@ -324,24 +364,31 @@ export class FlowAssistant implements OnInit, OnDestroy {
]);
this.persistSnapshot();
this.assistant.sendMessage(sessionId, { message: normalizedContent }).pipe(
take(1)
).subscribe({
next: ({ callId }) => {
const request = {
userPrompt: normalizedContent,
...(this.llmSelection() ? { llmSelection: this.llmSelection() } : {}),
...(intent === 'draft' ? {} : { flow: this.assistantFlowForRequest() }),
...(intent === 'fix' ? { validationErrors: this.sessionState()?.lastValidationErrors ?? [] } : {})
};
const action = intent === 'draft'
? this.assistant.draft(request)
: intent === 'fix'
? this.assistant.fix(request)
: intent === 'explain'
? this.assistant.explain(request)
: this.assistant.refine(request);
action.pipe(take(1)).subscribe({
next: (result) => {
this.requestPending.set(false);
this.currentCall.set({
id: callId,
sessionId,
status: 'QUEUED',
phase: 'queued'
});
this.applyFlowActionResult(result);
this.createPromptSubmitted.set(false);
this.persistSnapshot();
this.startPolling(callId, sessionId);
},
error: (err) => {
console.error('Assistant send message failed', err);
console.error('Assistant flow action failed', err);
this.requestPending.set(false);
this.handleAssistantErrorWithRetry(normalizedContent);
this.handleAssistantErrorWithRetry(normalizedContent, this.backendErrorMessage(err));
}
});
}
@ -354,7 +401,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
).subscribe({
next: (config) => {
this.assistantConfig.set(config);
this.loadModelsAndSession(config);
this.initializeConfiguration(config);
},
error: (err) => {
console.error('Assistant config loading failed', err);
@ -364,112 +411,150 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
}
private loadModelsAndSession(config: AssistantConfig) {
private initializeConfiguration(_config: AssistantConfig) {
this.activeFlowKey = this.isCreateModal()
? this.createModalFlowKey
: this.resolveFlowKey(this.currentFlow()?.id ?? null);
this.initialized = true;
void this.restoreSessionForFlow(this.activeFlowKey);
}
private loadProviders() {
const config = this.assistantConfig();
if (!config?.availableProvidersRetrieverUrl) return;
this.providersLoading.set(true);
this.providersError.set(null);
this.assistant.listProviders(config.availableProvidersRetrieverUrl).pipe(
take(1),
finalize(() => this.providersLoading.set(false))
).subscribe({
next: (providers) => this.providers.set(providers),
error: (err) => this.providersError.set(this.backendErrorMessage(err))
});
}
private loadModels(provider: string) {
const config = this.assistantConfig();
if (!config?.availableModelsRetrieverUrl) return;
this.modelsLoading.set(true);
const resolvedUrl = this.resolveAssistantModelsUrl(config.availableModelsRetrieverUrl);
this.assistant.listModels(config.availableModelsRetrieverUrl).pipe(
this.modelsError.set(null);
this.assistant.listModels(config.availableModelsRetrieverUrl, provider).pipe(
take(1),
finalize(() => this.modelsLoading.set(false))
).subscribe({
next: (models) => {
this.models.set(models);
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;
}
this.activeFlowKey = this.isCreateModal()
? this.createModalFlowKey
: this.resolveFlowKey(this.currentFlow()?.id ?? null);
this.initialized = true;
void this.restoreSessionForFlow(this.activeFlowKey);
if (!models.length) this.modelsError.set('No models are available for the selected provider.');
},
error: (err) => {
console.error('Assistant model loading failed', err);
this.sessionLoading.set(false);
this.modelsError.set(
`Unable to load assistant models from ${resolvedUrl}.`
);
}
error: (err) => this.modelsError.set(this.backendErrorMessage(err))
});
}
private async openSession(model: string, flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)) {
private llmSelection(): AssistantLlmSelection | undefined {
if (this.useDefaultConfiguration()) return undefined;
const provider = this.selectedProvider().trim();
const model = this.selectedModel().trim();
if (!provider || !model) return undefined;
const phaseModels = this.phaseModels();
const populatedPhases = phaseModels && Object.values(phaseModels).some(Boolean)
? phaseModels
: undefined;
return { provider, model, ...(populatedPhases ? { phaseModels: populatedPhases } : {}) };
}
private sessionRequest() {
const llmSelection = this.llmSelection();
return llmSelection ? { llmSelection } : {};
}
private resolveIntent(prompt: string): 'draft' | 'refine' | 'fix' | 'explain' {
const normalized = prompt.toLowerCase();
if (/\b(explain|what does|why|describe)\b/.test(normalized)) return 'explain';
if (/\b(fix|repair|invalid|error|broken)\b/.test(normalized)) return 'fix';
return this.canOfferCreate() ? 'draft' : 'refine';
}
private assistantFlowForRequest(): AssistantDraftPayload | undefined {
const draft = this.currentDraft();
if (draft) return draft;
const flow = this.currentFlow();
if (!flow) return undefined;
return { name: flow.name, description: flow.description, flow: flow.data };
}
private applyFlowActionResult(result: AssistantFlowActionResult) {
const message: AssistantChatMessage = {
id: crypto.randomUUID(),
role: 'assistant',
content: result.message,
warnings: result.warnings,
validationErrors: result.validationErrors
};
this.localMessages.update((messages) => [...messages, message]);
if (!result.flow) return;
const session = this.sessionState();
if (session) {
this.sessionState.set({
...session,
currentFlow: result.flow,
currentDraftFlow: result.flow,
lastValidationErrors: result.validationErrors
});
}
this.syncDraftToEditor(result.flow);
}
private backendErrorMessage(error: unknown): string {
const value = error as { error?: unknown; message?: unknown };
const payload = value?.error;
if (typeof payload === 'string' && payload.trim()) return payload;
if (payload && typeof payload === 'object') {
const body = payload as Record<string, unknown>;
for (const key of ['message', 'error', 'detail', 'title']) {
if (typeof body[key] === 'string' && body[key].trim()) return body[key] as string;
}
}
if (typeof value?.message === 'string' && value.message.trim()) return value.message;
return FlowAssistant.STANDARD_ASSISTANT_ERROR;
}
private async openSession(
promptToSend?: string,
flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)
) {
this.stopPolling();
this.currentCall.set(null);
this.localMessages.set([]);
this.sessionLoading.set(true);
this.assistant.createSession({ model }).pipe(
this.assistant.createSession(this.sessionRequest()).pipe(
take(1),
finalize(() => this.sessionLoading.set(false))
).subscribe({
next: (session) => {
this.applySessionState(session);
this.persistSnapshot(flowKey);
if (promptToSend) this.runFlowAction(promptToSend);
},
error: (err) => {
console.error('Assistant session creation failed', err);
this.pushLocalAssistantMessage('Unable to create an assistant session.');
const message = this.backendErrorMessage(err);
this.assistantErrorMessage.set(message);
this.pushLocalAssistantMessage(message);
}
});
}
private startPolling(callId: string, sessionId: string) {
this.stopPolling();
this.pollSubscription = interval(500).pipe(
switchMap(() => this.assistant.getCall(callId))
).subscribe({
next: (callState) => {
this.currentCall.set(callState);
this.persistSnapshot();
if (this.isTerminalCall(callState)) {
this.stopPolling();
if (callState.status === 'COMPLETED' && callState.flowResult?.flow) {
this.syncDraftToEditor(this.flowResultToDraft(callState.flowResult));
}
if (callState.status === 'CANCELLED') {
this.createPromptSubmitted.set(false);
}
void this.reloadSession(sessionId, callState.status === 'FAILED');
}
},
error: (err) => {
console.error('Assistant call polling failed', err);
this.stopPolling();
this.handleAssistantErrorWithRetry(this.lastSubmittedPrompt());
}
});
}
private async reloadSession(sessionId: string, hasFailedCall = false) {
this.assistant.getSession(sessionId).pipe(
take(1)
).subscribe({
next: (session) => {
this.applySessionState(session);
this.currentCall.set(null);
this.persistSnapshot();
if (hasFailedCall) {
this.handleAssistantErrorWithRetry(this.lastSubmittedPrompt());
}
},
error: (err) => {
console.error('Assistant session refresh failed', err);
this.currentCall.set(null);
this.handleAssistantErrorWithRetry(this.lastSubmittedPrompt());
}
});
}
private handleAssistantErrorWithRetry(promptForRetry: string) {
private handleAssistantErrorWithRetry(
promptForRetry: string,
message = FlowAssistant.STANDARD_ASSISTANT_ERROR
) {
const normalizedPrompt = String(promptForRetry ?? '').trim();
this.createPromptSubmitted.set(false);
this.assistantErrorMessage.set(FlowAssistant.STANDARD_ASSISTANT_ERROR);
this.assistantErrorMessage.set(message);
this.lastFailedPrompt.set(normalizedPrompt || null);
this.pushLocalAssistantMessage(FlowAssistant.STANDARD_ASSISTANT_ERROR);
this.pushLocalAssistantMessage(message);
this.persistSnapshot();
}
@ -539,15 +624,6 @@ export class FlowAssistant implements OnInit, OnDestroy {
};
}
private flowResultToDraft(result: AssistantFlowResult): AssistantDraftPayload {
const currentFlow = this.currentFlow();
return {
name: result.name ?? currentFlow?.name ?? 'Assistant Draft',
description: result.description ?? currentFlow?.description,
flow: result.flow
};
}
private pushLocalAssistantMessage(content: string) {
const normalizedContent = this.normalizeAssistantMessageContent(content);
const filtered = this.localMessages().filter((message) => message.role !== 'assistant');
@ -606,8 +682,8 @@ export class FlowAssistant implements OnInit, OnDestroy {
id: 'assistant-system-welcome',
role: 'system',
content: this.canOfferCreate()
? (this.isCreateModal() ? 'Describe the workflow you want to create.' : 'Select a model, then ask me to create a new workflow.')
: 'Select a model, then ask me to refine, fix, or explain the current workflow.'
? (this.isCreateModal() ? 'Describe the workflow you want to create.' : 'Use the default configuration or choose a provider and model, then ask me to create a new workflow.')
: 'Use the default configuration or choose a provider and model, then ask me to refine, fix, or explain the current workflow.'
};
}
@ -625,12 +701,12 @@ export class FlowAssistant implements OnInit, OnDestroy {
this.assistantErrorMessage.set(null);
this.lastFailedPrompt.set(null);
this.lastSubmittedPrompt.set('');
const model = this.selectedModel();
if (!model) {
this.sessionLoading.set(false);
return;
}
void this.openSession(model, flowKey);
this.useDefaultConfiguration.set(true);
this.selectedProvider.set('');
this.selectedModel.set('');
this.phaseModels.set(undefined);
this.advancedModelsOpen.set(false);
this.sessionLoading.set(false);
return;
}
@ -642,64 +718,29 @@ export class FlowAssistant implements OnInit, OnDestroy {
this.assistantErrorMessage.set(snapshot.assistantErrorMessage);
this.lastFailedPrompt.set(snapshot.lastFailedPrompt);
this.lastSubmittedPrompt.set(snapshot.lastSubmittedPrompt);
this.createPromptSubmitted.set(this.isCreateModal() && this.isActiveCall(snapshot.currentCall));
if (snapshot.selectedModel) {
this.selectedModel.set(snapshot.selectedModel);
}
this.createPromptSubmitted.set(false);
this.useDefaultConfiguration.set(snapshot.useDefaultConfiguration);
this.selectedProvider.set(snapshot.selectedProvider);
this.selectedModel.set(snapshot.selectedModel);
this.phaseModels.set(snapshot.phaseModels);
this.advancedModelsOpen.set(snapshot.advancedModelsOpen);
if (snapshot.sessionState) {
this.sessionState.set(snapshot.sessionState);
} else {
this.sessionState.set(null);
}
if (!snapshot.sessionId) {
const model = this.selectedModel();
if (!model) {
this.sessionLoading.set(false);
return;
}
void this.openSession(model, flowKey);
return;
this.sessionLoading.set(false);
if (!this.useDefaultConfiguration() && this.selectedProvider()) {
void this.loadProviders();
void this.loadModels(this.selectedProvider());
}
if (this.isActiveCall(snapshot.currentCall)) {
this.sessionLoading.set(false);
this.startPolling(snapshot.currentCall.id, snapshot.currentCall.sessionId || snapshot.sessionId);
return;
}
this.sessionLoading.set(true);
this.assistant.getSession(snapshot.sessionId).pipe(
take(1),
finalize(() => this.sessionLoading.set(false))
).subscribe({
next: (session) => {
this.applySessionState(session, {
clearLocalMessages: false,
syncDraftToEditor: true
});
},
error: (err) => {
console.error('Assistant session restore failed', err);
const model = this.selectedModel();
if (!model) return;
void this.openSession(model, flowKey);
}
});
}
private resolveFlowKey(flowId: string | null | undefined): string {
return this.sessionStore.flowKey(flowId);
}
private isActiveCall(call: AssistantCallState | null): call is AssistantCallState {
return call?.status === 'QUEUED' || call?.status === 'RUNNING';
}
private isTerminalCall(call: AssistantCallState): boolean {
return call.status === 'COMPLETED' || call.status === 'FAILED' || call.status === 'CANCELLED';
}
private clearSnapshot(flowKey: string) {
if (flowKey === this.activeFlowKey) {
this.skipDestroySnapshot = true;
@ -711,6 +752,10 @@ export class FlowAssistant implements OnInit, OnDestroy {
this.sessionStore.setSnapshot(flowKey, {
sessionId: this.sessionState()?.id ?? null,
selectedModel: this.selectedModel(),
useDefaultConfiguration: this.useDefaultConfiguration(),
selectedProvider: this.selectedProvider(),
phaseModels: this.phaseModels(),
advancedModelsOpen: this.advancedModelsOpen(),
prompt: this.prompt(),
modelPickerOpen: this.modelPickerOpen(),
quickPromptsOpen: this.quickPromptsOpen(),
@ -771,20 +816,6 @@ export class FlowAssistant implements OnInit, OnDestroy {
}
}
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();
}
private hasMeaningfulFlow(flow: Flow | null): boolean {
if (!flow) return false;
const data = flow.data;

View File

@ -1,9 +1,13 @@
import { Injectable } from '@angular/core';
import { AssistantCallState, AssistantChatMessage, AssistantSessionState } from '@models/assistant';
import { AssistantCallState, AssistantChatMessage, AssistantLlmSelection, AssistantSessionState } from '@models/assistant';
export type AssistantSessionSnapshot = {
sessionId: string | null;
selectedModel: string;
useDefaultConfiguration: boolean;
selectedProvider: string;
phaseModels: AssistantLlmSelection['phaseModels'];
advancedModelsOpen: boolean;
prompt: string;
modelPickerOpen: boolean;
quickPromptsOpen: boolean;
@ -85,6 +89,10 @@ export class AssistantSessionStore {
return {
sessionId: typeof snapshot['sessionId'] === 'string' ? snapshot['sessionId'] : null,
selectedModel: typeof snapshot['selectedModel'] === 'string' ? snapshot['selectedModel'] : '',
useDefaultConfiguration: snapshot['useDefaultConfiguration'] !== false,
selectedProvider: typeof snapshot['selectedProvider'] === 'string' ? snapshot['selectedProvider'] : '',
phaseModels: this.normalizePhaseModels(snapshot['phaseModels']),
advancedModelsOpen: snapshot['advancedModelsOpen'] === true,
prompt: typeof snapshot['prompt'] === 'string' ? snapshot['prompt'] : '',
modelPickerOpen: snapshot['modelPickerOpen'] === true,
quickPromptsOpen: snapshot['quickPromptsOpen'] !== false,
@ -109,6 +117,17 @@ export class AssistantSessionStore {
};
}
private normalizePhaseModels(raw: unknown): AssistantLlmSelection['phaseModels'] {
if (!raw || typeof raw !== 'object') return undefined;
const value = raw as Record<string, unknown>;
const phaseModels = {
planningModel: typeof value['planningModel'] === 'string' ? value['planningModel'] : undefined,
jsonModel: typeof value['jsonModel'] === 'string' ? value['jsonModel'] : undefined,
repairModel: typeof value['repairModel'] === 'string' ? value['repairModel'] : undefined
};
return Object.values(phaseModels).some(Boolean) ? phaseModels : undefined;
}
private normalizeSessionState(rawSession: unknown): AssistantSessionState | null {
if (!rawSession || typeof rawSession !== 'object') return null;
const session = rawSession as AssistantSessionState;