mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 17:47:27 +00:00
✨ feat: support upload images to chat with gpt4-vision model (#440)
This commit is contained in:
parent
cea884676a
commit
858d0476e0
64 changed files with 2058 additions and 138 deletions
|
|
@ -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;
|
||||
|
|
|
|||
25
__mocks__/zustand/traditional.ts
Normal file
25
__mocks__/zustand/traditional.ts
Normal 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));
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
72
src/app/api/files/image/imgur.ts
Normal file
72
src/app/api/files/image/imgur.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
42
src/app/api/files/image/route.ts
Normal file
42
src/app/api/files/image/route.ts
Normal 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 }));
|
||||
// };
|
||||
|
|
@ -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 ============ //
|
||||
|
||||
|
|
|
|||
166
src/app/chat/(desktop)/features/ChatInput/DragUpload.tsx
Normal file
166
src/app/chat/(desktop)/features/ChatInput/DragUpload.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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} />;
|
||||
});
|
||||
64
src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx
Normal file
64
src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx
Normal 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;
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
81
src/app/chat/components/FileList/FileItem.style.ts
Normal file
81
src/app/chat/components/FileList/FileItem.style.ts
Normal 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;
|
||||
`,
|
||||
};
|
||||
});
|
||||
69
src/app/chat/components/FileList/FileItem.tsx
Normal file
69
src/app/chat/components/FileList/FileItem.tsx
Normal 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;
|
||||
46
src/app/chat/components/FileList/Lightbox.tsx
Normal file
46
src/app/chat/components/FileList/Lightbox.tsx
Normal 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;
|
||||
63
src/app/chat/components/FileList/index.tsx
Normal file
63
src/app/chat/components/FileList/index.tsx
Normal 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;
|
||||
|
|
@ -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' })}
|
||||
>
|
||||
|
|
|
|||
53
src/app/chat/features/ChatInput/ActionBar/FileUpload.tsx
Normal file
53
src/app/chat/features/ChatInput/ActionBar/FileUpload.tsx
Normal 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;
|
||||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}, []);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
@ -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'] => {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
src/database/core/__tests__/db.test.ts
Normal file
77
src/database/core/__tests__/db.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
55
src/database/core/__tests__/model.test.ts
Normal file
55
src/database/core/__tests__/model.test.ts
Normal 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
25
src/database/core/db.ts
Normal 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();
|
||||
1
src/database/core/index.ts
Normal file
1
src/database/core/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './model';
|
||||
49
src/database/core/model.ts
Normal file
49
src/database/core/model.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
9
src/database/core/schema.ts
Normal file
9
src/database/core/schema.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { LocalFile } from '@/types/database/files';
|
||||
|
||||
export const lobeDBSchema = {
|
||||
files: '&id, name, fileType, saveMode',
|
||||
};
|
||||
|
||||
export interface LobeDBSchemaMap {
|
||||
files: LocalFile;
|
||||
}
|
||||
71
src/database/models/file.test.ts
Normal file
71
src/database/models/file.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
26
src/database/models/file.ts
Normal file
26
src/database/models/file.ts
Normal 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();
|
||||
|
|
@ -62,5 +62,10 @@ export default {
|
|||
},
|
||||
translateTo: '翻译',
|
||||
updateAgent: '更新助理信息',
|
||||
upload: {
|
||||
actionTooltip: '上传图片',
|
||||
dragDesc: '拖拽文件到这里,支持上传多个图片。按住 Shift 直接发送图片',
|
||||
dragTitle: '上传图片',
|
||||
},
|
||||
warp: '换行',
|
||||
};
|
||||
|
|
|
|||
88
src/services/__tests__/file.test.ts
Normal file
88
src/services/__tests__/file.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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
36
src/services/file.ts
Normal 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
2
src/store/files/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './selectors';
|
||||
export { useFileStore } from './store';
|
||||
7
src/store/files/initialState.ts
Normal file
7
src/store/files/initialState.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { ImageFileState, initialImageFileState } from './slices/images';
|
||||
|
||||
export type FilesStoreState = ImageFileState;
|
||||
|
||||
export const initialState: FilesStoreState = {
|
||||
...initialImageFileState,
|
||||
};
|
||||
75
src/store/files/selectors.test.ts
Normal file
75
src/store/files/selectors.test.ts
Normal 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
|
||||
]);
|
||||
});
|
||||
});
|
||||
34
src/store/files/selectors.ts
Normal file
34
src/store/files/selectors.ts
Normal 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,
|
||||
};
|
||||
186
src/store/files/slices/images/action.test.ts
Normal file
186
src/store/files/slices/images/action.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
src/store/files/slices/images/action.ts
Normal file
83
src/store/files/slices/images/action.ts
Normal 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;
|
||||
}),
|
||||
});
|
||||
3
src/store/files/slices/images/index.ts
Normal file
3
src/store/files/slices/images/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './action';
|
||||
export * from './initialState';
|
||||
// export * from './selectors';
|
||||
11
src/store/files/slices/images/initialState.ts
Normal file
11
src/store/files/slices/images/initialState.ts
Normal 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
27
src/store/files/store.ts
Normal 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,
|
||||
);
|
||||
|
|
@ -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);
|
||||
// });
|
||||
// });
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 activeTopicId,then 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();
|
||||
|
|
|
|||
38
src/store/session/slices/chat/reducers/files.test.ts
Normal file
38
src/store/session/slices/chat/reducers/files.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
37
src/store/session/slices/chat/reducers/files.ts
Normal file
37
src/store/session/slices/chat/reducers/files.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -25,6 +25,7 @@ export const initLobeSession: LobeAgentSession = {
|
|||
chats: {},
|
||||
config: initialLobeAgentConfig,
|
||||
createAt: Date.now(),
|
||||
files: [],
|
||||
id: '',
|
||||
meta: DEFAULT_AGENT_META,
|
||||
type: LobeSessionType.Agent,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
4
src/types/database/db.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export type DBModel<T> = T & {
|
||||
createdAt: number;
|
||||
id: string;
|
||||
};
|
||||
38
src/types/database/files.ts
Normal file
38
src/types/database/files.ts
Normal 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
8
src/types/files.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface LobeSessionBase extends BaseDataModel {
|
|||
* 聊天记录
|
||||
*/
|
||||
chats: ChatMessageMap;
|
||||
files?: string[];
|
||||
/**
|
||||
* 置顶
|
||||
*/
|
||||
|
|
|
|||
8
tests/setup.ts
Normal file
8
tests/setup.ts
Normal 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;
|
||||
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue