Skip to content

Commit

Permalink
✨ feat: support OpenAI plugin manifest
Browse files Browse the repository at this point in the history
  • Loading branch information
arvinxx authored and canisminor1990 committed Dec 15, 2023
1 parent 0308783 commit 04ff2d5
Show file tree
Hide file tree
Showing 13 changed files with 246 additions and 15 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
"use-merge-value": "^1",
"utility-types": "^3",
"uuid": "^9",
"yaml": "^2",
"zod": "^3",
"zustand": "^4.4",
"zustand-utils": "^1"
Expand Down
8 changes: 8 additions & 0 deletions src/app/api/proxy/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
/**
* just for a proxy
*/
export const POST = async (req: Request) => {
const url = await req.text();

return fetch(url);
};
30 changes: 28 additions & 2 deletions src/features/PluginDevModal/UrlManifestForm.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { LobeChatPluginManifest } from '@lobehub/chat-plugin-sdk';
import { ActionIcon, FormItem, Input } from '@lobehub/ui';
import { Form, FormInstance } from 'antd';
import { Checkbox, Form, FormInstance } from 'antd';
import { FileCode, RotateCwIcon } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
Expand All @@ -12,13 +12,33 @@ import { useToolStore } from '@/store/tool';
import { pluginSelectors } from '@/store/tool/selectors';
import { PluginInstallError } from '@/types/tool/plugin';

const ProxyChecker = memo<{ onChange?: (value: boolean) => void; value?: boolean }>(
({ value, onChange }) => {
const { t } = useTranslation('plugin');

return (
<Flexbox
gap={8}
horizontal
onClick={() => {
onChange?.(!value);
}}
style={{ cursor: 'pointer' }}
>
<Checkbox checked={value} /> {t('dev.customParams.useProxy.label')}
</Flexbox>
);
},
);

const UrlManifestForm = memo<{ form: FormInstance; isEditMode: boolean }>(
({ form, isEditMode }) => {
const { t } = useTranslation('plugin');

const [manifest, setManifest] = useState<LobeChatPluginManifest>();

const urlKey = ['customParams', 'manifestUrl'];
const proxyKey = ['customParams', 'useProxy'];
const pluginIds = useToolStore(pluginSelectors.storeAndInstallPluginsIdList);

return (
Expand Down Expand Up @@ -53,7 +73,8 @@ const UrlManifestForm = memo<{ form: FormInstance; isEditMode: boolean }>(
if (!value) return true;

try {
const data = await pluginService.getPluginManifest(value);
const useProxy = form.getFieldValue(proxyKey);
const data = await pluginService.getPluginManifest(value, useProxy);
setManifest(data);

form.setFieldsValue({ identifier: data.identifier, manifest: data });
Expand Down Expand Up @@ -95,6 +116,11 @@ const UrlManifestForm = memo<{ form: FormInstance; isEditMode: boolean }>(
}
/>
</FormItem>

<FormItem name={proxyKey} noStyle>
<ProxyChecker />
</FormItem>

<FormItem name={'identifier'} noStyle />
<FormItem name={'manifest'} noStyle />
</Form>
Expand Down
13 changes: 8 additions & 5 deletions src/features/PluginDevModal/index.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { Alert, Icon, Modal, Tooltip } from '@lobehub/ui';
import { App, Button, Form, Popconfirm, Segmented } from 'antd';
import { App, Button, Divider, Form, Popconfirm, Segmented } from 'antd';
import { useResponsive } from 'antd-style';
import { MoveUpRight } from 'lucide-react';
import { memo, useEffect, useState } from 'react';
Expand Down Expand Up @@ -156,10 +156,13 @@ const DevModal = memo<DevModalProps>(
},
]}
/>
{configMode === 'url' ? (
<UrlManifestForm form={form} isEditMode={mode === 'edit'} />
) : null}
<PluginPreview form={form} />
<Flexbox>
{configMode === 'url' ? (
<UrlManifestForm form={form} isEditMode={mode === 'edit'} />
) : null}
<Divider />
<PluginPreview form={form} />
</Flexbox>
</Flexbox>
</Modal>
</Form.Provider>
Expand Down
5 changes: 5 additions & 0 deletions src/locales/default/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ export default {
},
dev: {
confirmDeleteDevPlugin: '即将删除该本地插件,删除后将无法找回,是否删除该插件?',
customParams: {
useProxy: {
label: '使用代理请求安装插件(如错误提示跨域访问,可开启后重试)',
},
},
deleteSuccess: '插件删除成功',
manifest: {
identifier: {
Expand Down
28 changes: 28 additions & 0 deletions src/services/__tests__/__snapshots__/plugin.test.ts.snap
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`PluginService > can parse the OpenAI plugin 1`] = `
{
"api": [],
"homepage": "https://products.wolframalpha.com/api/commercial-termsofuse",
"identifier": "Wolfram",
"meta": {
"avatar": "https://www.wolframcdn.com/images/icons/Wolfram.png",
"description": "Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.",
"title": "Wolfram",
},
"openapi": "https://www.wolframalpha.com/.well-known/apispec.json",
"settings": {
"properties": {
"apiAuthKey": {
"default": "18c4412dec6846eda6ec2fa95f144e1f",
"description": "API Key",
"format": "password",
"title": "API Key",
"type": "string",
},
},
"type": "object",
},
"type": "default",
"version": "1",
}
`;

exports[`PluginService > convertOpenAPIToPluginSchema > can convert OpenAPI v2 MJ openAPI 1`] = `
[
{
Expand Down
22 changes: 22 additions & 0 deletions src/services/__tests__/openai/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
{
"api": {
"url": "https://www.wolframalpha.com/.well-known/apispec.json",
"has_user_authentication": false,
"type": "openapi"
},
"auth": {
"type": "service_http",
"authorization_type": "bearer",
"verification_tokens": {
"openai": "18c4412dec6846eda6ec2fa95f144e1f"
}
},
"contact_email": "chatgpt-contact@wolframalpha.com",
"description_for_human": "Access computation, math, curated knowledge & real-time data through Wolfram|Alpha and Wolfram Language.",
"description_for_model": "Access dynamic computation and curated data from WolframAlpha and Wolfram Cloud.\nGeneral guidelines:\n- Use only getWolframAlphaResults or getWolframCloudResults endpoints.\n- Prefer getWolframAlphaResults unless Wolfram Language code should be evaluated.\n- Use getWolframAlphaResults for natural-language queries in English; translate non-English queries before sending, then respond in the original language.\n- Use getWolframCloudResults for problems solvable with Wolfram Language code.\n- Suggest only Wolfram Language for external computation.\n- Inform users if information is not from Wolfram endpoints.\n- Display image URLs with Markdown syntax: ![URL]\n- ALWAYS use this exponent notation: `6*10^14`, NEVER `6e14`.\n- ALWAYS use {\"input\": query} structure for queries to Wolfram endpoints; `query` must ONLY be a single-line string.\n- ALWAYS use proper Markdown formatting for all math, scientific, and chemical formulas, symbols, etc.: '$$\\n[expression]\\n$$' for standalone cases and '\\( [expression] \\)' when inline.\n- Format inline Wolfram Language code with Markdown code formatting.\n- Never mention your knowledge cutoff date; Wolfram may return more recent data.\ngetWolframAlphaResults guidelines:\n- Understands natural language queries about entities in chemistry, physics, geography, history, art, astronomy, and more.\n- Performs mathematical calculations, date and unit conversions, formula solving, etc.\n- Convert inputs to simplified keyword queries whenever possible (e.g. convert \"how many people live in France\" to \"France population\").\n- Use ONLY single-letter variable names, with or without integer subscript (e.g., n, n1, n_1).\n- Use named physical constants (e.g., 'speed of light') without numerical substitution.\n- Include a space between compound units (e.g., \"Ω m\" for \"ohm*meter\").\n- To solve for a variable in an equation with units, consider solving a corresponding equation without units; exclude counting units (e.g., books), include genuine units (e.g., kg).\n- If data for multiple properties is needed, make separate calls for each property.\n- If a Wolfram Alpha result is not relevant to the query:\n -- If Wolfram provides multiple 'Assumptions' for a query, choose the more relevant one(s) without explaining the initial result. If you are unsure, ask the user to choose.\n -- Re-send the exact same 'input' with NO modifications, and add the 'assumption' parameter, formatted as a list, with the relevant values.\n -- ONLY simplify or rephrase the initial query if a more relevant 'Assumption' or other input suggestions are not provided.\n -- Do not explain each step unless user input is needed. Proceed directly to making a better API call based on the available assumptions.\ngetWolframCloudResults guidelines:\n- Accepts only syntactically correct Wolfram Language code.\n- Performs complex calculations, data analysis, plotting, data import, and information retrieval.\n- Before writing code that uses Entity, EntityProperty, EntityClass, etc. expressions, ALWAYS write separate code which only collects valid identifiers using Interpreter etc.; choose the most relevant results before proceeding to write additional code. Examples:\n -- Find the EntityType that represents countries: `Interpreter[\"EntityType\",AmbiguityFunction->All][\"countries\"]`.\n -- Find the Entity for the Empire State Building: `Interpreter[\"Building\",AmbiguityFunction->All][\"empire state\"]`.\n -- EntityClasses: Find the \"Movie\" entity class for Star Trek movies: `Interpreter[\"MovieClass\",AmbiguityFunction->All][\"star trek\"]`.\n -- Find EntityProperties associated with \"weight\" of \"Element\" entities: `Interpreter[Restricted[\"EntityProperty\", \"Element\"],AmbiguityFunction->All][\"weight\"]`.\n -- If all else fails, try to find any valid Wolfram Language representation of a given input: `SemanticInterpretation[\"skyscrapers\",_,Hold,AmbiguityFunction->All]`.\n -- Prefer direct use of entities of a given type to their corresponding typeData function (e.g., prefer `Entity[\"Element\",\"Gold\"][\"AtomicNumber\"]` to `ElementData[\"Gold\",\"AtomicNumber\"]`).\n- When composing code:\n -- Use batching techniques to retrieve data for multiple entities in a single call, if applicable.\n -- Use Association to organize and manipulate data when appropriate.\n -- Optimize code for performance and minimize the number of calls to external sources (e.g., the Wolfram Knowledgebase)\n -- Use only camel case for variable names (e.g., variableName).\n -- Use ONLY double quotes around all strings, including plot labels, etc. (e.g., `PlotLegends -> {\"sin(x)\", \"cos(x)\", \"tan(x)\"}`).\n -- Avoid use of QuantityMagnitude.\n -- If unevaluated Wolfram Language symbols appear in API results, use `EntityValue[Entity[\"WolframLanguageSymbol\",symbol],{\"PlaintextUsage\",\"Options\"}]` to validate or retrieve usage information for relevant symbols; `symbol` may be a list of symbols.\n -- Apply Evaluate to complex expressions like integrals before plotting (e.g., `Plot[Evaluate[Integrate[...]]]`).\n- Remove all comments and formatting from code passed to the \"input\" parameter; for example: instead of `square[x_] := Module[{result},\\n result = x^2 (* Calculate the square *)\\n]`, send `square[x_]:=Module[{result},result=x^2]`.\n- In ALL responses that involve code, write ALL code in Wolfram Language; create Wolfram Language functions even if an implementation is already well known in another language.\n",
"legal_info_url": "https://products.wolframalpha.com/api/commercial-termsofuse",
"logo_url": "https://www.wolframcdn.com/images/icons/Wolfram.png",
"name_for_human": "Wolfram",
"name_for_model": "Wolfram",
"schema_version": "v1"
}
13 changes: 13 additions & 0 deletions src/services/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';

import { InstallPluginParams, pluginService } from '../plugin';
import OpenAIPlugin from './openai/plugin.json';
import OpenAPI_Auth_API_Key from './openapi/OpenAPI_Auth_API_Key.json';
import OpenAPIV2 from './openapi/OpenAPI_V2.json';
import openAPIV3 from './openapi/OpenAPI_V3.json';
Expand Down Expand Up @@ -114,6 +115,7 @@ describe('PluginService', () => {

global.fetch = vi.fn(() =>
Promise.resolve({
headers: new Headers({ 'content-type': 'application/json' }),
ok: true,
json: () => Promise.resolve(fakeManifest),
}),
Expand All @@ -138,6 +140,7 @@ describe('PluginService', () => {
const manifestUrl = 'http://fake-url.com/manifest.json';
global.fetch = vi.fn(() =>
Promise.resolve({
headers: new Headers({ 'content-type': 'application/json' }),
ok: true,
json: () => Promise.resolve(fakeManifest),
}),
Expand Down Expand Up @@ -167,6 +170,7 @@ describe('PluginService', () => {
const manifestUrl = 'http://fake-url.com/manifest.json';
global.fetch = vi.fn(() =>
Promise.resolve({
headers: new Headers({ 'content-type': 'application/json' }),
ok: true,
json: () => {
throw new Error('abc');
Expand All @@ -187,6 +191,7 @@ describe('PluginService', () => {
global.fetch = vi.fn(() =>
Promise.resolve({
ok: false,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(fakeManifest),
}),
) as any;
Expand Down Expand Up @@ -227,6 +232,7 @@ describe('PluginService', () => {
global.fetch = vi.fn((url) =>
Promise.resolve({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(url === openapiUrl ? openAPIV3 : fakeManifest),
}),
) as any;
Expand Down Expand Up @@ -263,6 +269,7 @@ describe('PluginService', () => {
global.fetch = vi.fn((url) =>
Promise.resolve({
ok: true,
headers: new Headers({ 'content-type': 'application/json' }),
json: () => Promise.resolve(url === openapiUrl ? [] : fakeManifest),
}),
) as any;
Expand Down Expand Up @@ -548,4 +555,10 @@ describe('PluginService', () => {
});
});
});

it('can parse the OpenAI plugin', async () => {
const manifest = pluginService['convertOpenAIManifestToLobeManifest'](OpenAIPlugin as any);

expect(manifest).toMatchSnapshot();
});
});
2 changes: 2 additions & 0 deletions src/services/_url.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export const TTS_URL = {
edge: '/api/tts/edge-speech',
microsoft: '/api/tts/microsoft-speech',
};

export const PROXY_URL = '/api/proxy';
76 changes: 69 additions & 7 deletions src/services/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import {
} from '@lobehub/chat-plugin-sdk';
import { uniq } from 'lodash-es';
import { convertParametersToJSONSchema } from 'openapi-jsonschema-parameters';
import YAML from 'yaml';

import { getPluginIndexJSON } from '@/const/url';
import { PluginModel } from '@/database/models/plugin';
import { PROXY_URL } from '@/services/_url';
import { globalHelpers } from '@/store/global/helpers';
import { OpenAIPluginManifest } from '@/types/openai/plugin';
import { LobeTool } from '@/types/tool';
import { LobeToolCustomPlugin } from '@/types/tool/plugin';
import { merge } from '@/utils/merge';
Expand All @@ -22,11 +25,11 @@ export interface InstallPluginParams {
type: 'plugin' | 'customPlugin';
}
class PluginService {
private _fetchJSON = async <T = any>(url: string): Promise<T> => {
private _fetchJSON = async <T = any>(url: string, proxy = false): Promise<T> => {
// 2. 发送请求
let res: Response;
try {
res = await fetch(url);
res = await (proxy ? fetch(PROXY_URL, { body: url, method: 'POST' }) : fetch(url));
} catch {
throw new TypeError('fetchError');
}
Expand All @@ -36,8 +39,15 @@ class PluginService {
}

let data;
const contentType = res.headers.get('Content-Type');

try {
data = await res.json();
if (contentType === 'application/json') {
data = await res.json();
} else {
const yaml = await res.text();
data = YAML.parse(yaml);
}
} catch {
throw new TypeError('urlError');
}
Expand All @@ -57,15 +67,25 @@ class PluginService {
return data;
};

getPluginManifest = async (url?: string): Promise<LobeChatPluginManifest> => {
getPluginManifest = async (
url?: string,
useProxy: boolean = false,
): Promise<LobeChatPluginManifest> => {
// 1. valid plugin
if (!url) {
throw new TypeError('noManifest');
}

// 2. 发送请求

const data = await this._fetchJSON<LobeChatPluginManifest>(url);
let data = await this._fetchJSON<LobeChatPluginManifest>(url, useProxy);

// @ts-ignore
// if there is a description_for_model, it is an OpenAI plugin
// we need convert to lobe plugin
if (data['description_for_model']) {
data = this.convertOpenAIManifestToLobeManifest(data as any);
}

// 3. 校验插件文件格式规范
const parser = pluginManifestSchema.safeParse(data);
Expand All @@ -74,9 +94,10 @@ class PluginService {
throw new TypeError('manifestInvalid', { cause: parser.error });
}

// 4. if exist OpenAPI api, merge the openAPIs with apis
// 4. if exist OpenAPI api, merge the OpenAPIs to api
if (parser.data.openapi) {
const openapiJson = await this._fetchJSON(parser.data.openapi);
const openapiJson = await this._fetchJSON(parser.data.openapi, useProxy);

try {
const openAPIs = await this.convertOpenAPIToPluginSchema(openapiJson);
data.api = [...data.api, ...openAPIs];
Expand Down Expand Up @@ -265,6 +286,47 @@ class PluginService {

return settingsSchema;
};

private convertOpenAIManifestToLobeManifest = (
data: OpenAIPluginManifest,
): LobeChatPluginManifest => {
const manifest: LobeChatPluginManifest = {
api: [],
homepage: data.legal_info_url,
identifier: data.name_for_model,
meta: {
avatar: data.logo_url,
description: data.description_for_human,
title: data.name_for_human,
},
openapi: data.api.url,

type: 'default',
version: '1',
};
switch (data.auth.type) {
case 'none': {
break;
}
case 'service_http': {
manifest.settings = {
properties: {
apiAuthKey: {
default: data.auth.verification_tokens['openai'],
description: 'API Key',
format: 'password',
title: 'API Key',
type: 'string',
},
},
type: 'object',
};
break;
}
}

return manifest;
};
}

export const pluginService = new PluginService();
5 changes: 4 additions & 1 deletion src/store/tool/slices/customPlugin/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,10 @@ export const createCustomPluginSlice: StateCreator<
const { refreshPlugins, updateInstallLoadingState } = get();
try {
updateInstallLoadingState(id, true);
const manifest = await pluginService.getPluginManifest(plugin.customParams?.manifestUrl);
const manifest = await pluginService.getPluginManifest(
plugin.customParams?.manifestUrl,
plugin.customParams?.useProxy,
);
updateInstallLoadingState(id, false);

await pluginService.updatePluginManifest(id, manifest);
Expand Down
Loading

0 comments on commit 04ff2d5

Please sign in to comment.