0
Fork 0
mirror of https://github.com/logto-io/logto.git synced 2025-02-17 22:04:19 -05:00

feat(console): chatgpt plugins guide

This commit is contained in:
Gao Sun 2023-08-16 00:46:12 +08:00
parent c7a4eeb9a5
commit b29a984567
No known key found for this signature in database
GPG key ID: 13EBE123E4773688
15 changed files with 230 additions and 7 deletions

View file

@ -17,6 +17,7 @@ import webChatgpt from './web-chatgpt/index';
import webCsharp from './web-csharp/index';
import webExpress from './web-express/index';
import webGo from './web-go/index';
import webGptPlugin from './web-gpt-plugin/index';
import webJava from './web-java/index';
import webNext from './web-next/index';
import webNextAppRouter from './web-next-app-router/index';
@ -102,6 +103,13 @@ const guides: Readonly<Guide[]> = Object.freeze([
Component: lazy(async () => import('./web-express/README.mdx')),
metadata: webExpress,
},
{
id: 'web-gpt-plugin',
Logo: lazy(async () => import('./web-gpt-plugin/logo.svg')),
Component: lazy(async () => import('./web-gpt-plugin/README.mdx')),
metadata: webGptPlugin,
},
{
id: 'web-go',
Logo: lazy(async () => import('./web-go/logo.svg')),

View file

@ -0,0 +1,72 @@
import Steps from '@/mdx-components-v2/Steps';
import Step from '@/mdx-components-v2/Step';
import UriInputField from '@/mdx-components-v2/UriInputField';
import AlwaysIssueRefreshToken from './components/AlwaysIssueRefreshToken';
import AiPluginJson from './components/AiPluginJson';
import ClientBasics from './components/ClientBasics';
import chatgptPluginLogin from './assets/chatgpt-plugin-login.png';
import chatgptPluginOauthCredentials from './assets/chatgpt-plugin-oauth-credentials.png';
import logtoSignInExperience from './assets/logto-sign-in-experience.png';
<Steps>
<Step
title="Prerequisites"
>
[ChatGPT plugins](https://openai.com/blog/chatgpt-plugins) are tools designed specifically for language models, and help ChatGPT access up-to-date information, run computations, or use third-party services.
To get started, make sure you have a ChatGPT account with developer access for plugins. While ChatGPT plugins are available to all Plus members, you'll still need to join the waitlist to get the developer access.
Go through the [Chat Plugins introduction](https://platform.openai.com/docs/plugins/introduction) to have a basic understanding of how plugins work, you can stop at the "Authentication" section.
</Step>
<Step title="Fill out the redirect URI">
Once your plugin is registered, replace the `[your-plugin-id]` below with the acutal ID. For example, if your plugin ID is `foo123`, the value should be `https://chat.openai.com/aip/foo123/oauth/callback`.
<UriInputField name="redirectUris" defaultValue="https://chat.openai.com/aip/[your-plugin-id]/oauth/callback" />
Remember to click Save. To help ChatGPT maintain the authentication state, you can switch on the "Always issue Refresh Token" option below.
<AlwaysIssueRefreshToken />
</Step>
<Step title="Configure your plugin">
When setting up your plugin with ChatGPT, you will need to provide your OAuth Client ID and Client Secret.
<img alt="ChatGPT plugin OAuth credentials" src={chatgptPluginOauthCredentials} width="560" />
These correspond to the “App ID” and “App Secret” below (you can copy and paste):
<ClientBasics />
For the auth section in the `ai-plugin.json`, use the following template:
<AiPluginJson />
Remember to replace the `verification_tokens.openai` value with the actual one.
> The `profile` scope is a placeholder the ensure the `scope` parameter is not empty, since ChatGPT will add the parameter to the auth request even if it's not specified in the `ai-plugin.json` file which may cause unexpected behavior.
</Step>
<Step title="Test the authentication flow">
The ChatGPT UI will automatically prompt you to install the plugin. Once successful, you will see a dialog with a button that says "Log in with [your plugin name]."
<img alt="ChatGPT plugin login" src={chatgptPluginLogin} width="560" />
Click on the button, and you will be directed to the Logto sign-in experience.
<img alt="Logto sign-in experience" src={logtoSignInExperience} width="560" />
If everything is configured correctly, once you complete the sign-in or registration process in Logto, you will be redirected back to ChatGPT. From now on, every request sent by ChatGPT to your plugin server will carry the Authorization header, allowing you to decode and verify the token in your API.
</Step>
</Steps>

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

View file

@ -0,0 +1,30 @@
import { type SnakeCaseOidcConfig } from '@logto/schemas';
import useSWR from 'swr';
import { openIdProviderConfigPath } from '@/consts/oidc';
import CodeEditor from '@/ds-components/CodeEditor';
import { type RequestError } from '@/hooks/use-api';
// eslint-disable-next-line import/no-unused-modules
export default function AiPluginJson() {
const { data } = useSWR<SnakeCaseOidcConfig, RequestError>(openIdProviderConfigPath);
const authorizationEndpoint = data?.authorization_endpoint ?? '[LOADING]';
const authorizationUrl = data?.token_endpoint ?? '[LOADING]';
return (
<CodeEditor
isReadonly
language="json"
value={`"auth": {
"type": "oauth",
"client_url": "${authorizationEndpoint}",
"scope": "profile", // A placeholder scope, to make sure the \`scope\` parameter is not empty
"authorization_url": "${authorizationUrl}",
"authorization_content_type": "application/json",
"verification_tokens": {
"openai": "Replace_this_string_with_the_verification_token_generated_in_the_ChatGPT_UI"
}
}`}
/>
);
}

View file

@ -0,0 +1,47 @@
import { useContext, useState } from 'react';
import { toast } from 'react-hot-toast';
import { useTranslation } from 'react-i18next';
import FormField from '@/ds-components/FormField';
import Switch from '@/ds-components/Switch';
import useApi from '@/hooks/use-api';
import { GuideContext } from '@/pages/Applications/components/GuideV2';
// Used in the guide
// eslint-disable-next-line import/no-unused-modules
export default function AlwaysIssueRefreshToken() {
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
const {
app: {
id: appId,
customClientMetadata: { alwaysIssueRefreshToken },
},
} = useContext(GuideContext);
const [value, setValue] = useState(alwaysIssueRefreshToken ?? false);
const api = useApi();
const onSubmit = async (value: boolean) => {
setValue(value);
try {
await api.patch(`api/applications/${appId}`, {
json: {
customClientMetadata: {
alwaysIssueRefreshToken: value,
},
},
});
toast.success(t('general.saved'));
} catch {
setValue(!value);
}
};
return (
<FormField title="application_details.always_issue_refresh_token">
<Switch
label={t('application_details.always_issue_refresh_token_label')}
checked={value}
onChange={async ({ currentTarget: { checked } }) => onSubmit(checked)}
/>
</FormField>
);
}

View file

@ -0,0 +1,10 @@
@use '@/scss/underscore' as _;
.basic {
display: flex;
gap: _.unit(4);
.item {
margin: 0;
}
}

View file

@ -0,0 +1,27 @@
import { useContext } from 'react';
import { useTranslation } from 'react-i18next';
import CopyToClipboard from '@/ds-components/CopyToClipboard';
import FormField from '@/ds-components/FormField';
import { GuideContext } from '@/pages/Applications/components/GuideV2';
import * as styles from './index.module.scss';
// eslint-disable-next-line import/no-unused-modules
export default function ClientBasics() {
const {
app: { id, secret },
} = useContext(GuideContext);
const { t } = useTranslation(undefined, { keyPrefix: 'admin_console' });
return (
<div className={styles.basic}>
<FormField title="application_details.application_id" className={styles.item}>
<CopyToClipboard value={id} variant="border" />
</FormField>
<FormField title="application_details.application_secret" className={styles.item}>
<CopyToClipboard hasVisibilityToggle value={secret} variant="border" />
</FormField>
</div>
);
}

View file

@ -0,0 +1,11 @@
import { ApplicationType } from '@logto/schemas';
import { type GuideMetadata } from '../types';
const metadata: Readonly<GuideMetadata> = Object.freeze({
name: 'ChatGPT plugin',
description: 'Use Logto as an OAuth identity provider for ChatGPT plugins.',
target: ApplicationType.Traditional,
});
export default metadata;

View file

@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2406 2406">
<path
d="M1 578.4C1 259.5 259.5 1 578.4 1h1249.1c319 0 577.5 258.5 577.5 577.4V2406H578.4C259.5 2406 1 2147.5 1 1828.6V578.4z"
fill="#74aa9c" />
<path
d="M1107.3 299.1c-198 0-373.9 127.3-435.2 315.3C544.8 640.6 434.9 720.2 370.5 833c-99.3 171.4-76.6 386.9 56.4 533.8-41.1 123.1-27 257.7 38.6 369.2 98.7 172 297.3 260.2 491.6 219.2 86.1 97 209.8 152.3 339.6 151.8 198 0 373.9-127.3 435.3-315.3 127.5-26.3 237.2-105.9 301-218.5 99.9-171.4 77.2-386.9-55.8-533.9v-.6c41.1-123.1 27-257.8-38.6-369.8-98.7-171.4-297.3-259.6-491-218.6-86.6-96.8-210.5-151.8-340.3-151.2zm0 117.5-.6.6c79.7 0 156.3 27.5 217.6 78.4-2.5 1.2-7.4 4.3-11 6.1L952.8 709.3c-18.4 10.4-29.4 30-29.4 51.4V1248l-155.1-89.4V755.8c-.1-187.1 151.6-338.9 339-339.2zm434.2 141.9c121.6-.2 234 64.5 294.7 169.8 39.2 68.6 53.9 148.8 40.4 226.5-2.5-1.8-7.3-4.3-10.4-6.1l-360.4-208.2c-18.4-10.4-41-10.4-59.4 0L1024 984.2V805.4L1372.7 604c51.3-29.7 109.5-45.4 168.8-45.5zM650 743.5v427.9c0 21.4 11 40.4 29.4 51.4l421.7 243-155.7 90L597.2 1355c-162-93.8-217.4-300.9-123.8-462.8C513.1 823.6 575.5 771 650 743.5zm807.9 106 348.8 200.8c162.5 93.7 217.6 300.6 123.8 462.8l.6.6c-39.8 68.6-102.4 121.2-176.5 148.2v-428c0-21.4-11-41-29.4-51.4l-422.3-243.7 155-89.3zM1201.7 997l177.8 102.8v205.1l-177.8 102.8-177.8-102.8v-205.1L1201.7 997zm279.5 161.6 155.1 89.4v402.2c0 187.3-152 339.2-339 339.2v-.6c-79.1 0-156.3-27.6-217-78.4 2.5-1.2 8-4.3 11-6.1l360.4-207.5c18.4-10.4 30-30 29.4-51.4l.1-486.8zM1380 1421.9v178.8l-348.8 200.8c-162.5 93.1-369.6 38-463.4-123.7h.6c-39.8-68-54-148.8-40.5-226.5 2.5 1.8 7.4 4.3 10.4 6.1l360.4 208.2c18.4 10.4 41 10.4 59.4 0l421.9-243.7z"
fill="white" />
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View file

@ -24,7 +24,6 @@
.content {
max-width: 858px;
flex-grow: 1;
> :not(:last-child) {
margin-bottom: _.unit(6);

View file

@ -1,6 +1,6 @@
import { type Nullable } from '@silverhand/essentials';
import classNames from 'classnames';
import React, { useRef, type ReactElement, useEffect, useState } from 'react';
import React, { useRef, type ReactElement, useEffect, useState, useMemo } from 'react';
import useScroll from '@/hooks/use-scroll';
@ -11,7 +11,7 @@ import type Step from '../Step';
import * as styles from './index.module.scss';
type Props = {
children: Array<ReactElement<StepProps, typeof Step>>;
children: Array<ReactElement<StepProps, typeof Step>> | ReactElement<StepProps, typeof Step>;
};
/** Find the first scrollable element in the parent chain. */
@ -29,11 +29,15 @@ const findScrollableElement = (element: Nullable<HTMLElement>): Nullable<HTMLEle
return findScrollableElement(element.parentElement);
};
export default function Steps({ children }: Props) {
export default function Steps({ children: reactChildren }: Props) {
const contentRef = useRef<HTMLDivElement>(null);
const stepReferences = useRef<Array<Nullable<HTMLElement>>>([]);
const { scrollTop } = useScroll(findScrollableElement(contentRef.current));
const [activeIndex, setActiveIndex] = useState(-1);
const children = useMemo(
() => (Array.isArray(reactChildren) ? reactChildren : [reactChildren]),
[reactChildren]
);
useEffect(() => {
// Make sure the step references length matches the number of children.

View file

@ -1,5 +1,6 @@
import type { AdminConsoleKey } from '@logto/phrases';
import type { Application } from '@logto/schemas';
import { conditional } from '@silverhand/essentials';
import type { KeyboardEvent } from 'react';
import { useContext, useRef } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
@ -26,9 +27,11 @@ import * as styles from './index.module.scss';
type Props = {
name: 'redirectUris' | 'postLogoutRedirectUris';
/** The default value of the input field when there's no data. */
defaultValue?: string;
};
function UriInputField({ name }: Props) {
function UriInputField({ name, defaultValue }: Props) {
const methods = useForm<Partial<GuideForm>>();
const {
control,
@ -76,13 +79,17 @@ function UriInputField({ name }: Props) {
}
};
const defaultValueArray = data?.oidcClientMetadata[name].length
? data.oidcClientMetadata[name]
: conditional(defaultValue && [defaultValue]);
return (
<FormProvider {...methods}>
<form>
<Controller
name={name}
control={control}
defaultValue={data?.oidcClientMetadata[name]}
defaultValue={defaultValueArray}
rules={{
validate: createValidatorForRhf({
required: t(

View file

@ -48,7 +48,7 @@ function GuideV2({ app, isCompact, onClose }: Props) {
const { tenantEndpoint } = useContext(AppDataContext);
const { data: customDomain } = useCustomDomain();
const isCustomDomainActive = customDomain?.status === DomainStatus.Active;
const guide = guides.find(({ id }) => id === 'spa-react');
const guide = guides.find(({ id }) => id === 'web-gpt-plugin');
if (!app || !guide) {
throw new Error('Invalid app or guide');