Skip to content

Commit

Permalink
feat: 支持 abortSendMessage
Browse files Browse the repository at this point in the history
  • Loading branch information
xcatliu committed Nov 23, 2023
1 parent f64b77a commit 401df75
Show file tree
Hide file tree
Showing 7 changed files with 80 additions and 34 deletions.
9 changes: 7 additions & 2 deletions app/components/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ export const HistoryItemComp: FC<{ historyIndex: 'current' | number; isActive: b
historyIndex,
isActive,
}) => {
const { messages, history, loadHistory } = useContext(ChatContext)!;
const { messages, history, loadHistory, abortSendMessage } = useContext(ChatContext)!;
const { settings } = useContext(SettingsContext)!;

let historyItem: HistoryItem;
Expand All @@ -63,7 +63,12 @@ export const HistoryItemComp: FC<{ historyIndex: 'current' | number; isActive: b
className={classNames('p-4 border-b-[0.5px] relative cursor-default border-gray md:-mx-4 md:px-8', {
'bg-gray-300 dark:bg-gray-700': isActive,
})}
onClick={() => historyIndex !== 'current' && loadHistory(historyIndex)}
onClick={() => {
if (historyIndex !== 'current') {
abortSendMessage();
loadHistory(historyIndex);
}
}}
>
<h3 className="overflow-hidden whitespace-nowrap truncate">{getContentText(historyItem.messages[0])}</h3>
<p
Expand Down
10 changes: 8 additions & 2 deletions app/components/Messages.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ const LOADING_MESSAGE = '正在努力思考...';

export const Messages = () => {
const { settings } = useContext(SettingsContext)!;
let { isLoading, messages, history, historyIndex, startNewChat } = useContext(ChatContext)!;
let { isLoading, messages, history, historyIndex, startNewChat, abortSendMessage } = useContext(ChatContext)!;

// 初始化滚动事件
useEffect(initEventListenerScroll, []);
Expand Down Expand Up @@ -52,7 +52,13 @@ export const Messages = () => {
{messages.length > 1 && (
<SystemMessage>
连续对话会加倍消耗 tokens,
<a className="text-gray-link" onClick={startNewChat}>
<a
className="text-gray-link"
onClick={() => {
abortSendMessage();
startNewChat();
}}
>
开启新对话
</a>
</SystemMessage>
Expand Down
39 changes: 21 additions & 18 deletions app/components/TextareaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export const TextareaForm: FC = () => {
const { isMobile } = useContext(DeviceContext)!;
const { isLogged } = useContext(LoginContext)!;
const { settings } = useContext(SettingsContext)!;
const { images, appendImages, sendMessage } = useContext(ChatContext)!;
const { images, appendImages, sendMessage, abortSendMessage } = useContext(ChatContext)!;

// 是否正在中文输入
const [isComposing, setIsComposing] = useState(false);
Expand All @@ -43,26 +43,27 @@ export const TextareaForm: FC = () => {
});
}, []);


/**
* Handle pasting images into the textarea
*/
const handlePaste = useCallback(async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') === 0) {
const file = items[i].getAsFile();
if (file == null) {
throw new Error("Expected file")
const handlePaste = useCallback(
async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
const items = e.clipboardData?.items;
if (items) {
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf('image') === 0) {
const file = items[i].getAsFile();
if (file == null) {
throw new Error('Expected file');
}
const image = await readImageFile(file);
appendImages(image);
}
const image = await readImageFile(file);
appendImages(image);
}
}
}
}, [appendImages]);

},
[appendImages],
);

/**
* 更新 textarea 的 empty 状态
Expand Down Expand Up @@ -129,9 +130,10 @@ export const TextareaForm: FC = () => {
}
updateTextareaHeight();
updateIsTextareaEmpty();
abortSendMessage();
await sendMessage(value);
},
[sendMessage, updateTextareaHeight, updateIsTextareaEmpty],
[sendMessage, abortSendMessage, updateTextareaHeight, updateIsTextareaEmpty],
);

/**
Expand Down Expand Up @@ -195,7 +197,9 @@ export const TextareaForm: FC = () => {
{settings.model.includes('vision') && <AttachImage />}
<div className="flex items-end">
<input
className="px-3 py-2 h-full max-h-16"
className={classNames('px-3 py-2 h-10 md:h-16', {
'h-16': images.length > 0,
})}
type="submit"
disabled={isTextareaEmpty && images.length === 0}
value="发送"
Expand All @@ -206,4 +210,3 @@ export const TextareaForm: FC = () => {
</>
);
};

9 changes: 5 additions & 4 deletions app/components/buttons/AttachImageButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useContext } from 'react';

import { ChatContext } from '@/context/ChatContext';
import { LoginContext } from '@/context/LoginContext';
import { MAX_GPT_VISION_IMAGES } from '@/utils/constants';
import { readImageFile } from '@/utils/image';

/**
Expand All @@ -15,11 +16,11 @@ export const AttachImageButton: FC<{}> = () => {
const { isLogged } = useContext(LoginContext)!;

return (
<div className="h-full max-h-16">
<>
<label
htmlFor="input-attach-image"
className={classNames('button-attach-image flex items-center w-10 h-10 md:w-14 md:h-16', {
disabled: !isLogged || images.length === 9,
disabled: !isLogged || images.length >= MAX_GPT_VISION_IMAGES,
'justify-center': images.length === 0,
'justify-end w-14 h-16': images.length > 0,
})}
Expand All @@ -32,7 +33,7 @@ export const AttachImageButton: FC<{}> = () => {
type="file"
multiple
accept="image/jpeg, image/png"
disabled={!isLogged || images.length === 9}
disabled={!isLogged || images.length >= MAX_GPT_VISION_IMAGES}
className="hidden"
onChange={async (e) => {
const files = e.target.files;
Expand All @@ -48,6 +49,6 @@ export const AttachImageButton: FC<{}> = () => {
e.target.value = '';
}}
/>
</div>
</>
);
};
36 changes: 28 additions & 8 deletions app/context/ChatContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@

import omit from 'lodash.omit';
import type { FC, ReactNode } from 'react';
import { createContext, useCallback, useContext, useEffect, useState } from 'react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';

import { fetchApiChat } from '@/utils/api';
import { getCache, setCache } from '@/utils/cache';
import type { ChatResponse, Message, StructuredMessageContentItem } from '@/utils/constants';
import { MAX_TOKENS, MessageContentType, Model, Role } from '@/utils/constants';
import { MAX_GPT_VISION_IMAGES, MAX_TOKENS, MessageContentType, Model, Role } from '@/utils/constants';
import type { ResError } from '@/utils/error';
import type { ImageProp } from '@/utils/image';
import { isMessage } from '@/utils/message';
Expand All @@ -31,6 +31,7 @@ export interface HistoryItem {
*/
export const ChatContext = createContext<{
sendMessage: (content?: string) => Promise<void>;
abortSendMessage: () => void;
isLoading: boolean;
messages: (Message | ChatResponse)[];
images: ImageProp[];
Expand All @@ -52,6 +53,8 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
const [history, setHistory] = useState<HistoryItem[] | undefined>(undefined);
// 当前选中的对话在 history 中的 index,empty 表示未选中,current 表示选中的是当前对话
const [historyIndex, setHistoryIndex] = useState<'empty' | 'current' | number>('empty');
// 控制请求中断
const [abortController, setAbortController] = useState<AbortController>();

// 页面加载后从 cache 中读取 history 和 messages
// 如果 messages 不为空,则将最近的一条消息写入 history
Expand Down Expand Up @@ -165,6 +168,9 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
if (settings.systemMessage) {
fetchApiChatMessages.unshift(settings.systemMessage);
}
// 创建一个新的 abortController
const newAbortController = new AbortController();
setAbortController(newAbortController);
// TODO 收到完整消息后,写入 cache 中
const fullContent = await fetchApiChat({
// gpt-4-vision-preview 有个 bug:不传 max_tokens 时,会中断消息
Expand All @@ -184,6 +190,7 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
scrollToBottom();
}
},
signal: newAbortController.signal,
});

// 收到完整消息后,重新设置 messages
Expand All @@ -194,7 +201,11 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
if (gapToBottom() <= 72 && !getIsScrolling()) {
scrollToBottom();
}
} catch (e) {
} catch (e: any) {
// 如果是调用 abortController.abort() 捕获到的 error 则不处理
if (e.name === 'AbortError') {
return;
}
// 发生错误时,展示错误消息
setIsLoading(false);
setMessages([
Expand All @@ -203,9 +214,16 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
]);
}
},
[settings, messages, images, history, historyIndex],
[settings, messages, images, history, historyIndex, setAbortController],
);

/**
* 中断请求
*/
const abortSendMessage = useCallback(() => {
abortController?.abort();
}, [abortController]);

/**
* 加载聊天记录
*/
Expand Down Expand Up @@ -258,7 +276,8 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
setCache('history', newHistory);
setMessages([]);
setCache('messages', []);
setHistoryIndex(index);
// 此时因为将 current 进行归档了,所以需要 +1
setHistoryIndex(index + 1);
setSettings({
model: newModel,
});
Expand Down Expand Up @@ -339,9 +358,9 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
const appendImages = useCallback(
(...newImages: ImageProp[]) => {
const finalImages = [...images, ...newImages];
if (finalImages.length > 9) {
setImages(finalImages.slice(0, 9));
alert('最多只能发送九张图片,超出的图片已删除');
if (finalImages.length > MAX_GPT_VISION_IMAGES) {
setImages(finalImages.slice(0, MAX_GPT_VISION_IMAGES));
alert(`最多只能发送 ${MAX_GPT_VISION_IMAGES} 张图片,超出的图片已删除`);
return;
}
setImages(finalImages);
Expand All @@ -362,6 +381,7 @@ export const ChatProvider: FC<{ children: ReactNode }> = ({ children }) => {
<ChatContext.Provider
value={{
sendMessage,
abortSendMessage,
isLoading,
messages,
images,
Expand Down
6 changes: 6 additions & 0 deletions app/utils/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,23 @@ import { stream2string } from './stream';
*/
export const fetchApiChat = async ({
onMessage,
signal,
...chatRequest
}: {
/**
* 接受 stream 消息的回调函数
*/
onMessage?: (content: string) => void;
/**
* 控制请求中断的 AbortSignal
*/
signal?: AbortSignal;
} & Partial<ChatRequest>) => {
const fetchResult = await fetch('/api/chat', {
method: HttpMethod.POST,
headers: HttpHeaderJson,
body: JSON.stringify(chatRequest),
signal,
});

// 如果返回错误,则直接抛出错误
Expand Down
5 changes: 5 additions & 0 deletions app/utils/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ export const HttpHeaderJson = {
*/
export const FULL_SPACE = ' ';

/**
* 使用 gpt-4-vision 时单次可传输的最多图片数量
*/
export const MAX_GPT_VISION_IMAGES = 9;

/**
* 角色
* 参考 https://github.com/openai/openai-node/blob/master/api.ts
Expand Down

1 comment on commit 401df75

@vercel
Copy link

@vercel vercel bot commented on 401df75 Nov 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Successfully deployed to the following URLs:

chatgpt-next – ./

chatgpt-next-git-main-xcatliu.vercel.app
chatgpt-next-xcatliu.vercel.app

Please sign in to comment.