Skip to content

Commit

Permalink
refactor: generate customize gpt and GPT plugin (#11441)
Browse files Browse the repository at this point in the history
* refactor: generate customize gpt plugin

* refactor: more

* refactor: more

* refactor: more

* test: ut

* test: ut

* refactor: pr comment
  • Loading branch information
yuqizhou77 authored Apr 24, 2024
1 parent 17e10df commit f55496d
Show file tree
Hide file tree
Showing 12 changed files with 270 additions and 22 deletions.
6 changes: 5 additions & 1 deletion packages/fx-core/src/component/coordinator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,7 +186,11 @@ class Coordinator {
const res = await OfficeAddinGenerator.generate(context, inputs, projectPath);
if (res.isErr()) return err(res.error);
}
} else if (capability === CapabilityOptions.copilotPluginApiSpec().id) {
} else if (
capability === CapabilityOptions.copilotPluginApiSpec().id ||
inputs[QuestionNames.CustomizeGptWithPluginStart] ===
CapabilityOptions.copilotPluginApiSpec().id
) {
const res = await CopilotPluginGenerator.generatePluginFromApiSpec(
context,
inputs,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

import { FxError, Result, err, ok, CopilotGptManifestSchema } from "@microsoft/teamsfx-api";
import fs from "fs-extra";
import { FileNotFoundError, JSONSyntaxError } from "../../../../error/common";
import { FileNotFoundError, JSONSyntaxError, WriteFileError } from "../../../../error/common";
import stripBom from "strip-bom";

export class CopilotGptManifestUtils {
Expand All @@ -25,6 +25,48 @@ export class CopilotGptManifestUtils {
return err(new JSONSyntaxError(path, e, "CopilotGptManifestUtils"));
}
}

public async writeCopilotGptManifestFile(
manifest: CopilotGptManifestSchema,
path: string
): Promise<Result<undefined, FxError>> {
const content = JSON.stringify(manifest, undefined, 4);
try {
await fs.writeFile(path, content);
} catch (e) {
return err(new WriteFileError(e, "copilotGptManifestUtils"));
}
return ok(undefined);
}

public async addPlugin(
copilotGptPath: string,
id: string,
pluginFile: string
): Promise<Result<CopilotGptManifestSchema, FxError>> {
const gptManifestRes = await copilotGptManifestUtils.readCopilotGptManifestFile(copilotGptPath);
if (gptManifestRes.isErr()) {
return err(gptManifestRes.error);
} else {
const gptManifest = gptManifestRes.value;
if (!gptManifest.actions) {
gptManifest.actions = [];
}
gptManifest.actions?.push({
id,
file: pluginFile,
});
const updateGptManifestRes = await copilotGptManifestUtils.writeCopilotGptManifestFile(
gptManifest,
copilotGptPath
);
if (updateGptManifestRes.isErr()) {
return err(updateGptManifestRes.error);
} else {
return ok(gptManifest);
}
}
}
}

export const copilotGptManifestUtils = new CopilotGptManifestUtils();
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ import {
} from "./helper";
import { getLocalizedString } from "../../../common/localizeUtils";
import { manifestUtils } from "../../driver/teamsApp/utils/ManifestUtils";
import { ProgrammingLanguage } from "../../../question/create";
import { CapabilityOptions, ProgrammingLanguage } from "../../../question/create";
import * as fs from "fs-extra";
import { assembleError } from "../../../error";
import {
Expand All @@ -53,10 +53,13 @@ import {
WarningType,
ProjectType,
Utils,
ParseOptions,
} from "@microsoft/m365-spec-parser";
import * as util from "util";
import { isValidHttpUrl } from "../../../question/util";
import { merge } from "lodash";
import { TemplateNames } from "../templates/templateNames";
import { copilotGptManifestUtils } from "../../driver/teamsApp/utils/CopilotGptManifestUtils";

const fromApiSpecComponentName = "copilot-plugin-existing-api";
const pluginFromApiSpecComponentName = "api-copilot-plugin-existing-api";
Expand All @@ -69,6 +72,7 @@ const apiSpecFolderName = "apiSpecificationFile";
const apiSpecYamlFileName = "openapi.yaml";
const apiSpecJsonFileName = "openapi.json";
const pluginManifestFileName = "ai-plugin.json";
const defaultPluginId = "plugin_1";

const copilotPluginExistingApiSpecUrlTelemetryEvent = "copilot-plugin-existing-api-spec-url";

Expand Down Expand Up @@ -145,7 +149,11 @@ export class CopilotPluginGenerator {
(api) => !!api.data.authName && apiOperations.includes(api.id)
);

const templateName = apiPluginFromApiSpecTemplateName;
const templateName =
inputs[QuestionNames.CustomizeGptWithPluginStart] ===
CapabilityOptions.copilotPluginApiSpec().id
? TemplateNames.BasicGpt
: apiPluginFromApiSpecTemplateName;
const componentName = fromApiSpecComponentName;

merge(actionContext?.telemetryProps, { [telemetryProperties.templateName]: templateName });
Expand Down Expand Up @@ -293,15 +301,17 @@ export class CopilotPluginGenerator {
});

// validate API spec
const allowAPIKeyAuth = true;
const allowMultipleParameters = true;
const isGptPlugin = templateName === TemplateNames.BasicGpt;
const specParser = new SpecParser(
url,
isPlugin
? copilotPluginParserOptions
? {
...copilotPluginParserOptions,
isGptPlugin,
}
: {
allowBearerTokenAuth: allowAPIKeyAuth, // Currently, API key auth support is actually bearer token auth
allowMultipleParameters,
allowBearerTokenAuth: true, // Currently, API key auth support is actually bearer token auth
allowMultipleParameters: true,
projectType: type,
}
);
Expand Down Expand Up @@ -410,6 +420,25 @@ export class CopilotPluginGenerator {
if (updateManifestRes.isErr()) return err(updateManifestRes.error);
}

// update gpt.json including plugins
if (isGptPlugin && teamsManifest.copilotGpts && teamsManifest.copilotGpts.length > 0) {
const copilotGptPath = path.join(
destinationPath,
AppPackageFolderName,
teamsManifest.copilotGpts[0].file
);
await fs.ensureFile(copilotGptPath);
const addPluginRes = await copilotGptManifestUtils.addPlugin(
copilotGptPath,
defaultPluginId,
pluginManifestFileName
);

if (addPluginRes.isErr()) {
return err(addPluginRes.error);
}
}

if (componentName === forCustomCopilotRagCustomApi) {
const specs = await specParser.getFilteredSpecs(filters);
const spec = specs[1];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export enum TemplateNames {
CustomCopilotRagMicrosoft365 = "custom-copilot-rag-microsoft365",
CustomCopilotAssistantNew = "custom-copilot-assistant-new",
CustomCopilotAssistantAssistantsApi = "custom-copilot-assistant-assistants-api",
BasicGpt = "copilot-gpt-basic",
GptWithPluginFromScratch = "copilot-gpt-from-scratch-plugin",
}

export const Feature2TemplateName = {
Expand Down Expand Up @@ -123,4 +125,7 @@ export const Feature2TemplateName = {
[`${CapabilityOptions.customCopilotAssistant().id}:undefined:${
CustomCopilotAssistantOptions.assistantsApi().id
}`]: TemplateNames.CustomCopilotAssistantAssistantsApi,
[`${CapabilityOptions.customizeGptBasic().id}:undefined`]: TemplateNames.BasicGpt,
[`${CapabilityOptions.customizeGptWithPlugin().id}:undefined`]:
TemplateNames.GptWithPluginFromScratch,
};
6 changes: 3 additions & 3 deletions packages/fx-core/src/question/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -656,7 +656,7 @@ export class CapabilityOptions {
}

static customizeGptOptions(): OptionItem[] {
return [CapabilityOptions.customizeGptBasic(), CapabilityOptions.cusomizeGptWithPlugin()];
return [CapabilityOptions.customizeGptBasic(), CapabilityOptions.customizeGptWithPlugin()];
}

/**
Expand Down Expand Up @@ -870,7 +870,7 @@ export class CapabilityOptions {
};
}

static cusomizeGptWithPlugin(): OptionItem {
static customizeGptWithPlugin(): OptionItem {
return {
id: "customize-gpt-with-plugin",
label: "GPT with a plugin",
Expand Down Expand Up @@ -2499,7 +2499,7 @@ export function capabilitySubTree(): IQTreeNode {
},
// Customize GPT with plugin
{
condition: { equals: CapabilityOptions.cusomizeGptWithPlugin().id },
condition: { equals: CapabilityOptions.customizeGptWithPlugin().id },
data: CustomizeGptWithPluginStartQuestion(),
},
{
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT license.

import "mocha";
import * as sinon from "sinon";
import chai from "chai";
import fs from "fs-extra";
import { CopilotGptManifestSchema } from "@microsoft/teamsfx-api";
import { copilotGptManifestUtils } from "../../../../src/component/driver/teamsApp/utils/CopilotGptManifestUtils";
import { FileNotFoundError, WriteFileError } from "../../../../src/error";

describe("copilotGptManifestUtils", () => {
const sandbox = sinon.createSandbox();

afterEach(async () => {
sandbox.restore();
});

const gptManifest: CopilotGptManifestSchema = {
name: "name",
description: "description",
};

it("add plugin success", async () => {
sandbox.stub(fs, "pathExists").resolves(true);
sandbox.stub(fs, "readFile").resolves(JSON.stringify(gptManifest) as any);
sandbox.stub(fs, "writeFile").resolves();

const res = await copilotGptManifestUtils.addPlugin("testPath", "testId", "testFile");

chai.assert.isTrue(res.isOk());
if (res.isOk()) {
const updatedManifest = res.value;
chai.assert.deepEqual(updatedManifest.actions![0], {
id: "testId",
file: "testFile",
});
}
});

it("add plugin error: read manifest error", async () => {
sandbox.stub(fs, "pathExists").resolves(false);
const res = await copilotGptManifestUtils.addPlugin("testPath", "testId", "testFile");
chai.assert.isTrue(res.isErr());
if (res.isErr()) {
chai.assert.isTrue(res.error instanceof FileNotFoundError);
}
});

it("add plugin error: write file error", async () => {
sandbox.stub(fs, "pathExists").resolves(true);
sandbox.stub(fs, "readFile").resolves(JSON.stringify(gptManifest) as any);
sandbox.stub(fs, "writeFile").throws("some error");
const res = await copilotGptManifestUtils.addPlugin("testPath", "testId", "testFile");
chai.assert.isTrue(res.isErr());
if (res.isErr()) {
chai.assert.isTrue(res.error instanceof WriteFileError);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,8 @@ import { PluginManifestUtils } from "../../../src/component/driver/teamsApp/util
import path from "path";
import { OpenAPIV3 } from "openapi-types";
import { format } from "util";
import { TemplateNames } from "../../../src/component/generator/templates/templateNames";
import { copilotGptManifestUtils } from "../../../src/component/driver/teamsApp/utils/CopilotGptManifestUtils";

const openAIPluginManifest = {
schema_version: "v1",
Expand Down Expand Up @@ -431,6 +433,103 @@ describe("copilotPluginGenerator", function () {
assert.isTrue(updateManifestBasedOnOpenAIPlugin.calledOnce);
});

it("success if adding plugin for GPT basic", async function () {
const inputs: Inputs = {
platform: Platform.VSCode,
projectPath: "path",
[QuestionNames.Capabilities]: CapabilityOptions.customizeGptWithPlugin().id,
[QuestionNames.CustomizeGptWithPluginStart]: CapabilityOptions.copilotPluginApiSpec().id,
[QuestionNames.ApiSpecLocation]: "https://test.com",
[QuestionNames.ApiOperation]: ["operation1"],
supportedApisFromApiSpec: apiOperations,
};
const context = createContextV3();
sandbox
.stub(SpecParser.prototype, "validate")
.resolves({ status: ValidationStatus.Valid, errors: [], warnings: [] });
sandbox.stub(fs, "ensureDir").resolves();
sandbox.stub(fs, "ensureFile").resolves();
sandbox.stub(manifestUtils, "_readAppManifest").resolves(
ok({
...teamsManifest,
copilotGpts: [
{
id: "1",
file: "test",
},
],
})
);
sandbox.stub(CopilotPluginHelper, "isYamlSpecFile").resolves(false);
sandbox.stub(copilotGptManifestUtils, "addPlugin").resolves(ok({} as any));
const generateBasedOnSpec = sandbox
.stub(SpecParser.prototype, "generateForCopilot")
.resolves({ allSuccess: true, warnings: [] });
const getDefaultVariables = sandbox.stub(Generator, "getDefaultVariables").resolves(undefined);
const downloadTemplate = sandbox.stub(Generator, "generateTemplate").resolves(ok(undefined));

const result = await CopilotPluginGenerator.generatePluginFromApiSpec(
context,
inputs,
"projectPath"
);

assert.isTrue(result.isOk());
assert.isTrue(getDefaultVariables.calledOnce);
assert.isTrue(downloadTemplate.calledOnce);
assert.isTrue(generateBasedOnSpec.calledOnce);
assert.equal(downloadTemplate.args[0][2], TemplateNames.BasicGpt);
});

it("error if adding plugin for GPT basic", async function () {
const inputs: Inputs = {
platform: Platform.VSCode,
projectPath: "path",
[QuestionNames.Capabilities]: CapabilityOptions.customizeGptWithPlugin().id,
[QuestionNames.CustomizeGptWithPluginStart]: CapabilityOptions.copilotPluginApiSpec().id,
[QuestionNames.ApiSpecLocation]: "https://test.com",
[QuestionNames.ApiOperation]: ["operation1"],
supportedApisFromApiSpec: apiOperations,
};
const context = createContextV3();
sandbox
.stub(SpecParser.prototype, "validate")
.resolves({ status: ValidationStatus.Valid, errors: [], warnings: [] });
sandbox.stub(fs, "ensureDir").resolves();
sandbox.stub(manifestUtils, "_readAppManifest").resolves(
ok({
...teamsManifest,
copilotGpts: [
{
id: "1",
file: "test",
},
],
})
);
sandbox.stub(CopilotPluginHelper, "isYamlSpecFile").resolves(false);
sandbox.stub(fs, "ensureFile").resolves();
sandbox
.stub(copilotGptManifestUtils, "addPlugin")
.resolves(err(new SystemError("testSource", "testName", "", "")));
sandbox
.stub(SpecParser.prototype, "generateForCopilot")
.resolves({ allSuccess: true, warnings: [] });
sandbox.stub(Generator, "getDefaultVariables").resolves(undefined);
sandbox.stub(Generator, "generateTemplate").resolves(ok(undefined));

const result = await CopilotPluginGenerator.generatePluginFromApiSpec(
context,
inputs,
"projectPath"
);

assert.isTrue(result.isErr());
if (result.isErr()) {
assert.equal(result.error.source, "testSource");
}
});

it("failed to download template generator", async function () {
const inputs: Inputs = {
platform: Platform.VSCode,
Expand Down
4 changes: 2 additions & 2 deletions packages/fx-core/tests/question/create.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3265,7 +3265,7 @@ describe("scaffold question", () => {
const options = await select.dynamicOptions!(inputs);
assert.isTrue(options.length === 2);

return ok({ type: "success", result: CapabilityOptions.cusomizeGptWithPlugin().id });
return ok({ type: "success", result: CapabilityOptions.customizeGptWithPlugin().id });
} else if (question.name === QuestionNames.CustomizeGptWithPluginStart) {
const select = question as SingleSelectQuestion;
const options = await select.staticOptions;
Expand Down Expand Up @@ -3318,7 +3318,7 @@ describe("scaffold question", () => {
const options = await select.dynamicOptions!(inputs);
assert.isTrue(options.length === 2);

return ok({ type: "success", result: CapabilityOptions.cusomizeGptWithPlugin().id });
return ok({ type: "success", result: CapabilityOptions.customizeGptWithPlugin().id });
} else if (question.name === QuestionNames.CustomizeGptWithPluginStart) {
const select = question as SingleSelectQuestion;
const options = await select.staticOptions;
Expand Down
Loading

0 comments on commit f55496d

Please sign in to comment.