diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css index 128485d..bac0a8c 100644 --- a/src/app/shared/flow-assistant/flow-assistant.css +++ b/src/app/shared/flow-assistant/flow-assistant.css @@ -242,6 +242,20 @@ gap: 8px; } +/* What the backend will use when nothing is chosen here. */ +.assistant-defaults { + display: grid; + gap: 2px; + padding: 8px 10px; + border: 1px solid #dbe4ee; + border-radius: 10px; + background: #f8fafc; +} + +.assistant-defaults .assistant-meta { + margin: 0; +} + .assistant-add-credential { justify-self: start; } diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index 4ad4fc1..a01163c 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -43,10 +43,23 @@

Assistant model

+ + @if (effectiveModelSummary(); as summary) { +

{{ summary }}

+ } @else {

Provider credentials are managed by the backend.

+ }
+
+ @if (modelPickerOpen()) { The LLM configuration is locked for this session.

} + @if (useDefaultConfiguration()) { +
+ @if (defaultConfigurationSummary(); as summary) { +

{{ summary }}

+ } @else { +

The backend has not published a default provider and model.

+ } + @for (phase of defaultPhaseModelSummary(); track phase.label) { +

{{ phase.label }}: {{ phase.model }}

+ } +
+ } + @if (!useDefaultConfiguration()) {
@@ -188,6 +214,7 @@
} } + } @if (assistantErrorMessage()) { diff --git a/src/app/shared/flow-assistant/flow-assistant.spec.ts b/src/app/shared/flow-assistant/flow-assistant.spec.ts index 3977870..aa0c57f 100644 --- a/src/app/shared/flow-assistant/flow-assistant.spec.ts +++ b/src/app/shared/flow-assistant/flow-assistant.spec.ts @@ -259,3 +259,145 @@ describe('FlowAssistant credentials', () => { expect(provider.options).toEqual([{ label: 'Gemini', value: 'Gemini' }]); }); }); + +describe('FlowAssistant quick prompts', () => { + function build() { + const assistant = { + getConfig: vi.fn(() => of({ + defaultProvider: '', + defaultModel: '', + availableProvidersRetrieverUrl: '/retriever/LLM/providers', + availableModelsRetrieverUrl: '/retriever/LLM/models?provider={provider}', + defaultPhaseModels: {}, + providerCatalogUrl: '/llm/providers' + })), + listProviders: vi.fn(() => of([])), + listModels: vi.fn(() => of([])), + areModelsOpen: vi.fn(() => of(false)), + listProviderCatalog: vi.fn(() => of([])) + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AssistantService, useValue: assistant }, + { provide: NodeSettingsDialogService, useValue: { open: vi.fn((_input: unknown) => Promise.resolve(null)) } }, + { provide: VaultService, useValue: { listSecrets: vi.fn(() => of([])) } }, + { + provide: EditorStateHolder, + useValue: { currentFlow: vi.fn(() => null), activeFlowData: vi.fn(() => null) } + }, + { provide: Authorization, useValue: { currentUser: vi.fn(() => null) } }, + { + provide: AssistantSessionStore, + useValue: { + flowKey: vi.fn(() => 'flow-1'), + getSnapshot: vi.fn(() => null), + setSnapshot: vi.fn(), + clearSnapshot: vi.fn(), + cloneSnapshot: vi.fn((snapshot: unknown) => snapshot) + } + } + ] + }); + + return TestBed.createComponent(FlowAssistant).componentInstance as any; + } + + afterEach(() => TestBed.resetTestingModule()); + + it('starts collapsed, like the other collapsible sections', () => { + expect(build().quickPromptsOpen()).toBe(false); + }); + + it('starts with the assistant model panel collapsed too', () => { + // The signal and its toggle already existed; the template never used them, so the panel was + // permanently expanded and there was no way to fold it away. + expect(build().modelPickerOpen()).toBe(false); + }); + + it('stays collapsed when a flow with no saved session is opened', async () => { + const component = build(); + component.quickPromptsOpen.set(true); + + await component.restoreSessionForFlow('flow-2'); + + expect(component.quickPromptsOpen()).toBe(false); + }); +}); + +describe('FlowAssistant default configuration', () => { + function build(config: Record) { + const assistant = { + getConfig: vi.fn(() => of(config)), + listProviders: vi.fn(() => of([])), + listModels: vi.fn(() => of([])), + areModelsOpen: vi.fn(() => of(false)), + listProviderCatalog: vi.fn(() => of([])) + }; + + TestBed.configureTestingModule({ + providers: [ + { provide: AssistantService, useValue: assistant }, + { provide: NodeSettingsDialogService, useValue: { open: vi.fn((_input: unknown) => Promise.resolve(null)) } }, + { provide: VaultService, useValue: { listSecrets: vi.fn(() => of([])) } }, + { + provide: EditorStateHolder, + useValue: { currentFlow: vi.fn(() => null), activeFlowData: vi.fn(() => null) } + }, + { provide: Authorization, useValue: { currentUser: vi.fn(() => null) } }, + { + provide: AssistantSessionStore, + useValue: { + flowKey: vi.fn(() => 'flow-1'), + getSnapshot: vi.fn(() => null), + setSnapshot: vi.fn(), + clearSnapshot: vi.fn(), + cloneSnapshot: vi.fn((snapshot: unknown) => snapshot) + } + } + ] + }); + + const component = TestBed.createComponent(FlowAssistant).componentInstance as any; + component.assistantConfig.set(config); + return component; + } + + afterEach(() => TestBed.resetTestingModule()); + + it('names the backend default while Use default configuration is on', () => { + // Ticking the box said a default applied without ever saying which one. + const component = build({ defaultProvider: 'InternalOllama', defaultModel: 'llama3.2:3b', defaultPhaseModels: {} }); + + expect(component.useDefaultConfiguration()).toBe(true); + expect(component.defaultConfigurationSummary()).toBe('InternalOllama \u00b7 llama3.2:3b'); + expect(component.effectiveModelSummary()).toBe('Default: InternalOllama \u00b7 llama3.2:3b'); + }); + + it('lists only the phases the backend actually overrides', () => { + const component = build({ + defaultProvider: 'InternalOllama', + defaultModel: 'llama3.2:3b', + defaultPhaseModels: { planningModel: 'qwen2.5:7b', jsonModel: ' ', repairModel: undefined } + }); + + expect(component.defaultPhaseModelSummary()).toEqual([{ label: 'Planning', model: 'qwen2.5:7b' }]); + }); + + it('says nothing rather than something empty when no default was published', () => { + const component = build({ defaultProvider: '', defaultModel: '', defaultPhaseModels: {} }); + + expect(component.defaultConfigurationSummary()).toBeNull(); + expect(component.effectiveModelSummary()).toBeNull(); + }); + + it('summarises the chosen provider and model once the default is turned off', () => { + const component = build({ defaultProvider: 'InternalOllama', defaultModel: 'llama3.2:3b', defaultPhaseModels: {} }); + + component.useDefaultConfiguration.set(false); + component.selectedProvider.set('Gemini'); + component.selectedModel.set('gemini-2.5-flash'); + + expect(component.effectiveModelSummary()).toBe('Gemini \u00b7 gemini-2.5-flash'); + }); +}); diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index f73af95..8b86298 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -84,7 +84,8 @@ export class FlowAssistant implements OnInit, OnDestroy { readonly credentialSaving = signal(false); readonly advancedModelsOpen = signal(false); readonly modelPickerOpen = signal(false); - readonly quickPromptsOpen = signal(true); + /** Closed on arrival, like the other collapsible sections: the prompt box is what you came for. */ + readonly quickPromptsOpen = signal(false); readonly assistantErrorMessage = signal(null); readonly lastFailedPrompt = signal(null); readonly lastSubmittedPrompt = signal(''); @@ -205,6 +206,37 @@ export class FlowAssistant implements OnInit, OnDestroy { readonly currentFlow = this.editorState.currentFlow; readonly currentDraft = computed(() => this.sessionState()?.currentDraftFlow ?? null); readonly configurationLocked = computed(() => !!this.sessionState()?.id); + /** + * What the backend says it will use when nothing is chosen here. It has always been in the + * config payload alongside the retriever URLs; the panel just never showed it, so ticking Use + * default configuration told you a default applied without ever saying which. + */ + readonly defaultConfigurationSummary = computed(() => { + const config = this.assistantConfig(); + const parts = [config?.defaultProvider?.trim(), config?.defaultModel?.trim()].filter((part) => !!part); + return parts.length ? parts.join(' \u00b7 ') : null; + }); + + /** The phases the backend overrides, if any. Silence here means they all use the main model. */ + readonly defaultPhaseModelSummary = computed(() => { + const phases = this.assistantConfig()?.defaultPhaseModels; + return [ + { label: 'Planning', model: phases?.planningModel?.trim() }, + { label: 'JSON', model: phases?.jsonModel?.trim() }, + { label: 'Repair', model: phases?.repairModel?.trim() } + ].filter((entry): entry is { label: string; model: string } => !!entry.model); + }); + + /** What the run will use, readable with the panel collapsed. */ + readonly effectiveModelSummary = computed(() => { + if (this.useDefaultConfiguration()) { + const summary = this.defaultConfigurationSummary(); + return summary ? `Default: ${summary}` : null; + } + const parts = [this.selectedProvider().trim(), this.selectedModel().trim()].filter((part) => !!part); + return parts.length ? parts.join(' \u00b7 ') : null; + }); + readonly providerNeedsCredential = computed(() => !!this.selectedProvider().trim() && !this.isInternalProvider(this.selectedProvider()) ); @@ -892,7 +924,7 @@ export class FlowAssistant implements OnInit, OnDestroy { if (!snapshot) { this.prompt.set(''); this.modelPickerOpen.set(false); - this.quickPromptsOpen.set(true); + this.quickPromptsOpen.set(false); this.sessionState.set(null); this.localMessages.set([]); this.assistantErrorMessage.set(null);