Skip to content

Commit

Permalink
✨ feat: support password auth and error (lobehub#22)
Browse files Browse the repository at this point in the history
* ✨ feat: Server 端支持密码鉴权 & 完善错误码处理逻辑

* 🎨 fix: 修正 package.json 的引入问题

* 💬 style: 优化设置文案

* 💬 style: 优化错误文案

* ✨ feat: 支持 ChatList loading 渲染

* ✨ feat: 新增密码输入或 api key 输入组件

* 🌐 style: 修正 i18n 文案问题

* 🔧 chore: remove error lint rule

* ✨ feat: 支持展示 OpenAI 的业务错误内容

* ✨ feat: 完成 OpenAI 报错内容展示优化
  • Loading branch information
arvinxx authored Jul 28, 2023
1 parent 62f2332 commit 67f1f4d
Show file tree
Hide file tree
Showing 29 changed files with 607 additions and 89 deletions.
3 changes: 2 additions & 1 deletion .eslintrc.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
const config = require('@lobehub/lint').eslint;

config.extends.push('plugin:@next/next/recommended');
//config.extends.push('plugin:@next/next/core-web-vitals');

config.rules['unicorn/no-negated-condition'] = 0;
config.rules['unicorn/prefer-type-error'] = 0;

module.exports = config;
22 changes: 22 additions & 0 deletions src/config/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {
ACCESS_CODE?: string;
OPENAI_API_KEY?: string;
OPENAI_PROXY_URL?: string;
}
}
}

export const getServerConfig = () => {
if (typeof process === 'undefined') {
throw new Error('[Server Config] you are importing a nodejs-only module outside of nodejs');
}

return {
ACCESS_CODE: process.env.ACCESS_CODE,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
OPENAI_PROXY_URL: process.env.OPENAI_PROXY_URL,
};
};
4 changes: 2 additions & 2 deletions src/const/fetch.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export const OPENAI_SERVICE_ERROR_CODE = 555;

export const OPENAI_API_KEY_HEADER_KEY = 'X-OPENAI-API-KEY';

export const LOBE_CHAT_ACCESS_CODE = 'X-LOBE_CHAT_ACCESS_CODE';
17 changes: 3 additions & 14 deletions src/locales/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,23 +7,12 @@ import type { Namespaces } from '@/types/locale';

import resources from './resources';

// const getRes = (res: Resources, namespace: Namespaces[]) => {
// const newRes: any = {};
// for (const [locale, value] of Object.entries(res)) {
// newRes[locale] = {};
// for (const ns of namespace) {
// newRes[locale][ns] = value[ns];
// }
// }
// return newRes;
// };

export const createI18nNext = (namespace?: Namespaces[] | Namespaces) => {
const ns: Namespaces[] = namespace
? isArray(namespace)
? ['common', ...namespace]
: ['common', namespace]
: ['common'];
? ['error', 'common', ...namespace]
: ['error', 'common', namespace]
: ['error', 'common'];
return (
i18n
// detect user language
Expand Down
1 change: 1 addition & 0 deletions src/locales/default/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default {
},
feedback: '反馈与建议',
import: '导入配置',
moreSetting: '更多设置...',
newAgent: '新建助手',
noDescription: '暂无描述',
ok: '确定',
Expand Down
27 changes: 27 additions & 0 deletions src/locales/default/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
export default {
response: {
400: '很抱歉,服务器不明白您的请求,请确认您的请求参数是否正确',
401: '很抱歉,服务器拒绝了您的请求,可能是因为您的权限不足或未提供有效的身份验证',
403: '很抱歉,服务器拒绝了您的请求,您没有访问此内容的权限 ',
404: '很抱歉,服务器找不到您请求的页面或资源,请确认您的 URL 是否正确',
429: '很抱歉,您的请求太多,服务器有点累了,请稍后再试',
500: '很抱歉,服务器似乎遇到了一些困难,暂时无法完成您的请求,请稍后再试',
502: '很抱歉,服务器似乎迷失了方向,暂时无法提供服务,请稍后再试',
503: '很抱歉,服务器当前无法处理您的请求,可能是由于过载或正在进行维护,请稍后再试',
504: '很抱歉,服务器没有等到上游服务器的回应,请稍后再试',

InvalidAccessCode: '密码不正确或为空,请输入正确的访问密码,或者添加自定义 OpenAI API Key',
OpenAIBizError: '请求 OpenAI 服务出错,请根据以下信息排查或重试',
},
unlock: {
apikey: {
description: '输入你的 OpenAI API Key 即可绕过密码验证。应用不会记录你的 API Key',
title: '使用自定义 API Key',
},
confirm: '确认并重试',
password: {
description: '管理员已开启应用加密,输入应用密码后即可解锁应用。密码只需填写一次',
title: '输入密码解锁应用',
},
},
};
2 changes: 2 additions & 0 deletions src/locales/resources/zh_CN.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import common from '../default/common';
import error from '../default/error';
import plugin from '../default/plugin';
import setting from '../default/setting';

const resources = {
common,
error,
plugin,
setting,
} as const;
Expand Down
25 changes: 25 additions & 0 deletions src/pages/api/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { getServerConfig } from '@/config/server';
import { ErrorType } from '@/types/fetch';

interface AuthConfig {
accessCode?: string | null;
apiKey?: string | null;
}

export const checkAuth = ({ apiKey, accessCode }: AuthConfig) => {
const { ACCESS_CODE } = getServerConfig();

// 如果存在 apiKey
if (apiKey) {
return { auth: true };
}

// 如果不存在,则检查 accessCode
if (!ACCESS_CODE) return { auth: true };

if (accessCode !== ACCESS_CODE) {
return { auth: false, error: ErrorType.InvalidAccessCode };
}

return { auth: true };
};
21 changes: 21 additions & 0 deletions src/pages/api/error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { ErrorResponse, ErrorType } from '@/types/fetch';

const getStatus = (errorType: ErrorType) => {
switch (errorType) {
case ErrorType.InvalidAccessCode: {
return 401;
}

case ErrorType.OpenAIBizError: {
return 577;
}
}
};

export const createErrorResponse = (errorType: ErrorType, body?: any) => {
const statusCode = getStatus(errorType);

const data: ErrorResponse = { body, errorType };

return new Response(JSON.stringify(data), { status: statusCode });
};
16 changes: 11 additions & 5 deletions src/pages/api/openai.api.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,23 @@
import { StreamingTextResponse } from 'ai';

import { OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
import { LOBE_CHAT_ACCESS_CODE, OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
import { ErrorType } from '@/types/fetch';
import { OpenAIStreamPayload } from '@/types/openai';

import { checkAuth } from './auth';
import { createErrorResponse } from './error';
import { createChatCompletion } from './openai';

export const runtime = 'edge';

export default async function handler(req: Request) {
const payload = (await req.json()) as OpenAIStreamPayload;
const apiKey = req.headers.get(OPENAI_API_KEY_HEADER_KEY);
const accessCode = req.headers.get(LOBE_CHAT_ACCESS_CODE);

const result = checkAuth({ accessCode, apiKey });

const stream = await createChatCompletion({ OPENAI_API_KEY: apiKey, payload });
if (!result.auth) {
return createErrorResponse(result.error as ErrorType);
}

return new StreamingTextResponse(stream);
return createChatCompletion({ OPENAI_API_KEY: apiKey, payload });
}
22 changes: 17 additions & 5 deletions src/pages/api/openai.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,22 @@
import { OpenAIStream, OpenAIStreamCallbacks } from 'ai';
import { OpenAIStream, OpenAIStreamCallbacks, StreamingTextResponse } from 'ai';
import { Configuration, OpenAIApi } from 'openai-edge';
import { ChatCompletionFunctions } from 'openai-edge/types/api';

import { getServerConfig } from '@/config/server';
import { createErrorResponse } from '@/pages/api/error';
import { ErrorType } from '@/types/fetch';
import { OpenAIStreamPayload } from '@/types/openai';

import pluginList from '../../plugins';

const isDev = process.env.NODE_ENV === 'development';
const OPENAI_PROXY_URL = process.env.OPENAI_PROXY_URL;

// 创建 OpenAI 实例
export const createOpenAI = (OPENAI_API_KEY: string | null) => {
export const createOpenAI = (userApiKey: string | null) => {
const { OPENAI_API_KEY, OPENAI_PROXY_URL } = getServerConfig();

const config = new Configuration({
apiKey: !OPENAI_API_KEY ? process.env.OPENAI_API_KEY : OPENAI_API_KEY,
apiKey: !userApiKey ? OPENAI_API_KEY : userApiKey,
});

return new OpenAIApi(config, isDev && OPENAI_PROXY_URL ? OPENAI_PROXY_URL : undefined);
Expand Down Expand Up @@ -58,5 +62,13 @@ export const createChatCompletion = async ({

const response = await openai.createChatCompletion(requestParams);

return OpenAIStream(response, callbacks?.(requestParams));
if (!response.ok) {
const error = await response.json();

return createErrorResponse(ErrorType.OpenAIBizError, error);
}

const stream = OpenAIStream(response, callbacks?.(requestParams));

return new StreamingTextResponse(stream);
};
5 changes: 1 addition & 4 deletions src/pages/api/plugins.api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { StreamingTextResponse } from 'ai';
import { ChatCompletionRequestMessage } from 'openai-edge';

import { OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
Expand All @@ -15,7 +14,7 @@ export default async function handler(req: Request) {

const openai = createOpenAI(apiKey);

const stream = await createChatCompletion({
return await createChatCompletion({
OPENAI_API_KEY: apiKey,
callbacks: (payload) => ({
experimental_onFunctionCall: async (
Expand All @@ -42,6 +41,4 @@ export default async function handler(req: Request) {
}),
payload,
});

return new StreamingTextResponse(stream);
}
40 changes: 40 additions & 0 deletions src/pages/chat/[id]/Conversation/ChatList/Error/ApiKeyForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { Avatar } from '@lobehub/ui';
import { Button, Input } from 'antd';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { shallow } from 'zustand/shallow';

import { useSettings } from '@/store/settings';

import { useStyles } from './style';

const APIKeyForm = memo<{ onConfirm?: () => void }>(({ onConfirm }) => {
const { t } = useTranslation('error');
const { styles, theme } = useStyles();
const [apiKey, setSettings] = useSettings(
(s) => [s.settings.OPENAI_API_KEY, s.setSettings],
shallow,
);

return (
<Center gap={16} style={{ maxWidth: 300 }}>
<Avatar avatar={'🔑'} background={theme.colorText} gap={12} size={80} />
<Flexbox style={{ fontSize: 20 }}>{t('unlock.apikey.title')}</Flexbox>
<Flexbox className={styles.desc}>{t('unlock.apikey.description')}</Flexbox>
<Input
onChange={(e) => {
setSettings({ OPENAI_API_KEY: e.target.value });
}}
placeholder={'sk-*****************************************'}
type={'block'}
value={apiKey}
/>
<Button block onClick={onConfirm} style={{ marginTop: 8 }} type={'primary'}>
{t('unlock.confirm')}
</Button>
</Center>
);
});

export default APIKeyForm;
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { createStyles } from 'antd-style';
import { ReactNode, memo } from 'react';
import { Center } from 'react-layout-kit';

const useStyles = createStyles(({ css, token }) => ({
container: css`
background: ${token.colorBgContainer};
border: 1px solid ${token.colorSplit};
border-radius: 8px;
`,
}));

const ErrorActionContainer = memo<{ children: ReactNode }>(({ children }) => {
const { styles } = useStyles();

return (
<Center className={styles.container} gap={24} padding={24}>
{children}
</Center>
);
});

export default ErrorActionContainer;
76 changes: 76 additions & 0 deletions src/pages/chat/[id]/Conversation/ChatList/Error/InvalidAccess.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { Icon } from '@lobehub/ui';
import { Button, Segmented } from 'antd';
import { KeySquare, SquareAsterisk } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import { shallow } from 'zustand/shallow';

import { useSessionStore } from '@/store/session';
import { useSettings } from '@/store/settings';

import OtpInput from '../OTPInput';
import APIKeyForm from './ApiKeyForm';
import { ErrorActionContainer, FormAction } from './style';

const InvalidAccess = memo<{ id: string }>(({ id }) => {
const { t } = useTranslation('error');
const [mode, setMode] = useState('password');
const [password, setSettings] = useSettings((s) => [s.settings.password, s.setSettings], shallow);
const [resend, deleteMessage] = useSessionStore(
(s) => [s.resendMessage, s.deleteMessage],
shallow,
);

return (
<ErrorActionContainer>
<Segmented
block
onChange={(value) => setMode(value as string)}
options={[
{ icon: <Icon icon={SquareAsterisk} />, label: '密码', value: 'password' },
{ icon: <Icon icon={KeySquare} />, label: 'OpenAI API Key', value: 'api' },
]}
style={{ width: '100%' }}
value={mode}
/>
<Flexbox gap={24}>
{mode === 'password' ? (
<>
<FormAction
avatar={'🗳'}
description={t('unlock.password.description')}
title={t('unlock.password.title')}
>
<OtpInput
onChange={(e) => {
setSettings({ password: e });
}}
validationPattern={/[\dA-Za-z]/}
value={password}
/>
</FormAction>
<Button
onClick={() => {
resend(id);
deleteMessage(id);
}}
type={'primary'}
>
{t('unlock.confirm')}
</Button>
</>
) : (
<APIKeyForm
onConfirm={() => {
resend(id);
deleteMessage(id);
}}
/>
)}
</Flexbox>
</ErrorActionContainer>
);
});

export default InvalidAccess;
Loading

0 comments on commit 67f1f4d

Please sign in to comment.