diff --git a/automation/playwright/README.md b/automation/playwright/README.md index f035edc1..2351b714 100644 --- a/automation/playwright/README.md +++ b/automation/playwright/README.md @@ -80,6 +80,12 @@ The resource-administration inventory renders every management list and creation npm run capture -- --plan examples/inspect-resource-administration.capture-plan.json --output .work/resource-administration ``` +The local Ollama extension backs a fully offline create/update/delete journey for a model provider, model profile, and runtime profile: + +```powershell +npm run capture -- --plan examples/exercise-model-administration.capture-plan.json --output .work/model-administration +``` + The solution-discovery video Flow and Entry are captured with: ```powershell diff --git a/automation/playwright/examples/exercise-model-administration.capture-plan.json b/automation/playwright/examples/exercise-model-administration.capture-plan.json new file mode 100644 index 00000000..54fd09cf --- /dev/null +++ b/automation/playwright/examples/exercise-model-administration.capture-plan.json @@ -0,0 +1,18 @@ +{ + "journey": "exercise-model-administration", + "input": { + "providerName": "playwright-ollama", + "providerDisplayName": "Playwright Ollama", + "profileName": "playwright-model", + "profileDisplayName": "Playwright model", + "runtimeName": "playwright-runtime", + "runtimeDisplayName": "Playwright runtime" + }, + "checkpoints": [ + "model-administration-provider", + "model-administration-runtime", + "model-administration-profile", + "model-administration-updated", + "model-administration-deleted" + ] +} diff --git a/automation/playwright/src/contracts/checkpoints.ts b/automation/playwright/src/contracts/checkpoints.ts index 60d1dfcd..74e1e885 100644 --- a/automation/playwright/src/contracts/checkpoints.ts +++ b/automation/playwright/src/contracts/checkpoints.ts @@ -74,4 +74,11 @@ export const Checkpoints = { profileEditors: 'resource-administration-profile-editors', protectedEditors: 'resource-administration-protected-editors', }, + modelAdministration: { + provider: 'model-administration-provider', + runtime: 'model-administration-runtime', + profile: 'model-administration-profile', + updated: 'model-administration-updated', + deleted: 'model-administration-deleted', + }, } as const; diff --git a/automation/playwright/src/contracts/test-ids.ts b/automation/playwright/src/contracts/test-ids.ts index e1244b03..e4d029d2 100644 --- a/automation/playwright/src/contracts/test-ids.ts +++ b/automation/playwright/src/contracts/test-ids.ts @@ -73,6 +73,32 @@ export const TestIds = { triggerDetails: 'resource-trigger-details', triggerEditor: 'resource-trigger-editor', }, + modelAdministration: { + providerForm: 'model-provider-form', + providerName: 'model-provider-name', + providerDisplayName: 'model-provider-display-name', + providerExtension: 'model-provider-extension', + providerContribution: 'model-provider-contribution', + providerSave: 'model-provider-save', + providerTest: 'model-provider-test', + providerStatus: 'model-provider-status', + providerDelete: 'model-provider-delete', + providerDeleteConfirm: 'model-provider-delete-dialog-confirm', + profileForm: 'model-profile-form', + profileName: 'model-profile-name', + profileDisplayName: 'model-profile-display-name', + profileProvider: 'model-profile-provider', + profileModel: 'model-profile-model', + profileSave: 'model-profile-save', + profileDelete: 'model-profile-delete', + profileDeleteConfirm: 'model-profile-delete-dialog-confirm', + runtimeForm: 'runtime-profile-form', + runtimeName: 'runtime-profile-name', + runtimeDisplayName: 'runtime-profile-display-name', + runtimeSave: 'runtime-profile-save', + runtimeDelete: 'runtime-profile-delete', + runtimeDeleteConfirm: 'runtime-profile-delete-dialog-confirm', + }, login: { username: 'login-username', password: 'login-password', diff --git a/automation/playwright/src/coverage/application-surfaces.ts b/automation/playwright/src/coverage/application-surfaces.ts index 909595d2..7f612b80 100644 --- a/automation/playwright/src/coverage/application-surfaces.ts +++ b/automation/playwright/src/coverage/application-surfaces.ts @@ -108,6 +108,15 @@ function partialResourceAdministration( }; } +function coveredModelAdministration(id: string, source: string, routes: readonly string[], fixtureKeys: readonly string[] = []): ApplicationSurface { + return { + id, host: 'console', source, routes, fixtureKeys, + pageObject: 'src/pages/model-administration.page.ts', + journey: 'exercise-model-administration', + specification: 'tests/model-administration.spec.ts', coverage: 'covered', + }; +} + export const applicationSurfaces: readonly ApplicationSurface[] = [ { id: 'console-home', host: 'console', source: `${consolePages}/Home.razor`, routes: ['/'], fixtureKeys: [], @@ -170,12 +179,12 @@ export const applicationSurfaces: readonly ApplicationSurface[] = [ coveredFlow('task-flow-run-details', `${consolePages}/TaskFlowRunDetails.razor`, ['/tasks/{TaskId:guid}/flowruns/{RunId}'], ['taskId', 'runId'], 'inspect-flow-observability'), partialResourceAdministration('entries', `${consolePages}/Entries.razor`, ['/entries']), - partialResourceAdministration('model-profiles', `${consolePages}/ModelProfiles.razor`, ['/modelprofiles']), - partialResourceAdministration('model-profile-editor', `${consolePages}/ModelProfileEditor.razor`, ['/modelprofiles/new', '/modelprofiles/{Name}'], ['name']), - partialResourceAdministration('model-providers', `${consolePages}/ModelProviders.razor`, ['/modelproviders']), - partialResourceAdministration('model-provider-details', `${consolePages}/ModelProviderDetails.razor`, ['/modelproviders/new', '/modelproviders/{Name}'], ['name']), - partialResourceAdministration('runtime-profiles', `${consolePages}/RuntimeProfiles.razor`, ['/runtimeprofiles']), - partialResourceAdministration('runtime-profile-editor', `${consolePages}/RuntimeProfileEditor.razor`, ['/runtimeprofiles/new', '/runtimeprofiles/{Name}'], ['name']), + coveredModelAdministration('model-profiles', `${consolePages}/ModelProfiles.razor`, ['/modelprofiles']), + coveredModelAdministration('model-profile-editor', `${consolePages}/ModelProfileEditor.razor`, ['/modelprofiles/new', '/modelprofiles/{Name}'], ['name']), + coveredModelAdministration('model-providers', `${consolePages}/ModelProviders.razor`, ['/modelproviders']), + coveredModelAdministration('model-provider-details', `${consolePages}/ModelProviderDetails.razor`, ['/modelproviders/new', '/modelproviders/{Name}'], ['name']), + coveredModelAdministration('runtime-profiles', `${consolePages}/RuntimeProfiles.razor`, ['/runtimeprofiles']), + coveredModelAdministration('runtime-profile-editor', `${consolePages}/RuntimeProfileEditor.razor`, ['/runtimeprofiles/new', '/runtimeprofiles/{Name}'], ['name']), partialResourceAdministration('secrets', `${consolePages}/Secrets.razor`, ['/secrets']), partialResourceAdministration('secret-editor', `${consolePages}/SecretEditor.razor`, ['/secrets/new', '/secrets/{Name}'], ['name']), partialResourceAdministration('vaults', `${consolePages}/Vaults.razor`, ['/vaults']), diff --git a/automation/playwright/src/journeys/exercise-model-administration.journey.ts b/automation/playwright/src/journeys/exercise-model-administration.journey.ts new file mode 100644 index 00000000..bee86a91 --- /dev/null +++ b/automation/playwright/src/journeys/exercise-model-administration.journey.ts @@ -0,0 +1,42 @@ +import { Checkpoints } from '../contracts/checkpoints.js'; +import type { ModelAdministrationDefinition } from '../pages/model-administration.page.js'; +import type { Journey } from './journey.js'; +import { prepareConsoleJourney } from './prepare-console.journey.js'; + +export type ExerciseModelAdministrationInput = ModelAdministrationDefinition & { + username?: string; + password?: string; + workspaceName?: string; +}; + +export const exerciseModelAdministration: Journey = async (context, input) => { + validate(input); + await prepareConsoleJourney(context, input); + const administration = context.pages.modelAdministration; + + const provider = await administration.createProvider(context.consoleUrl, input); + await context.checkpoint({ name: Checkpoints.modelAdministration.provider, page: context.pages.page, target: provider }); + await administration.updateProviderDisplayName(`${input.providerDisplayName} updated`); + + const runtime = await administration.createRuntime(context.consoleUrl, input); + await context.checkpoint({ name: Checkpoints.modelAdministration.runtime, page: context.pages.page, target: runtime }); + await administration.updateRuntimeDisplayName(`${input.runtimeDisplayName} updated`); + + const profile = await administration.createProfile(context.consoleUrl, input); + await context.checkpoint({ name: Checkpoints.modelAdministration.profile, page: context.pages.page, target: profile }); + await administration.updateProfileDisplayName(`${input.profileDisplayName} updated`); + await context.checkpoint({ name: Checkpoints.modelAdministration.updated, page: context.pages.page, target: profile }); + + await administration.deleteProfile(); + await administration.openRuntime(context.consoleUrl, input.runtimeName); + await administration.deleteRuntime(); + await administration.openProvider(context.consoleUrl, input.providerName); + await administration.deleteProvider(); + await context.checkpoint({ name: Checkpoints.modelAdministration.deleted, page: context.pages.page }); +}; + +function validate(input: ExerciseModelAdministrationInput): void { + for (const key of ['providerName', 'providerDisplayName', 'profileName', 'profileDisplayName', 'runtimeName', 'runtimeDisplayName'] as const) { + if (!input[key]?.trim()) throw new Error(`Model administration input '${key}' is required.`); + } +} diff --git a/automation/playwright/src/journeys/registry.ts b/automation/playwright/src/journeys/registry.ts index 51f6275c..d88d2026 100644 --- a/automation/playwright/src/journeys/registry.ts +++ b/automation/playwright/src/journeys/registry.ts @@ -10,6 +10,7 @@ import { createPackProject, type CreatePackProjectInput } from './create-pack-pr import { inspectFlowObservability, type InspectFlowObservabilityInput } from './inspect-flow-observability.journey.js'; import { inspectConsoleAdministration, type InspectConsoleAdministrationInput } from './inspect-console-administration.journey.js'; import { inspectResourceAdministration, type InspectResourceAdministrationInput } from './inspect-resource-administration.journey.js'; +import { exerciseModelAdministration, type ExerciseModelAdministrationInput } from './exercise-model-administration.journey.js'; import type { Journey } from './journey.js'; export const journeys: Readonly>>> = { @@ -25,4 +26,5 @@ export const journeys: Readonly>> 'inspect-flow-observability': (context, input) => inspectFlowObservability(context, input as unknown as InspectFlowObservabilityInput), 'inspect-console-administration': (context, input) => inspectConsoleAdministration(context, input as unknown as InspectConsoleAdministrationInput), 'inspect-resource-administration': (context, input) => inspectResourceAdministration(context, input as unknown as InspectResourceAdministrationInput), + 'exercise-model-administration': (context, input) => exerciseModelAdministration(context, input as unknown as ExerciseModelAdministrationInput), }; diff --git a/automation/playwright/src/pages/model-administration.page.ts b/automation/playwright/src/pages/model-administration.page.ts new file mode 100644 index 00000000..4e0897a3 --- /dev/null +++ b/automation/playwright/src/pages/model-administration.page.ts @@ -0,0 +1,150 @@ +import { type Locator, type Page } from '@playwright/test'; +import { TestIds } from '../contracts/test-ids.js'; +import { fillAndCommit } from './controls.js'; + +export interface ModelAdministrationDefinition { + providerName: string; + providerDisplayName: string; + profileName: string; + profileDisplayName: string; + runtimeName: string; + runtimeDisplayName: string; +} + +export class ModelAdministrationPage { + public constructor(private readonly page: Page) {} + + public async createProvider(consoleUrl: string, definition: ModelAdministrationDefinition): Promise { + await this.open(consoleUrl, '/modelproviders/new', TestIds.resourceAdministration.modelProviderEditor); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.providerName), definition.providerName); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.providerDisplayName), definition.providerDisplayName); + await selectMatchingOption(this.page.getByTestId(TestIds.modelAdministration.providerExtension), 'ollama-extension'); + await selectFirstOption(this.page.getByTestId(TestIds.modelAdministration.providerContribution)); + await this.page.getByTestId(TestIds.modelAdministration.providerSave).click(); + await this.page.waitForURL(url => url.pathname === `/modelproviders/${definition.providerName}`); + const marker = this.page.getByTestId(TestIds.resourceAdministration.modelProviderEditor); + await marker.waitFor({ state: 'visible' }); + await this.waitForInteractive(TestIds.modelAdministration.providerForm); + const testResponse = this.waitForApiResponse('POST', `/api/modelproviders/${encodeURIComponent(definition.providerName)}/test`); + await this.page.getByTestId(TestIds.modelAdministration.providerTest).click(); + await testResponse; + await this.page.getByTestId(TestIds.modelAdministration.providerStatus).waitFor({ state: 'visible' }); + return marker; + } + + public async createRuntime(consoleUrl: string, definition: ModelAdministrationDefinition): Promise { + await this.open(consoleUrl, '/runtimeprofiles/new', TestIds.resourceAdministration.runtimeProfileEditor); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.runtimeName), definition.runtimeName); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.runtimeDisplayName), definition.runtimeDisplayName); + await this.page.getByTestId(TestIds.modelAdministration.runtimeSave).click(); + await this.page.waitForURL(url => url.pathname === `/runtimeprofiles/${definition.runtimeName}`); + await this.waitForInteractive(TestIds.modelAdministration.runtimeForm); + return this.page.getByTestId(TestIds.resourceAdministration.runtimeProfileEditor); + } + + public async createProfile(consoleUrl: string, definition: ModelAdministrationDefinition): Promise { + await this.open(consoleUrl, '/modelprofiles/new', TestIds.resourceAdministration.modelProfileEditor); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.profileName), definition.profileName); + await fillAndCommit(this.page.getByTestId(TestIds.modelAdministration.profileDisplayName), definition.profileDisplayName); + await selectMatchingOption(this.page.getByTestId(TestIds.modelAdministration.profileProvider), definition.providerName); + await selectFirstOption(this.page.getByTestId(TestIds.modelAdministration.profileModel)); + await this.page.getByTestId(TestIds.modelAdministration.profileSave).click(); + await this.page.waitForURL(url => url.pathname === `/modelprofiles/${definition.profileName}`); + await this.waitForInteractive(TestIds.modelAdministration.profileForm); + return this.page.getByTestId(TestIds.resourceAdministration.modelProfileEditor); + } + + public async updateProviderDisplayName(value: string): Promise { + await this.updateDisplayName( + this.page.getByTestId(TestIds.modelAdministration.providerDisplayName), + this.page.getByTestId(TestIds.modelAdministration.providerSave), + value, + '/api/modelproviders/', + ); + } + + public async updateRuntimeDisplayName(value: string): Promise { + await this.updateDisplayName( + this.page.getByTestId(TestIds.modelAdministration.runtimeDisplayName), + this.page.getByTestId(TestIds.modelAdministration.runtimeSave), + value, + '/api/runtimeprofiles/', + ); + } + + public async updateProfileDisplayName(value: string): Promise { + await this.updateDisplayName( + this.page.getByTestId(TestIds.modelAdministration.profileDisplayName), + this.page.getByTestId(TestIds.modelAdministration.profileSave), + value, + '/api/modelprofiles/', + ); + } + + public async deleteProfile(): Promise { + await this.deleteCurrent(TestIds.modelAdministration.profileDelete, TestIds.modelAdministration.profileDeleteConfirm, '/modelprofiles'); + } + + public async openRuntime(consoleUrl: string, name: string): Promise { + await this.open(consoleUrl, `/runtimeprofiles/${name}`, TestIds.resourceAdministration.runtimeProfileEditor); + } + + public async deleteRuntime(): Promise { + await this.deleteCurrent(TestIds.modelAdministration.runtimeDelete, TestIds.modelAdministration.runtimeDeleteConfirm, '/runtimeprofiles'); + } + + public async openProvider(consoleUrl: string, name: string): Promise { + await this.open(consoleUrl, `/modelproviders/${name}`, TestIds.resourceAdministration.modelProviderEditor); + } + + public async deleteProvider(): Promise { + await this.deleteCurrent(TestIds.modelAdministration.providerDelete, TestIds.modelAdministration.providerDeleteConfirm, '/modelproviders'); + } + + private async updateDisplayName(input: Locator, save: Locator, value: string, apiPath: string): Promise { + await fillAndCommit(input, value); + const response = this.waitForApiResponse('PUT', apiPath); + await save.click(); + await response; + } + + private async waitForApiResponse(method: string, apiPath: string): Promise { + const response = await this.page.waitForResponse(candidate => + candidate.request().method() === method && new URL(candidate.url()).pathname.includes(apiPath)); + if (!response.ok()) { + throw new Error(`${method} ${new URL(response.url()).pathname} returned HTTP ${response.status()}.`); + } + } + + private async waitForInteractive(testId: string): Promise { + await this.page.locator(`[data-testid="${testId}"][data-interactive="true"]`).waitFor({ state: 'visible' }); + } + + private async deleteCurrent(deleteButtonId: string, confirmButtonId: string, listPath: string): Promise { + await this.page.getByTestId(deleteButtonId).click(); + await this.page.getByTestId(confirmButtonId).click(); + await this.page.waitForURL(url => url.pathname === listPath); + } + + private async open(consoleUrl: string, path: string, marker: string): Promise { + const response = await this.page.goto(`${consoleUrl}${path}`, { waitUntil: 'domcontentloaded' }); + if (!response?.ok()) throw new Error(`Model administration route '${path}' returned HTTP ${response?.status() ?? 'no response'}.`); + await this.page.getByTestId(marker).waitFor({ state: 'visible' }); + } +} + +async function selectMatchingOption(select: Locator, text: string): Promise { + const option = select.locator('option').filter({ hasText: text }).first(); + await option.waitFor({ state: 'attached' }); + const value = await option.getAttribute('value'); + if (!value) throw new Error(`No selectable option contains '${text}'.`); + await select.selectOption(value); +} + +async function selectFirstOption(select: Locator): Promise { + const option = select.locator('option:not([value=""]):not([disabled])').first(); + await option.waitFor({ state: 'attached' }); + const value = await option.getAttribute('value'); + if (!value) throw new Error('No selectable option is available.'); + await select.selectOption(value); +} diff --git a/automation/playwright/src/pages/product.pages.ts b/automation/playwright/src/pages/product.pages.ts index a515015d..f9693bcd 100644 --- a/automation/playwright/src/pages/product.pages.ts +++ b/automation/playwright/src/pages/product.pages.ts @@ -12,6 +12,7 @@ import { FlowObservabilityPage } from './flow-observability.page.js'; import { ConsoleAdministrationPage } from './console-administration.page.js'; import { OperationsPage } from './operations.page.js'; import { ResourceAdministrationPage } from './resource-administration.page.js'; +import { ModelAdministrationPage } from './model-administration.page.js'; export class ProductPages { public readonly agentEditor: AgentEditorPage; @@ -26,6 +27,7 @@ export class ProductPages { public readonly consoleAdministration: ConsoleAdministrationPage; public readonly operations: OperationsPage; public readonly resourceAdministration: ResourceAdministrationPage; + public readonly modelAdministration: ModelAdministrationPage; public constructor(public readonly page: Page) { this.agentEditor = new AgentEditorPage(page); @@ -40,6 +42,7 @@ export class ProductPages { this.consoleAdministration = new ConsoleAdministrationPage(page); this.operations = new OperationsPage(page); this.resourceAdministration = new ResourceAdministrationPage(page); + this.modelAdministration = new ModelAdministrationPage(page); } public async ensureTheme(theme: 'light' | 'dark'): Promise { diff --git a/automation/playwright/tests/model-administration.spec.ts b/automation/playwright/tests/model-administration.spec.ts new file mode 100644 index 00000000..8fccb970 --- /dev/null +++ b/automation/playwright/tests/model-administration.spec.ts @@ -0,0 +1,19 @@ +import { exerciseModelAdministration, type ExerciseModelAdministrationInput } from '../src/journeys/exercise-model-administration.journey.js'; +import { ignoreCheckpoints } from '../src/journeys/journey.js'; +import { ProductPages } from '../src/pages/product.pages.js'; +import { expect, test } from '../src/fixtures/test.js'; + +const definition: ExerciseModelAdministrationInput = { + providerName: 'playwright-ollama', + providerDisplayName: 'Playwright Ollama', + profileName: 'playwright-model', + profileDisplayName: 'Playwright model', + runtimeName: 'playwright-runtime', + runtimeDisplayName: 'Playwright runtime', +}; + +test('model provider, model profile, and runtime profile complete their lifecycle @smoke', async ({ page, product }) => { + const pages = new ProductPages(page); + await exerciseModelAdministration({ ...product, pages, checkpoint: ignoreCheckpoints }, definition); + await expect(page).toHaveURL(/\/modelproviders$/); +}); diff --git a/src/Agentstration.Web.Components/ConfirmationDialog.razor b/src/Agentstration.Web.Components/ConfirmationDialog.razor index beaaa866..a7721945 100644 --- a/src/Agentstration.Web.Components/ConfirmationDialog.razor +++ b/src/Agentstration.Web.Components/ConfirmationDialog.razor @@ -1,10 +1,10 @@ @if (IsOpen) { } -@code { [Parameter] public bool IsOpen { get; set; } [Parameter] public string Title { get; set; } = "Confirm action"; [Parameter] public string Message { get; set; } = "This action cannot be undone."; [Parameter] public string ConfirmLabel { get; set; } = "Confirm"; [Parameter] public EventCallback OnConfirm { get; set; } [Parameter] public EventCallback OnCancel { get; set; } private Task ConfirmAsync() => OnConfirm.InvokeAsync(); private Task CancelAsync() => OnCancel.InvokeAsync(); } +@code { [Parameter] public bool IsOpen { get; set; } [Parameter] public string Title { get; set; } = "Confirm action"; [Parameter] public string Message { get; set; } = "This action cannot be undone."; [Parameter] public string ConfirmLabel { get; set; } = "Confirm"; [Parameter] public string? TestId { get; set; } [Parameter] public EventCallback OnConfirm { get; set; } [Parameter] public EventCallback OnCancel { get; set; } private Task ConfirmAsync() => OnConfirm.InvokeAsync(); private Task CancelAsync() => OnCancel.InvokeAsync(); } diff --git a/src/Agentstration.Web/Components/Pages/ModelProfileEditor.razor b/src/Agentstration.Web/Components/Pages/ModelProfileEditor.razor index e9513440..cdd10dec 100644 --- a/src/Agentstration.Web/Components/Pages/ModelProfileEditor.razor +++ b/src/Agentstration.Web/Components/Pages/ModelProfileEditor.razor @@ -10,7 +10,7 @@ @(IsNew ? T("CreateModelProfile") : $"{Name} · {T("ModelProfile")}") · Agentstration - @if (!IsNew) { }@T("Back") + @if (!IsNew) { }@T("Back") @if (loading || model is null) @@ -23,13 +23,13 @@ else @if (!string.IsNullOrWhiteSpace(warning)) {
@warning
} @if (!IsNew && usages.Count > 0) {
@T("ChangingProfileAffects", AgentCount(usages.Count))
} - +

@T("Identity")

@T("ProfileResource")

@if (resolution is not null) { }
- + - +
@@ -37,8 +37,8 @@ else

@T("ModelSelection")

@T("ProviderAndModel")

- - + +
@if (models.Count == 0 && !loadingModels && !string.IsNullOrWhiteSpace(model.ProviderName)) {

@T("NoModelsReported")

}
@@ -105,7 +105,7 @@ else }
-
@T("Cancel")
+
@T("Cancel")
@if (!IsNew) @@ -140,7 +140,7 @@ else } } - + @code { [Parameter] public string? Name { get; set; } @@ -153,7 +153,7 @@ else private ModelProfileEditorModel? model; private IReadOnlyList providers = []; private IReadOnlyList models = []; private IReadOnlyList extensions = []; private IReadOnlyList usages = []; private ModelProfileResolutionResponse? resolution; private ExtensionOptionsEditorModel? optionEditor; private bool rawOptions; - private AgentstrationApiException? error; private string? etag; private string? warning; private bool loading = true; private bool loadingModels; private bool saving; private bool diagnosing; private bool confirmDelete; + private AgentstrationApiException? error; private string? etag; private string? warning; private bool loading = true; private bool loadingModels; private bool saving; private bool diagnosing; private bool confirmDelete; private bool interactive; private ResourceScopeRef? selectedScope; private bool IsNew => string.IsNullOrWhiteSpace(Name); private ResourceNamespace SelectedNamespace => ResourceNamespace.Parse(Namespace); @@ -168,6 +168,7 @@ else } } protected override Task OnParametersSetAsync() => LoadAsync(); + protected override void OnAfterRender(bool firstRender) { if (!firstRender) return; interactive = true; StateHasChanged(); } private async Task LoadAsync() { loading = true; error = null; warning = null; diff --git a/src/Agentstration.Web/Components/Pages/ModelProviderDetails.razor b/src/Agentstration.Web/Components/Pages/ModelProviderDetails.razor index cd94d072..b9bb4e45 100644 --- a/src/Agentstration.Web/Components/Pages/ModelProviderDetails.razor +++ b/src/Agentstration.Web/Components/Pages/ModelProviderDetails.razor @@ -9,7 +9,7 @@ @(IsNew ? T("CreateProvider") : $"{Name} · {T("ModelProvider")}") · Agentstration - @if (!IsNew) { @T("CreateModelProfile") }@T("Back") + @if (!IsNew) { @T("CreateModelProfile") }@T("Back") @if (model is null) { } @@ -17,18 +17,18 @@ else { @if (error is not null) { } @if (usages.Count > 0) {
@T("ProviderReferencedBy", ProfileCount(usages.Count))
} - +

@T("Identity")

@T("ProviderResource")

@if (status is not null) { }
- + - + - - + +
-

@T("Connection")

@T("ExtensionOwnedEndpoint")

@T("ConnectionHelp")

@if (!IsNew) { }
@if (SelectedExtension is { } selected) {
@T("AepEndpoint")
@selected.Endpoint
@T("ExtensionId")
@(selected.Extension?.Id ?? T("NotObserved"))
@T("Source")
@selected.DiscoverySource
}@if (status is not null) {

@T("LastTest", status.CheckedAt.ToLocalTime().ToString("g")) : @StatusLabel(status.Status)@if (!string.IsNullOrWhiteSpace(status.Details)) { — @status.Details }

}
-
@T("Cancel")
+

@T("Connection")

@T("ExtensionOwnedEndpoint")

@T("ConnectionHelp")

@if (!IsNew) { }
@if (SelectedExtension is { } selected) {
@T("AepEndpoint")
@selected.Endpoint
@T("ExtensionId")
@(selected.Extension?.Id ?? T("NotObserved"))
@T("Source")
@selected.DiscoverySource
}@if (status is not null) {

@T("LastTest", status.CheckedAt.ToLocalTime().ToString("g")) : @StatusLabel(status.Status)@if (!string.IsNullOrWhiteSpace(status.Details)) { — @status.Details }

}
+
@T("Cancel")
@if (!IsNew) @@ -42,7 +42,7 @@ else } } - + @code { [Parameter] public string? Name { get; set; } @@ -59,11 +59,13 @@ else private ModelProviderEditorModel? model; private IReadOnlyList usages = []; private IReadOnlyList models = []; private IReadOnlyList extensions = []; private ModelProviderStatusResponse? status; private AgentstrationApiException? error; private string? modelsError; private string? etag; private bool saving; private bool testing; private bool confirmDelete; private ResourceScopeRef? selectedScope; + private bool interactive; private bool IsNew => string.IsNullOrWhiteSpace(Name); private ExtensionResponse? SelectedExtension => model is null ? null : extensions.SingleOrDefault(value => ExtensionKey(value) == model.ExtensionId); private IReadOnlyList Contributions => SelectedExtension?.Contributions.Where(value => value.Kind == "model-provider").ToArray() ?? []; private ResourceNamespace SelectedNamespace => ResourceNamespace.Parse(Namespace); protected override Task OnParametersSetAsync() => LoadAsync(); + protected override void OnAfterRender(bool firstRender) { if (!firstRender) return; interactive = true; StateHasChanged(); } private async Task LoadAsync() { error = null; modelsError = null; try { extensions = await ExtensionsClient.GetExtensionsAsync(cancellation.Token); if (IsNew) { var extensionNamespace = SuggestedExtensionNamespace ?? SelectedNamespace.Value; model = new ModelProviderEditorModel { Namespace = SelectedNamespace.Value, ExtensionId = string.IsNullOrWhiteSpace(SuggestedExtension) ? string.Empty : $"{extensionNamespace}:{SuggestedExtension}", ContributionId = SuggestedContributionId ?? string.Empty, DisplayName = SuggestedDisplayName ?? string.Empty }; return; } var snapshot = await Client.GetModelProviderAsync(SelectedNamespace, Name!, cancellation.Token); model = ModelProviderEditorModel.FromResource(snapshot.Value); selectedScope = snapshot.Value.ScopeRef; etag = snapshot.ETag; usages = (await Client.GetModelProviderUsagesAsync(SelectedNamespace, Name!, cancellation.Token)).Value; await LoadObservedAsync(); } catch (AgentstrationApiException exception) { error = exception; model ??= new ModelProviderEditorModel { Namespace = SelectedNamespace.Value }; } } private void ExtensionChanged(ChangeEventArgs args) { if (model is null) return; model.ExtensionId = args.Value?.ToString() ?? string.Empty; if (!Contributions.Any(value => value.Id == model.ContributionId)) model.ContributionId = Contributions.Count == 1 ? Contributions[0].Id : string.Empty; } private async Task LoadObservedAsync() { if (model is null) return; var @namespace = ResourceNamespace.Parse(model.Namespace); try { status = await Client.GetProviderStatusAsync(@namespace, model.Name, cancellation.Token); if (status.Status == "available") models = await Client.GetProviderModelsAsync(@namespace, model.Name, cancellation.Token); else models = []; } catch (AgentstrationApiException exception) { modelsError = exception.Message; models = []; } } diff --git a/src/Agentstration.Web/Components/Pages/RuntimeProfileEditor.razor b/src/Agentstration.Web/Components/Pages/RuntimeProfileEditor.razor index a189afd9..a83c8c63 100644 --- a/src/Agentstration.Web/Components/Pages/RuntimeProfileEditor.razor +++ b/src/Agentstration.Web/Components/Pages/RuntimeProfileEditor.razor @@ -8,7 +8,7 @@ @(IsNew ? T("CreateProfile") : $"{Name} · {T("RuntimeProfile")}") · Agentstration - @if (!IsNew) { }@T("Back") + @if (!IsNew) { }@T("Back") @if (model is null) { } @@ -16,10 +16,10 @@ else { @if (error is not null) {
@T("UnableToSave")@error.Message
} @if (usages.Count > 0) {
@T("ProfileUsedWarning", DeploymentCount(usages.Count))
} - +

@T("Identity")

@T("RuntimeResource")

- + @@ -30,12 +30,12 @@ else

@T("Adapter")

@T("RuntimeOptions")

@T("Advanced")
-
@T("Cancel")
+
@T("Cancel")
@if (!IsNew && usages.Count > 0) {

@T("Deployments")

@usages.Count
    @foreach (var usage in usages) {
  • @usage.Name · @usage.Environment
  • }
} } - + @code { [Parameter] public string? Name { get; set; } @@ -46,11 +46,12 @@ else private string ToolInvocationLabel(RuntimeToolInvocationMode value) => Localizer[$"ToolInvocation.{value}"]; private string StreamingLabel(StreamingMode value) => Localizer[$"Streaming.{value}"]; private readonly CancellationTokenSource cancellation = new(); - private RuntimeProfileEditorModel? model; private IReadOnlyList usages = []; private string? etag; private AgentstrationApiException? error; private bool saving; private bool confirmDelete; + private RuntimeProfileEditorModel? model; private IReadOnlyList usages = []; private string? etag; private AgentstrationApiException? error; private bool saving; private bool confirmDelete; private bool interactive; private ResourceScopeRef? selectedScope; private bool IsNew => string.IsNullOrWhiteSpace(Name); private ResourceNamespace SelectedNamespace => ResourceNamespace.Parse(Namespace); protected override Task OnParametersSetAsync() => LoadAsync(); + protected override void OnAfterRender(bool firstRender) { if (!firstRender) return; interactive = true; StateHasChanged(); } private async Task LoadAsync() { error = null; if (IsNew) { model = new RuntimeProfileEditorModel { Namespace = SelectedNamespace.Value }; return; } try { var snapshot = await Client.GetRuntimeProfileAsync(SelectedNamespace, Name!, cancellation.Token); model = RuntimeProfileEditorModel.FromResource(snapshot.Value); selectedScope = snapshot.Value.ScopeRef; etag = snapshot.ETag; usages = (await Client.GetRuntimeProfileUsagesAsync(SelectedNamespace, model.Name, cancellation.Token)).Value; } catch (AgentstrationApiException exception) { error = exception; model ??= new RuntimeProfileEditorModel { Namespace = SelectedNamespace.Value }; } } private async Task SaveAsync() { if (model is null || saving) return; saving = true; error = null; try { var @namespace = ResourceNamespace.Parse(model.Namespace); var snapshot = IsNew ? await Client.CreateRuntimeProfileAsync(model.ToCreateRequest() with { ScopeRef = selectedScope }, cancellation.Token) : await Client.UpdateRuntimeProfileAsync(@namespace, model.Name, model.ToPutRequest(), etag!, cancellation.Token); Notifications.Add(new NotificationItem(Guid.NewGuid(), IsNew ? T("CreatedNotification") : T("UpdatedNotification"), snapshot.Value.Name, DateTimeOffset.Now, UiStatus.Success)); Navigation.NavigateTo(DetailsUrl(snapshot.Value.Namespace, snapshot.Value.Name)); } catch (ArgumentException exception) { error = new AgentstrationApiException(exception.Message, Guid.NewGuid().ToString("N")); } catch (AgentstrationApiException exception) { error = exception; } finally { saving = false; } } private void RequestDelete() { if (usages.Count == 0) confirmDelete = true; else error = new AgentstrationApiException(T("ProfileUsedError", DeploymentCount(usages.Count)), Guid.NewGuid().ToString("N")); }