feat(assistant): select provider, model and vault credential

Lets the assistant panel pick the provider and model for a session, and
choose or create the vault credential an external provider needs. Status
messages for the credential endpoints come from the shared
CREDENTIAL_ERROR_MESSAGES map, so the assistant and the vault report
400/401/409 the same way.

Also moves the remaining Italian UI strings to English, matching the
rest of the application.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucio Lelii 2026-09-01 23:23:14 +02:00
parent 7e9b483882
commit 4a40a8aa2a
6 changed files with 326 additions and 19 deletions

View File

@ -104,6 +104,7 @@ describe('AssistantCallService', () => {
const selection = {
provider: 'OpenAI',
model: 'gpt-oss:20b',
credentialId: 'credential-1',
phaseModels: { planningModel: 'planning-model' }
};
const actionRequests = [

View File

@ -74,8 +74,13 @@ function mapAssistantConfig(raw: unknown): AssistantConfig {
return {
defaultProvider: String(value['defaultProvider'] ?? value['provider'] ?? ''),
defaultModel: String(value['defaultModel'] ?? ''),
availableProvidersRetrieverUrl: String(value['availableProvidersRetrieverUrl'] ?? ''),
availableModelsRetrieverUrl: String(value['availableModelsRetrieverUrlTemplate'] ?? value['availableModelsRetrieverUrl'] ?? ''),
availableProvidersRetrieverUrl: String(value['providersRetrieverUrl'] ?? value['availableProvidersRetrieverUrl'] ?? ''),
availableModelsRetrieverUrl: String(
value['modelsRetrieverUrlTemplate']
?? value['availableModelsRetrieverUrlTemplate']
?? value['availableModelsRetrieverUrl']
?? ''
),
defaultPhaseModels: mapPhaseModels(value['defaultPhaseModels'])
};
}

View File

@ -237,6 +237,55 @@
gap: 10px;
}
.assistant-credential-picker,
.assistant-credentials-list,
.assistant-credential-form {
display: grid;
gap: 8px;
}
.assistant-add-credential {
justify-self: start;
}
.assistant-credential-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px;
border: 1px solid #dbe4ee;
border-radius: 10px;
background: #f8fafc;
}
.assistant-credential-item strong,
.assistant-credential-item p {
margin: 0;
}
.assistant-credential-item strong {
color: #0f172a;
font-size: 13px;
}
.assistant-credential-item p {
margin-top: 3px;
color: #64748b;
font-size: 12px;
}
.assistant-credential-inactive {
opacity: 0.7;
}
.assistant-credential-actions {
display: flex;
align-items: flex-start;
flex-wrap: wrap;
justify-content: flex-end;
gap: 6px;
}
.assistant-llm-settings .assistant-copy {
margin-bottom: 0;
}
@ -482,4 +531,12 @@
.assistant-phase-fields {
grid-template-columns: 1fr;
}
.assistant-credential-item {
flex-direction: column;
}
.assistant-credential-actions {
justify-content: flex-start;
}
}

View File

@ -42,8 +42,8 @@
<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>
<p class="assistant-label">Assistant model</p>
<p class="assistant-copy">Provider credentials are managed by the backend.</p>
</div>
</div>
@ -51,11 +51,11 @@
[checked]="useDefaultConfiguration()"
[disabled]="configurationLocked() || assistantBusy()"
(change)="setUseDefaultConfiguration($event.checked)">
Usa configurazione predefinita
Use default configuration
</mat-checkbox>
@if (configurationLocked()) {
<p class="assistant-meta">La configurazione LLM è fissata per questa sessione.</p>
<p class="assistant-meta">The LLM configuration is locked for this session.</p>
}
@if (!useDefaultConfiguration()) {
@ -70,12 +70,12 @@
<mat-option [value]="provider">{{ provider }}</mat-option>
}
</mat-select>
@if (providersLoading()) { <mat-hint>Caricamento provider...</mat-hint> }
@if (providersLoading()) { <mat-hint>Loading providers...</mat-hint> }
@if (providersError()) { <mat-error>{{ providersError() }}</mat-error> }
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Modello</mat-label>
<mat-label>Model</mat-label>
<mat-select
[value]="selectedModel()"
[disabled]="configurationLocked() || assistantBusy() || !selectedProvider() || modelsLoading()"
@ -84,13 +84,36 @@
<mat-option [value]="model">{{ model }}</mat-option>
}
</mat-select>
@if (modelsLoading()) { <mat-hint>Caricamento modelli...</mat-hint> }
@if (modelsLoading()) { <mat-hint>Loading models...</mat-hint> }
@if (modelsError()) { <mat-error>{{ modelsError() }}</mat-error> }
</mat-form-field>
</div>
@if (providerNeedsCredential()) {
<div class="assistant-credential-picker">
<mat-form-field appearance="outline">
<mat-label>Credential</mat-label>
<mat-select
[value]="selectedCredentialId()"
[disabled]="configurationLocked() || assistantBusy() || credentialsLoading()"
(selectionChange)="selectCredential($event.value)">
@for (credential of compatibleCredentials(); track credential.id) {
<mat-option [value]="credential.id">{{ credential.label }}</mat-option>
}
</mat-select>
@if (credentialsLoading()) { <mat-hint>Loading credentials...</mat-hint> }
@if (credentialsError()) { <mat-error>{{ credentialsError() }}</mat-error> }
</mat-form-field>
@if (!credentialsLoading() && !compatibleCredentials().length) {
<button type="button" mat-stroked-button class="assistant-add-credential" [disabled]="configurationLocked() || assistantBusy()" (click)="openCredentialForm()">
Add credential
</button>
}
</div>
}
<button type="button" class="assistant-advanced-toggle" [disabled]="configurationLocked() || assistantBusy()" (click)="toggleAdvancedModels()">
{{ advancedModelsOpen() ? 'Nascondi' : 'Mostra' }} modelli per fase (opzionale)
{{ advancedModelsOpen() ? 'Hide' : 'Show' }} per-phase models (optional)
</button>
@if (advancedModelsOpen()) {
@ -98,21 +121,21 @@
<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>
<mat-option value="">Main model</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>
<mat-option value="">Main model</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>
<mat-option value="">Main model</mat-option>
@for (model of models(); track model) { <mat-option [value]="model">{{ model }}</mat-option> }
</mat-select>
</mat-form-field>
@ -121,6 +144,75 @@
}
</section>
<section class="assistant-card assistant-credentials">
<div class="assistant-starters-head">
<div>
<p class="assistant-label">Provider credentials</p>
<p class="assistant-copy">Keys are stored in the vault and are never displayed.</p>
</div>
<button type="button" class="assistant-section-toggle" (click)="toggleCredentialsPanel()" [attr.aria-label]="credentialsPanelOpen() ? 'Collapse credentials' : 'Expand credentials'">
{{ credentialsPanelOpen() ? '−' : '+' }}
</button>
</div>
@if (credentialsPanelOpen()) {
<div class="assistant-credentials-list">
@if (credentialsLoading()) {
<p class="assistant-meta">Loading credentials...</p>
} @else if (!credentials().length) {
<p class="assistant-meta">No credentials saved yet.</p>
} @else {
@for (credential of credentials(); track credential.id) {
<article class="assistant-credential-item" [class.assistant-credential-inactive]="!credential.active">
<div>
<strong>{{ credential.label }}</strong>
<p>{{ credential.provider }} · {{ credential.active ? 'Active' : 'Inactive' }}</p>
@if (credential.description) { <p>{{ credential.description }}</p> }
<p>Key configured · last used: {{ credential.lastUsedAt || 'never' }}</p>
</div>
<div class="assistant-credential-actions">
<button type="button" mat-stroked-button (click)="editCredential(credential)">Edit / rotate</button>
<button type="button" mat-stroked-button [disabled]="credentialSaving()" (click)="setCredentialActive(credential, !credential.active)">
{{ credential.active ? 'Disable' : 'Enable' }}
</button>
</div>
</article>
}
}
</div>
@if (!credentialFormOpen()) {
<button type="button" mat-stroked-button class="assistant-add-credential" (click)="openCredentialForm()">Add credential</button>
} @else {
<form class="assistant-credential-form" (ngSubmit)="saveCredential()">
<mat-form-field appearance="outline">
<mat-label>Label</mat-label>
<input matInput [ngModel]="credentialLabel()" (ngModelChange)="credentialLabel.set($event)" name="credentialLabel" required />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Provider</mat-label>
<mat-select [value]="credentialProvider()" [disabled]="!!editingCredentialId()" (selectionChange)="credentialProvider.set($event.value)" name="credentialProvider" required>
@for (provider of providers(); track provider) { <mat-option [value]="provider">{{ provider }}</mat-option> }
</mat-select>
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>Description (optional)</mat-label>
<input matInput [ngModel]="credentialDescription()" (ngModelChange)="credentialDescription.set($event)" name="credentialDescription" />
</mat-form-field>
<mat-form-field appearance="outline">
<mat-label>{{ editingCredentialId() ? 'New value (to rotate)' : 'API key' }}</mat-label>
<input matInput type="password" autocomplete="off" [ngModel]="credentialValue()" (ngModelChange)="credentialValue.set($event)" name="credentialValue" [required]="!editingCredentialId()" />
<mat-hint>{{ editingCredentialId() ? 'Leave empty to keep the current key.' : 'The value will not be shown again after saving.' }}</mat-hint>
</mat-form-field>
@if (credentialsError()) { <p class="assistant-error">{{ credentialsError() }}</p> }
<button type="submit" mat-flat-button class="assistant-send" [disabled]="credentialSaving() || !credentialLabel().trim() || !credentialProvider().trim() || (!editingCredentialId() && !credentialValue().trim())">
{{ credentialSaving() ? 'Saving...' : editingCredentialId() ? 'Save changes' : 'Save credential' }}
</button>
</form>
}
}
</section>
@if (assistantErrorMessage()) {
<section class="assistant-card assistant-error-card">
<p class="assistant-error-message">{{ assistantErrorMessage() }}</p>

View File

@ -14,10 +14,12 @@ import {
AssistantDraftPayload,
AssistantFlowActionResult,
AssistantLlmSelection,
AssistantSessionState
AssistantSessionState,
VaultSecret
} from '@models/assistant';
import { Flow } from '@models/flow';
import { AssistantService } from '@services/assistant/assistant';
import { CREDENTIAL_ERROR_MESSAGES, VaultService } from '@services/vault/vault';
import { Authorization } from '@services/authorization/authorization';
import { AssistantSessionStore } from '@stores/assistant-session-store';
import { EditorStateHolder } from '@stores/flow-editor';
@ -35,6 +37,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
@ViewChild('assistantScroll') assistantScrollElement?: ElementRef<HTMLDivElement>;
private readonly assistant = inject(AssistantService);
private readonly vault = inject(VaultService);
private readonly editorState = inject(EditorStateHolder);
private readonly authorization = inject(Authorization);
private readonly sessionStore = inject(AssistantSessionStore);
@ -66,6 +69,18 @@ export class FlowAssistant implements OnInit, OnDestroy {
readonly useDefaultConfiguration = signal(true);
readonly selectedProvider = signal('');
readonly phaseModels = signal<AssistantLlmSelection['phaseModels']>(undefined);
readonly credentials = signal<VaultSecret[]>([]);
readonly credentialsLoading = signal(false);
readonly credentialsError = signal<string | null>(null);
readonly selectedCredentialId = signal('');
readonly credentialsPanelOpen = signal(false);
readonly credentialFormOpen = signal(false);
readonly credentialSaving = signal(false);
readonly editingCredentialId = signal<string | null>(null);
readonly credentialLabel = signal('');
readonly credentialProvider = signal('');
readonly credentialDescription = signal('');
readonly credentialValue = signal('');
readonly advancedModelsOpen = signal(false);
readonly modelPickerOpen = signal(false);
readonly quickPromptsOpen = signal(true);
@ -189,8 +204,22 @@ 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 providerNeedsCredential = computed(() =>
!!this.selectedProvider().trim() && !this.isInternalProvider(this.selectedProvider())
);
readonly compatibleCredentials = computed(() => {
const provider = this.selectedProvider().trim().toLowerCase();
if (!provider) return [];
return this.credentials().filter((credential) =>
credential.active && credential.provider.trim().toLowerCase() === provider
);
});
readonly customConfigurationValid = computed(() =>
!!this.selectedProvider().trim() && !!this.selectedModel().trim()
!!this.selectedProvider().trim()
&& !!this.selectedModel().trim()
&& (!this.providerNeedsCredential() || this.compatibleCredentials().some(
(credential) => credential.id === this.selectedCredentialId()
))
);
readonly configurationValid = computed(() =>
this.useDefaultConfiguration() || this.customConfigurationValid()
@ -260,12 +289,99 @@ export class FlowAssistant implements OnInit, OnDestroy {
if (this.configurationLocked()) return;
this.selectedProvider.set(provider);
this.selectedModel.set('');
this.selectedCredentialId.set('');
this.models.set([]);
this.modelsError.set(null);
if (provider) void this.loadModels(provider);
if (provider) void this.loadCredentials();
this.persistSnapshot();
}
selectCredential(credentialId: string) {
if (this.configurationLocked()) return;
this.selectedCredentialId.set(credentialId);
}
toggleCredentialsPanel() {
this.credentialsPanelOpen.update((open) => !open);
if (this.credentialsPanelOpen()) {
void this.loadCredentials();
void this.loadProviders();
}
}
openCredentialForm(provider = this.selectedProvider()) {
if (this.configurationLocked()) return;
this.editingCredentialId.set(null);
this.credentialLabel.set('');
this.credentialProvider.set(provider);
this.credentialDescription.set('');
this.credentialValue.set('');
this.credentialsError.set(null);
this.credentialsPanelOpen.set(true);
this.credentialFormOpen.set(true);
}
editCredential(credential: VaultSecret) {
this.editingCredentialId.set(credential.id);
this.credentialLabel.set(credential.label);
this.credentialProvider.set(credential.provider);
this.credentialDescription.set(credential.description ?? '');
this.credentialValue.set('');
this.credentialsError.set(null);
this.credentialsPanelOpen.set(true);
this.credentialFormOpen.set(true);
}
saveCredential() {
const label = this.credentialLabel().trim();
const provider = this.credentialProvider().trim();
const description = this.credentialDescription().trim();
const value = this.credentialValue();
const editingId = this.editingCredentialId();
if (!label || !provider || (!editingId && !value.trim()) || this.credentialSaving()) return;
this.credentialSaving.set(true);
this.credentialsError.set(null);
const request = editingId
? this.vault.updateSecret(editingId, {
label,
description: description || undefined,
...(value.trim() ? { value } : {})
})
: this.vault.createSecret({ label, provider, description: description || undefined, value });
request.pipe(finalize(() => {
this.credentialSaving.set(false);
this.credentialValue.set('');
})).subscribe({
next: (credential) => {
this.credentialFormOpen.set(false);
this.editingCredentialId.set(null);
if (credential.active && this.sameProvider(credential.provider, this.selectedProvider())) {
this.selectedCredentialId.set(credential.id);
}
void this.loadCredentials();
},
error: (err) => this.credentialsError.set(this.backendErrorMessage(err))
});
}
setCredentialActive(credential: VaultSecret, active: boolean) {
if (this.credentialSaving()) return;
this.credentialSaving.set(true);
this.credentialsError.set(null);
this.vault.updateSecret(credential.id, { active }).pipe(
finalize(() => this.credentialSaving.set(false))
).subscribe({
next: () => {
if (!active && this.selectedCredentialId() === credential.id) this.selectedCredentialId.set('');
void this.loadCredentials();
},
error: (err) => this.credentialsError.set(this.backendErrorMessage(err))
});
}
setPhaseModel(phase: keyof NonNullable<AssistantLlmSelection['phaseModels']>, model: string) {
if (this.configurationLocked()) return;
this.phaseModels.update((current) => ({ ...current, [phase]: model || undefined }));
@ -354,6 +470,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
const request = {
userPrompt: normalizedContent,
maxRepairAttempts: 2,
...(this.llmSelection() ? { llmSelection: this.llmSelection() } : {}),
...(intent === 'draft' ? {} : { flow: this.assistantFlowForRequest() }),
...(intent === 'fix' ? { validationErrors: this.sessionState()?.lastValidationErrors ?? [] } : {})
@ -391,6 +508,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
next: (config) => {
this.assistantConfig.set(config);
this.initializeConfiguration(config);
void this.loadCredentials();
},
error: (err) => {
console.error('Assistant config loading failed', err);
@ -439,6 +557,24 @@ export class FlowAssistant implements OnInit, OnDestroy {
});
}
private loadCredentials() {
this.credentialsLoading.set(true);
this.credentialsError.set(null);
this.vault.listSecrets().pipe(
take(1),
finalize(() => this.credentialsLoading.set(false))
).subscribe({
next: (credentials) => {
this.credentials.set(credentials);
const selectedId = this.selectedCredentialId();
if (selectedId && !credentials.some((credential) => credential.id === selectedId && credential.active)) {
this.selectedCredentialId.set('');
}
},
error: (err) => this.credentialsError.set(this.backendErrorMessage(err))
});
}
private llmSelection(): AssistantLlmSelection | undefined {
if (this.useDefaultConfiguration()) return undefined;
const provider = this.selectedProvider().trim();
@ -448,7 +584,13 @@ export class FlowAssistant implements OnInit, OnDestroy {
const populatedPhases = phaseModels && Object.values(phaseModels).some(Boolean)
? phaseModels
: undefined;
return { provider, model, ...(populatedPhases ? { phaseModels: populatedPhases } : {}) };
const credentialId = this.selectedCredentialId();
return {
provider,
model,
...(this.providerNeedsCredential() && credentialId ? { credentialId } : {}),
...(populatedPhases ? { phaseModels: populatedPhases } : {})
};
}
private sessionRequest() {
@ -501,7 +643,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
}
private backendErrorMessage(error: unknown): string {
const value = error as { error?: unknown; message?: unknown };
const value = error as { error?: unknown; message?: unknown; status?: unknown };
const payload = value?.error;
if (typeof payload === 'string' && payload.trim()) return payload;
if (payload && typeof payload === 'object') {
@ -510,10 +652,20 @@ export class FlowAssistant implements OnInit, OnDestroy {
if (typeof body[key] === 'string' && body[key].trim()) return body[key] as string;
}
}
const statusMessage = CREDENTIAL_ERROR_MESSAGES[Number(value?.status)];
if (statusMessage) return statusMessage;
if (typeof value?.message === 'string' && value.message.trim()) return value.message;
return FlowAssistant.STANDARD_ASSISTANT_ERROR;
}
private isInternalProvider(provider: string): boolean {
return provider.trim().toLowerCase() === 'internalollama';
}
private sameProvider(left: string, right: string): boolean {
return left.trim().toLowerCase() === right.trim().toLowerCase();
}
private async openSession(
promptToSend?: string,
flowKey = this.activeFlowKey ?? this.resolveFlowKey(this.currentFlow()?.id ?? null)

View File

@ -376,7 +376,7 @@ export class TitleToolbar {
},
error: err => {
console.error('Save failed', err);
this.showSnackbar(err instanceof Error ? err.message : 'Errore durante il salvataggio', 'error');
this.showSnackbar(err instanceof Error ? err.message : 'Unable to save the flow', 'error');
}
});
}
@ -399,7 +399,7 @@ export class TitleToolbar {
error: (err) => {
this.executeLoading.set(false);
console.error('Create execution failed', err);
this.showSnackbar('Errore durante la creazione dell\'esecuzione', 'error');
this.showSnackbar('Unable to create the execution', 'error');
}
});
}