feat: support upload images to chat with gpt4-vision model (#440)

This commit is contained in:
Arvin Xu 2023-11-14 11:55:28 +08:00 committed by GitHub
parent cea884676a
commit 858d0476e0
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
64 changed files with 2058 additions and 138 deletions

View file

@ -13,5 +13,6 @@ config.rules['no-extra-boolean-cast'] = 0;
config.rules['unicorn/no-useless-undefined'] = 0;
config.rules['react/no-unknown-property'] = 0;
config.rules['unicorn/prefer-ternary'] = 0;
config.rules['unicorn/prefer-spread'] = 0;
module.exports = config;

View file

@ -0,0 +1,25 @@
import { act } from 'react-dom/test-utils';
import { beforeEach } from 'vitest';
import { createWithEqualityFn as actualCreate } from 'zustand/traditional';
// a variable to hold reset functions for all stores declared in the app
const storeResetFns = new Set<() => void>();
// when creating a store, we get its initial state, create a reset function and add it in the set
const createImpl = (createState: any) => {
const store = actualCreate(createState, Object.is);
const initialState = store.getState();
storeResetFns.add(() => store.setState(initialState, true));
return store;
};
// Reset all stores after each test run
beforeEach(() => {
act(() =>
{ for (const resetFn of storeResetFns) {
resetFn();
} },
);
});
export const createWithEqualityFn = (f: any) => (f === undefined ? createImpl : createImpl(f));

View file

@ -73,13 +73,14 @@
"@lobehub/ui": "latest",
"@vercel/analytics": "^1",
"ahooks": "^3",
"ai": "2.2.20",
"ai": "^2.2.22",
"antd": "^5",
"antd-style": "^3",
"brotli-wasm": "^1",
"chroma-js": "^2",
"copy-to-clipboard": "^3",
"dayjs": "^1",
"dexie": "^3",
"fast-deep-equal": "^3",
"gpt-tokenizer": "^2",
"i18next": "^23",
@ -93,7 +94,7 @@
"modern-screenshot": "^4",
"nanoid": "^5",
"next": "14.0.1",
"openai": "~4.15.0",
"openai": "^4.17.3",
"polished": "^4",
"posthog-js": "^1",
"query-string": "^8",
@ -104,6 +105,7 @@
"react-intersection-observer": "^9",
"react-layout-kit": "^1",
"react-lazy-load": "^4",
"react-spring-lightbox": "^1",
"remark": "^14",
"remark-gfm": "^3",
"remark-html": "^15",
@ -116,6 +118,7 @@
"use-merge-value": "^1",
"utility-types": "^3",
"uuid": "^9",
"zod": "^3",
"zustand": "^4.4",
"zustand-utils": "^1"
},
@ -144,6 +147,7 @@
"consola": "^3",
"dpdm": "^3",
"eslint": "^8",
"fake-indexeddb": "^5",
"husky": "^8",
"jsdom": "^22",
"lint-staged": "^15",

View file

@ -0,0 +1,72 @@
import { getServerConfig } from '@/config/server';
interface UploadResponse {
data: UploadData;
status: number;
success: boolean;
}
interface UploadData {
account_id: any;
account_url: any;
ad_type: any;
ad_url: any;
animated: boolean;
bandwidth: number;
datetime: number;
deletehash: string;
description: any;
favorite: boolean;
has_sound: boolean;
height: number;
hls: string;
id: string;
in_gallery: boolean;
in_most_viral: boolean;
is_ad: boolean;
link: string;
mp4: string;
name: string;
nsfw: any;
section: any;
size: number;
tags: any[];
title: any;
type: string;
views: number;
vote: any;
width: number;
}
export class Imgur {
clientId: string;
api = 'https://api.imgur.com/3';
constructor() {
this.clientId = getServerConfig().IMGUR_CLIENT_ID;
}
async upload(image: Blob) {
const formData = new FormData();
formData.append('image', image, 'image.png');
const res = await fetch(`${this.api}/upload`, {
body: formData,
headers: {
Authorization: `Client-ID ${this.clientId}`,
},
method: 'POST',
});
if (!res.ok) {
console.log(await res.text());
}
const data: UploadResponse = await res.json();
if (data.success) {
return data.data.link;
}
return undefined;
}
}

View file

@ -0,0 +1,42 @@
import { Imgur } from './imgur';
const updateByImgur = async ({ url, blob }: { blob?: Blob; url?: string }) => {
let imageBlob: Blob;
if (url) {
const res = await fetch(url);
imageBlob = await res.blob();
} else if (blob) {
imageBlob = blob;
} else {
// TODO: error handle
return;
}
const imgur = new Imgur();
return await imgur.upload(imageBlob);
};
export const POST = async (req: Request) => {
const { url } = await req.json();
const cdnUrl = await updateByImgur({ url });
return new Response(JSON.stringify({ url: cdnUrl }));
};
// import { Imgur } from './imgur';
export const runtime = 'edge';
// export const POST = async (req: Request) => {
// const { url } = await req.json();
//
// const imgur = new Imgur();
//
// const image = await fetch(url);
//
// const cdnUrl = await imgur.upload(await image.blob());
//
// return new Response(JSON.stringify({ url: cdnUrl }));
// };

View file

@ -14,11 +14,12 @@ export const createChatCompletion = async ({ payload, openai }: CreateChatComple
// ============ 1. preprocess messages ============ //
const { messages, ...params } = payload;
// remove unnecessary fields like `plugins` or `files` by lobe-chat
const formatMessages = messages.map((m) => ({
content: m.content,
name: m.name,
role: m.role,
}));
})) as OpenAI.ChatCompletionMessageParam[];
// ============ 2. send api ============ //

View file

@ -0,0 +1,166 @@
import { Icon } from '@lobehub/ui';
import { createStyles } from 'antd-style';
import { FileImage, FileText, FileUpIcon } from 'lucide-react';
import { rgba } from 'polished';
import { memo, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center, Flexbox } from 'react-layout-kit';
import { useFileStore } from '@/store/files';
const useStyles = createStyles(({ css, token, stylish }) => {
return {
container: css`
width: 300px;
height: 300px;
padding: 16px;
color: ${token.colorWhite};
background: ${token.geekblue};
border-radius: 16px;
box-shadow:
${rgba(token.geekblue, 0.1)} 0 1px 1px 0 inset,
${rgba(token.geekblue, 0.1)} 0 50px 100px -20px,
${rgba(token.geekblue, 0.3)} 0 30px 60px -30px;
`,
content: css`
width: 100%;
height: 100%;
padding: 16px;
border: 2px dashed ${token.colorWhite};
border-radius: 12px;
`,
desc: css`
color: ${rgba(token.colorTextLightSolid, 0.6)};
`,
title: css`
font-size: 24px;
font-weight: bold;
`,
wrapper: css`
position: fixed;
z-index: 10000000;
top: 0;
left: 0;
width: 100%;
height: 100%;
transition: all 0.3s ease-in-out;
background: ${token.colorBgMask};
${stylish.blur};
`,
};
});
const handleDragOver = (e: DragEvent) => {
e.preventDefault();
};
const DragUpload = memo(() => {
const { styles } = useStyles();
const { t } = useTranslation('chat');
const [isDragging, setIsDragging] = useState(false);
// When a file is dragged to a different area, the 'dragleave' event may be triggered,
// causing isDragging to be mistakenly set to false.
// to fix this issue, use a counter to ensure the status change only when drag event left the browser window .
const dragCounter = useRef(0);
const uploadFile = useFileStore((s) => s.uploadFile);
const uploadImages = async (fileList: FileList | undefined) => {
if (!fileList || fileList.length === 0) return;
const pools = Array.from(fileList).map(async (file) => {
// skip none-file items
if (!file.type.startsWith('image')) return;
await uploadFile(file);
});
await Promise.all(pools);
};
const handleDragEnter = (e: DragEvent) => {
e.preventDefault();
dragCounter.current += 1;
if (e.dataTransfer?.items && e.dataTransfer.items.length > 0) {
setIsDragging(true);
}
};
const handleDragLeave = (e: DragEvent) => {
e.preventDefault();
// reset counter
dragCounter.current -= 1;
if (dragCounter.current === 0) {
setIsDragging(false);
}
};
const handleDrop = async (e: DragEvent) => {
e.preventDefault();
// reset counter
dragCounter.current = 0;
setIsDragging(false);
// get filesList
// TODO: support folder files upload
const files = e.dataTransfer?.files;
// upload files
uploadImages(files);
};
const handlePaste = (event: ClipboardEvent) => {
// get files from clipboard
const files = event.clipboardData?.files;
uploadImages(files);
};
useEffect(() => {
window.addEventListener('dragenter', handleDragEnter);
window.addEventListener('dragover', handleDragOver);
window.addEventListener('dragleave', handleDragLeave);
window.addEventListener('drop', handleDrop);
window.addEventListener('paste', handlePaste);
return () => {
window.removeEventListener('dragenter', handleDragEnter);
window.removeEventListener('dragover', handleDragOver);
window.removeEventListener('dragleave', handleDragLeave);
window.removeEventListener('drop', handleDrop);
window.removeEventListener('paste', handlePaste);
};
}, []);
return (
isDragging && (
<Center className={styles.wrapper}>
<div className={styles.container}>
<Center className={styles.content} gap={40}>
<Flexbox horizontal>
<Icon icon={FileImage} size={{ fontSize: 64, strokeWidth: 1 }} />
<Icon icon={FileUpIcon} size={{ fontSize: 64, strokeWidth: 1 }} />
<Icon icon={FileText} size={{ fontSize: 64, strokeWidth: 1 }} />
</Flexbox>
<Flexbox align={'center'} gap={8} style={{ textAlign: 'center' }}>
<Flexbox className={styles.title}>{t('upload.dragTitle')}</Flexbox>
<Flexbox className={styles.desc}>{t('upload.dragDesc')}</Flexbox>
</Flexbox>
</Center>
</div>
</Center>
)
);
});
export default DragUpload;

View file

@ -1,63 +0,0 @@
import { Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { createStyles } from 'antd-style';
import { ArrowBigUp, CornerDownLeft, Loader2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import SaveTopic from '@/app/chat/features/ChatInput/Topic';
import { useSessionStore } from '@/store/session';
import { useSendMessage } from './useSend';
const useStyles = createStyles(({ css }) => ({
footerBar: css`
display: flex;
flex: none;
gap: 8px;
align-items: center;
justify-content: flex-end;
padding: 0 24px;
`,
}));
const Footer = memo(() => {
const { t } = useTranslation('chat');
const { styles, theme } = useStyles();
const [loading, onStop] = useSessionStore((s) => [!!s.chatLoadingId, s.stopGenerateMessage]);
const onSend = useSendMessage();
return (
<div className={styles.footerBar}>
<Flexbox
gap={4}
horizontal
style={{ color: theme.colorTextDescription, fontSize: 12, marginRight: 12 }}
>
<Icon icon={CornerDownLeft} />
<span>{t('send')}</span>
<span>/</span>
<Flexbox horizontal>
<Icon icon={ArrowBigUp} />
<Icon icon={CornerDownLeft} />
</Flexbox>
<span>{t('warp')}</span>
</Flexbox>
<SaveTopic />
{loading ? (
<Button icon={loading && <Icon icon={Loader2} spin />} onClick={onStop}>
{t('stop')}
</Button>
) : (
<Button onClick={() => onSend()} type={'primary'}>
{t('send')}
</Button>
)}
</div>
);
});
export default Footer;

View file

@ -0,0 +1,10 @@
import { memo } from 'react';
import FileList from '@/app/chat/components/FileList';
import { useFileStore } from '@/store/files';
export const LocalFiles = memo(() => {
const inputFilesList = useFileStore((s) => s.inputFilesList);
return <FileList items={inputFilesList} />;
});

View file

@ -0,0 +1,64 @@
import { Icon } from '@lobehub/ui';
import { Button } from 'antd';
import { useTheme } from 'antd-style';
import { ArrowBigUp, CornerDownLeft, Loader2 } from 'lucide-react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Flexbox } from 'react-layout-kit';
import SaveTopic from '@/app/chat/features/ChatInput/Topic';
import { useSendMessage } from '@/app/chat/features/ChatInput/useSend';
import { useSessionStore } from '@/store/session';
import { agentSelectors } from '@/store/session/selectors';
import { LocalFiles } from './LocalFiles';
const Footer = memo(() => {
const { t } = useTranslation('chat');
const theme = useTheme();
const [loading, onStop] = useSessionStore((s) => [!!s.chatLoadingId, s.stopGenerateMessage]);
const onSend = useSendMessage();
const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
return (
<Flexbox
align={'end'}
distribution={'space-between'}
flex={'none'}
gap={8}
horizontal
padding={'0 24px'}
>
<Flexbox>{canUpload && <LocalFiles />}</Flexbox>
<Flexbox align={'center'} gap={8} horizontal>
<Flexbox
gap={4}
horizontal
style={{ color: theme.colorTextDescription, fontSize: 12, marginRight: 12 }}
>
<Icon icon={CornerDownLeft} />
<span>{t('send')}</span>
<span>/</span>
<Flexbox horizontal>
<Icon icon={ArrowBigUp} />
<Icon icon={CornerDownLeft} />
</Flexbox>
<span>{t('warp')}</span>
</Flexbox>
<SaveTopic />
{loading ? (
<Button icon={loading && <Icon icon={Loader2} spin />} onClick={onStop}>
{t('stop')}
</Button>
) : (
<Button onClick={() => onSend()} type={'primary'}>
{t('send')}
</Button>
)}
</Flexbox>
</Flexbox>
);
});
export default Footer;

View file

@ -5,7 +5,7 @@ import { useTranslation } from 'react-i18next';
import { useSessionStore } from '@/store/session';
import { useSendMessage } from './useSend';
import { useSendMessage } from '../../../features/ChatInput/useSend';
const useStyles = createStyles(({ css }) => {
return {
@ -23,10 +23,10 @@ const useStyles = createStyles(({ css }) => {
const InputArea = memo(() => {
const { t } = useTranslation('common');
const isChineseInput = useRef(false);
const { cx, styles } = useStyles();
const isChineseInput = useRef(false);
const [loading, message, updateInputMessage] = useSessionStore((s) => [
!!s.chatLoadingId,
s.inputMessage,

View file

@ -3,11 +3,14 @@ import { createStyles } from 'antd-style';
import { Maximize2, Minimize2 } from 'lucide-react';
import { memo, useState } from 'react';
import Footer from '@/app/chat/(desktop)/features/ChatInput/Footer';
import ActionBar from '@/app/chat/features/ChatInput/ActionBar';
import { CHAT_TEXTAREA_HEIGHT, HEADER_HEIGHT } from '@/const/layoutTokens';
import { useGlobalStore } from '@/store/global';
import { useSessionStore } from '@/store/session';
import { agentSelectors } from '@/store/session/selectors';
import DragUpload from './DragUpload';
import Footer from './Footer';
import InputArea from './InputArea';
const useStyles = createStyles(({ css }) => {
@ -34,37 +37,42 @@ const ChatInputDesktopLayout = memo(() => {
s.updatePreference,
]);
const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
return (
<DraggablePanel
fullscreen={expand}
headerHeight={HEADER_HEIGHT}
minHeight={CHAT_TEXTAREA_HEIGHT}
onSizeChange={(_, size) => {
if (!size) return;
<>
{canUpload && <DragUpload />}
<DraggablePanel
fullscreen={expand}
headerHeight={HEADER_HEIGHT}
minHeight={CHAT_TEXTAREA_HEIGHT}
onSizeChange={(_, size) => {
if (!size) return;
updatePreference({
inputHeight: typeof size.height === 'string' ? Number.parseInt(size.height) : size.height,
});
}}
placement="bottom"
size={{ height: inputHeight, width: '100%' }}
style={{ zIndex: 10 }}
>
<section className={styles.container} style={{ minHeight: CHAT_TEXTAREA_HEIGHT }}>
<ActionBar
rightAreaEndRender={
<ActionIcon
icon={expand ? Minimize2 : Maximize2}
onClick={() => {
setExpand(!expand);
}}
/>
}
/>
<InputArea />
<Footer />
</section>
</DraggablePanel>
updatePreference({
inputHeight:
typeof size.height === 'string' ? Number.parseInt(size.height) : size.height,
});
}}
placement="bottom"
size={{ height: inputHeight, width: '100%' }}
style={{ zIndex: 10 }}
>
<section className={styles.container} style={{ minHeight: CHAT_TEXTAREA_HEIGHT }}>
<ActionBar
rightAreaEndRender={
<ActionIcon
icon={expand ? Minimize2 : Maximize2}
onClick={() => {
setExpand(!expand);
}}
/>
}
/>
<InputArea />
<Footer />
</section>
</DraggablePanel>
</>
);
});

View file

@ -0,0 +1,81 @@
import { createStyles } from 'antd-style';
export const IMAGE_SIZE = 64;
const imageBorderRaidus = 8;
export const useStyles = createStyles(({ css, cx, token, isDarkMode }) => {
const closeIcon = cx(css`
cursor: pointer;
position: absolute;
top: -6px;
right: -6px;
width: 16px;
height: 16px;
color: ${isDarkMode ? token.colorTextQuaternary : token.colorTextTertiary};
opacity: 0;
background: ${isDarkMode ? token.colorTextSecondary : token.colorBgContainer};
border-radius: 50%;
transition: all 0.2s ease-in;
&:hover {
scale: 1.2;
}
`);
return {
closeIcon,
container: css`
cursor: pointer;
position: relative;
width: ${IMAGE_SIZE}px;
height: ${IMAGE_SIZE}px;
border-radius: ${imageBorderRaidus}px;
&:hover {
.${closeIcon} {
opacity: 1;
}
}
`,
image: css`
opacity: ${isDarkMode ? 0.6 : 1};
object-fit: cover;
border-radius: 8px;
animation: fade-in 0.3s ease-in;
@keyframes fade-in {
from {
scale: 1.2;
opacity: 1;
}
to {
scale: 1;
opacity: 1;
}
}
`,
imageCtn: css`
position: relative;
`,
imageWrapper: css`
overflow: hidden;
width: ${IMAGE_SIZE}px;
height: ${IMAGE_SIZE}px;
border-radius: ${imageBorderRaidus}px;
`,
notFound: css`
color: ${token.colorTextSecondary};
background: ${token.colorFillTertiary};
border-radius: ${imageBorderRaidus}px;
`,
};
});

View file

@ -0,0 +1,69 @@
import { CloseCircleFilled } from '@ant-design/icons';
import { Icon } from '@lobehub/ui';
import { Skeleton } from 'antd';
import { LucideImageOff } from 'lucide-react';
import Image from 'next/image';
import { memo } from 'react';
import { Center, Flexbox } from 'react-layout-kit';
import { useFileStore } from '@/store/files';
import { IMAGE_SIZE, useStyles } from './FileItem.style';
const FileItem = memo<{ editable: boolean; id: string; onClick: () => void }>(
({ editable, id, onClick }) => {
const { styles } = useStyles();
const [useFetchFile, removeFile] = useFileStore((s) => [s.useFetchFile, s.removeFile]);
const { data, isLoading } = useFetchFile(id);
return (
<Flexbox className={styles.container} onClick={onClick}>
{isLoading ? (
<Skeleton
active
title={{
style: { borderRadius: 8, height: IMAGE_SIZE },
width: IMAGE_SIZE,
}}
/>
) : (
<Flexbox className={styles.imageCtn}>
<div className={styles.imageWrapper}>
{data ? (
<Image
alt={data.name || ''}
className={styles.image}
fetchPriority={'high'}
height={IMAGE_SIZE}
loading={'lazy'}
src={data.url}
width={IMAGE_SIZE}
/>
) : (
<Center className={styles.notFound} height={'100%'}>
<Icon icon={LucideImageOff} size={{ fontSize: 28 }} />
</Center>
)}
</div>
</Flexbox>
)}
{/* only show close icon when editable */}
{editable && (
<Center
className={styles.closeIcon}
onClick={(e) => {
e.stopPropagation();
removeFile(id);
}}
>
<CloseCircleFilled />
</Center>
)}
</Flexbox>
);
},
);
export default FileItem;

View file

@ -0,0 +1,46 @@
import { createStyles } from 'antd-style';
import { memo } from 'react';
import Lightbox from 'react-spring-lightbox';
import { filesSelectors, useFileStore } from '@/store/files';
const useStyles = createStyles(({ css, token }) => ({
wrapper: css`
background: ${token.colorBgMask};
backdrop-filter: blur(4px);
`,
}));
interface LightBoxProps {
index: number;
items: string[];
onNext: () => void;
onOpenChange: (open: boolean) => void;
onPrev: () => void;
open: boolean;
}
const LightBox = memo<LightBoxProps>(({ onOpenChange, open, items, index, onNext, onPrev }) => {
const { styles } = useStyles();
const list = useFileStore(filesSelectors.getImageDetailByList(items));
return (
<Lightbox
className={styles.wrapper}
currentIndex={index}
images={list.map((i) => ({
alt: i.name,
loading: 'lazy',
src: i.url,
}))}
isOpen={open}
onClose={() => {
onOpenChange(false);
}}
onNext={onNext}
onPrev={onPrev}
/>
);
});
export default LightBox;

View file

@ -0,0 +1,63 @@
import { memo, useState } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileItem from './FileItem';
import Lightbox from './Lightbox';
// TODO后续看情况是否做图片过多的滚动功能
// const useStyles = createStyles(({ css }) => ({
// container: css`
// width: ${IMAGE_SIZE * 5 + 4 * 8 + IMAGE_SIZE / 2}px;
// overflow-x: scroll;
// height: ${IMAGE_SIZE + 12}px;
// `,
// }));
interface FileListProps {
editable?: boolean;
items: string[];
}
const FileList = memo<FileListProps>(({ items, editable = true }) => {
// const { styles } = useStyles();
const [showLightbox, setShowLightbox] = useState(false);
const [currentImageIndex, setCurrentIndex] = useState(0);
const gotoPrevious = () => currentImageIndex > 0 && setCurrentIndex(currentImageIndex - 1);
const gotoNext = () =>
currentImageIndex + 1 < items.length && setCurrentIndex(currentImageIndex + 1);
return (
<>
<Flexbox
align={'flex-end'}
// className={styles.container}
gap={8}
horizontal
>
{items.map((i, index) => (
<FileItem
editable={editable}
id={i}
key={i}
onClick={() => {
setCurrentIndex(index);
setShowLightbox(true);
}}
/>
))}
</Flexbox>
<Lightbox
index={currentImageIndex}
items={items}
onNext={gotoNext}
onOpenChange={setShowLightbox}
onPrev={gotoPrevious}
open={showLightbox}
/>
</>
);
});
export default FileList;

View file

@ -1,20 +1,27 @@
import { ActionIcon } from '@lobehub/ui';
import { Popconfirm } from 'antd';
import { Eraser } from 'lucide-react';
import { memo } from 'react';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import HotKeys from '@/components/HotKeys';
import { CLEAN_MESSAGE_KEY, PREFIX_KEY } from '@/const/hotkeys';
import { useFileStore } from '@/store/files';
import { useSessionStore } from '@/store/session';
const Clear = memo(() => {
const { t } = useTranslation('setting');
const [clearMessage] = useSessionStore((s) => [s.clearMessage, s.updateAgentConfig]);
const [clearMessage] = useSessionStore((s) => [s.clearMessage]);
const [clearImageList] = useFileStore((s) => [s.clearImageList]);
const hotkeys = [PREFIX_KEY, CLEAN_MESSAGE_KEY].join('+');
useHotkeys(hotkeys, clearMessage, {
const resetConversation = useCallback(() => {
clearMessage();
clearImageList();
}, []);
useHotkeys(hotkeys, resetConversation, {
preventDefault: true,
});
@ -23,7 +30,9 @@ const Clear = memo(() => {
cancelText={t('cancel', { ns: 'common' })}
okButtonProps={{ danger: true }}
okText={t('ok', { ns: 'common' })}
onConfirm={() => clearMessage()}
onConfirm={() => {
resetConversation();
}}
placement={'topRight'}
title={t('confirmClearCurrentMessages', { ns: 'chat' })}
>

View file

@ -0,0 +1,53 @@
import { ActionIcon, Icon } from '@lobehub/ui';
import { Upload } from 'antd';
import { useTheme } from 'antd-style';
import { LucideImage, LucideLoader2 } from 'lucide-react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Center } from 'react-layout-kit';
import { useFileStore } from '@/store/files';
import { useSessionStore } from '@/store/session';
import { agentSelectors } from '@/store/session/slices/agentConfig';
const FileUpload = memo(() => {
const { t } = useTranslation('chat');
const [loading, setLoading] = useState(false);
const theme = useTheme();
const upload = useFileStore((s) => s.uploadFile);
const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
if (!canUpload) return null;
return (
<Upload
accept="image/*"
beforeUpload={async (file) => {
setLoading(true);
await upload(file);
setLoading(false);
return false;
}}
multiple={true}
showUploadList={false}
>
{loading ? (
<Center height={36} width={36}>
<Icon
color={theme.colorTextSecondary}
icon={LucideLoader2}
size={{ fontSize: 18 }}
spin
></Icon>
</Center>
) : (
<ActionIcon icon={LucideImage} placement={'bottom'} title={t('upload.actionTooltip')} />
)}
</Upload>
);
});
export default FileUpload;

View file

@ -1,6 +1,7 @@
import { FC } from 'react';
import Clear from './Clear';
import FileUpload from './FileUpload';
import History from './History';
import ModelSwitch from './ModelSwitch';
import Temperature from './Temperature';
@ -8,6 +9,7 @@ import Token from './Token';
export const actionMap: Record<string, FC> = {
clear: Clear,
fileUpload: FileUpload,
history: History,
model: ModelSwitch,
temperature: Temperature,
@ -15,5 +17,5 @@ export const actionMap: Record<string, FC> = {
};
// we can make these action lists configurable in the future
export const leftActionList = ['model', 'temperature', 'history', 'token'];
export const leftActionList = ['model', 'fileUpload', 'temperature', 'history', 'token'];
export const rightActionList = ['clear'];

View file

@ -1,5 +1,6 @@
import { useCallback } from 'react';
import { filesSelectors, useFileStore } from '@/store/files';
import { useSessionStore } from '@/store/session';
export const useSendMessage = () => {
@ -11,7 +12,10 @@ export const useSendMessage = () => {
return useCallback(() => {
const store = useSessionStore.getState();
if (!!store.chatLoadingId) return;
sendMessage(store.inputMessage);
const imageList = filesSelectors.imageUrlOrBase64List(useFileStore.getState());
sendMessage(store.inputMessage, imageList);
updateInputMessage('');
useFileStore.getState().clearImageList();
}, []);
};

View file

@ -0,0 +1,23 @@
import { ReactNode, memo } from 'react';
import { Flexbox } from 'react-layout-kit';
import FileList from '@/app/chat/components/FileList';
import { LOADING_FLAT } from '@/const/message';
import { ChatMessage } from '@/types/chatMessage';
import BubblesLoading from '../Loading';
export const UserMessage = memo<
ChatMessage & {
editableContent: ReactNode;
}
>(({ id, editableContent, content, ...res }) => {
if (content === LOADING_FLAT) return <BubblesLoading />;
return (
<Flexbox gap={8} id={id}>
{editableContent}
{res.files && <FileList editable={false} items={res.files} />}
</Flexbox>
);
});

View file

@ -10,11 +10,13 @@ import { pathString } from '@/utils/url';
import { AssistantMessage } from './Assistant';
import { DefaultMessage } from './Default';
import { FunctionMessage } from './Function';
import { UserMessage } from './User';
export const renderMessages: ChatListProps['renderMessages'] = {
assistant: AssistantMessage,
default: DefaultMessage,
function: FunctionMessage,
user: UserMessage,
};
export const useAvatarsClick = (): ChatListProps['onAvatarsClick'] => {

View file

@ -38,4 +38,15 @@ describe('getServerConfig', () => {
const config = getServerConfig();
expect(config.USE_AZURE_OPENAI).toBe(false);
});
it('returns default IMGUR_CLIENT_ID when no environment variable is set', () => {
const config = getServerConfig();
expect(config.IMGUR_CLIENT_ID).toBe('e415f320d6e24f9');
});
it('returns custom IMGUR_CLIENT_ID when environment variable is set', () => {
process.env.IMGUR_CLIENT_ID = 'custom-client-id';
const config = getServerConfig();
expect(config.IMGUR_CLIENT_ID).toBe('custom-client-id');
});
});

View file

@ -12,10 +12,16 @@ declare global {
AZURE_API_KEY?: string;
AZURE_API_VERSION?: string;
USE_AZURE_OPENAI?: string;
IMGUR_CLIENT_ID?: string;
}
}
}
// we apply a free imgur app to get a client id
// refs: https://apidocs.imgur.com/
const DEFAULT_IMAGUR_CLIENT_ID = 'e415f320d6e24f9';
export const getServerConfig = () => {
if (typeof process === 'undefined') {
throw new Error('[Server Config] you are importing a nodejs-only module outside of nodejs');
@ -30,5 +36,7 @@ export const getServerConfig = () => {
AZURE_API_KEY: process.env.AZURE_API_KEY,
AZURE_API_VERSION: process.env.AZURE_API_VERSION,
USE_AZURE_OPENAI: process.env.USE_AZURE_OPENAI === '1',
IMGUR_CLIENT_ID: process.env.IMGUR_CLIENT_ID || DEFAULT_IMAGUR_CLIENT_ID,
};
};

View file

@ -3,7 +3,7 @@ import type { FormProps } from '@lobehub/ui';
export const HEADER_HEIGHT = 64;
export const MOBILE_NABBAR_HEIGHT = 44;
export const MOBILE_TABBAR_HEIGHT = 48;
export const CHAT_TEXTAREA_HEIGHT = 200;
export const CHAT_TEXTAREA_HEIGHT = 230;
export const CHAT_TEXTAREA_HEIGHT_MOBILE = 108;
export const CHAT_SIDEBAR_WIDTH = 280;
export const MARKET_SIDEBAR_WIDTH = 400;

View file

@ -12,3 +12,6 @@ export const LanguageModelWhiteList = [
];
export const DEFAULT_OPENAI_MODEL_LIST: string[] = Object.values(LanguageModel);
// vision model white list, these models will change the content from string to array
export const VISION_MODEL_WHITE_LIST = ['gpt-4-vision-preview'];

View file

@ -18,6 +18,8 @@ export const DEFAULT_BASE_SETTINGS: GlobalBaseSettings = {
themeMode: 'auto',
};
export const VISION_MODEL_DEFAULT_MAX_TOKENS = 1000;
export const DEFAULT_AGENT_CONFIG: LobeAgentConfig = {
displayMode: 'chat',
historyCount: 1,

View file

@ -0,0 +1,77 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DBModel } from '@/types/database/db';
import { LocalFile } from '@/types/database/files';
import { LocalDB } from '../db';
describe('LocalDB', () => {
let db: LocalDB;
beforeEach(() => {
db = new LocalDB();
});
afterEach(async () => {
await db.delete();
db.close();
});
it('should be instantiated with the correct schema', () => {
const filesTable = db.files;
expect(filesTable).toBeDefined();
});
it('should allow adding a file', async () => {
const file: DBModel<LocalFile> = {
id: 'file1',
name: 'testfile.txt',
data: new ArrayBuffer(3),
saveMode: 'local',
fileType: 'plain/text',
size: 3,
createdAt: Date.now(),
};
await db.files.add(file);
expect(await db.files.get(file.id)).toEqual(file);
});
it('should allow updating a file', async () => {
const file: DBModel<LocalFile> = {
id: 'file1',
name: 'testfile.txt',
data: new ArrayBuffer(3),
saveMode: 'local',
fileType: 'plain/text',
size: 3,
createdAt: Date.now(),
};
await db.files.add(file);
// Act
await db.files.update(file.id, { name: 'update.txt' });
// Assert
expect(await db.files.get(file.id)).toHaveProperty('name', 'update.txt');
});
it('should allow deleting a file', async () => {
const file: DBModel<LocalFile> = {
id: 'file1',
name: 'testfile.txt',
data: new ArrayBuffer(3),
saveMode: 'local',
fileType: 'plain/text',
size: 3,
createdAt: Date.now(),
};
await db.files.add(file);
await db.files.delete(file.id);
expect(await db.files.get(file.id)).toBeUndefined();
});
});

View file

@ -0,0 +1,55 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ZodSchema, z } from 'zod';
import { BaseModel } from '../model';
// Define a mock schema for testing
const mockSchema = z.object({
name: z.string(),
content: z.string(),
});
// Define a mock table name
const mockTableName = 'files';
describe('BaseModel', () => {
let baseModel: BaseModel<typeof mockTableName>;
beforeEach(() => {
baseModel = new BaseModel(mockTableName, mockSchema);
// Mock the console.error to test error logging
vi.spyOn(console, 'error').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
// console.error = originalConsoleError;
});
it('should have a table property', () => {
expect(baseModel.table).toBeDefined();
});
describe('add method', () => {
it('should add a valid record to the database', async () => {
const validData = {
name: 'testfile.txt',
content: 'Hello, World!',
};
const result = await baseModel['add'](validData);
expect(result).toHaveProperty('id');
expect(console.error).not.toHaveBeenCalled();
});
it('should throw an error and log to console when adding an invalid record', async () => {
const invalidData = {
name: 123, // Invalid type, should be a string
content: 'Hello, World!',
};
await expect(baseModel['add'](invalidData)).rejects.toThrow(TypeError);
});
});
});

25
src/database/core/db.ts Normal file
View file

@ -0,0 +1,25 @@
import Dexie from 'dexie';
import { LobeDBSchemaMap as SchemaMap, lobeDBSchema } from '@/database/core/schema';
import { DBModel } from '@/types/database/db';
export type LocalDBSchema = {
[t in keyof SchemaMap]: {
model: SchemaMap[t];
table: Dexie.Table<DBModel<SchemaMap[t]>, string>;
};
};
// Define a local DB
export class LocalDB extends Dexie {
public files: LocalDBSchema['files']['table'];
constructor() {
super('LOBE_CHAT_DB');
this.version(1).stores(lobeDBSchema);
this.files = this.table('files');
}
}
export const LocalDBInstance = new LocalDB();

View file

@ -0,0 +1 @@
export * from './model';

View file

@ -0,0 +1,49 @@
import { ZodSchema } from 'zod';
import { DBModel } from '@/types/database/db';
import { LocalFile } from '@/types/database/files';
import { nanoid } from '@/utils/uuid';
import { LocalDB, LocalDBInstance, LocalDBSchema } from './db';
export class BaseModel<N extends keyof LocalDBSchema = any> {
private readonly db: LocalDB;
private readonly schema: ZodSchema;
private readonly _tableName: keyof LocalDBSchema;
constructor(table: N, schema: ZodSchema, db = LocalDBInstance) {
this.db = db;
this.schema = schema;
this._tableName = table;
}
get table() {
return this.db[this._tableName];
}
/**
* create a new record
* @param data
* @param id
*/
protected async add<T = LocalDBSchema[N]['model']>(data: T, id: string | number = nanoid()) {
const result = this.schema.safeParse(data);
if (!result.success) {
const errorMsg = `[${this.db.name}][${this._tableName}] Failed to create new record. Error: ${result.error}`;
const newError = new TypeError(errorMsg);
// make this error show on console to help debug
console.error(newError);
throw newError;
}
const tableName = this._tableName;
const record: DBModel<LocalFile> = { ...result.data, createdAt: Date.now(), id };
const newId = await this.db[tableName].add(record);
return { id: newId };
}
}

View file

@ -0,0 +1,9 @@
import { LocalFile } from '@/types/database/files';
export const lobeDBSchema = {
files: '&id, name, fileType, saveMode',
};
export interface LobeDBSchemaMap {
files: LocalFile;
}

View file

@ -0,0 +1,71 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { LocalFile } from '@/types/database/files';
import { LocalDB } from '../core/db';
import { FileModel } from './file';
// Assuming LocalDB is already mocked or using an in-memory database
// and LocalFileSchema has been imported correctly.
describe('_FileModel', () => {
let fileData: LocalFile;
beforeEach(() => {
// Set up file data with the correct structure according to LocalFileSchema
fileData = {
data: new ArrayBuffer(10),
fileType: 'image/png',
name: 'test.png',
saveMode: 'local',
size: 10,
// url is optional, only needed if saveMode is 'url'
};
});
afterEach(async () => {
// Clean up the database after each test
const db = new LocalDB();
await db.files.clear();
db.close();
});
it('should create a file record', async () => {
// First, create a file to test the create method
const fileData: LocalFile = {
data: new ArrayBuffer(10),
fileType: 'image/png',
name: 'test.png',
saveMode: 'local',
size: 10,
};
const result = await FileModel.create(fileData);
expect(result).toHaveProperty('id');
expect(result.id).toMatch(/^file-/);
// Verify that the file has been added to the database
const fileInDb = await FileModel.findById(result.id);
expect(fileInDb).toEqual(expect.objectContaining(fileData));
});
it('should find a file by id', async () => {
// First, create a file to test the findById method
const createdFile = await FileModel.create(fileData);
const foundFile = await FileModel.findById(createdFile.id);
expect(foundFile).toEqual(expect.objectContaining(fileData));
});
it('should delete a file by id', async () => {
// First, create a file to test the delete method
const createdFile = await FileModel.create(fileData);
await FileModel.delete(createdFile.id);
// Verify that the file has been removed from the database
const fileInDb = await FileModel.findById(createdFile.id);
expect(fileInDb).toBeUndefined();
});
});

View file

@ -0,0 +1,26 @@
import { LocalFile, LocalFileSchema } from '@/types/database/files';
import { nanoid } from '@/utils/uuid';
import { BaseModel } from '../core';
class _FileModel extends BaseModel {
constructor() {
super('files', LocalFileSchema);
}
async create(file: LocalFile) {
const id = nanoid();
return this.add(file, `file-${id}`);
}
async findById(id: string) {
return this.table.get(id);
}
async delete(id: string) {
return this.table.delete(id);
}
}
export const FileModel = new _FileModel();

View file

@ -62,5 +62,10 @@ export default {
},
translateTo: '翻译',
updateAgent: '更新助理信息',
upload: {
actionTooltip: '上传图片',
dragDesc: '拖拽文件到这里,支持上传多个图片。按住 Shift 直接发送图片',
dragTitle: '上传图片',
},
warp: '换行',
};

View file

@ -0,0 +1,88 @@
import { Mock, beforeEach, describe, expect, it, vi } from 'vitest';
import { FileModel } from '@/database/models/file';
import { LocalFile } from '@/types/database/files';
import { fileService } from '../file';
// Mocks for the FileModel
vi.mock('@/database/models/file', () => ({
FileModel: {
create: vi.fn(),
delete: vi.fn(),
findById: vi.fn(),
},
}));
// Mocks for the URL and Blob objects
global.URL.createObjectURL = vi.fn();
global.Blob = vi.fn();
describe('FileService', () => {
beforeEach(() => {
// Reset all mocks before each test
vi.resetAllMocks();
});
it('uploadFile should save the file to the database', async () => {
const localFile: LocalFile = {
name: 'test',
data: new ArrayBuffer(1),
fileType: 'image/png',
saveMode: 'local',
size: 1,
};
(FileModel.create as Mock).mockResolvedValue(localFile);
const result = await fileService.uploadFile(localFile);
expect(FileModel.create).toHaveBeenCalledWith(localFile);
expect(result).toEqual(localFile);
});
it('removeFile should delete the file from the database', async () => {
const fileId = '1';
(FileModel.delete as Mock).mockResolvedValue(true);
const result = await fileService.removeFile(fileId);
expect(FileModel.delete).toHaveBeenCalledWith(fileId);
expect(result).toBe(true);
});
it('getFile should retrieve and convert file info to FilePreview', async () => {
const fileId = '1';
const fileData: LocalFile = {
name: 'test',
data: new ArrayBuffer(1),
fileType: 'image/png',
saveMode: 'local',
size: 1,
};
(FileModel.findById as Mock).mockResolvedValue(fileData);
(global.URL.createObjectURL as Mock).mockReturnValue('blob:test');
(global.Blob as Mock).mockImplementation(() => ['test']);
const result = await fileService.getFile(fileId);
expect(FileModel.findById).toHaveBeenCalledWith(fileId);
expect(result).toEqual({
base64Url: 'data:image/png;base64,AA==',
fileType: 'image/png',
name: 'test',
saveMode: 'local',
url: 'blob:test',
});
});
it('getFile should throw an error when the file is not found', async () => {
const fileId = 'non-existent';
(FileModel.findById as Mock).mockResolvedValue(null);
const getFilePromise = fileService.getFile(fileId);
await expect(getFilePromise).rejects.toThrow('file not found');
});
});

View file

@ -1,8 +1,9 @@
import { merge } from 'lodash-es';
import { VISION_MODEL_WHITE_LIST } from '@/const/llm';
import { pluginSelectors, usePluginStore } from '@/store/plugin';
import { initialLobeAgentConfig } from '@/store/session/initialState';
import type { ChatCompletionFunctions, OpenAIChatStreamPayload } from '@/types/openai/chat';
import type { OpenAIChatStreamPayload } from '@/types/openai/chat';
import { createHeaderWithOpenAI } from './_header';
import { OPENAI_URLS } from './_url';
@ -26,13 +27,17 @@ export const fetchChatModel = (
},
params,
);
// ============ 1. 前置处理 functions ============ //
// ============ preprocess tools ============ //
const filterFunctions: ChatCompletionFunctions[] = pluginSelectors.enabledSchema(enabledPlugins)(
usePluginStore.getState(),
);
const filterTools = pluginSelectors.enabledSchema(enabledPlugins)(usePluginStore.getState());
const functions = filterFunctions.length === 0 ? undefined : filterFunctions;
// the rule that model can use tools:
// 1. tools is not empty
// 2. model is not in vision white list, because vision model can't use tools
// TODO: we need to find some method to let vision model use tools
const shouldUseTools = filterTools.length > 0 && !VISION_MODEL_WHITE_LIST.includes(payload.model);
const functions = shouldUseTools ? filterTools : undefined;
return fetch(OPENAI_URLS.chat, {
body: JSON.stringify({ ...payload, functions }),

36
src/services/file.ts Normal file
View file

@ -0,0 +1,36 @@
import { FileModel } from '@/database/models/file';
import { LocalFile } from '@/types/database/files';
import { FilePreview } from '@/types/files';
class FileService {
async uploadFile(file: LocalFile) {
// save to local storage
// we may want to save to a remote server later
return FileModel.create(file);
}
async removeFile(id: string) {
return FileModel.delete(id);
}
async getFile(id: string): Promise<FilePreview> {
const item = await FileModel.findById(id);
if (!item) {
throw new Error('file not found');
}
// arrayBuffer to url
const url = URL.createObjectURL(new Blob([item.data]));
const base64 = Buffer.from(item.data).toString('base64');
return {
base64Url: `data:${item.fileType};base64,${base64}`,
fileType: item.fileType,
name: item.name,
saveMode: 'local',
url,
};
}
}
export const fileService = new FileService();

2
src/store/files/index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from './selectors';
export { useFileStore } from './store';

View file

@ -0,0 +1,7 @@
import { ImageFileState, initialImageFileState } from './slices/images';
export type FilesStoreState = ImageFileState;
export const initialState: FilesStoreState = {
...initialImageFileState,
};

View file

@ -0,0 +1,75 @@
import { beforeEach, describe, expect, it } from 'vitest';
import { FilesStoreState } from './initialState';
import { filesSelectors } from './selectors';
describe('filesSelectors', () => {
let state: FilesStoreState;
beforeEach(() => {
// 创建并初始化 state 的模拟实例
state = {
imagesMap: {
'1': {
name: 'a',
fileType: 'image/png',
saveMode: 'local',
base64Url: 'base64string1',
url: 'blob:abc',
},
'2': {
name: 'b',
fileType: 'image/png',
saveMode: 'url',
base64Url: 'base64string2',
url: 'url2',
},
},
// 假设 '3' 是不存在的 ID
inputFilesList: ['1', '2', '3'],
};
});
it('getImageDetailByList should return details for the provided list of image IDs', () => {
const list = ['1', '2'];
const details = filesSelectors.getImageDetailByList(list)(state);
expect(details.length).toBe(2);
expect(details[0].name).toBe('a');
expect(details[1].name).toBe('b');
});
it('imageDetailList should return details for the images in the inputFilesList', () => {
const details = filesSelectors.imageDetailList(state);
// '3' should be filtered due to not exist in map
expect(details.length).toBe(2);
});
it('getImageUrlOrBase64ById should return the correct URL or Base64 based on saveMode', () => {
const localImage = filesSelectors.getImageUrlOrBase64ById('1')(state);
expect(localImage).toEqual({ id: '1', url: 'base64string1' });
const serverImage = filesSelectors.getImageUrlOrBase64ById('2')(state);
expect(serverImage).toEqual({ id: '2', url: 'url2' });
const nonExistentImage = filesSelectors.getImageUrlOrBase64ById('3')(state);
expect(nonExistentImage).toBeUndefined();
});
it('getImageUrlOrBase64ByList should return the correct list of URLs or Base64 strings', () => {
const list = ['1', '2', '3'];
const urlsOrBase64s = filesSelectors.getImageUrlOrBase64ByList(list)(state);
expect(urlsOrBase64s.length).toBe(2); // '3' 应该被过滤掉,因为它不存在
expect(urlsOrBase64s[0].url).toBe('base64string1');
expect(urlsOrBase64s[1].url).toBe('url2');
});
it('imageUrlOrBase64List should return a list of image URLs or Base64 strings for all images in inputFilesList', () => {
const urlsOrBase64s = filesSelectors.imageUrlOrBase64List(state);
expect(urlsOrBase64s.length).toBe(2); // '3' 是不存在的 ID所以应该被过滤掉
expect(urlsOrBase64s).toEqual([
{ id: '1', url: 'base64string1' }, // 本地保存的图像应该使用 base64 URL
{ id: '2', url: 'url2' }, // 服务器保存的图像应该使用普通 URL
]);
});
});

View file

@ -0,0 +1,34 @@
import { FilesStoreState } from './initialState';
const getImageDetailByList = (list: string[]) => (s: FilesStoreState) =>
list.map((i) => s.imagesMap[i]).filter(Boolean);
const imageDetailList = (s: FilesStoreState) => getImageDetailByList(s.inputFilesList)(s);
const getImageUrlOrBase64ById =
(id: string) =>
(s: FilesStoreState): { id: string; url: string } | undefined => {
const preview = s.imagesMap[id];
if (!preview) return undefined;
const url = preview.saveMode === 'local' ? (preview.base64Url as string) : preview.url;
return { id, url: url };
};
const getImageUrlOrBase64ByList = (idList: string[]) => (s: FilesStoreState) =>
idList.map((i) => getImageUrlOrBase64ById(i)(s)).filter(Boolean) as {
id: string;
url: string;
}[];
const imageUrlOrBase64List = (s: FilesStoreState) => getImageUrlOrBase64ByList(s.inputFilesList)(s);
export const filesSelectors = {
getImageDetailByList,
getImageUrlOrBase64ById,
getImageUrlOrBase64ByList,
imageDetailList,
imageUrlOrBase64List,
};

View file

@ -0,0 +1,186 @@
import { act, renderHook } from '@testing-library/react';
import useSWR from 'swr';
import { Mock, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { fileService } from '@/services/file';
import { useFileStore as useStore } from '../../store';
vi.mock('zustand/traditional');
// Mocks for fileService
vi.mock('@/services/file', () => ({
fileService: {
removeFile: vi.fn(),
uploadFile: vi.fn(),
getFile: vi.fn(),
},
}));
// Mock for useSWR
vi.mock('swr', () => ({
default: vi.fn(),
}));
// mock the arrayBuffer
beforeAll(() => {
Object.defineProperty(File.prototype, 'arrayBuffer', {
writable: true,
value: function () {
return new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => {
resolve(reader.result);
};
reader.readAsArrayBuffer(this);
});
},
});
});
beforeEach(() => {
// Reset all mocks before each test
vi.resetAllMocks();
});
describe('useFileStore:images', () => {
it('clearImageList should clear the inputFilesList', () => {
const { result } = renderHook(() => useStore());
// Populate the list to clear it later
act(() => {
useStore.setState({ inputFilesList: ['test-id'] });
});
expect(result.current.inputFilesList).toEqual(['test-id']);
act(() => {
result.current.clearImageList();
});
expect(result.current.inputFilesList).toEqual([]);
});
it('removeFile should call fileService.removeFile and update the store', async () => {
const { result } = renderHook(() => useStore());
const fileId = 'test-id';
// Mock the fileService.removeFile to resolve
(fileService.removeFile as Mock).mockResolvedValue(undefined);
// Populate the list to remove an item later
act(() => {
useStore.setState(({ inputFilesList }) => ({ inputFilesList: [...inputFilesList, fileId] }));
// // result.current.inputFilesList.push(fileId);
});
await act(async () => {
await result.current.removeFile(fileId);
});
expect(fileService.removeFile).toHaveBeenCalledWith(fileId);
expect(result.current.inputFilesList).toEqual([]);
});
// Test for useFetchFile
it('useFetchFile should call useSWR and update the store', async () => {
const fileId = 'test-id';
const fileData = {
id: fileId,
name: 'test',
url: 'blob:test',
fileType: 'image/png',
base64Url: '',
saveMode: 'local',
};
// Mock the fileService.getFile to resolve with fileData
(fileService.getFile as Mock).mockResolvedValue(fileData);
// Mock useSWR to call the fetcher function immediately
const useSWRMock = vi.mocked(useSWR);
useSWRMock.mockImplementation(((key: string, fetcher: any) => {
const data = fetcher(key);
return { data, error: undefined, isValidating: false, mutate: vi.fn() };
}) as any);
const { result } = renderHook(() => useStore().useFetchFile(fileId));
await act(async () => {
await result.current.data;
});
expect(fileService.getFile).toHaveBeenCalledWith(fileId);
// Since we are not rendering a component with the hook, we cannot test the state update here
// Instead, we would need to use a test renderer that can work with hooks, like @testing-library/react
});
describe('uploadFile', () => {
it('uploadFile should handle errors', async () => {
const { result } = renderHook(() => useStore());
const testFile = new File(['content'], 'test.png', { type: 'image/png' });
// 模拟 fileService.uploadFile 抛出错误
const errorMessage = 'Upload failed';
(fileService.uploadFile as Mock).mockRejectedValue(new Error(errorMessage));
// Mock console.error for testing
const consoleErrorMock = vi.spyOn(console, 'error').mockImplementation(() => {});
await act(async () => {
await result.current.uploadFile(testFile);
});
expect(fileService.uploadFile).toHaveBeenCalledWith({
createdAt: testFile.lastModified,
data: await testFile.arrayBuffer(),
fileType: testFile.type,
name: testFile.name,
saveMode: 'local',
size: testFile.size,
});
// 由于上传失败inputFilesList 应该没有变化
expect(result.current.inputFilesList).toEqual([]);
// 确保错误被正确记录
expect(consoleErrorMock).toHaveBeenCalledWith('upload error:', expect.any(Error));
// Cleanup mock
consoleErrorMock.mockRestore();
});
it('uploadFile should upload the file and update inputFilesList', async () => {
const { result } = renderHook(() => useStore());
const testFile = new File(['content'], 'test.png', { type: 'image/png' });
// 模拟 fileService.uploadFile 返回的数据
const uploadedFileData = {
id: 'new-file-id',
createdAt: testFile.lastModified,
data: await testFile.arrayBuffer(),
fileType: testFile.type,
name: testFile.name,
saveMode: 'local',
size: testFile.size,
};
// Mock the fileService.uploadFile to resolve with uploadedFileData
(fileService.uploadFile as Mock).mockResolvedValue(uploadedFileData);
await act(async () => {
await result.current.uploadFile(testFile);
});
expect(fileService.uploadFile).toHaveBeenCalledWith({
createdAt: testFile.lastModified,
data: await testFile.arrayBuffer(),
fileType: testFile.type,
name: testFile.name,
saveMode: 'local',
size: testFile.size,
});
expect(result.current.inputFilesList).toContain(uploadedFileData.id);
});
});
});

View file

@ -0,0 +1,83 @@
import { produce } from 'immer';
import useSWR, { SWRResponse } from 'swr';
import { StateCreator } from 'zustand/vanilla';
import { fileService } from '@/services/file';
import { FilePreview } from '@/types/files';
import { setNamespace } from '@/utils/storeDebug';
import { FileStore } from '../../store';
const t = setNamespace('files/image');
/**
*
*/
export interface FileAction {
clearImageList: () => void;
removeFile: (id: string) => Promise<void>;
setImageMapItem: (id: string, item: FilePreview) => void;
uploadFile: (file: File) => Promise<void>;
useFetchFile: (id: string) => SWRResponse<FilePreview>;
}
export const createFileSlice: StateCreator<
FileStore,
[['zustand/devtools', never]],
[],
FileAction
> = (set, get) => ({
clearImageList: () => {
set({ inputFilesList: [] }, false, t('clearImageList'));
},
removeFile: async (id) => {
await fileService.removeFile(id);
set(
({ inputFilesList }) => ({ inputFilesList: inputFilesList.filter((i) => i !== id) }),
false,
t('removeFile'),
);
},
setImageMapItem: (id, item) => {
set(
produce((draft) => {
if (draft.imagesMap[id]) return;
draft.imagesMap[id] = item;
}),
false,
t('setImageMapItem'),
);
},
uploadFile: async (file) => {
try {
const data = await fileService.uploadFile({
createdAt: file.lastModified,
data: await file.arrayBuffer(),
fileType: file.type,
name: file.name,
saveMode: 'local',
size: file.size,
});
set(
({ inputFilesList }) => ({ inputFilesList: [...inputFilesList, data.id] }),
false,
t('uploadFile'),
);
} catch (error) {
// 提示用户上传失败
console.error('upload error:', error);
}
},
useFetchFile: (id) =>
useSWR(id, async (id) => {
const item = await fileService.getFile(id);
get().setImageMapItem(id, item);
return item;
}),
});

View file

@ -0,0 +1,3 @@
export * from './action';
export * from './initialState';
// export * from './selectors';

View file

@ -0,0 +1,11 @@
import { FilePreview } from '@/types/files';
export interface ImageFileState {
imagesMap: Record<string, FilePreview>;
inputFilesList: string[];
}
export const initialImageFileState: ImageFileState = {
imagesMap: {},
inputFilesList: [],
};

27
src/store/files/store.ts Normal file
View file

@ -0,0 +1,27 @@
import { devtools } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { StateCreator } from 'zustand/vanilla';
import { isDev } from '@/utils/env';
import { FilesStoreState, initialState } from './initialState';
import { FileAction, createFileSlice } from './slices/images';
// =============== 聚合 createStoreFn ============ //
export type FileStore = FilesStoreState & FileAction;
const createStore: StateCreator<FileStore, [['zustand/devtools', never]]> = (...parameters) => ({
...initialState,
...createFileSlice(...parameters),
});
// =============== 实装 useStore ============ //
export const useFileStore = createWithEqualityFn<FileStore>()(
devtools(createStore, {
name: 'LobeChat_File' + (isDev ? '_DEV' : ''),
}),
shallow,
);

View file

@ -75,7 +75,7 @@ describe('settingsSelectors', () => {
});
});
});
//
// describe('defaultAgent', () => {
// it('should merge DEFAULT_AGENT and s.settings.defaultAgent correctly', () => {
// const s: GlobalStore = {
@ -95,8 +95,6 @@ describe('settingsSelectors', () => {
//
// const result = settingsSelectors.defaultAgent(s);
//
//
//
// expect(result).toEqual(expected);
// });
// });

View file

@ -1,6 +1,6 @@
import { t } from 'i18next';
import { DEFAULT_OPENAI_MODEL_LIST } from '@/const/llm';
import { DEFAULT_OPENAI_MODEL_LIST, VISION_MODEL_WHITE_LIST } from '@/const/llm';
import { DEFAULT_AVATAR, DEFAULT_BACKGROUND_COLOR } from '@/const/meta';
import { SessionStore } from '@/store/session';
import { LanguageModel } from '@/types/llm';
@ -49,6 +49,11 @@ const currentAgentModel = (s: SessionStore): LanguageModel | string => {
return config?.model || LanguageModel.GPT3_5;
};
const modelHasVisionAbility = (s: SessionStore): boolean => {
const model = currentAgentModel(s);
return VISION_MODEL_WHITE_LIST.includes(model);
};
const currentAgentPlugins = (s: SessionStore) => {
const config = currentAgentConfig(s);
@ -87,5 +92,6 @@ export const agentSelectors = {
getDescription,
getTitle,
hasSystemRole,
modelHasVisionAbility,
showTokenTag,
};

View file

@ -1,10 +1,14 @@
import { template } from 'lodash-es';
import { StateCreator } from 'zustand/vanilla';
import { VISION_MODEL_WHITE_LIST } from '@/const/llm';
import { LOADING_FLAT } from '@/const/message';
import { VISION_MODEL_DEFAULT_MAX_TOKENS } from '@/const/settings';
import { fetchChatModel } from '@/services/chatModel';
import { filesSelectors, useFileStore } from '@/store/files';
import { SessionStore } from '@/store/session';
import { ChatMessage } from '@/types/chatMessage';
import { OpenAIChatMessage, UserMessageContentPart } from '@/types/openai/chat';
import { fetchSSE } from '@/utils/fetch';
import { isFunctionMessageAtStart, testFunctionMessageAtEnd } from '@/utils/message';
import { setNamespace } from '@/utils/storeDebug';
@ -12,6 +16,7 @@ import { nanoid } from '@/utils/uuid';
import { agentSelectors } from '../../agentConfig/selectors';
import { sessionSelectors } from '../../session/selectors';
import { FileDispatch, filesReducer } from '../reducers/files';
import { MessageDispatch, messagesReducer } from '../reducers/message';
import { chatSelectors } from '../selectors';
import { getSlicedMessagesWithConfig } from '../utils';
@ -37,6 +42,10 @@ export interface ChatMessageAction {
* @param id - ID
*/
deleteMessage: (id: string) => void;
/**
* agent files dispatch method
*/
dispatchAgentFile: (payload: FileDispatch) => void;
/**
*
* @param payload -
@ -66,7 +75,7 @@ export interface ChatMessageAction {
*
* @param text -
*/
sendMessage: (text: string) => Promise<void>;
sendMessage: (text: string, images?: { id: string; url: string }[]) => Promise<void>;
stopGenerateMessage: () => void;
toggleChatLoading: (
loading: boolean,
@ -164,6 +173,15 @@ export const chatMessage: StateCreator<
get().dispatchMessage({ id, type: 'deleteMessage' });
},
dispatchAgentFile: (payload) => {
const { activeId } = get();
const session = sessionSelectors.currentSession(get());
if (!activeId || !session) return;
const files = filesReducer(session.files || [], payload);
get().dispatchSession({ files, id: activeId, type: 'updateSessionFiles' });
},
dispatchMessage: (payload) => {
const { activeId } = get();
const session = sessionSelectors.currentSession(get());
@ -173,6 +191,7 @@ export const chatMessage: StateCreator<
get().dispatchSession({ chats, id: activeId, type: 'updateSessionChat' });
},
fetchAIChatMessage: async (messages, assistantId) => {
const { dispatchMessage, toggleChatLoading } = get();
@ -186,17 +205,17 @@ export const chatMessage: StateCreator<
const compiler = template(config.inputTemplate, { interpolate: /{{([\S\s]+?)}}/g });
// ========================== //
// 对 messages 做统一预处理 //
// ========================== //
// ================================== //
// messages uniformly preprocess //
// ================================== //
// 1. 按参数设定截断长度
const slicedMessages = getSlicedMessagesWithConfig(messages, config);
// 1. slice messages with config
let preprocessMsgs = getSlicedMessagesWithConfig(messages, config);
// 2. 替换 inputMessage 模板
const postMessages = !config.inputTemplate
? slicedMessages
: slicedMessages.map((m) => {
// 2. replace inputMessage template
preprocessMsgs = !config.inputTemplate
? preprocessMsgs
: preprocessMsgs.map((m) => {
if (m.role === 'user') {
try {
return { ...m, content: compiler({ text: m.content }) };
@ -206,12 +225,41 @@ export const chatMessage: StateCreator<
return m;
}
}
return m;
});
// 3. 添加 systemRole
// 3. add systemRole
if (config.systemRole) {
postMessages.unshift({ content: config.systemRole, role: 'system' } as ChatMessage);
preprocessMsgs.unshift({ content: config.systemRole, role: 'system' } as ChatMessage);
}
let postMessages: OpenAIChatMessage[] = preprocessMsgs;
// 4. handle content type for vision model
// for the models with visual ability, add image url to content
// refs: https://platform.openai.com/docs/guides/vision/quick-start
if (VISION_MODEL_WHITE_LIST.includes(config.model)) {
postMessages = preprocessMsgs.map((m) => {
if (!m.files) return m;
const imageList = filesSelectors.getImageUrlOrBase64ByList(m.files)(
useFileStore.getState(),
);
if (imageList.length === 0) return m;
const content: UserMessageContentPart[] = [
{ text: m.content, type: 'text' },
...imageList.map(
(i) => ({ image_url: { detail: 'auto', url: i.url }, type: 'image_url' }) as const,
),
];
return { ...m, content };
});
// due to vision model's default max_tokens is very small, we need to set the max_tokens a larger one.
if (!config.params.max_tokens) config.params.max_tokens = VISION_MODEL_DEFAULT_MAX_TOKENS;
}
const fetcher = () =>
@ -248,7 +296,7 @@ export const chatMessage: StateCreator<
toggleChatLoading(false, undefined, t('generateMessage(end)') as string);
// also exist message like this: 请稍等,我帮您查询一下。{"function_call": {"name": "plugin-identifier____recommendClothes____standalone", "arguments": "{\n "mood": "",\n "gender": "man"\n}"}}
// also exist message like this: 请稍等,我帮您查询一下。{"function_call": {"name": "plugin-identifier____recommendClothes____standalone", "arguments": "{\n "mood": "",\n "gender": "man"\n}"}}
if (!isFunctionCall) {
const { content, valid } = testFunctionMessageAtEnd(output);
@ -305,13 +353,27 @@ export const chatMessage: StateCreator<
await coreProcessMessage(contextMessages, latestMsg.id);
},
sendMessage: async (message) => {
const { dispatchMessage, coreProcessMessage, activeTopicId } = get();
sendMessage: async (message, files) => {
const { dispatchMessage, dispatchAgentFile, coreProcessMessage, activeTopicId } = get();
const session = sessionSelectors.currentSession(get());
if (!session || !message) return;
const userId = nanoid();
dispatchMessage({ id: userId, message, role: 'user', type: 'addMessage' });
dispatchMessage({
id: userId,
message: message,
role: 'user',
type: 'addMessage',
});
// if message has attached with files, then add files to message and the agent
if (files && files.length > 0) {
const fileIdList = files.map((f) => f.id);
dispatchMessage({ id: userId, key: 'files', type: 'updateMessage', value: fileIdList });
dispatchAgentFile({ files: fileIdList, type: 'addFiles' });
}
// if there is activeTopicIdthen add topicId to message
if (activeTopicId) {
@ -341,7 +403,6 @@ export const chatMessage: StateCreator<
toggleChatLoading(false);
},
toggleChatLoading: (loading, id, action) => {
if (loading) {
const abortController = new AbortController();

View file

@ -0,0 +1,38 @@
import { FileDispatch, FilesState, filesReducer } from './files';
describe('filesReducer', () => {
it('should add a file to the state', () => {
const initialState: FilesState = ['file1', 'file2'];
const action: FileDispatch = { type: 'addFile', file: 'file3' };
const newState = filesReducer(initialState, action);
expect(newState).toEqual(['file1', 'file2', 'file3']);
});
it('should delete a file from the state by ID', () => {
const initialState: FilesState = ['file1', 'file2', 'file3'];
const action: FileDispatch = { type: 'deleteFile', id: 'file2' };
const newState = filesReducer(initialState, action);
expect(newState).toEqual(['file1', 'file3']);
});
it('should return the state unchanged if file ID does not exist for deletion', () => {
const initialState: FilesState = ['file1', 'file2', 'file3'];
const action: FileDispatch = { type: 'deleteFile', id: 'file4' };
const newState = filesReducer(initialState, action);
expect(newState).toEqual(['file1', 'file2', 'file3']);
});
it('should add multiple files to the state', () => {
const initialState: FilesState = ['file1', 'file2'];
const action: FileDispatch = { type: 'addFiles', files: ['file3', 'file4'] };
const newState = filesReducer(initialState, action);
expect(newState).toEqual(['file1', 'file2', 'file3', 'file4']);
});
it('should return the initial state if the action type is unknown', () => {
const initialState: FilesState = ['file1', 'file2'];
const action = { type: 'unknown', id: 'file1' };
const newState = filesReducer(initialState, action as FileDispatch);
expect(newState).toEqual(['file1', 'file2']);
});
});

View file

@ -0,0 +1,37 @@
import { produce } from 'immer';
export type FilesState = string[];
export type FileDispatch =
| { file: string; type: 'addFile' }
| { id: string; type: 'deleteFile' }
| { files: string[]; type: 'addFiles' };
export const filesReducer = (state: FilesState, payload: FileDispatch): FilesState => {
switch (payload.type) {
case 'addFile': {
return produce(state, (draftState) => {
draftState.push(payload.file);
});
}
case 'deleteFile': {
return produce(state, (draftState) => {
const index = draftState.indexOf(payload.id);
if (index !== -1) {
draftState.splice(index, 1);
}
});
}
case 'addFiles': {
return produce(state, (draftState) => {
draftState.push(...payload.files);
});
}
default: {
return state;
}
}
};

View file

@ -25,6 +25,7 @@ export const initLobeSession: LobeAgentSession = {
chats: {},
config: initialLobeAgentConfig,
createAt: Date.now(),
files: [],
id: '',
meta: DEFAULT_AGENT_META,
type: LobeSessionType.Agent,

View file

@ -206,6 +206,103 @@ describe('sessionsReducer', () => {
});
});
describe('updateSessionFiles', () => {
it('should update session files when valid id, key, and value are provided', () => {
const state: LobeSessions = {
'session-id': {
id: 'session-id',
config: {
model: 'gpt-3.5-turbo',
params: {},
systemRole: 'system-role',
},
type: 'agent',
meta: {
avatar: 'avatar-url',
backgroundColor: 'background-color',
description: 'description',
tags: ['tag1', 'tag2'],
title: 'title',
},
} as LobeAgentSession,
};
const id = 'session-id';
const files = ['new-file'];
const payload: SessionDispatch = { type: 'updateSessionFiles', id, files };
const newState = sessionsReducer(state, payload);
expect(newState).toEqual({
'session-id': {
...state['session-id'],
files: ['new-file'],
},
});
});
it('should not change state when invalid id, key, and value are provided', () => {
const state: LobeSessions = {
'session-id': {
id: 'session-id',
config: {
model: 'gpt-3.5-turbo',
params: {},
systemRole: 'system-role',
},
type: 'agent',
meta: {
avatar: 'avatar-url',
backgroundColor: 'background-color',
description: 'description',
tags: ['tag1', 'tag2'],
title: 'title',
},
} as LobeAgentSession,
};
const id = 'non-existent-id';
const files = ['new-file'];
const payload: SessionDispatch = { type: 'updateSessionFiles', id, files };
const newState = sessionsReducer(state, payload);
expect(newState).toEqual(state);
});
it('should not change state when valid id, invalid files are provided', () => {
const state: LobeSessions = {
'session-id': {
id: 'session-id',
config: {
model: 'gpt-3.5-turbo',
params: {},
systemRole: 'system-role',
},
type: 'agent',
meta: {
avatar: 'avatar-url',
backgroundColor: 'background-color',
description: 'description',
tags: ['tag1', 'tag2'],
title: 'title',
},
} as LobeAgentSession,
};
const id = 'session-id';
const errFile1 = 123213412 as any;
const res1 = sessionsReducer(state, { type: 'updateSessionFiles', id, files: errFile1 });
expect(res1).toEqual(state);
const errFile2 = undefined as any;
const res2 = sessionsReducer(state, { type: 'updateSessionFiles', id, files: errFile2 });
expect(res2).toEqual(state);
const errFile3 = ['state', 123] as any;
const res3 = sessionsReducer(state, { type: 'updateSessionFiles', id, files: errFile3 });
expect(res3).toEqual(state);
});
});
describe('updateSessionConfig', () => {
it('should update session config when valid id and partial config are provided', () => {
const state: LobeSessions = {
@ -345,7 +442,7 @@ describe('sessionsReducer', () => {
});
});
test('should not change state when invalid operation type is provided', () => {
it('should not change state when invalid operation type is provided', () => {
const state: LobeSessions = {
session1: {
id: 'session1',

View file

@ -1,4 +1,5 @@
import { produce } from 'immer';
import { z } from 'zod';
import { ChatMessageMap } from '@/types/chatMessage';
import { MetaData } from '@/types/meta';
@ -39,7 +40,7 @@ interface UpdateSessionChat {
}
/**
* @title
*
*/
interface UpdateSessionTopic {
/**
@ -51,6 +52,15 @@ interface UpdateSessionTopic {
type: 'updateSessionTopic';
}
/**
*
*/
interface UpdateSessionFiles {
files: string[];
id: string;
type: 'updateSessionFiles';
}
interface UpdateSessionMeta {
id: string;
key: keyof MetaData;
@ -76,6 +86,7 @@ export type SessionDispatch =
| UpdateSessionMeta
| UpdateSessionAgentConfig
| UpdateSessionTopic
| UpdateSessionFiles
| ToggleSessionPinned;
export const sessionsReducer = (state: LobeSessions, payload: SessionDispatch): LobeSessions => {
@ -136,6 +147,20 @@ export const sessionsReducer = (state: LobeSessions, payload: SessionDispatch):
});
}
case 'updateSessionFiles': {
return produce(state, (draft) => {
const session = draft[payload.id];
if (!session || !payload.files) return;
const schema = z.array(z.string());
const { success } = schema.safeParse(payload.files);
if (!success) return;
session.files = payload.files;
});
}
case 'updateSessionConfig': {
return produce(state, (draft) => {
const { id, config } = payload;

View file

@ -36,18 +36,19 @@ export interface ChatMessage extends BaseDataModel {
translate?: ChatTranslate;
} & Record<string, any>;
files?: string[];
/**
* replace with plugin
* @deprecated
*/
function_call?: OpenAIFunctionCall;
name?: string;
parentId?: string;
plugin?: PluginRequestPayload;
pluginState?: any;
pluginState?: any;
// 引用
quotaId?: string;
/**

4
src/types/database/db.ts Normal file
View file

@ -0,0 +1,4 @@
export type DBModel<T> = T & {
createdAt: number;
id: string;
};

View file

@ -0,0 +1,38 @@
import { z } from 'zod';
export const LocalFileSchema = z.object({
/**
* create Time
*/
createdAt: z.number().optional(),
/**
* file data array buffer
*/
data: z.instanceof(ArrayBuffer),
/**
* file type
* @example 'image/png'
*/
fileType: z.string(),
/**
* file name
* @example 'test.png'
*/
name: z.string(),
/**
* the mode database save the file
* local mean save the raw file into data
* url mean upload the file to a cdn and then save the url
*/
saveMode: z.enum(['local', 'url']),
/**
* file size
*/
size: z.number(),
/**
* file url if saveMode is url
*/
url: z.string().url().optional(),
});
export type LocalFile = z.infer<typeof LocalFileSchema>;

8
src/types/files.ts Normal file
View file

@ -0,0 +1,8 @@
import { LocalFile } from './database/files';
export interface FilePreview extends Pick<LocalFile, 'saveMode' | 'fileType'> {
base64Url?: string;
data?: ArrayBuffer;
name: string;
url: string;
}

View file

@ -1,12 +1,26 @@
import { OpenAIFunctionCall } from '@/types/chatMessage';
import { LLMRoleType } from '@/types/llm';
interface UserMessageContentPartText {
text: string;
type: 'text';
}
interface UserMessageContentPartImage {
image_url: {
detail?: 'auto' | 'low' | 'high';
url: string;
};
type: 'image_url';
}
export type UserMessageContentPart = UserMessageContentPartText | UserMessageContentPartImage;
export interface OpenAIChatMessage {
/**
* @title
* @description
*/
content: string;
content: string | UserMessageContentPart[];
function_call?: OpenAIFunctionCall;
name?: string;

View file

@ -19,6 +19,7 @@ interface LobeSessionBase extends BaseDataModel {
*
*/
chats: ChatMessageMap;
files?: string[];
/**
*
*/

8
tests/setup.ts Normal file
View file

@ -0,0 +1,8 @@
import '@testing-library/jest-dom';
// remove antd hash on test
import { theme } from 'antd';
// mock indexedDB to test with dexie
// refs: https://github.com/dumbmatter/fakeIndexedDB#dexie-and-other-indexeddb-api-wrappers
import 'fake-indexeddb/auto';
theme.defaultConfig.hashed = false;

View file

@ -7,10 +7,12 @@ export default defineConfig({
'@': resolve(__dirname, './src'),
},
coverage: {
exclude: ['__mocks__/**'],
provider: 'v8',
reporter: ['text', 'json', 'lcov', 'text-summary'],
},
environment: 'jsdom',
globals: true,
setupFiles: './tests/setup.ts',
},
});