Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: using SnippetManager in the Service Details to create code snippet #556

Merged
merged 9 commits into from Mar 19, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/backend/src/managers/SnippetManager.spec.ts
Expand Up @@ -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',
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/managers/SnippetManager.ts
Expand Up @@ -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<Language[]> implements Disposable {
constructor(webview: Webview) {
Expand All @@ -30,9 +31,9 @@ export class SnippetManager extends Publisher<Language[]> implements Disposable
return getLanguageList();
}

generate(url: string, language: string, variant: string): Promise<string> {
generate(requestOptions: RequestOptions, language: string, variant: string): Promise<string> {
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);
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/studio-api-impl.spec.ts
Expand Up @@ -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 {
Expand Down Expand Up @@ -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');

Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/studio-api-impl.ts
Expand Up @@ -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';

Expand All @@ -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<void> {
Expand Down Expand Up @@ -86,6 +90,14 @@ export class StudioApiImpl implements StudioAPI {
return this.playgroundV2.getConversations();
}

async getSnippetLanguages(): Promise<Language[]> {
return this.snippetManager.getLanguageList();
}

createSnippet(options: RequestOptions, language: string, variant: string): Promise<string> {
return this.snippetManager.generate(options, language, variant);
}

async getInferenceServers(): Promise<InferenceServer[]> {
return this.inferenceManager.getServers();
}
Expand Down
5 changes: 4 additions & 1 deletion packages/backend/src/studio.spec.ts
Expand Up @@ -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 () => {
Expand All @@ -51,7 +52,7 @@ vi.mock('@podman-desktop/api', async () => {
webview: {
html: '',
onDidReceiveMessage: vi.fn(),
postMessage: vi.fn(),
postMessage: mocks.postMessage,
},
onDidChangeViewState: vi.fn(),
}),
Expand Down Expand Up @@ -85,6 +86,8 @@ beforeEach(() => {
event: vi.fn(),
fire: vi.fn(),
} as unknown as EventEmitter<unknown>);

mocks.postMessage.mockResolvedValue(undefined);
});

afterEach(() => {
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/studio.ts
Expand Up @@ -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');
Expand Down Expand Up @@ -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,
Expand All @@ -182,6 +186,7 @@ export class Studio {
taskRegistry,
this.#inferenceManager,
playgroundV2,
snippetManager,
);

this.catalogManager.init();
Expand Down
49 changes: 48 additions & 1 deletion packages/frontend/src/pages/InferenceServerDetails.spec.ts
Expand Up @@ -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(),
};
});

Expand All @@ -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: {},
};
Expand All @@ -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,
Expand All @@ -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();
});
100 changes: 99 additions & 1 deletion packages/frontend/src/pages/InferenceServerDetails.svelte
Expand Up @@ -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,
);
};
</script>

<NavPage title="Service Details" searchEnabled="{false}">
Expand Down Expand Up @@ -74,6 +128,50 @@ $: service = $inferenceServers.find(server => server.container.containerId === c
</div>
</div>
</div>

<!-- code client -->
<div>
<div class="flex flex-row">
<span class="text-base grow">Client code</span>

<!-- language choice -->
<select
required
aria-label="snippet language selection"
bind:value="{selectedLanguage}"
on:change="{onLanguageChange}"
id="languages"
class="border text-sm rounded-lg focus:ring-purple-500 focus:border-purple-500 block p-1 bg-charcoal-900 border-charcoal-900 placeholder-gray-700 text-white"
name="languages">
{#each $snippetLanguages as language}
<option class="my-1" value="{language.key}">{language.label}</option>
{/each}
</select>
{#if selectedVariant !== undefined}
<select
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The select should be disable if the language has only one variant

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we keep the select, disable it and put a "N/A" for "Not Applicable"?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would rather apply the suggestion of @jeffmaury as displaying N/A or Not Applicable could be weird, when we do have a name for the variant. Showing a hint, of the library used generally.

Here is why it look likes when it is disabled when only one variant.

image image image

required
aria-label="snippet language variant"
id="variants"
bind:value="{selectedVariant}"
on:change="{generate}"
disabled="{variants.length === 1}"
class="border ml-1 text-sm rounded-lg focus:ring-purple-500 focus:border-purple-500 block p-1 bg-charcoal-900 border-charcoal-900 placeholder-gray-700 text-white"
name="variants">
{#each variants as variant}
<option class="my-1" value="{variant.key}">{variant.key}</option>
{/each}
</select>
{/if}
</div>

{#if snippet !== undefined}
<div class="bg-charcoal-900 rounded-md w-full p-4 mt-2">
<code class="whitespace-break-spaces">
{snippet}
</code>
</div>
{/if}
</div>
{/if}
</div>
</div>
Expand Down
28 changes: 28 additions & 0 deletions 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<Language[]> = RPCReadable<Language[]>(
[],
[Messages.MSG_SUPPORTED_LANGUAGES_UPDATE],
studioClient.getSnippetLanguages,
);
15 changes: 15 additions & 0 deletions packages/shared/src/StudioAPI.ts
Expand Up @@ -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';
Expand Down Expand Up @@ -147,6 +149,19 @@ export abstract class StudioAPI {
*/
abstract createPlaygroundConversation(): Promise<string>;

/**
* Return the list of supported languages to generate code from.
*/
abstract getSnippetLanguages(): Promise<Language[]>;

/**
* 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<string>;

abstract createPlayground(name: string, model: ModelInfo): Promise<void>;

abstract getPlaygroundsV2(): Promise<PlaygroundV2[]>;
Expand Down