From 6112bc26a122d417e602719bf24da43fef6c6a5e Mon Sep 17 00:00:00 2001 From: axel7083 <42176370+axel7083@users.noreply.github.com> Date: Tue, 19 Mar 2024 10:02:14 +0100 Subject: [PATCH] feat: using SnippetManager in the Service Details to create code snippet (#556) * feat: snippet in details Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * feat: using snippet language Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: missing SnippetManager Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * test: ensuring StudioApiImpl tests works Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: tests Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * tests: ensuring expected behavior Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: unit tests Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: RequestOptions model file directory Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> * fix: comments Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> --------- Signed-off-by: axel7083 <42176370+axel7083@users.noreply.github.com> --- .../src/managers/SnippetManager.spec.ts | 8 +- .../backend/src/managers/SnippetManager.ts | 5 +- packages/backend/src/studio-api-impl.spec.ts | 2 + packages/backend/src/studio-api-impl.ts | 12 +++ packages/backend/src/studio.spec.ts | 5 +- packages/backend/src/studio.ts | 5 + .../src/pages/InferenceServerDetails.spec.ts | 49 ++++++++- .../src/pages/InferenceServerDetails.svelte | 100 +++++++++++++++++- .../frontend/src/stores/snippetLanguages.ts | 28 +++++ packages/shared/src/StudioAPI.ts | 15 +++ packages/shared/src/models/RequestOptions.ts | 13 +++ 11 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 packages/frontend/src/stores/snippetLanguages.ts create mode 100644 packages/shared/src/models/RequestOptions.ts diff --git a/packages/backend/src/managers/SnippetManager.spec.ts b/packages/backend/src/managers/SnippetManager.spec.ts index fd9c32f99..e729610f5 100644 --- a/packages/backend/src/managers/SnippetManager.spec.ts +++ b/packages/backend/src/managers/SnippetManager.spec.ts @@ -63,7 +63,13 @@ test('expect postman-code-generators to have nodejs supported.', () => { test('expect postman-code-generators to generate proper nodejs native code', async () => { const manager = new SnippetManager(webviewMock); - const snippet = await manager.generate('http://localhost:8080', 'nodejs', 'Request'); + const snippet = await manager.generate( + { + url: 'http://localhost:8080', + }, + 'nodejs', + 'Request', + ); expect(snippet).toBe(`var request = require('request'); var options = { 'method': 'GET', diff --git a/packages/backend/src/managers/SnippetManager.ts b/packages/backend/src/managers/SnippetManager.ts index b462d0198..239356006 100644 --- a/packages/backend/src/managers/SnippetManager.ts +++ b/packages/backend/src/managers/SnippetManager.ts @@ -20,6 +20,7 @@ import { getLanguageList, convert, type Language } from 'postman-code-generators import { Request } from 'postman-collection'; import { Publisher } from '../utils/Publisher'; import { Messages } from '@shared/Messages'; +import type { RequestOptions } from '@shared/src/models/RequestOptions'; export class SnippetManager extends Publisher implements Disposable { constructor(webview: Webview) { @@ -30,9 +31,9 @@ export class SnippetManager extends Publisher implements Disposable return getLanguageList(); } - generate(url: string, language: string, variant: string): Promise { + generate(requestOptions: RequestOptions, language: string, variant: string): Promise { return new Promise((resolve, reject) => { - const request = new Request(url); + const request = new Request(requestOptions); convert(language, variant, request, {}, (error: unknown, snippet: string) => { if (error) { reject(error); diff --git a/packages/backend/src/studio-api-impl.spec.ts b/packages/backend/src/studio-api-impl.spec.ts index c0b4daeb3..16a122be6 100644 --- a/packages/backend/src/studio-api-impl.spec.ts +++ b/packages/backend/src/studio-api-impl.spec.ts @@ -36,6 +36,7 @@ import type { TaskRegistry } from './registries/TaskRegistry'; import type { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry'; import type { Recipe } from '@shared/src/models/IRecipe'; import type { PlaygroundV2Manager } from './managers/playgroundV2Manager'; +import type { SnippetManager } from './managers/SnippetManager'; vi.mock('./ai.json', () => { return { @@ -107,6 +108,7 @@ beforeEach(async () => { {} as unknown as TaskRegistry, {} as unknown as InferenceManager, {} as unknown as PlaygroundV2Manager, + {} as unknown as SnippetManager, ); vi.mock('node:fs'); diff --git a/packages/backend/src/studio-api-impl.ts b/packages/backend/src/studio-api-impl.ts index b793bb91b..30aeb8a9c 100644 --- a/packages/backend/src/studio-api-impl.ts +++ b/packages/backend/src/studio-api-impl.ts @@ -40,6 +40,9 @@ import type { Conversation } from '@shared/src/models/IPlaygroundMessage'; import type { PlaygroundV2Manager } from './managers/playgroundV2Manager'; import { getFreeRandomPort } from './utils/ports'; import { withDefaultConfiguration } from './utils/inferenceUtils'; +import type { RequestOptions } from '@shared/src/models/RequestOptions'; +import type { SnippetManager } from './managers/SnippetManager'; +import type { Language } from 'postman-code-generators'; import type { ModelOptions } from '@shared/src/models/IModelOptions'; import type { PlaygroundV2 } from '@shared/src/models/IPlaygroundV2'; @@ -58,6 +61,7 @@ export class StudioApiImpl implements StudioAPI { private taskRegistry: TaskRegistry, private inferenceManager: InferenceManager, private playgroundV2: PlaygroundV2Manager, + private snippetManager: SnippetManager, ) {} async createPlayground(name: string, model: ModelInfo): Promise { @@ -86,6 +90,14 @@ export class StudioApiImpl implements StudioAPI { return this.playgroundV2.getConversations(); } + async getSnippetLanguages(): Promise { + return this.snippetManager.getLanguageList(); + } + + createSnippet(options: RequestOptions, language: string, variant: string): Promise { + return this.snippetManager.generate(options, language, variant); + } + async getInferenceServers(): Promise { return this.inferenceManager.getServers(); } diff --git a/packages/backend/src/studio.spec.ts b/packages/backend/src/studio.spec.ts index 9f050d84a..7a6e17437 100644 --- a/packages/backend/src/studio.spec.ts +++ b/packages/backend/src/studio.spec.ts @@ -35,6 +35,7 @@ const studio = new Studio(mockedExtensionContext); const mocks = vi.hoisted(() => ({ listContainers: vi.fn(), getContainerConnections: vi.fn(), + postMessage: vi.fn(), })); vi.mock('@podman-desktop/api', async () => { @@ -51,7 +52,7 @@ vi.mock('@podman-desktop/api', async () => { webview: { html: '', onDidReceiveMessage: vi.fn(), - postMessage: vi.fn(), + postMessage: mocks.postMessage, }, onDidChangeViewState: vi.fn(), }), @@ -85,6 +86,8 @@ beforeEach(() => { event: vi.fn(), fire: vi.fn(), } as unknown as EventEmitter); + + mocks.postMessage.mockResolvedValue(undefined); }); afterEach(() => { diff --git a/packages/backend/src/studio.ts b/packages/backend/src/studio.ts index 5af613da0..b62825d80 100644 --- a/packages/backend/src/studio.ts +++ b/packages/backend/src/studio.ts @@ -40,6 +40,7 @@ import { PodmanConnection } from './managers/podmanConnection'; import { LocalRepositoryRegistry } from './registries/LocalRepositoryRegistry'; import { InferenceManager } from './managers/inference/inferenceManager'; import { PlaygroundV2Manager } from './managers/playgroundV2Manager'; +import { SnippetManager } from './managers/SnippetManager'; // TODO: Need to be configured export const AI_STUDIO_FOLDER = path.join('podman-desktop', 'ai-studio'); @@ -171,6 +172,9 @@ export class Studio { const playgroundV2 = new PlaygroundV2Manager(this.#panel.webview, this.#inferenceManager); + const snippetManager = new SnippetManager(this.#panel.webview); + snippetManager.init(); + // Creating StudioApiImpl this.studioApi = new StudioApiImpl( applicationManager, @@ -182,6 +186,7 @@ export class Studio { taskRegistry, this.#inferenceManager, playgroundV2, + snippetManager, ); this.catalogManager.init(); diff --git a/packages/frontend/src/pages/InferenceServerDetails.spec.ts b/packages/frontend/src/pages/InferenceServerDetails.spec.ts index 14cf76af6..e774f8296 100644 --- a/packages/frontend/src/pages/InferenceServerDetails.spec.ts +++ b/packages/frontend/src/pages/InferenceServerDetails.spec.ts @@ -21,10 +21,12 @@ import { vi, test, expect, beforeEach } from 'vitest'; import { screen, render } from '@testing-library/svelte'; import type { InferenceServer } from '@shared/src/models/IInference'; import InferenceServerDetails from '/@/pages/InferenceServerDetails.svelte'; +import type { Language } from 'postman-code-generators'; const mocks = vi.hoisted(() => { return { getInferenceServersMock: vi.fn(), + getSnippetLanguagesMock: vi.fn(), }; }); @@ -37,7 +39,16 @@ vi.mock('../stores/inferenceServers', () => ({ }, })); -vi.mock('../utils/client', async () => { +vi.mock('../stores/snippetLanguages', () => ({ + snippetLanguages: { + subscribe: (f: (msg: any) => void) => { + f(mocks.getSnippetLanguagesMock()); + return () => {}; + }, + }, +})); + +vi.mock('../utils/client', () => { return { studioClient: {}, }; @@ -46,6 +57,22 @@ vi.mock('../utils/client', async () => { beforeEach(() => { vi.resetAllMocks(); + mocks.getSnippetLanguagesMock.mockReturnValue([ + { + key: 'dummyLanguageKey', + label: 'dummyLanguageLabel', + syntax_mode: 'dummySynthaxMode', + variants: [ + { + key: 'dummyLanguageVariant1', + }, + { + key: 'dummyLanguageVariant2', + }, + ], + }, + ] as Language[]); + mocks.getInferenceServersMock.mockReturnValue([ { health: undefined, @@ -68,3 +95,23 @@ test('ensure address is displayed', async () => { const address = screen.getByText('http://localhost:9999/v1'); expect(address).toBeDefined(); }); + +test('language select must have the mocked snippet languages', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + const select: HTMLSelectElement = screen.getByLabelText('snippet language selection'); + expect(select).toBeDefined(); + expect(select.options.length).toBe(1); + expect(select.options[0].value).toBe('dummyLanguageKey'); +}); + +test('variant select must be hidden when no language selected', async () => { + render(InferenceServerDetails, { + containerId: 'dummyContainerId', + }); + + const variantSelect = screen.queryByLabelText('snippet language variant'); + expect(variantSelect).toBeNull(); +}); diff --git a/packages/frontend/src/pages/InferenceServerDetails.svelte b/packages/frontend/src/pages/InferenceServerDetails.svelte index 66ba7eb6a..ec4dbc41b 100644 --- a/packages/frontend/src/pages/InferenceServerDetails.svelte +++ b/packages/frontend/src/pages/InferenceServerDetails.svelte @@ -4,13 +4,67 @@ import NavPage from '/@/lib/NavPage.svelte'; import ServiceStatus from '/@/lib/table/service/ServiceStatus.svelte'; import ServiceAction from '/@/lib/table/service/ServiceAction.svelte'; import Fa from 'svelte-fa'; -import { faBuildingColumns, faCopy, faMicrochip, faScaleBalanced } from '@fortawesome/free-solid-svg-icons'; +import { faBuildingColumns, faMicrochip, faScaleBalanced } from '@fortawesome/free-solid-svg-icons'; import type { InferenceServer } from '@shared/src/models/IInference'; +import { snippetLanguages } from '/@/stores/snippetLanguages'; +import type { LanguageVariant } from 'postman-code-generators'; +import { studioClient } from '/@/utils/client'; export let containerId: string | undefined = undefined; let service: InferenceServer | undefined; $: service = $inferenceServers.find(server => server.container.containerId === containerId); + +let selectedLanguage: string | undefined = undefined; +$: selectedLanguage; + +let variants: LanguageVariant[] = []; +$: variants = $snippetLanguages.find(language => language.key === selectedLanguage)?.variants || []; + +let selectedVariant: string | undefined = undefined; +$: selectedVariant; + +const onLanguageChange = (): void => { + selectedVariant = variants.length > 0 ? variants[0].key : undefined; + generate(); +}; + +let snippet: string | undefined = undefined; +$: snippet; + +const generate = async () => { + if (selectedVariant === undefined || selectedLanguage === undefined) return; + + snippet = await studioClient.createSnippet( + { + url: `http://localhost:${service?.connection.port || '??'}/v1/chat/completions`, + method: 'POST', + header: [ + { + key: 'Content-Type', + value: 'application/json', + }, + ], + body: { + mode: 'raw', + raw: `{ + "messages": [ + { + "content": "You are a helpful assistant.", + "role": "system" + }, + { + "content": "What is the capital of France?", + "role": "user" + } + ] +}`, + }, + }, + selectedLanguage, + selectedVariant, + ); +}; @@ -74,6 +128,50 @@ $: service = $inferenceServers.find(server => server.container.containerId === c + + +
+
+ Client code + + + + {#if selectedVariant !== undefined} + + {/if} +
+ + {#if snippet !== undefined} +
+ + {snippet} + +
+ {/if} +
{/if} diff --git a/packages/frontend/src/stores/snippetLanguages.ts b/packages/frontend/src/stores/snippetLanguages.ts new file mode 100644 index 000000000..b7d434c5a --- /dev/null +++ b/packages/frontend/src/stores/snippetLanguages.ts @@ -0,0 +1,28 @@ +/********************************************************************** + * Copyright (C) 2024 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 { Readable } from 'svelte/store'; +import { Messages } from '@shared/Messages'; +import { studioClient } from '/@/utils/client'; +import { RPCReadable } from '/@/stores/rpcReadable'; +import type { Language } from 'postman-code-generators'; + +export const snippetLanguages: Readable = RPCReadable( + [], + [Messages.MSG_SUPPORTED_LANGUAGES_UPDATE], + studioClient.getSnippetLanguages, +); diff --git a/packages/shared/src/StudioAPI.ts b/packages/shared/src/StudioAPI.ts index ca9f29b60..795428eb4 100644 --- a/packages/shared/src/StudioAPI.ts +++ b/packages/shared/src/StudioAPI.ts @@ -25,6 +25,8 @@ import type { ApplicationState } from './models/IApplicationState'; import type { Task } from './models/ITask'; import type { LocalRepository } from './models/ILocalRepository'; import type { InferenceServer } from './models/IInference'; +import type { RequestOptions } from './models/RequestOptions'; +import type { Language } from 'postman-code-generators'; import type { CreationInferenceServerOptions } from './models/InferenceServerConfig'; import type { ModelOptions } from './models/IModelOptions'; import type { Conversation } from './models/IPlaygroundMessage'; @@ -147,6 +149,19 @@ export abstract class StudioAPI { */ abstract createPlaygroundConversation(): Promise; + /** + * Return the list of supported languages to generate code from. + */ + abstract getSnippetLanguages(): Promise; + + /** + * return a code snippet as a string matching the arguments and options provided + * @param options the options for the request + * @param language the language to use + * @param variant the variant of the language + */ + abstract createSnippet(options: RequestOptions, language: string, variant: string): Promise; + abstract createPlayground(name: string, model: ModelInfo): Promise; abstract getPlaygroundsV2(): Promise; diff --git a/packages/shared/src/models/RequestOptions.ts b/packages/shared/src/models/RequestOptions.ts new file mode 100644 index 000000000..03d5d7959 --- /dev/null +++ b/packages/shared/src/models/RequestOptions.ts @@ -0,0 +1,13 @@ +export interface RequestOptions { + url: string; + method?: string; + header?: { + key?: string; + value?: string; + system?: boolean; + }[]; + body?: { + mode: 'raw'; + raw?: string; + }; +}