Skip to content

Commit

Permalink
feat: --env 支持 gpt-4-vision-preview
Browse files Browse the repository at this point in the history
  • Loading branch information
xcatliu committed Nov 14, 2023
1 parent 0e7ccad commit 64e893b
Show file tree
Hide file tree
Showing 17 changed files with 499 additions and 77 deletions.
32 changes: 32 additions & 0 deletions app/components/AttachImage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
'use client';

import { XMarkIcon } from '@heroicons/react/24/outline';
import Image from 'next/image';
import type { FC } from 'react';
import { useContext } from 'react';

import { ChatContext } from '@/context/ChatContext';

import { AttachImageButton } from './buttons/AttachImageButton';

export const AttachImage: FC = () => {
const { images, deleteImage } = useContext(ChatContext)!;

return (
<div className="flex flex-row-reverse items-center">
<AttachImageButton />
{[...images].reverse().map((imageProp, index) => (
<div key={index} className="attach-image-container relative mr-[-28px] z-10 cursor-pointer">
<XMarkIcon
className="attach-image-delete absolute top-0 right-0 w-5 h-5 p-0.5 m-0.5 rounded-sm hidden"
onClick={() => {
// 因为前面将 images revert 了,所以这里需要计算真正的 index
deleteImage(images.length - 1 - index);
}}
/>
<Image {...imageProp} className="h-16 w-14 object-cover rounded border-[0.5px] border-gray" alt="图片" />
</div>
))}
</div>
);
};
6 changes: 3 additions & 3 deletions app/components/History.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { SettingsContext } from '@/context/SettingsContext';
import { FULL_SPACE } from '@/utils/constants';
import { exportJSON } from '@/utils/export';
import { last } from '@/utils/last';
import { getContent } from '@/utils/message';
import { getContentText } from '@/utils/message';

import { DeleteHistoryButton } from './buttons/DeleteHistoryButton';

Expand Down Expand Up @@ -65,13 +65,13 @@ export const HistoryItemComp: FC<{ historyIndex: 'current' | number; isActive: b
})}
onClick={() => historyIndex !== 'current' && loadHistory(historyIndex)}
>
<h3 className="overflow-hidden whitespace-nowrap truncate">{getContent(historyItem.messages[0])}</h3>
<h3 className="overflow-hidden whitespace-nowrap truncate">{getContentText(historyItem.messages[0])}</h3>
<p
className={classNames('mt-1 text-gray text-[15px] overflow-hidden whitespace-nowrap truncate', {
'pr-8 md:pr-4': isActive,
})}
>
{historyItem.messages.length > 1 ? getContent(last(historyItem.messages)) : FULL_SPACE}
{historyItem.messages.length > 1 ? getContentText(last(historyItem.messages)) : FULL_SPACE}
</p>
{isActive && <DeleteHistoryButton historyIndex={historyIndex} />}
</li>
Expand Down
2 changes: 1 addition & 1 deletion app/components/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,7 @@ const MenuTabs = () => {
return (
<button
key={key}
className={classNames({
className={classNames('button-icon', {
'text-gray-700 hover:text-gray-700': currentMenu === key,
'dark:text-gray-200 dark:hover:text-gray-200': currentMenu === key,
})}
Expand Down
118 changes: 108 additions & 10 deletions app/components/Message.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,20 @@
'use client';

import classNames from 'classnames';
import Image from 'next/image';
import type { FC, ReactNode } from 'react';
import { useCallback, useContext, useState } from 'react';

import { MessageDetailContext } from '@/context/MessageDetailContext';
import { SettingsContext } from '@/context/SettingsContext';
import { Model, Role } from '@/utils/constants';
import type { ChatResponse, Message as MessageType } from '@/utils/constants';
import { MessageContentType, Model, Role } from '@/utils/constants';
import type {
ChatResponse,
MessageContentItemImageUrl,
MessageContentItemText,
Message as MessageType,
StructuredMessageContentItem,
} from '@/utils/constants';
import { formatMessage, FormatMessageMode } from '@/utils/formatMessage';
import { getContent, getRole } from '@/utils/message';
import { disableScroll, scrollToTop } from '@/utils/scroll';
Expand All @@ -19,33 +26,82 @@ import { HeroiconsUser } from './icons/HeroiconsUser';
* 单个消息气泡
*/
export const Message: FC<MessageType | ChatResponse> = (props) => {
const { settings } = useContext(SettingsContext)!;
const { setMessageDetail, setFormatMessageMode } = useContext(MessageDetailContext)!;

const role = getRole(props);
const content = getContent(props);
const isError = !!(props as MessageType).isError;

const isUser = role === Role.user;
const isAssistant = role === Role.assistant;

let structuredMessageContent: StructuredMessageContentItem[] = [];

if (typeof content === 'string') {
structuredMessageContent.push({
type: MessageContentType.text,
text: content,
});
} else {
structuredMessageContent = content;
}

return (
<>
{structuredMessageContent.map((messageContent, index) => {
if (messageContent.type === MessageContentType.text) {
return (
<MessageContentItemTextComp
key={index}
{...messageContent}
isAssistant={isAssistant}
isUser={isUser}
isError={isError}
/>
);
}
if (messageContent.type === MessageContentType.image_url) {
return (
<MessageContentItemImageUrlComp key={index} {...messageContent} isAssistant={isAssistant} isUser={isUser} />
);
}
return null;
})}
</>
);
};

/**
* 文本消息
*/
export const MessageContentItemTextComp: FC<
MessageContentItemText & {
isAssistant: boolean;
isUser: boolean;
isError: boolean;
}
> = (props) => {
const { text, isAssistant, isUser, isError } = props;

const { settings } = useContext(SettingsContext)!;
const { setMessageDetail, setFormatMessageMode } = useContext(MessageDetailContext)!;

const [lastTapTime, setLastTapTime] = useState(0);

const handleTap = useCallback(() => {
const currentTime = new Date().getTime();
const tapLength = currentTime - lastTapTime;

if (tapLength < 500 && tapLength > 0) {
scrollToTop();
disableScroll();
setMessageDetail(content);
setMessageDetail(text);
setFormatMessageMode(isAssistant ? FormatMessageMode.partial : FormatMessageMode.zero);
// 处理双击事件
} else {
// 处理单击事件
}

setLastTapTime(currentTime);
}, [lastTapTime, setLastTapTime, content, setMessageDetail, isAssistant, setFormatMessageMode]);
}, [lastTapTime, setLastTapTime, text, setMessageDetail, isAssistant, setFormatMessageMode]);

return (
<div
Expand All @@ -56,8 +112,8 @@ export const Message: FC<MessageType | ChatResponse> = (props) => {
{isAssistant ? (
<ChatGPTIcon
className={classNames('rounded w-10 h-10 p-1 text-white', {
'bg-[#1aa181]': [Model['gpt-3.5-turbo'], Model['gpt-3.5-turbo-0613']].includes(settings.model),
'bg-[#a969f8]': [Model['gpt-4'], Model['gpt-4-0613'], Model['gpt-4-32k'], Model['gpt-4-32k-0613']].includes(
'bg-[#1aa181]': [Model['gpt-3.5-turbo']].includes(settings.model),
'bg-[#a969f8]': [Model['gpt-4'], Model['gpt-4-32k'], Model['gpt-4-vision-preview']].includes(
settings.model,
),
})}
Expand All @@ -72,7 +128,7 @@ export const Message: FC<MessageType | ChatResponse> = (props) => {
'text-red-500': isError,
})}
dangerouslySetInnerHTML={{
__html: formatMessage(content, isAssistant ? FormatMessageMode.partial : FormatMessageMode.zero),
__html: formatMessage(text, isAssistant ? FormatMessageMode.partial : FormatMessageMode.zero),
}}
onTouchEnd={handleTap}
/>
Expand All @@ -87,6 +143,48 @@ export const Message: FC<MessageType | ChatResponse> = (props) => {
);
};

/**
* 图片消息
*/
export const MessageContentItemImageUrlComp: FC<
MessageContentItemImageUrl & {
isAssistant: boolean;
isUser: boolean;
}
> = (props) => {
const { image_url, isAssistant, isUser } = props;

const { settings } = useContext(SettingsContext)!;

return (
<div
className={classNames('relative px-3 my-4 flex', {
'flex-row-reverse': isUser,
})}
>
{isAssistant ? (
<ChatGPTIcon
className={classNames('rounded w-10 h-10 p-1 text-white', {
'bg-[#1aa181]': [Model['gpt-3.5-turbo']].includes(settings.model),
'bg-[#a969f8]': [Model['gpt-4'], Model['gpt-4-32k'], Model['gpt-4-vision-preview']].includes(
settings.model,
),
})}
/>
) : (
<HeroiconsUser className="rounded w-10 h-10 p-1.5 bg-white dark:bg-gray-200" />
)}
<Image
src={image_url.url}
alt="图片"
className="mx-3 max-w-[calc(100%-6rem)]"
width={image_url.width / 4 ?? 192}
height={image_url.height / 4 ?? 192}
/>
</div>
);
};

/**
* 系统消息
*/
Expand Down
4 changes: 2 additions & 2 deletions app/components/MessageDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const MessageDetail = () => {
<div className="flex">
<div className="flex-grow" />
<button
className="flex"
className="button-icon flex"
onClick={() => {
const revertFormatMessageMode = {
[FormatMessageMode.zero]: FormatMessageMode.partial,
Expand All @@ -48,7 +48,7 @@ export const MessageDetail = () => {
</span>
</button>
<button
className="text-gray-700 dark:text-gray-200"
className="button-icon text-gray-700 dark:text-gray-200"
onClick={() => {
setMessageDetail(undefined);
enableScroll();
Expand Down
4 changes: 2 additions & 2 deletions app/components/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useContext } from 'react';
import { ChatContext } from '@/context/ChatContext';
import { SettingsContext } from '@/context/SettingsContext';
import type { Model } from '@/utils/constants';
import { AllModels, MAX_TOKENS, MIN_TOKENS, Role, TOKENS_STEP } from '@/utils/constants';
import { AllModels, MAX_TOKENS, MIN_TOKENS, Role } from '@/utils/constants';

/**
* 聊天记录
Expand Down Expand Up @@ -76,7 +76,7 @@ export const Settings = () => {
<input
className="w-36 mr-2"
type="range"
step={TOKENS_STEP[settings.model]}
step={(MAX_TOKENS[settings.model] - MIN_TOKENS[settings.model]) / 1024 < 7 ? 512 : 1024}
min={MIN_TOKENS[settings.model]}
max={MAX_TOKENS[settings.model]}
value={settings.max_tokens ?? MAX_TOKENS[settings.model]}
Expand Down
39 changes: 23 additions & 16 deletions app/components/TextareaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,20 @@ import { useCallback, useContext, useEffect, useRef, useState } from 'react';
import { ChatContext } from '@/context/ChatContext';
import { DeviceContext } from '@/context/DeviceContext';
import { LoginContext } from '@/context/LoginContext';
import { SettingsContext } from '@/context/SettingsContext';
import { isDomChildren } from '@/utils/isDomChildren';

import { AttachImage } from './AttachImage';

export const TextareaForm: FC = () => {
const { isMobile } = useContext(DeviceContext)!;
const { isLogged } = useContext(LoginContext)!;
const { sendMessage } = useContext(ChatContext)!;
const { settings } = useContext(SettingsContext)!;
const { images, sendMessage } = useContext(ChatContext)!;

// 是否正在中文输入
const [isComposing, setIsComposing] = useState(false);
const [submitDisabled, setSubmitDisabled] = useState(true);
const [isTextareaEmpty, setIsTextareaEmpty] = useState(true);
const formContainerRef = useRef<HTMLDivElement>(null);
const placeholderRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
Expand All @@ -38,14 +42,14 @@ export const TextareaForm: FC = () => {
}, []);

/**
* 更新 submit 按钮的 disable 态
* 更新 textarea 的 empty 状态
*/
const updateSubmitDisabled = useCallback(() => {
const updateIsTextareaEmpty = useCallback(() => {
const value = textareaRef.current?.value?.trim();
if (value) {
setSubmitDisabled(false);
setIsTextareaEmpty(false);
} else {
setSubmitDisabled(true);
setIsTextareaEmpty(true);
}
}, []);

Expand Down Expand Up @@ -74,15 +78,15 @@ export const TextareaForm: FC = () => {
updateTextareaHeight();
// 保持滚动到最底下,bug 太多,先关闭
// scrollToBottom();
updateSubmitDisabled();
}, [updateTextareaHeight, updateSubmitDisabled]);
updateIsTextareaEmpty();
}, [updateTextareaHeight, updateIsTextareaEmpty]);
/** 中文输入法控制 */
const onCompositionStart = useCallback(() => setIsComposing(true), []);
const onCompositionEnd = useCallback(() => {
setIsComposing(false);
// 由于 onChange 和 onCompositionEnd 的时序问题,这里也需要调用 updateSubmitDisabled
updateSubmitDisabled();
}, [updateSubmitDisabled]);
updateIsTextareaEmpty();
}, [updateIsTextareaEmpty]);

/**
* 提交表单处理
Expand All @@ -91,18 +95,15 @@ export const TextareaForm: FC = () => {
async (e?: FormEvent<HTMLFormElement>) => {
e?.preventDefault();
const value = textareaRef.current?.value?.trim();
if (!value) {
return;
}
// 提交后清空内容
if (textareaRef.current?.value) {
textareaRef.current.value = '';
}
updateTextareaHeight();
updateSubmitDisabled();
updateIsTextareaEmpty();
await sendMessage(value);
},
[sendMessage, updateTextareaHeight, updateSubmitDisabled],
[sendMessage, updateTextareaHeight, updateIsTextareaEmpty],
);

/**
Expand Down Expand Up @@ -157,8 +158,14 @@ export const TextareaForm: FC = () => {
onCompositionEnd={onCompositionEnd}
rows={1}
/>
{settings.model.includes('vision') && <AttachImage />}
<div className="flex items-center">
<input className="px-3 py-2 h-full max-h-16" type="submit" disabled={submitDisabled} value="发送" />
<input
className="px-3 py-2 h-full max-h-16"
type="submit"
disabled={isTextareaEmpty && images.length === 0}
value="发送"
/>
</div>
</form>
</div>
Expand Down
Loading

1 comment on commit 64e893b

@vercel
Copy link

@vercel vercel bot commented on 64e893b Nov 14, 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-xcatliu.vercel.app
chatgpt-next-git-main-xcatliu.vercel.app

Please sign in to comment.