Let the copilot take a typed model too
Making Gemini's model list empty left the assistant's own picker with an empty select and no way out: it has its own provider and model controls, not the schema-driven field machinery, so the rule that an unlistable catalogue has to be typed never reached it. Worse, it called the empty list "No models are available for the selected provider" - wrong twice, since the models exist and the message hid the fix. It now asks the same /open endpoint, and the four model controls - the main one and the three per-phase overrides, which were disabled outright while the list was empty - take a typed name. The question is asked alongside the list rather than derived from it: an open provider is exactly the one whose list comes back empty, so "nothing to show" and "nothing to offer" must not collapse into one answer. Closed on failure, which leaves a select the user can see is broken. The URL derivation moved to a shared helper. Both callers suffix the path while keeping the query string, and the provider rides in that query - a second copy of that detail is where the two would have drifted apart. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
a6a811549a
commit
8f8d2cb9f7
|
|
@ -15,6 +15,12 @@ export abstract class AssistantCallServiceBase {
|
|||
|
||||
abstract listModels(retrieverUrlTemplate: string, provider: string): Observable<string[]>;
|
||||
|
||||
/**
|
||||
* Whether the provider's model list is incomplete, so the model has to be typed. True for the
|
||||
* hosted providers, whose catalogues cannot be enumerated without a credential.
|
||||
*/
|
||||
abstract areModelsOpen(retrieverUrlTemplate: string, provider: string): Observable<boolean>;
|
||||
|
||||
abstract createSession(request: AssistantSessionRequest): Observable<AssistantSessionState>;
|
||||
|
||||
abstract getSession(sessionId: string): Observable<AssistantSessionState>;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ import { AssistantCallServiceBase } from './assistant-call.base';
|
|||
export class AssistantCallServiceFake extends AssistantCallServiceBase {
|
||||
private readonly models = ['llama3.1:8b', 'qwen2.5:7b', 'mistral:7b'];
|
||||
private readonly providers = ['InternalOllama', 'OpenAI'];
|
||||
/**
|
||||
* The hosted providers list nothing, exactly as the real ones do: their catalogues cannot be
|
||||
* enumerated without a credential, so the model has to be typed.
|
||||
*/
|
||||
private readonly openModelProviders = new Set(['OpenAI', 'Anthropic', 'Gemini']);
|
||||
private readonly sessions = new Map<string, AssistantSessionState>();
|
||||
private readonly calls = new Map<string, { sessionId: string; intent: AssistantIntent; result: AssistantFlowActionResult }>();
|
||||
|
||||
|
|
@ -34,7 +39,11 @@ export class AssistantCallServiceFake extends AssistantCallServiceBase {
|
|||
}
|
||||
|
||||
override listModels(_retrieverUrlTemplate: string, _provider: string): Observable<string[]> {
|
||||
return of(this.models);
|
||||
return of(this.openModelProviders.has(_provider) ? [] : this.models);
|
||||
}
|
||||
|
||||
override areModelsOpen(_retrieverUrlTemplate: string, provider: string): Observable<boolean> {
|
||||
return of(this.openModelProviders.has(provider));
|
||||
}
|
||||
|
||||
override createSession(request: AssistantSessionRequest): Observable<AssistantSessionState> {
|
||||
|
|
|
|||
|
|
@ -105,6 +105,22 @@ describe('AssistantCallService', () => {
|
|||
await expect(models).resolves.toEqual(['gpt-oss:20b']);
|
||||
});
|
||||
|
||||
it('asks whether a provider can be enumerated on the sibling endpoint, keeping the query', async () => {
|
||||
// The suffix goes on the path, not the end of the URL: the provider has to survive it, or the
|
||||
// answer would be about no provider at all.
|
||||
const open = firstValueFrom(service.areModelsOpen('/retriever/LLM/models?provider={provider}', 'Gemini'));
|
||||
httpMock.expectOne(`${environment.apiUrl}/retriever/LLM/models/open?provider=Gemini`).flush(true);
|
||||
await expect(open).resolves.toBe(true);
|
||||
});
|
||||
|
||||
it('treats anything but a plain true as a closed list', async () => {
|
||||
// Closed is the safe answer: a select the user can see is empty beats a text box that silently
|
||||
// accepts a model the provider does not have.
|
||||
const open = firstValueFrom(service.areModelsOpen('/retriever/LLM/models?provider={provider}', 'InternalOllama'));
|
||||
httpMock.expectOne(`${environment.apiUrl}/retriever/LLM/models/open?provider=InternalOllama`).flush('yes');
|
||||
await expect(open).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it('sends llmSelection only when supplied when creating a session', async () => {
|
||||
const defaultSession = firstValueFrom(service.createSession({}));
|
||||
const defaultSessionRequest = httpMock.expectOne(`${environment.apiUrl}/assistant/sessions`);
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import {
|
|||
AssistantValidationIssue
|
||||
} from '@models/assistant';
|
||||
import { environment } from '@environment';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { map, Observable, of } from 'rxjs';
|
||||
import { appendRetrieverQuestion } from '@services/retriever/retriever-url';
|
||||
import { AssistantCallServiceBase } from './assistant-call.base';
|
||||
|
||||
export class AssistantCallService extends AssistantCallServiceBase {
|
||||
|
|
@ -39,6 +40,15 @@ export class AssistantCallService extends AssistantCallServiceBase {
|
|||
.pipe(map((raw) => mapModelList(raw)));
|
||||
}
|
||||
|
||||
override areModelsOpen(retrieverUrlTemplate: string, provider: string): Observable<boolean> {
|
||||
const retrieverUrl = retrieverUrlTemplate.replace('{provider}', encodeURIComponent(provider));
|
||||
const openUrl = appendRetrieverQuestion(retrieverUrl, 'open');
|
||||
if (!openUrl) return of(false);
|
||||
return this.http
|
||||
.get<unknown>(resolveAssistantUrl(openUrl))
|
||||
.pipe(map((raw) => raw === true));
|
||||
}
|
||||
|
||||
override createSession(request: AssistantSessionRequest): Observable<AssistantSessionState> {
|
||||
return this.http
|
||||
.post<unknown>(`${environment.apiUrl}/assistant/sessions`, request)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,10 @@ export class AssistantService {
|
|||
return this.assistantCall.listModels(retrieverUrlTemplate, provider);
|
||||
}
|
||||
|
||||
areModelsOpen(retrieverUrlTemplate: string, provider: string) {
|
||||
return this.assistantCall.areModelsOpen(retrieverUrlTemplate, provider);
|
||||
}
|
||||
|
||||
createSession(request: AssistantSessionRequest) {
|
||||
return this.assistantCall.createSession(request);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,9 +3,7 @@ import { inject } from "@angular/core";
|
|||
import { environment } from "@environment";
|
||||
import { map, Observable, of } from "rxjs";
|
||||
import { FieldRetrieverCallServiceBase, RetrieverStructuredItem } from "./field-retriever-call.base";
|
||||
|
||||
/** The boolean questions a retriever answers about a field, each on its own sibling endpoint. */
|
||||
type RetrieverQuestion = 'required' | 'open';
|
||||
import { appendRetrieverQuestion, RetrieverQuestion } from "./retriever-url";
|
||||
|
||||
export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
||||
private readonly http = inject(HttpClient);
|
||||
|
|
@ -40,7 +38,7 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
|||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<boolean> {
|
||||
const requiredRetrieverUrl = this.appendSuffix(retrieverUrl, 'required');
|
||||
const requiredRetrieverUrl = appendRetrieverQuestion(retrieverUrl, 'required');
|
||||
const { url, params } = this.resolveRequest(blockType, key, context, requiredRetrieverUrl, 'required');
|
||||
return this.http.get<boolean>(url, { params });
|
||||
}
|
||||
|
|
@ -51,7 +49,7 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
|||
context?: Record<string, string>,
|
||||
retrieverUrl?: string | null
|
||||
): Observable<boolean> {
|
||||
const openRetrieverUrl = this.appendSuffix(retrieverUrl, 'open');
|
||||
const openRetrieverUrl = appendRetrieverQuestion(retrieverUrl, 'open');
|
||||
const { url, params } = this.resolveRequest(blockType, key, context, openRetrieverUrl, 'open');
|
||||
return this.http.get<boolean>(url, { params });
|
||||
}
|
||||
|
|
@ -123,17 +121,6 @@ export class FieldRetrieverCallService extends FieldRetrieverCallServiceBase {
|
|||
return { url, params };
|
||||
}
|
||||
|
||||
/**
|
||||
* The yes/no endpoints sit beside the values one, so their URL is the configured retriever URL
|
||||
* with a suffix - no second URL to declare on the field.
|
||||
*/
|
||||
private appendSuffix(rawUrl: string | null | undefined, suffix: RetrieverQuestion): string | null {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
|
||||
const [path, queryString] = rawUrl.split('?', 2);
|
||||
const normalizedPath = path.endsWith(`/${suffix}`) ? path : `${path}/${suffix}`;
|
||||
return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
|
||||
}
|
||||
|
||||
private normalizeStringList(raw: unknown): string[] {
|
||||
if (Array.isArray(raw)) {
|
||||
return this.normalizeStringArray(raw);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import { appendRetrieverQuestion } from './retriever-url';
|
||||
|
||||
/**
|
||||
* Two callers derive these URLs - the schema-driven fields and the assistant's own model picker -
|
||||
* so the rule lives in one place. The query string is the part that breaks if it does not.
|
||||
*/
|
||||
describe('appendRetrieverQuestion', () => {
|
||||
it('suffixes the path and keeps the query, which carries the provider', () => {
|
||||
expect(appendRetrieverQuestion('/retriever/LLM/models?provider=Gemini', 'open'))
|
||||
.toBe('/retriever/LLM/models/open?provider=Gemini');
|
||||
expect(appendRetrieverQuestion('/retriever/LLM/models', 'required'))
|
||||
.toBe('/retriever/LLM/models/required');
|
||||
});
|
||||
|
||||
it('does not suffix twice, so a derived URL survives a second pass', () => {
|
||||
expect(appendRetrieverQuestion('/retriever/LLM/models/open?provider=Gemini', 'open'))
|
||||
.toBe('/retriever/LLM/models/open?provider=Gemini');
|
||||
});
|
||||
|
||||
it('has no answer for a field that declares no retriever URL', () => {
|
||||
expect(appendRetrieverQuestion(null, 'open')).toBeNull();
|
||||
expect(appendRetrieverQuestion(undefined, 'open')).toBeNull();
|
||||
expect(appendRetrieverQuestion(' ', 'open')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
/** The boolean questions a retriever answers about a field, each on its own sibling endpoint. */
|
||||
export type RetrieverQuestion = 'required' | 'open';
|
||||
|
||||
/**
|
||||
* The endpoint that answers one of those questions, derived from the values URL by suffixing its
|
||||
* path - so a field declares one URL and nothing has to stay in sync.
|
||||
*
|
||||
* <p>Shared rather than reimplemented per caller: the query string has to survive the suffix, and
|
||||
* a second copy of that detail is where the two would drift apart.
|
||||
*/
|
||||
export function appendRetrieverQuestion(
|
||||
rawUrl: string | null | undefined,
|
||||
question: RetrieverQuestion
|
||||
): string | null {
|
||||
if (typeof rawUrl !== 'string' || rawUrl.trim().length === 0) return null;
|
||||
const [path, queryString] = rawUrl.split('?', 2);
|
||||
const normalizedPath = path.endsWith(`/${question}`) ? path : `${path}/${question}`;
|
||||
return queryString ? `${normalizedPath}?${queryString}` : normalizedPath;
|
||||
}
|
||||
|
|
@ -76,6 +76,15 @@
|
|||
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Model</mat-label>
|
||||
@if (modelsOpen()) {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
placeholder="Type the model name"
|
||||
[value]="selectedModel()"
|
||||
[disabled]="configurationLocked() || assistantBusy() || !selectedProvider()"
|
||||
(input)="selectModel($any($event.target).value)" />
|
||||
} @else {
|
||||
<mat-select
|
||||
[value]="selectedModel()"
|
||||
[disabled]="configurationLocked() || assistantBusy() || !selectedProvider() || modelsLoading()"
|
||||
|
|
@ -84,7 +93,8 @@
|
|||
<mat-option [value]="model">{{ model }}</mat-option>
|
||||
}
|
||||
</mat-select>
|
||||
@if (modelsLoading()) { <mat-hint>Loading models...</mat-hint> }
|
||||
}
|
||||
@if (modelsLoading() && !modelsOpen()) { <mat-hint>Loading models...</mat-hint> }
|
||||
@if (modelsError()) { <mat-error>{{ modelsError() }}</mat-error> }
|
||||
</mat-form-field>
|
||||
</div>
|
||||
|
|
@ -120,24 +130,54 @@
|
|||
<div class="assistant-settings-fields assistant-phase-fields">
|
||||
<mat-form-field appearance="outline">
|
||||
<mat-label>Planning</mat-label>
|
||||
@if (modelsOpen()) {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
placeholder="Main model"
|
||||
[value]="phaseModels()?.planningModel ?? ''"
|
||||
[disabled]="configurationLocked() || assistantBusy()"
|
||||
(input)="setPhaseModel('planningModel', $any($event.target).value)" />
|
||||
} @else {
|
||||
<mat-select [value]="phaseModels()?.planningModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('planningModel', $event.value)">
|
||||
<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>
|
||||
@if (modelsOpen()) {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
placeholder="Main model"
|
||||
[value]="phaseModels()?.jsonModel ?? ''"
|
||||
[disabled]="configurationLocked() || assistantBusy()"
|
||||
(input)="setPhaseModel('jsonModel', $any($event.target).value)" />
|
||||
} @else {
|
||||
<mat-select [value]="phaseModels()?.jsonModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('jsonModel', $event.value)">
|
||||
<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>
|
||||
@if (modelsOpen()) {
|
||||
<input
|
||||
matInput
|
||||
type="text"
|
||||
placeholder="Main model"
|
||||
[value]="phaseModels()?.repairModel ?? ''"
|
||||
[disabled]="configurationLocked() || assistantBusy()"
|
||||
(input)="setPhaseModel('repairModel', $any($event.target).value)" />
|
||||
} @else {
|
||||
<mat-select [value]="phaseModels()?.repairModel ?? ''" [disabled]="configurationLocked() || assistantBusy() || !models().length" (selectionChange)="setPhaseModel('repairModel', $event.value)">
|
||||
<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>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,111 @@
|
|||
import { TestBed } from '@angular/core/testing';
|
||||
import { AssistantService } from '@services/assistant/assistant';
|
||||
import { Authorization } from '@services/authorization/authorization';
|
||||
import { VaultService } from '@services/vault/vault';
|
||||
import { AssistantSessionStore } from '@stores/assistant-session-store';
|
||||
import { EditorStateHolder } from '@stores/flow-editor';
|
||||
import { of } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { FlowAssistant } from './flow-assistant';
|
||||
|
||||
/**
|
||||
* The copilot picks a provider and a model through its own controls, not through the schema-driven
|
||||
* field machinery, so the rule that a hosted catalogue has to be typed rather than picked has to
|
||||
* be honoured here too - and its "no models available" message must not fire on a provider whose
|
||||
* models were never listable in the first place.
|
||||
*/
|
||||
describe('FlowAssistant model selection', () => {
|
||||
const MODELS_URL = '/retriever/LLM/models?provider={provider}';
|
||||
|
||||
function build(options: { models: string[]; open: boolean }) {
|
||||
const assistant = {
|
||||
getConfig: vi.fn(() => of({
|
||||
defaultProvider: '',
|
||||
defaultModel: '',
|
||||
availableProvidersRetrieverUrl: '/retriever/LLM/providers',
|
||||
availableModelsRetrieverUrl: MODELS_URL,
|
||||
defaultPhaseModels: {},
|
||||
providerCatalogUrl: '/llm/providers'
|
||||
})),
|
||||
listProviders: vi.fn(() => of(['InternalOllama', 'Gemini'])),
|
||||
listModels: vi.fn(() => of(options.models)),
|
||||
areModelsOpen: vi.fn(() => of(options.open)),
|
||||
listProviderCatalog: vi.fn(() => of([]))
|
||||
};
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
{ provide: AssistantService, useValue: assistant },
|
||||
{ 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({ availableModelsRetrieverUrl: MODELS_URL });
|
||||
return { component, assistant };
|
||||
}
|
||||
|
||||
afterEach(() => TestBed.resetTestingModule());
|
||||
|
||||
it('takes a typed model when the provider cannot be enumerated, and reports no error', () => {
|
||||
// Gemini lists nothing because its catalogue needs a credential. Calling that "no models are
|
||||
// available" would be wrong twice: the models exist, and the message hides the text field.
|
||||
const { component, assistant } = build({ models: [], open: true });
|
||||
|
||||
component.selectProvider('Gemini');
|
||||
|
||||
expect(assistant.areModelsOpen).toHaveBeenCalledWith(MODELS_URL, 'Gemini');
|
||||
expect(component.modelsOpen()).toBe(true);
|
||||
expect(component.modelsError()).toBeNull();
|
||||
});
|
||||
|
||||
it('still reports an empty list from a provider that was supposed to have one', () => {
|
||||
// Our own Ollama is asked what it has, so nothing back means it is unreachable - a text field
|
||||
// there would hide a broken provider.
|
||||
const { component } = build({ models: [], open: false });
|
||||
|
||||
component.selectProvider('InternalOllama');
|
||||
|
||||
expect(component.modelsOpen()).toBe(false);
|
||||
expect(component.modelsError()).toBe('No models are available for the selected provider.');
|
||||
});
|
||||
|
||||
it('keeps the select when the provider lists its models', () => {
|
||||
const { component } = build({ models: ['llama3.2:3b'], open: false });
|
||||
|
||||
component.selectProvider('InternalOllama');
|
||||
|
||||
expect(component.modelsOpen()).toBe(false);
|
||||
expect(component.models()).toEqual(['llama3.2:3b']);
|
||||
expect(component.modelsError()).toBeNull();
|
||||
});
|
||||
|
||||
it('forgets that a provider was open when another one is chosen', () => {
|
||||
const { component } = build({ models: [], open: true });
|
||||
component.selectProvider('Gemini');
|
||||
expect(component.modelsOpen()).toBe(true);
|
||||
|
||||
// The next provider decides for itself; carrying the flag over would offer a text field
|
||||
// against a closed list.
|
||||
component.assistantConfig.set(null);
|
||||
component.selectProvider('InternalOllama');
|
||||
|
||||
expect(component.modelsOpen()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -62,6 +62,11 @@ export class FlowAssistant implements OnInit, OnDestroy {
|
|||
readonly providersError = signal<string | null>(null);
|
||||
readonly modelsLoading = signal(false);
|
||||
readonly modelsError = signal<string | null>(null);
|
||||
/**
|
||||
* The provider cannot be asked what it offers, so the model is typed rather than picked. An
|
||||
* empty list is not enough to tell: for our own Ollama it means the provider is unreachable.
|
||||
*/
|
||||
readonly modelsOpen = signal(false);
|
||||
readonly sessionLoading = signal(false);
|
||||
readonly requestPending = signal(false);
|
||||
readonly prompt = signal('');
|
||||
|
|
@ -293,6 +298,7 @@ export class FlowAssistant implements OnInit, OnDestroy {
|
|||
this.selectedCredentialId.set('');
|
||||
this.models.set([]);
|
||||
this.modelsError.set(null);
|
||||
this.modelsOpen.set(false);
|
||||
if (provider) void this.loadModels(provider);
|
||||
if (provider) void this.loadCredentials();
|
||||
this.persistSnapshot();
|
||||
|
|
@ -607,13 +613,27 @@ export class FlowAssistant implements OnInit, OnDestroy {
|
|||
if (!config?.availableModelsRetrieverUrl) return;
|
||||
this.modelsLoading.set(true);
|
||||
this.modelsError.set(null);
|
||||
|
||||
// Asked alongside the list and not derived from it: an open provider is exactly the one whose
|
||||
// list comes back empty, so "nothing to show" and "nothing to offer" must not be the same
|
||||
// answer. Closed on failure, which keeps a select the user can see is broken.
|
||||
this.assistant.areModelsOpen(config.availableModelsRetrieverUrl, provider).pipe(take(1)).subscribe({
|
||||
next: (open) => {
|
||||
this.modelsOpen.set(open);
|
||||
if (open) this.modelsError.set(null);
|
||||
},
|
||||
error: () => this.modelsOpen.set(false)
|
||||
});
|
||||
|
||||
this.assistant.listModels(config.availableModelsRetrieverUrl, provider).pipe(
|
||||
take(1),
|
||||
finalize(() => this.modelsLoading.set(false))
|
||||
).subscribe({
|
||||
next: (models) => {
|
||||
this.models.set(models);
|
||||
if (!models.length) this.modelsError.set('No models are available for the selected provider.');
|
||||
if (!models.length && !this.modelsOpen()) {
|
||||
this.modelsError.set('No models are available for the selected provider.');
|
||||
}
|
||||
},
|
||||
error: (err) => this.modelsError.set(this.backendErrorMessage(err))
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue