diff --git a/packages/backend/src/registries/CommandRegistry.ts b/packages/backend/src/registries/CommandRegistry.ts new file mode 100644 index 000000000..8468f3835 --- /dev/null +++ b/packages/backend/src/registries/CommandRegistry.ts @@ -0,0 +1,119 @@ +/********************************************************************** + * Copyright (C) 2026 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ +import { type Disposable, commands } from '@podman-desktop/api'; +import type { InferenceServer } from '@shared/models/IInference'; +import type { Conversation } from '@shared/models/IPlaygroundMessage'; +import type { PlaygroundV2Manager } from '../managers/playgroundV2Manager'; +import type { InferenceManager } from '../managers/inference/inferenceManager'; +import { + MODEL_NAVIGATE_COMMAND, + PLAYGROUND_NAVIGATE_COMMAND, + RECIPE_NAVIGATE_COMMAND, + SERVICE_NAVIGATE_COMMAND, +} from './NavigationRegistry'; +import type { CatalogManager } from '../managers/catalogManager'; +import type { ModelsManager } from '../managers/modelsManager'; +import type { ModelInfo } from '@shared/models/IModelInfo'; +import type { Recipe } from '@shared/models/IRecipe'; + +export const LIST_RESOURCES_COMMAND = 'ai-lab.command.list-resources'; + +interface NavigationResourceInfo { + title: string; + icon: string; + command: string; + id: string; + hidden: boolean; +} +export class CommandRegistry implements Disposable { + #disposables: Disposable[] = []; + + constructor( + private inferenceManager: InferenceManager, + private playgroundManager: PlaygroundV2Manager, + private modelsManager: ModelsManager, + private catalogManager: CatalogManager, + ) {} + + init(): void { + this.#disposables.push(commands.registerCommand(LIST_RESOURCES_COMMAND, this.listResources.bind(this))); + } + + dispose(): void { + this.#disposables.forEach(disposable => disposable.dispose()); + } + + /** + * Lists all available resources (running inference servers, created playgrounds) + */ + public async listResources(): Promise { + // Build resource list + const resources: NavigationResourceInfo[] = []; + + const inferenceServers: InferenceServer[] = this.inferenceManager.getServers(); + inferenceServers.forEach((server: InferenceServer) => { + const containerId = server.container.containerId; + const model: ModelInfo | undefined = server.models?.[0]; + const modelName: string = model ? `(${model.name})` : ''; + const serviceName: string = `${containerId.substring(0, 12)}`; + + resources.push({ + title: `Service > ${serviceName}${modelName}`, + icon: 'fas fa-rocket', + command: SERVICE_NAVIGATE_COMMAND, + id: containerId, + hidden: false, + }); + }); + + const conversations: Conversation[] = this.playgroundManager.getConversations(); + conversations.forEach((conversation: Conversation) => { + resources.push({ + title: `Playground > ${conversation.name}`, + icon: 'fas fa-message', + command: PLAYGROUND_NAVIGATE_COMMAND, + id: conversation.id, + hidden: false, + }); + }); + + const models: ModelInfo[] = this.modelsManager.getModelsInfo(); + models.forEach((model: ModelInfo) => { + resources.push({ + title: `Model > ${model.name}`, + icon: 'fas fa-book-open', + command: MODEL_NAVIGATE_COMMAND, + id: model.id, + hidden: true, + }); + }); + + const recipes: Recipe[] = this.catalogManager.getRecipes(); + recipes.forEach((recipe: Recipe) => { + resources.push({ + title: `Recipe > ${recipe.name}`, + icon: 'fas fa-book-open', + command: RECIPE_NAVIGATE_COMMAND, + id: recipe.id, + hidden: true, + }); + }); + + return resources; + } +} diff --git a/packages/backend/src/registries/NavigationRegistry.ts b/packages/backend/src/registries/NavigationRegistry.ts index dbe69cf5a..052b670dc 100644 --- a/packages/backend/src/registries/NavigationRegistry.ts +++ b/packages/backend/src/registries/NavigationRegistry.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (C) 2024 Red Hat, Inc. + * Copyright (C) 2024-2026 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,12 +19,48 @@ import { type Disposable, navigation, type WebviewPanel, commands } from '@podma import { MSG_NAVIGATION_ROUTE_UPDATE } from '@shared/Messages'; import type { RpcExtension } from '@shared/messages/MessageProxy'; +// Route identifiers and commands +export const DASHBOARD_ROUTE = 'dashboard'; +export const DASHBOARD_NAVIGATE_COMMAND = 'ai-lab.navigation.dashboard'; + +export const RECIPES_ROUTE = 'recipes'; +export const RECIPES_NAVIGATE_COMMAND = 'ai-lab.navigation.recipes'; + export const RECIPE_START_ROUTE = 'recipe.start'; export const RECIPE_START_NAVIGATE_COMMAND = 'ai-lab.navigation.recipe.start'; +export const APPLICATIONS_ROUTE = 'applications'; +export const APPLICATIONS_NAVIGATE_COMMAND = 'ai-lab.navigation.applications'; + +export const MODELS_ROUTE = 'models'; +export const MODELS_NAVIGATE_COMMAND = 'ai-lab.navigation.models'; + +export const PLAYGROUNDS_ROUTE = 'playgrounds'; +export const PLAYGROUNDS_NAVIGATE_COMMAND = 'ai-lab.navigation.playgrounds'; + +export const SERVICES_ROUTE = 'services'; +export const SERVICES_NAVIGATE_COMMAND = 'ai-lab.navigation.services'; + export const INFERENCE_CREATE_ROUTE = 'inference.create'; export const INFERENCE_CREATE_NAVIGATE_COMMAND = 'ai-lab.navigation.inference.create'; +export const LLAMASTACK_ROUTE = 'llamastack'; +export const LLAMASTACK_NAVIGATE_COMMAND = 'ai-lab.navigation.llamastack'; + +export const LOCAL_SERVER_ROUTE = 'localserver'; +export const LOCAL_SERVER_NAVIGATE_COMMAND = 'ai-lab.navigation.localserver'; + +export const ABOUT_INSTRUCTLAB_ROUTE = 'about-instructlab'; +export const ABOUT_INSTRUCTLAB_NAVIGATE_COMMAND = 'ai-lab.navigation.about-instructlab'; + +export const INSTRUCTLAB_ROUTE = 'instructlab'; +export const INSTRUCTLAB_NAVIGATE_COMMAND = 'ai-lab.navigation.instructlab'; + +export const SERVICE_NAVIGATE_COMMAND = 'ai-lab.navigation.service'; +export const PLAYGROUND_NAVIGATE_COMMAND = 'ai-lab.navigation.playground'; +export const RECIPE_NAVIGATE_COMMAND = 'ai-lab.navigation.recipe'; +export const MODEL_NAVIGATE_COMMAND = 'ai-lab.navigation.model'; + export class NavigationRegistry implements Disposable { #disposables: Disposable[] = []; #route: string | undefined = undefined; @@ -51,6 +87,112 @@ export class NavigationRegistry implements Disposable { commands.registerCommand(INFERENCE_CREATE_NAVIGATE_COMMAND, this.navigateToInferenceCreate.bind(this)), ); this.#disposables.push(navigation.register(INFERENCE_CREATE_ROUTE, INFERENCE_CREATE_NAVIGATE_COMMAND)); + + // Register Dashboard + this.#disposables.push(commands.registerCommand(DASHBOARD_NAVIGATE_COMMAND, this.navigateToDashboard.bind(this))); + this.#disposables.push( + navigation.register(DASHBOARD_ROUTE, DASHBOARD_NAVIGATE_COMMAND, { + title: 'Dashboard', + icon: 'fas fa-house', + }), + ); + + // Register Recipes Catalog + this.#disposables.push(commands.registerCommand(RECIPES_NAVIGATE_COMMAND, this.navigateToRecipes.bind(this))); + this.#disposables.push( + navigation.register(RECIPES_ROUTE, RECIPES_NAVIGATE_COMMAND, { + title: 'Recipe Catalog', + icon: 'fas fa-book-open', + }), + ); + + // Register Applications + this.#disposables.push( + commands.registerCommand(APPLICATIONS_NAVIGATE_COMMAND, this.navigateToApplications.bind(this)), + ); + this.#disposables.push( + navigation.register(APPLICATIONS_ROUTE, APPLICATIONS_NAVIGATE_COMMAND, { + title: 'Running', + icon: 'fas fa-server', + }), + ); + + // Register Models Catalog + this.#disposables.push(commands.registerCommand(MODELS_NAVIGATE_COMMAND, this.navigateToModels.bind(this))); + this.#disposables.push( + navigation.register(MODELS_ROUTE, MODELS_NAVIGATE_COMMAND, { + title: 'Catalog', + icon: 'fas fa-book-open', + }), + ); + + // Register Services + this.#disposables.push(commands.registerCommand(SERVICES_NAVIGATE_COMMAND, this.navigateToServices.bind(this))); + this.#disposables.push( + navigation.register(SERVICES_ROUTE, SERVICES_NAVIGATE_COMMAND, { + title: 'Services', + icon: 'fas fa-rocket', + }), + ); + + // Register Playgrounds + this.#disposables.push( + commands.registerCommand(PLAYGROUNDS_NAVIGATE_COMMAND, this.navigateToPlaygrounds.bind(this)), + ); + this.#disposables.push( + navigation.register(PLAYGROUNDS_ROUTE, PLAYGROUNDS_NAVIGATE_COMMAND, { + title: 'Playgrounds', + icon: 'fas fa-message', + }), + ); + + // Register Llama Stack + this.#disposables.push(commands.registerCommand(LLAMASTACK_NAVIGATE_COMMAND, this.navigateToLlamaStack.bind(this))); + this.#disposables.push( + navigation.register(LLAMASTACK_ROUTE, LLAMASTACK_NAVIGATE_COMMAND, { + title: 'Llama Stack', + icon: 'fas fa-rocket', + }), + ); + + // Register Local Server + this.#disposables.push( + commands.registerCommand(LOCAL_SERVER_NAVIGATE_COMMAND, this.navigateToLocalServer.bind(this)), + ); + this.#disposables.push( + navigation.register(LOCAL_SERVER_ROUTE, LOCAL_SERVER_NAVIGATE_COMMAND, { + title: 'Local Server', + icon: 'fas fa-gear', + }), + ); + + // Register About InstructLab + this.#disposables.push( + commands.registerCommand(ABOUT_INSTRUCTLAB_NAVIGATE_COMMAND, this.navigateToAboutInstructLab.bind(this)), + ); + this.#disposables.push( + navigation.register(ABOUT_INSTRUCTLAB_ROUTE, ABOUT_INSTRUCTLAB_NAVIGATE_COMMAND, { + title: 'About InstructLab', + icon: 'fas fa-info-circle', + }), + ); + + // Register InstructLab + this.#disposables.push( + commands.registerCommand(INSTRUCTLAB_NAVIGATE_COMMAND, this.navigateToInstructLab.bind(this)), + ); + this.#disposables.push( + navigation.register(INSTRUCTLAB_ROUTE, INSTRUCTLAB_NAVIGATE_COMMAND, { + title: 'Try InstructLab', + icon: 'fas fa-circle-down', + }), + ); + + // Register navigation commands for resources + this.#disposables.push(commands.registerCommand(SERVICE_NAVIGATE_COMMAND, this.navigateToService.bind(this))); + this.#disposables.push(commands.registerCommand(PLAYGROUND_NAVIGATE_COMMAND, this.navigateToPlayground.bind(this))); + this.#disposables.push(commands.registerCommand(RECIPE_NAVIGATE_COMMAND, this.navigateToRecipe.bind(this))); + this.#disposables.push(commands.registerCommand(MODEL_NAVIGATE_COMMAND, this.navigateToModel.bind(this))); } /** @@ -73,11 +215,67 @@ export class NavigationRegistry implements Disposable { this.panel.reveal(); } + public async navigateToDashboard(): Promise { + return this.updateRoute('/'); + } + + public async navigateToRecipes(): Promise { + return this.updateRoute('/recipes'); + } + public async navigateToRecipeStart(recipeId: string, trackingId: string): Promise { return this.updateRoute(`/recipe/${recipeId}/start?trackingId=${trackingId}`); } + public async navigateToApplications(): Promise { + return this.updateRoute('/applications'); + } + + public async navigateToModels(): Promise { + return this.updateRoute('/models'); + } + + public async navigateToPlaygrounds(): Promise { + return this.updateRoute('/playgrounds'); + } + + public async navigateToServices(): Promise { + return this.updateRoute('/services'); + } + public async navigateToInferenceCreate(trackingId: string): Promise { return this.updateRoute(`/service/create?trackingId=${trackingId}`); } + + public async navigateToLlamaStack(): Promise { + return this.updateRoute('/llamastack/try'); + } + + public async navigateToInstructLab(): Promise { + return this.updateRoute('/instructlab/try'); + } + + public async navigateToAboutInstructLab(): Promise { + return this.updateRoute('/about-instructlab'); + } + + public async navigateToLocalServer(): Promise { + return this.updateRoute('/local-server'); + } + + public async navigateToService(id: string): Promise { + return this.updateRoute(`/service/${id}`); + } + + public async navigateToPlayground(id: string): Promise { + return this.updateRoute(`/playground/${id}`); + } + + public async navigateToRecipe(id: string): Promise { + return this.updateRoute(`/recipe/${id}`); + } + + public async navigateToModel(id: string): Promise { + return this.updateRoute(`/model/${id}`); + } } diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index f124161ad..0a7e9ee49 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -1,5 +1,5 @@ /********************************************************************** - * Copyright (C) 2024-2025 Red Hat, Inc. + * Copyright (C) 2024-2026 Red Hat, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -65,6 +65,7 @@ import { LlamaStackManager } from './managers/llama-stack/llamaStackManager'; import { OpenVINO } from './workers/provider/OpenVINO'; import { McpServerManager } from './managers/playground/McpServerManager'; import os from 'node:os'; +import { CommandRegistry } from './registries/CommandRegistry'; export class Studio { readonly #extensionContext: ExtensionContext; @@ -102,6 +103,7 @@ export class Studio { #configurationRegistry: ConfigurationRegistry | undefined; #gpuManager: GPUManager | undefined; #navigationRegistry: NavigationRegistry | undefined; + #commandRegistry: CommandRegistry | undefined; #instructlabManager: InstructlabManager | undefined; #llamaStackManager: LlamaStackManager | undefined; @@ -387,6 +389,18 @@ export class Studio { this.#snippetManager = new SnippetManager(this.#rpcExtension, this.#telemetry); this.#snippetManager.init(); + /** + * The command registry is used to register and managed the commands of the extension + */ + this.#commandRegistry = new CommandRegistry( + this.#inferenceManager, + this.#playgroundManager, + this.#modelsManager, + this.#catalogManager, + ); + this.#commandRegistry.init(); + this.#extensionContext.subscriptions.push(this.#commandRegistry); + /** * The StudioApiImpl is the implementation of our API between backend and frontend */ diff --git a/packages/frontend/src/pages/InferenceServerDetails.spec.ts b/packages/frontend/src/pages/InferenceServerDetails.spec.ts index da8683840..8d150e37a 100644 --- a/packages/frontend/src/pages/InferenceServerDetails.spec.ts +++ b/packages/frontend/src/pages/InferenceServerDetails.spec.ts @@ -261,7 +261,7 @@ test('invalid container id should redirect to services page', async () => { containerId: 'fakeContainerId', }); - expect(gotoSpy).toHaveBeenCalledWith('/services'); + await vi.waitFor(() => expect(gotoSpy).toHaveBeenCalledWith('/services'), 1_500); }); test('ensure dummyContainerId is visible', async () => { diff --git a/packages/frontend/src/pages/InferenceServerDetails.svelte b/packages/frontend/src/pages/InferenceServerDetails.svelte index 6a6cb06e1..8c44384ca 100644 --- a/packages/frontend/src/pages/InferenceServerDetails.svelte +++ b/packages/frontend/src/pages/InferenceServerDetails.svelte @@ -30,7 +30,10 @@ interface Props { let { containerId }: Props = $props(); -let service: InferenceServer | undefined = $state(); +let servers: InferenceServer[] = $derived($inferenceServers); +let service: InferenceServer | undefined = $derived( + servers.find(server => server.container.containerId === containerId), +); let selectedLanguage: string = $state('curl'); let variants: LanguageVariant[] = $derived( @@ -172,12 +175,12 @@ function copySnippet(): void { } onMount(() => { - return inferenceServers.subscribe(servers => { - service = servers.find(server => server.container.containerId === containerId); + const timeout = setTimeout(() => { if (!service) { router.goto('/services'); } - }); + }, 1000); + return (): void => clearTimeout(timeout); }); export function goToUpPage(): void {