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:
parent
c7a4eeb9a5
commit
b29a984567
15 changed files with 230 additions and 7 deletions
|
@ -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')),
|
||||
|
|
|
@ -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 |
|
@ -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"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
@use '@/scss/underscore' as _;
|
||||
|
||||
.basic {
|
||||
display: flex;
|
||||
gap: _.unit(4);
|
||||
|
||||
.item {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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;
|
|
@ -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 |
|
@ -24,7 +24,6 @@
|
|||
|
||||
.content {
|
||||
max-width: 858px;
|
||||
flex-grow: 1;
|
||||
|
||||
> :not(:last-child) {
|
||||
margin-bottom: _.unit(6);
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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');
|
||||
|
|
Loading…
Add table
Reference in a new issue