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

refactor: generate customize copilot #11441

Merged
merged 7 commits into from
Apr 24, 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.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading