diff --git a/src/app/services/assistant/assistant-call.spec.ts b/src/app/services/assistant/assistant-call.spec.ts index 5301392..3dcf7ad 100644 --- a/src/app/services/assistant/assistant-call.spec.ts +++ b/src/app/services/assistant/assistant-call.spec.ts @@ -104,6 +104,7 @@ describe('AssistantCallService', () => { const selection = { provider: 'OpenAI', model: 'gpt-oss:20b', + credentialId: 'credential-1', phaseModels: { planningModel: 'planning-model' } }; const actionRequests = [ diff --git a/src/app/services/assistant/assistant-call.ts b/src/app/services/assistant/assistant-call.ts index 179cab8..4d24509 100644 --- a/src/app/services/assistant/assistant-call.ts +++ b/src/app/services/assistant/assistant-call.ts @@ -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']) }; } diff --git a/src/app/shared/flow-assistant/flow-assistant.css b/src/app/shared/flow-assistant/flow-assistant.css index c3d6310..1072109 100644 --- a/src/app/shared/flow-assistant/flow-assistant.css +++ b/src/app/shared/flow-assistant/flow-assistant.css @@ -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; + } } diff --git a/src/app/shared/flow-assistant/flow-assistant.html b/src/app/shared/flow-assistant/flow-assistant.html index 7f4bdeb..0958cf8 100644 --- a/src/app/shared/flow-assistant/flow-assistant.html +++ b/src/app/shared/flow-assistant/flow-assistant.html @@ -42,8 +42,8 @@
-

Configurazione assistente

-

Le credenziali dei provider sono gestite dal backend.

+

Assistant model

+

Provider credentials are managed by the backend.

@@ -51,11 +51,11 @@ [checked]="useDefaultConfiguration()" [disabled]="configurationLocked() || assistantBusy()" (change)="setUseDefaultConfiguration($event.checked)"> - Usa configurazione predefinita + Use default configuration @if (configurationLocked()) { -

La configurazione LLM è fissata per questa sessione.

+

The LLM configuration is locked for this session.

} @if (!useDefaultConfiguration()) { @@ -70,12 +70,12 @@ {{ provider }} } - @if (providersLoading()) { Caricamento provider... } + @if (providersLoading()) { Loading providers... } @if (providersError()) { {{ providersError() }} } - Modello + Model {{ model }} } - @if (modelsLoading()) { Caricamento modelli... } + @if (modelsLoading()) { Loading models... } @if (modelsError()) { {{ modelsError() }} } + @if (providerNeedsCredential()) { +
+ + Credential + + @for (credential of compatibleCredentials(); track credential.id) { + {{ credential.label }} + } + + @if (credentialsLoading()) { Loading credentials... } + @if (credentialsError()) { {{ credentialsError() }} } + + @if (!credentialsLoading() && !compatibleCredentials().length) { + + } +
+ } + @if (advancedModelsOpen()) { @@ -98,21 +121,21 @@ Planning - Modello principale + Main model @for (model of models(); track model) { {{ model }} } JSON - Modello principale + Main model @for (model of models(); track model) { {{ model }} } Repair - Modello principale + Main model @for (model of models(); track model) { {{ model }} } @@ -121,6 +144,75 @@ }
+
+
+
+

Provider credentials

+

Keys are stored in the vault and are never displayed.

+
+ +
+ + @if (credentialsPanelOpen()) { +
+ @if (credentialsLoading()) { +

Loading credentials...

+ } @else if (!credentials().length) { +

No credentials saved yet.

+ } @else { + @for (credential of credentials(); track credential.id) { +
+
+ {{ credential.label }} +

{{ credential.provider }} · {{ credential.active ? 'Active' : 'Inactive' }}

+ @if (credential.description) {

{{ credential.description }}

} +

Key configured · last used: {{ credential.lastUsedAt || 'never' }}

+
+
+ + +
+
+ } + } +
+ + @if (!credentialFormOpen()) { + + } @else { +
+ + Label + + + + Provider + + @for (provider of providers(); track provider) { {{ provider }} } + + + + Description (optional) + + + + {{ editingCredentialId() ? 'New value (to rotate)' : 'API key' }} + + {{ editingCredentialId() ? 'Leave empty to keep the current key.' : 'The value will not be shown again after saving.' }} + + @if (credentialsError()) {

{{ credentialsError() }}

} + +
+ } + } +
+ @if (assistantErrorMessage()) {

{{ assistantErrorMessage() }}

diff --git a/src/app/shared/flow-assistant/flow-assistant.ts b/src/app/shared/flow-assistant/flow-assistant.ts index fcae636..97e5594 100644 --- a/src/app/shared/flow-assistant/flow-assistant.ts +++ b/src/app/shared/flow-assistant/flow-assistant.ts @@ -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; 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(undefined); + readonly credentials = signal([]); + readonly credentialsLoading = signal(false); + readonly credentialsError = signal(null); + readonly selectedCredentialId = signal(''); + readonly credentialsPanelOpen = signal(false); + readonly credentialFormOpen = signal(false); + readonly credentialSaving = signal(false); + readonly editingCredentialId = signal(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, 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) diff --git a/src/app/shared/title-toolbar/title-toolbar.ts b/src/app/shared/title-toolbar/title-toolbar.ts index ef01342..f2a8843 100644 --- a/src/app/shared/title-toolbar/title-toolbar.ts +++ b/src/app/shared/title-toolbar/title-toolbar.ts @@ -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'); } }); }