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:
Arvin Xu
2023-07-28 20:48:40 +08:00
committed by GitHub
parent 62f2332f52
commit 67f1f4d758
29 changed files with 607 additions and 89 deletions

View File

@@ -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
View 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,
};
};

View File

@@ -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';

View File

@@ -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

View File

@@ -30,6 +30,7 @@ export default {
},
feedback: '反馈与建议',
import: '导入配置',
moreSetting: '更多设置...',
newAgent: '新建助手',
noDescription: '暂无描述',
ok: '确定',

View 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: '输入密码解锁应用',
},
},
};

View File

@@ -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
View 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
View 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 });
};

View File

@@ -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 });
}

View File

@@ -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);
};

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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>
);
});

View 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;

View File

@@ -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 }}

View File

@@ -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]);

View File

@@ -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
/>

View File

@@ -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',

View File

@@ -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 };
},

View File

@@ -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 = {};

View File

@@ -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
View 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;
}

View File

@@ -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;