mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-27 13:29:15 +07:00
✨ feat: support password auth and error (#22)
* ✨ feat: Server 端支持密码鉴权 & 完善错误码处理逻辑 * 🎨 fix: 修正 package.json 的引入问题 * 💬 style: 优化设置文案 * 💬 style: 优化错误文案 * ✨ feat: 支持 ChatList loading 渲染 * ✨ feat: 新增密码输入或 api key 输入组件 * 🌐 style: 修正 i18n 文案问题 * 🔧 chore: remove error lint rule * ✨ feat: 支持展示 OpenAI 的业务错误内容 * ✨ feat: 完成 OpenAI 报错内容展示优化
This commit is contained in:
@@ -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
src/config/server.ts
Normal file
22
src/config/server.ts
Normal file
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,6 +30,7 @@ export default {
|
||||
},
|
||||
feedback: '反馈与建议',
|
||||
import: '导入配置',
|
||||
moreSetting: '更多设置...',
|
||||
newAgent: '新建助手',
|
||||
noDescription: '暂无描述',
|
||||
ok: '确定',
|
||||
|
||||
27
src/locales/default/error.ts
Normal file
27
src/locales/default/error.ts
Normal file
@@ -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: '输入密码解锁应用',
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
25
src/pages/api/auth.ts
Normal file
25
src/pages/api/auth.ts
Normal file
@@ -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
src/pages/api/error.ts
Normal file
21
src/pages/api/error.ts
Normal file
@@ -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 });
|
||||
};
|
||||
@@ -1,8 +1,9 @@
|
||||
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';
|
||||
@@ -10,8 +11,13 @@ 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 stream = await createChatCompletion({ OPENAI_API_KEY: apiKey, payload });
|
||||
const result = checkAuth({ accessCode, apiKey });
|
||||
|
||||
return new StreamingTextResponse(stream);
|
||||
if (!result.auth) {
|
||||
return createErrorResponse(result.error as ErrorType);
|
||||
}
|
||||
|
||||
return createChatCompletion({ OPENAI_API_KEY: apiKey, payload });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { StreamingTextResponse } from 'ai';
|
||||
import { ChatCompletionRequestMessage } from 'openai-edge';
|
||||
|
||||
import { OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
|
||||
@@ -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 (
|
||||
@@ -42,6 +41,4 @@ export default async function handler(req: Request) {
|
||||
}),
|
||||
payload,
|
||||
});
|
||||
|
||||
return new StreamingTextResponse(stream);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Highlighter } from '@lobehub/ui';
|
||||
import { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { useSessionStore } from '@/store/session';
|
||||
|
||||
import APIKeyForm from './ApiKeyForm';
|
||||
import { ErrorActionContainer, useStyles } from './style';
|
||||
|
||||
interface OpenAIError {
|
||||
code: 'invalid_api_key' | string;
|
||||
message: string;
|
||||
param?: any;
|
||||
type: string;
|
||||
}
|
||||
|
||||
interface OpenAIErrorResponse {
|
||||
error: OpenAIError;
|
||||
}
|
||||
|
||||
const OpenAiBizError = memo<{ content: OpenAIErrorResponse; id: string }>(({ content, id }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
const [resend, deleteMessage] = useSessionStore(
|
||||
(s) => [s.resendMessage, s.deleteMessage],
|
||||
shallow,
|
||||
);
|
||||
|
||||
const errorCode = content.error?.code;
|
||||
|
||||
if (errorCode === 'invalid_api_key') {
|
||||
return (
|
||||
<ErrorActionContainer>
|
||||
<APIKeyForm
|
||||
onConfirm={() => {
|
||||
console.log(id);
|
||||
resend(id);
|
||||
deleteMessage(id);
|
||||
}}
|
||||
/>
|
||||
</ErrorActionContainer>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Flexbox className={styles.container} style={{ maxWidth: 600 }}>
|
||||
<Highlighter language={'json'}>{JSON.stringify(content, null, 2)}</Highlighter>
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default OpenAiBizError;
|
||||
44
src/pages/chat/[id]/Conversation/ChatList/Error/style.tsx
Normal file
44
src/pages/chat/[id]/Conversation/ChatList/Error/style.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Avatar } from '@lobehub/ui';
|
||||
import { createStyles } from 'antd-style';
|
||||
import { ReactNode, memo } from 'react';
|
||||
import { Center, Flexbox } from 'react-layout-kit';
|
||||
|
||||
export const useStyles = createStyles(({ css, token }) => ({
|
||||
container: css`
|
||||
background: ${token.colorBgContainer};
|
||||
border: 1px solid ${token.colorSplit};
|
||||
border-radius: 8px;
|
||||
`,
|
||||
desc: css`
|
||||
color: ${token.colorTextTertiary};
|
||||
text-align: center;
|
||||
`,
|
||||
}));
|
||||
|
||||
export const ErrorActionContainer = memo<{ children: ReactNode }>(({ children }) => {
|
||||
const { styles } = useStyles();
|
||||
|
||||
return (
|
||||
<Center className={styles.container} gap={24} padding={24}>
|
||||
{children}
|
||||
</Center>
|
||||
);
|
||||
});
|
||||
|
||||
export const FormAction = memo<{
|
||||
avatar: string;
|
||||
children: ReactNode;
|
||||
description: string;
|
||||
title: string;
|
||||
}>(({ children, title, description, avatar }) => {
|
||||
const { styles, theme } = useStyles();
|
||||
|
||||
return (
|
||||
<Center gap={16} style={{ maxWidth: 300 }}>
|
||||
<Avatar avatar={avatar} background={theme.colorText} gap={12} size={80} />
|
||||
<Flexbox style={{ fontSize: 20 }}>{title}</Flexbox>
|
||||
<Flexbox className={styles.desc}>{description}</Flexbox>
|
||||
{children}
|
||||
</Center>
|
||||
);
|
||||
});
|
||||
142
src/pages/chat/[id]/Conversation/ChatList/OTPInput.tsx
Normal file
142
src/pages/chat/[id]/Conversation/ChatList/OTPInput.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useControllableValue } from 'ahooks';
|
||||
import { createStyles } from 'antd-style';
|
||||
import React, { memo } from 'react';
|
||||
import { Flexbox } from 'react-layout-kit';
|
||||
|
||||
const useStyles = createStyles(
|
||||
({ css, token }) => css`
|
||||
width: ${token.controlHeight}px;
|
||||
height: ${token.controlHeight}px;
|
||||
|
||||
font-size: 16px;
|
||||
color: ${token.colorText};
|
||||
text-align: center;
|
||||
|
||||
background: ${token.colorBgContainer};
|
||||
border: 1px solid ${token.colorBorder};
|
||||
border-radius: 8px;
|
||||
|
||||
&:focus,
|
||||
&:focus-visible {
|
||||
border-color: ${token.colorPrimary};
|
||||
outline: none;
|
||||
}
|
||||
`,
|
||||
);
|
||||
|
||||
/**
|
||||
* Let's borrow some props from HTML "input". More info below:
|
||||
*
|
||||
* [Pick Documentation](https://www.typescriptlang.org/docs/handbook/utility-types.html#picktype-keys)
|
||||
*
|
||||
* [How to extend HTML Elements](https://reacthustle.com/blog/how-to-extend-html-elements-in-react-typescript)
|
||||
*/
|
||||
type PartialInputProps = Pick<React.ComponentPropsWithoutRef<'input'>, 'className' | 'style'>;
|
||||
|
||||
interface OtpInputProps extends PartialInputProps {
|
||||
onChange?: (value: string) => void;
|
||||
/**
|
||||
* Number of characters/input for this component
|
||||
*/
|
||||
size?: number;
|
||||
/**
|
||||
* Validation pattern for each input.
|
||||
* e.g: /[0-9]{1}/ for digits only or /[0-9a-zA-Z]{1}/ for alphanumeric
|
||||
*/
|
||||
validationPattern?: RegExp;
|
||||
/**
|
||||
* full value of the otp input, up to {size} characters
|
||||
*/
|
||||
value?: string;
|
||||
}
|
||||
|
||||
const handleKeyUp = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
const current = e.currentTarget;
|
||||
if (e.key === 'ArrowLeft' || e.key === 'Backspace') {
|
||||
const prev = current.previousElementSibling as HTMLInputElement | null;
|
||||
prev?.focus();
|
||||
prev?.setSelectionRange(0, 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
const prev = current.nextSibling as HTMLInputElement | null;
|
||||
prev?.focus();
|
||||
prev?.setSelectionRange(0, 1);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const OtpInput = memo<OtpInputProps>((props) => {
|
||||
const {
|
||||
//Set the default size to 6 characters
|
||||
size = 6,
|
||||
//Default validation is digits
|
||||
validationPattern = /\d/,
|
||||
value: outerValue,
|
||||
onChange,
|
||||
className,
|
||||
...restProps
|
||||
} = props;
|
||||
const [value, setValue] = useControllableValue({ onChange, value: outerValue });
|
||||
|
||||
const { styles, cx } = useStyles();
|
||||
// Create an array based on the size.
|
||||
const arr = Array.from({ length: size }).fill('-');
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>, index: number) => {
|
||||
const elem = e.target;
|
||||
const val = e.target.value;
|
||||
|
||||
// check if the value is valid
|
||||
if (!validationPattern.test(val) && val !== '') return;
|
||||
|
||||
// change the value using onChange props
|
||||
const valueArr = value?.split('') || [];
|
||||
valueArr[index] = val;
|
||||
const newVal = valueArr.join('').slice(0, 6);
|
||||
setValue(newVal);
|
||||
|
||||
//focus the next element if there's a value
|
||||
if (val) {
|
||||
const next = elem.nextElementSibling as HTMLInputElement | null;
|
||||
next?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
||||
e.preventDefault();
|
||||
const val = e.clipboardData.getData('text').slice(0, Math.max(0, size));
|
||||
|
||||
setValue(val);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flexbox gap={12} horizontal>
|
||||
{arr.map((_, index) => {
|
||||
return (
|
||||
<input
|
||||
key={index}
|
||||
{...restProps}
|
||||
/**
|
||||
* Add some styling to the input using daisyUI + tailwind.
|
||||
* Allows the user to override the classname for a different styling
|
||||
*/
|
||||
autoComplete="one-time-code"
|
||||
className={cx(styles, className)}
|
||||
inputMode="numeric"
|
||||
maxLength={6}
|
||||
onChange={(e) => handleInputChange(e, index)}
|
||||
onKeyUp={handleKeyUp}
|
||||
onPaste={handlePaste}
|
||||
pattern={validationPattern.source}
|
||||
type="text"
|
||||
value={value?.at(index) ?? ''}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Flexbox>
|
||||
);
|
||||
});
|
||||
|
||||
export default OtpInput;
|
||||
@@ -1,38 +1,55 @@
|
||||
import { ChatList, ChatMessage } from '@lobehub/ui';
|
||||
import { ChatList, RenderErrorMessage, RenderMessage } from '@lobehub/ui';
|
||||
import isEqual from 'fast-deep-equal';
|
||||
import { ReactNode, memo } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { shallow } from 'zustand/shallow';
|
||||
|
||||
import { agentSelectors, chatSelectors, useSessionStore } from '@/store/session';
|
||||
import { ErrorType } from '@/types/fetch';
|
||||
import { isFunctionMessage } from '@/utils/message';
|
||||
|
||||
import InvalidAccess from './Error/InvalidAccess';
|
||||
import OpenAiBizError from './Error/OpenAiBizError';
|
||||
import FunctionMessage from './FunctionMessage';
|
||||
import MessageExtra from './MessageExtra';
|
||||
|
||||
const renderMessage: RenderMessage = (content, message) => {
|
||||
if (message.role === 'function')
|
||||
return isFunctionMessage(message.content) ? <FunctionMessage /> : content;
|
||||
|
||||
return content;
|
||||
};
|
||||
|
||||
const renderErrorMessage: RenderErrorMessage = (error, message) => {
|
||||
switch (error.type as ErrorType) {
|
||||
case 'InvalidAccessCode': {
|
||||
return <InvalidAccess id={message.id} />;
|
||||
}
|
||||
case 'OpenAIBizError': {
|
||||
return <OpenAiBizError content={(error as any).body} id={message.id} />;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const List = () => {
|
||||
const { t } = useTranslation('common');
|
||||
const data = useSessionStore(chatSelectors.currentChats, isEqual);
|
||||
const [displayMode, deleteMessage, resendMessage, dispatchMessage] = useSessionStore(
|
||||
(s) => [
|
||||
agentSelectors.currentAgentConfigSafe(s).displayMode,
|
||||
s.deleteMessage,
|
||||
s.resendMessage,
|
||||
s.dispatchMessage,
|
||||
],
|
||||
shallow,
|
||||
);
|
||||
|
||||
const renderMessage = (content: ReactNode, message: ChatMessage) => {
|
||||
if (message.role === 'function')
|
||||
return isFunctionMessage(message.content) ? <FunctionMessage /> : content;
|
||||
|
||||
return content;
|
||||
};
|
||||
const [displayMode, chatLoadingId, deleteMessage, resendMessage, dispatchMessage] =
|
||||
useSessionStore(
|
||||
(s) => [
|
||||
agentSelectors.currentAgentConfigSafe(s).displayMode,
|
||||
s.chatLoadingId,
|
||||
s.deleteMessage,
|
||||
s.resendMessage,
|
||||
s.dispatchMessage,
|
||||
],
|
||||
shallow,
|
||||
);
|
||||
|
||||
return (
|
||||
<ChatList
|
||||
data={data}
|
||||
loadingId={chatLoadingId}
|
||||
onActionClick={(key, id) => {
|
||||
switch (key) {
|
||||
case 'delete': {
|
||||
@@ -49,6 +66,7 @@ const List = () => {
|
||||
onMessageChange={(id, content) => {
|
||||
dispatchMessage({ id, key: 'content', type: 'updateMessage', value: content });
|
||||
}}
|
||||
renderErrorMessage={renderErrorMessage}
|
||||
renderMessage={renderMessage}
|
||||
renderMessageExtra={MessageExtra}
|
||||
style={{ marginTop: 24 }}
|
||||
@@ -8,7 +8,7 @@ import { ModelTokens } from '@/const/modelTokens';
|
||||
import { agentSelectors, chatSelectors, useSessionStore } from '@/store/session';
|
||||
|
||||
const Token = memo<{ input: string }>(({ input }) => {
|
||||
const { t } = useTranslation();
|
||||
const { t } = useTranslation('common');
|
||||
|
||||
const inputTokenCount = useMemo(() => encode(input).length, [input]);
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import Router from 'next/router';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { version } from '@/../package.json';
|
||||
import pkg from '@/../package.json';
|
||||
import HeaderTitle from '@/components/HeaderTitle';
|
||||
|
||||
const Header = memo(() => {
|
||||
@@ -12,7 +12,7 @@ const Header = memo(() => {
|
||||
|
||||
return (
|
||||
<ChatHeader
|
||||
left={<HeaderTitle tag={<Tag>{`v${version}`}</Tag>} title={t('header.global')} />}
|
||||
left={<HeaderTitle tag={<Tag>{`v${pkg.version}`}</Tag>} title={t('header.global')} />}
|
||||
onBackClick={() => Router.back()}
|
||||
showBackButton
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { merge } from 'lodash-es';
|
||||
|
||||
import { OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
|
||||
import { LOBE_CHAT_ACCESS_CODE, OPENAI_API_KEY_HEADER_KEY } from '@/const/fetch';
|
||||
import { initialLobeAgentConfig } from '@/store/session';
|
||||
import { useSettings } from '@/store/settings';
|
||||
import type { OpenAIStreamPayload } from '@/types/openai';
|
||||
@@ -32,6 +32,7 @@ export const fetchChatModel = (
|
||||
body: JSON.stringify(payload),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
[LOBE_CHAT_ACCESS_CODE]: useSettings.getState().settings.password || '',
|
||||
[OPENAI_API_KEY_HEADER_KEY]: useSettings.getState().settings.OPENAI_API_KEY || '',
|
||||
},
|
||||
method: 'POST',
|
||||
|
||||
@@ -110,7 +110,7 @@ export const chatMessage: StateCreator<
|
||||
|
||||
generateMessage: async (messages, assistantId, withPlugin) => {
|
||||
const { dispatchMessage } = get();
|
||||
set({ chatLoading: true });
|
||||
set({ chatLoadingId: assistantId });
|
||||
const config = agentSelectors.currentAgentConfigSafe(get());
|
||||
|
||||
const fetcher = () =>
|
||||
@@ -156,7 +156,7 @@ export const chatMessage: StateCreator<
|
||||
},
|
||||
});
|
||||
|
||||
set({ chatLoading: false });
|
||||
set({ chatLoadingId: undefined });
|
||||
|
||||
return { isFunctionCall, output };
|
||||
},
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
export interface ChatState {
|
||||
activeTopicId?: string;
|
||||
chatLoading: boolean;
|
||||
chatLoadingId?: string;
|
||||
topicLoadingId?: string;
|
||||
}
|
||||
|
||||
export const initialChatState: ChatState = {
|
||||
chatLoading: false,
|
||||
};
|
||||
export const initialChatState: ChatState = {};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { ErrorType } from '@/types/fetch';
|
||||
|
||||
import { LLMRoleType } from './llm';
|
||||
import { BaseDataModel } from './meta';
|
||||
|
||||
@@ -5,12 +7,9 @@ import { BaseDataModel } from './meta';
|
||||
* 聊天消息错误对象
|
||||
*/
|
||||
export interface ChatMessageError {
|
||||
/**
|
||||
* 错误信息
|
||||
*/
|
||||
body?: any;
|
||||
message: string;
|
||||
status: number;
|
||||
type: 'general' | 'llm';
|
||||
type: ErrorType;
|
||||
}
|
||||
|
||||
export interface ChatMessage extends BaseDataModel {
|
||||
|
||||
26
src/types/fetch.ts
Normal file
26
src/types/fetch.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export enum ErrorType {
|
||||
// ******* 业务错误语义 ******* //
|
||||
|
||||
// 密码无效
|
||||
InvalidAccessCode = 'InvalidAccessCode',
|
||||
// OpenAI 返回的业务错误
|
||||
OpenAIBizError = 'OpenAIBizError',
|
||||
|
||||
// ******* 客户端错误 ******* //
|
||||
BadRequest = 400,
|
||||
Unauthorized = 401,
|
||||
Forbidden = 403,
|
||||
ContentNotFound = 404, // 没找到接口
|
||||
TooManyRequests = 429,
|
||||
|
||||
// ******* 服务端错误 ******* //
|
||||
InternalServerError = 500,
|
||||
BadGateway = 502,
|
||||
ServiceUnavailable = 503,
|
||||
GatewayTimeout = 504,
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
body: any;
|
||||
errorType: ErrorType;
|
||||
}
|
||||
@@ -1,24 +1,8 @@
|
||||
// import { notification } from '@/layout';
|
||||
import { t } from 'i18next';
|
||||
|
||||
import { fetchChatModel } from '@/services/chatModel';
|
||||
import { ChatMessageError } from '@/types/chatMessage';
|
||||
|
||||
const codeMessage: Record<number, string> = {
|
||||
200: '成功获取数据,服务已响应',
|
||||
201: '操作成功,数据已保存',
|
||||
202: '您的请求已进入后台排队,请耐心等待异步任务完成',
|
||||
204: '数据已成功删除',
|
||||
400: '很抱歉,您的请求出错,服务器未执行任何数据的创建或修改操作',
|
||||
401: '很抱歉,您的权限不足。请确认用户名或密码是否正确',
|
||||
403: '很抱歉,您无权访问此内容',
|
||||
404: '很抱歉,您请求的记录不存在,服务器未能执行任何操作',
|
||||
406: '很抱歉,服务器不支持该请求格式',
|
||||
410: '很抱歉,你所请求的资源已永久删除',
|
||||
422: '很抱歉,在创建对象时遇到验证错误,请稍后再试',
|
||||
500: '很抱歉,服务器出现了问题,请稍后再试',
|
||||
502: '很抱歉,您遇到了网关错误。这可能是由于网络故障或服务器问题导致的。请稍后再试,或联系管理员以获取更多帮助',
|
||||
503: '很抱歉,我们的服务器过载或处在维护中,服务暂时不可用',
|
||||
504: '很抱歉,网关请求超时,请稍后再试',
|
||||
};
|
||||
import { ErrorResponse } from '@/types/fetch';
|
||||
|
||||
export interface FetchSSEOptions {
|
||||
onErrorHandle?: (error: ChatMessageError) => void;
|
||||
@@ -33,13 +17,25 @@ export interface FetchSSEOptions {
|
||||
export const fetchSSE = async (fetchFn: () => Promise<Response>, options: FetchSSEOptions = {}) => {
|
||||
const response = await fetchFn();
|
||||
|
||||
// 如果不 ok 说明有连接请求错误
|
||||
// 如果不 ok 说明有请求错误
|
||||
if (!response.ok) {
|
||||
const chatMessageError: ChatMessageError = {
|
||||
message: codeMessage[response.status],
|
||||
status: response.status,
|
||||
type: 'general',
|
||||
};
|
||||
let chatMessageError: ChatMessageError;
|
||||
|
||||
// 尝试取一波业务错误语义
|
||||
try {
|
||||
const data = (await response.json()) as ErrorResponse;
|
||||
chatMessageError = {
|
||||
body: data.body,
|
||||
message: t(`response.${data.errorType}`),
|
||||
type: data.errorType,
|
||||
};
|
||||
} catch {
|
||||
// 如果无法正常返回,说明是常规报错
|
||||
chatMessageError = {
|
||||
message: t(`response.${response.status}`),
|
||||
type: response.status,
|
||||
};
|
||||
}
|
||||
|
||||
options.onErrorHandle?.(chatMessageError);
|
||||
return;
|
||||
|
||||
Reference in New Issue
Block a user