From cf6b25bd1d890a652309419b33692211ae43cbc7 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Tue, 1 Sep 2026 15:53:28 +0200 Subject: [PATCH] feat(assistant): add provider and model selection --- src/app/models/assistant.ts | 36 +- .../services/assistant/assistant-call.base.ts | 22 +- .../assistant/assistant-call.fake.spec.ts | 41 +- .../services/assistant/assistant-call.fake.ts | 263 +++-------- .../services/assistant/assistant-call.spec.ts | 55 ++- src/app/services/assistant/assistant-call.ts | 163 +++---- src/app/services/assistant/assistant.ts | 32 +- .../shared/flow-assistant/flow-assistant.css | 42 ++ .../shared/flow-assistant/flow-assistant.html | 97 +++- .../shared/flow-assistant/flow-assistant.ts | 437 ++++++++++-------- src/app/stores/assistant-session-store.ts | 21 +- 11 files changed, 635 insertions(+), 574 deletions(-) diff --git a/src/app/models/assistant.ts b/src/app/models/assistant.ts index 88d0163..1622875 100644 --- a/src/app/models/assistant.ts +++ b/src/app/models/assistant.ts @@ -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'; diff --git a/src/app/services/assistant/assistant-call.base.ts b/src/app/services/assistant/assistant-call.base.ts index 20eaf61..74c0449 100644 --- a/src/app/services/assistant/assistant-call.base.ts +++ b/src/app/services/assistant/assistant-call.base.ts @@ -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; - abstract listModels(retrieverUrl: string): Observable; + abstract listProviders(retrieverUrl: string): Observable; - abstract createSession(request: { - model: string; - }): Observable; + abstract listModels(retrieverUrlTemplate: string, provider: string): Observable; - abstract sendMessage(sessionId: string, request: AssistantSendMessageRequest): Observable<{ callId: string }>; + abstract createSession(request: AssistantSessionRequest): Observable; - abstract getCall(callId: string): Observable; + abstract draft(request: AssistantFlowRequest): Observable; - abstract cancelCall(callId: string): Observable; + abstract refine(request: AssistantFlowRequest): Observable; + + abstract fix(request: AssistantFlowRequest): Observable; + + abstract explain(request: AssistantFlowRequest): Observable; - abstract getSession(sessionId: string): Observable; } diff --git a/src/app/services/assistant/assistant-call.fake.spec.ts b/src/app/services/assistant/assistant-call.fake.spec.ts index 834f4f3..71e8a6b 100644 --- a/src/app/services/assistant/assistant-call.fake.spec.ts +++ b/src/app/services/assistant/assistant-call.fake.spec.ts @@ -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((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)['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((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'); }); }); diff --git a/src/app/services/assistant/assistant-call.fake.ts b/src/app/services/assistant/assistant-call.fake.ts index 1fcf7b4..aa5803f 100644 --- a/src/app/services/assistant/assistant-call.fake.ts +++ b/src/app/services/assistant/assistant-call.fake.ts @@ -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(); - private readonly calls = new Map(); + private readonly providers = ['InternalOllama', 'OpenAI']; override getConfig(): Observable { 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 { + override listProviders(_retrieverUrl: string): Observable { + return of(this.providers); + } + + override listModels(_retrieverUrlTemplate: string, _provider: string): Observable { return of(this.models); } - override createSession(request: { - model: string; - }): Observable { + override createSession(request: AssistantSessionRequest): Observable { 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 { - 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 { - 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 { - 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 { + 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 { + 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 { + 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 { + 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 { diff --git a/src/app/services/assistant/assistant-call.spec.ts b/src/app/services/assistant/assistant-call.spec.ts index a4fecbe..5301392 100644 --- a/src/app/services/assistant/assistant-call.spec.ts +++ b/src/app/services/assistant/assistant-call.spec.ts @@ -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; 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: [] }; +} diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index 6cbf977..179cab8 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -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 { + override listProviders(retrieverUrl: string): Observable { + return this.http + .get(resolveAssistantUrl(retrieverUrl)) + .pipe(map((raw) => mapModelList(raw))); + } + + override listModels(retrieverUrlTemplate: string, provider: string): Observable { + const retrieverUrl = retrieverUrlTemplate.replace('{provider}', encodeURIComponent(provider)); const resolvedUrl = resolveAssistantUrl(retrieverUrl); return this.http .get(resolvedUrl) .pipe(map((raw) => mapModelList(raw))); } - override createSession(request: { - model: string; - }): Observable { + override createSession(request: AssistantSessionRequest): Observable { return this.http .post(`${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(`${environment.apiUrl}/assistant/sessions/${encodedId}/messages`, request) - .pipe(map((raw) => mapSendMessageResponse(raw))); + override draft(request: AssistantFlowRequest): Observable { + return this.runFlowAction('draft', request); } - override getCall(callId: string): Observable { - const encodedId = encodeURIComponent(callId); - return this.http - .get(`${environment.apiUrl}/assistant/calls/${encodedId}`) - .pipe(map((raw) => mapAssistantCallState(raw))); + override refine(request: AssistantFlowRequest): Observable { + return this.runFlowAction('refine', request); } - override cancelCall(callId: string): Observable { - const encodedId = encodeURIComponent(callId); - return this.http - .put(`${environment.apiUrl}/assistant/calls/${encodedId}/cancel`, {}) - .pipe(map((raw) => mapAssistantCallState(raw))); + override fix(request: AssistantFlowRequest): Observable { + return this.runFlowAction('fix', request); } - override getSession(sessionId: string): Observable { - const encodedId = encodeURIComponent(sessionId); + override explain(request: AssistantFlowRequest): Observable { + return this.runFlowAction('explain', request); + } + + private runFlowAction( + action: 'draft' | 'refine' | 'fix' | 'explain', + request: AssistantFlowRequest + ): Observable { return this.http - .get(`${environment.apiUrl}/assistant/sessions/${encodedId}`) - .pipe(map((raw) => mapAssistantSessionState(raw))); + .post(`${environment.apiUrl}/assistant/flows/${action}`, request) + .pipe(map((raw) => mapAssistantFlowActionResult(raw))); } } function mapAssistantConfig(raw: unknown): AssistantConfig { const value = (raw ?? {}) as Record; 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; + 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; - return { - callId: String(value['callId'] ?? value['id'] ?? '') - }; -} - -function mapAssistantCallState(raw: unknown): AssistantCallState { - const value = (raw ?? {}) as Record; - 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; + 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; - 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; diff --git a/src/app/services/assistant/assistant.ts b/src/app/services/assistant/assistant.ts index 400a262..33f7e70 100644 --- a/src/app/services/assistant/assistant.ts +++ b/src/app/services/assistant/assistant.ts @@ -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); } + } diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css index 06f8e03..2f2c7e4 100644 --- a/src/app/shared/flow-assistant/flow-assistant.css +++ b/src/app/shared/flow-assistant/flow-assistant.css @@ -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; + } +} diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index 54a753b..e25f61c 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -39,6 +39,99 @@ } @if (!progressOnlyMode()) { +
+
+
+

Configurazione assistente

+

Le credenziali dei provider sono gestite dal backend.

+
+ @if (configurationLocked()) { + + } +
+ + + Usa configurazione predefinita + + + @if (configurationLocked()) { +

La configurazione LLM รจ fissata per questa sessione. Crea una nuova sessione per modificarla.

+ } + + @if (!useDefaultConfiguration()) { +
+ + Provider + + @for (provider of providers(); track provider) { + {{ provider }} + } + + @if (providersLoading()) { Caricamento provider... } + @if (providersError()) { {{ providersError() }} } + + + + Modello + + @for (model of models(); track model) { + {{ model }} + } + + @if (modelsLoading()) { Caricamento modelli... } + @if (modelsError()) { {{ modelsError() }} } + +
+ + + + @if (advancedModelsOpen()) { +
+ + Planning + + Modello principale + @for (model of models(); track model) { {{ model }} } + + + + JSON + + Modello principale + @for (model of models(); track model) { {{ model }} } + + + + Repair + + Modello principale + @for (model of models(); track model) { {{ model }} } + + +
+ } + } +
+ + @if (assistantErrorMessage()) { +
+

{{ assistantErrorMessage() }}

+
+ } +

Quick prompts

@@ -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()"> @@ -125,7 +218,7 @@ type="submit" mat-flat-button class="assistant-send" - [disabled]="!selectedModel() || assistantBusy() || !prompt().trim()"> + [disabled]="!configurationValid() || assistantBusy() || !prompt().trim()"> Send diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index 49c6c59..5bee246 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -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(null); readonly localMessages = signal([]); readonly models = signal([]); + readonly providers = signal([]); + readonly providersLoading = signal(false); + readonly providersError = signal(null); readonly modelsLoading = signal(false); readonly modelsError = signal(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(undefined); + readonly advancedModelsOpen = signal(false); readonly modelPickerOpen = signal(false); readonly quickPromptsOpen = signal(true); readonly assistantErrorMessage = signal(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, 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 { - 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; + 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; diff --git a/src/app/stores/assistant-session-store.ts b/src/app/stores/assistant-session-store.ts index ec86e8f..3abeae8 100644 --- a/src/app/stores/assistant-session-store.ts +++ b/src/app/stores/assistant-session-store.ts @@ -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; + 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;