@lobehub/chat 1.36.46 → 1.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -0
- package/README.ja-JP.md +8 -8
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/changelog/v1.json +9 -0
- package/next.config.mjs +4 -1
- package/package.json +5 -3
- package/scripts/migrateClientDB/compile-migrations.ts +14 -0
- package/src/app/(main)/(mobile)/me/(home)/layout.tsx +2 -0
- package/src/app/(main)/chat/_layout/Desktop/index.tsx +3 -2
- package/src/app/(main)/chat/_layout/Mobile.tsx +5 -3
- package/src/app/(main)/chat/features/Migration/DBReader.ts +290 -0
- package/src/app/(main)/chat/features/Migration/UpgradeButton.tsx +4 -8
- package/src/app/(main)/chat/features/Migration/index.tsx +26 -15
- package/src/app/(main)/settings/_layout/Desktop/index.tsx +2 -0
- package/src/app/loading/Client/Content.tsx +11 -1
- package/src/app/loading/Client/Error.tsx +27 -0
- package/src/app/loading/stage.ts +8 -0
- package/src/components/FullscreenLoading/index.tsx +4 -3
- package/src/const/version.ts +1 -0
- package/src/database/client/db.test.ts +172 -0
- package/src/database/client/db.ts +246 -0
- package/src/database/client/migrations.json +289 -0
- package/src/features/InitClientDB/EnableModal.tsx +111 -0
- package/src/features/InitClientDB/ErrorResult.tsx +125 -0
- package/src/features/InitClientDB/InitIndicator.tsx +124 -0
- package/src/features/InitClientDB/PGliteSVG.tsx +22 -0
- package/src/features/InitClientDB/index.tsx +37 -0
- package/src/hooks/useCheckPluginsIsInstalled.ts +2 -2
- package/src/hooks/useFetchInstalledPlugins.ts +2 -2
- package/src/hooks/useFetchMessages.ts +2 -2
- package/src/hooks/useFetchSessions.ts +2 -2
- package/src/hooks/useFetchThreads.ts +2 -2
- package/src/hooks/useFetchTopics.ts +2 -2
- package/src/layout/GlobalProvider/StoreInitialization.tsx +2 -2
- package/src/services/baseClientService/index.ts +9 -0
- package/src/services/debug.ts +32 -34
- package/src/services/file/index.ts +6 -2
- package/src/services/file/pglite.test.ts +198 -0
- package/src/services/file/pglite.ts +84 -0
- package/src/services/file/type.ts +4 -3
- package/src/services/github.ts +17 -0
- package/src/services/import/index.ts +6 -2
- package/src/services/import/pglite.test.ts +997 -0
- package/src/services/import/pglite.ts +34 -0
- package/src/services/message/client.ts +2 -0
- package/src/services/message/index.ts +6 -2
- package/src/services/message/pglite.test.ts +430 -0
- package/src/services/message/pglite.ts +118 -0
- package/src/services/message/server.ts +9 -9
- package/src/services/message/type.ts +3 -4
- package/src/services/plugin/index.ts +6 -2
- package/src/services/plugin/pglite.test.ts +175 -0
- package/src/services/plugin/pglite.ts +51 -0
- package/src/services/session/client.ts +1 -1
- package/src/services/session/index.ts +6 -2
- package/src/services/session/pglite.test.ts +411 -0
- package/src/services/session/pglite.ts +184 -0
- package/src/services/session/type.ts +14 -1
- package/src/services/topic/index.ts +6 -3
- package/src/services/topic/pglite.test.ts +212 -0
- package/src/services/topic/pglite.ts +85 -0
- package/src/services/user/client.test.ts +0 -1
- package/src/services/user/index.ts +8 -2
- package/src/services/user/pglite.test.ts +98 -0
- package/src/services/user/pglite.ts +92 -0
- package/src/store/chat/slices/builtinTool/action.test.ts +3 -4
- package/src/store/global/actions/clientDb.ts +51 -0
- package/src/store/global/initialState.ts +13 -0
- package/src/store/global/selectors.ts +24 -3
- package/src/store/global/store.ts +3 -1
- package/src/store/session/slices/sessionGroup/reducer.test.ts +6 -6
- package/src/store/user/slices/common/action.ts +2 -4
- package/src/types/clientDB.ts +29 -0
- package/src/types/importer.ts +17 -5
- package/src/types/meta.ts +0 -9
- package/src/types/session/sessionGroup.ts +3 -3
- package/src/services/message/index.test.ts +0 -48
@@ -0,0 +1,124 @@
|
|
1
|
+
'use client';
|
2
|
+
|
3
|
+
import { Progress } from 'antd';
|
4
|
+
import { createStyles } from 'antd-style';
|
5
|
+
import { AnimatePresence, motion } from 'framer-motion';
|
6
|
+
import { rgba } from 'polished';
|
7
|
+
import { memo } from 'react';
|
8
|
+
import { useTranslation } from 'react-i18next';
|
9
|
+
import { Center, Flexbox } from 'react-layout-kit';
|
10
|
+
|
11
|
+
import { useGlobalStore } from '@/store/global';
|
12
|
+
import { ClientDatabaseInitStages, DatabaseLoadingState } from '@/types/clientDB';
|
13
|
+
|
14
|
+
import ErrorResult from './ErrorResult';
|
15
|
+
|
16
|
+
const useStyles = createStyles(({ css, token, prefixCls }) => ({
|
17
|
+
bg: css`
|
18
|
+
padding-block: 8px;
|
19
|
+
padding-inline: 8px 32px;
|
20
|
+
background: ${token.colorText};
|
21
|
+
border-radius: 40px;
|
22
|
+
`,
|
23
|
+
container: css`
|
24
|
+
position: fixed;
|
25
|
+
z-index: 1000;
|
26
|
+
`,
|
27
|
+
progress: css`
|
28
|
+
.${prefixCls}-progress-text {
|
29
|
+
font-size: 12px;
|
30
|
+
color: ${token.colorBgContainer} !important;
|
31
|
+
}
|
32
|
+
`,
|
33
|
+
progressReady: css`
|
34
|
+
.${prefixCls}-progress-text {
|
35
|
+
color: ${token.colorSuccessBorder} !important;
|
36
|
+
}
|
37
|
+
`,
|
38
|
+
|
39
|
+
text: css`
|
40
|
+
font-size: 15px;
|
41
|
+
color: ${token.colorBgContainer};
|
42
|
+
`,
|
43
|
+
}));
|
44
|
+
|
45
|
+
interface InitClientDBProps {
|
46
|
+
bottom?: number;
|
47
|
+
show: boolean;
|
48
|
+
}
|
49
|
+
|
50
|
+
const InitClientDB = memo<InitClientDBProps>(({ bottom = 80, show }) => {
|
51
|
+
const { styles, theme, cx } = useStyles();
|
52
|
+
const currentStage = useGlobalStore((s) => s.initClientDBStage || DatabaseLoadingState.Idle);
|
53
|
+
const { t } = useTranslation('common');
|
54
|
+
const useInitClientDB = useGlobalStore((s) => s.useInitClientDB);
|
55
|
+
|
56
|
+
useInitClientDB();
|
57
|
+
|
58
|
+
const getStateMessage = (state: DatabaseLoadingState) => {
|
59
|
+
switch (state) {
|
60
|
+
case DatabaseLoadingState.Finished:
|
61
|
+
case DatabaseLoadingState.Ready: {
|
62
|
+
return t('clientDB.initing.ready');
|
63
|
+
}
|
64
|
+
|
65
|
+
case DatabaseLoadingState.Idle: {
|
66
|
+
return t('clientDB.initing.idle');
|
67
|
+
}
|
68
|
+
case DatabaseLoadingState.Initializing: {
|
69
|
+
return t('clientDB.initing.initializing');
|
70
|
+
}
|
71
|
+
case DatabaseLoadingState.LoadingDependencies: {
|
72
|
+
return t('clientDB.initing.loadingDependencies');
|
73
|
+
}
|
74
|
+
|
75
|
+
case DatabaseLoadingState.LoadingWasm: {
|
76
|
+
return t('clientDB.initing.loadingWasmModule');
|
77
|
+
}
|
78
|
+
|
79
|
+
case DatabaseLoadingState.Migrating: {
|
80
|
+
return t('clientDB.initing.migrating');
|
81
|
+
}
|
82
|
+
}
|
83
|
+
};
|
84
|
+
|
85
|
+
const currentStageIndex = ClientDatabaseInitStages.indexOf(currentStage);
|
86
|
+
const isReady = currentStage === DatabaseLoadingState.Finished;
|
87
|
+
const isError = currentStage === DatabaseLoadingState.Error;
|
88
|
+
return (
|
89
|
+
<AnimatePresence>
|
90
|
+
{show && (
|
91
|
+
<Center className={styles.container} style={{ bottom }} width={'100%'}>
|
92
|
+
<motion.div
|
93
|
+
animate={{ opacity: 1, y: 0 }}
|
94
|
+
exit={{ opacity: 0, y: 30 }}
|
95
|
+
initial={{ opacity: 0, y: 30 }}
|
96
|
+
transition={{ duration: 0.3 }}
|
97
|
+
>
|
98
|
+
{isError ? (
|
99
|
+
<ErrorResult />
|
100
|
+
) : (
|
101
|
+
<Flexbox align={'center'} className={styles.bg} gap={12} horizontal>
|
102
|
+
<Progress
|
103
|
+
className={cx(styles.progress, isReady && styles.progressReady)}
|
104
|
+
format={isReady ? undefined : (percent) => percent}
|
105
|
+
percent={parseInt(
|
106
|
+
((currentStageIndex / (ClientDatabaseInitStages.length - 1)) * 100).toFixed(0),
|
107
|
+
)}
|
108
|
+
size={40}
|
109
|
+
strokeColor={isReady ? theme.colorSuccessActive : theme.colorBgContainer}
|
110
|
+
strokeLinecap={'round'}
|
111
|
+
strokeWidth={10}
|
112
|
+
trailColor={rgba(theme.colorBgContainer, 0.1)}
|
113
|
+
type={'circle'}
|
114
|
+
/>
|
115
|
+
<span className={styles.text}>{getStateMessage(currentStage)}</span>
|
116
|
+
</Flexbox>
|
117
|
+
)}
|
118
|
+
</motion.div>
|
119
|
+
</Center>
|
120
|
+
)}
|
121
|
+
</AnimatePresence>
|
122
|
+
);
|
123
|
+
});
|
124
|
+
export default InitClientDB;
|
@@ -0,0 +1,22 @@
|
|
1
|
+
import { LucideIcon, LucideProps } from 'lucide-react';
|
2
|
+
import { forwardRef } from 'react';
|
3
|
+
|
4
|
+
// @ts-expect-error 类型感觉不对,未来修正
|
5
|
+
export const PGliteSVG: LucideIcon = forwardRef<SVGElement, Partial<Omit<LucideProps, 'ref'>>>(
|
6
|
+
({ size }, ref) => (
|
7
|
+
<svg
|
8
|
+
fill="currentColor"
|
9
|
+
height={size}
|
10
|
+
ref={ref as any}
|
11
|
+
viewBox={'0 0 1024 1024'}
|
12
|
+
width={size}
|
13
|
+
xmlns="http://www.w3.org/2000/svg"
|
14
|
+
>
|
15
|
+
<path
|
16
|
+
clip-rule="evenodd"
|
17
|
+
d="M941.581 335.737v460.806c0 15.926-12.913 28.836-28.832 28.818l-115.283-.137c-15.243-.018-27.706-11.88-28.703-26.877.011-.569.018-1.138.018-1.711l-.004-172.904c0-47.745-38.736-86.451-86.454-86.451-46.245 0-84.052-36.359-86.342-82.068V191.496l201.708.149c79.484.058 143.892 64.553 143.892 144.092Zm-576-144.281v201.818c0 47.746 38.682 86.456 86.4 86.456h86.4v-5.796c0 66.816 54.13 120.98 120.902 120.98 28.617 0 51.815 23.213 51.815 51.848v149.644c0 .688.011 1.372.025 2.057-.943 15.065-13.453 26.992-28.746 26.992l-144.982-.007.986-201.586c.079-15.915-12.755-28.88-28.66-28.959-15.904-.079-28.861 12.763-28.94 28.678l-.986 201.741v.118l-172.174-.01V623.722c0-15.915-12.895-28.819-28.8-28.819-15.906 0-28.8 12.904-28.8 28.819v201.704l-143.642-.007c-15.905-.004-28.798-12.904-28.798-28.819V335.547c0-79.58 64.471-144.093 144.001-144.092l143.999.001Zm446.544 173.693c0-23.874-19.343-43.228-43.2-43.228-23.861 0-43.2 19.354-43.2 43.228 0 23.875 19.339 43.226 43.2 43.226 23.857 0 43.2-19.351 43.2-43.226Z"
|
18
|
+
fill-rule="evenodd"
|
19
|
+
/>
|
20
|
+
</svg>
|
21
|
+
),
|
22
|
+
);
|
@@ -0,0 +1,37 @@
|
|
1
|
+
'use client';
|
2
|
+
|
3
|
+
import { Spin } from 'antd';
|
4
|
+
import dynamic from 'next/dynamic';
|
5
|
+
import { memo } from 'react';
|
6
|
+
|
7
|
+
import { useGlobalStore } from '@/store/global';
|
8
|
+
import { systemStatusSelectors } from '@/store/global/selectors';
|
9
|
+
|
10
|
+
const Modal = dynamic(() => import('./EnableModal'), {
|
11
|
+
loading: () => <Spin fullscreen />,
|
12
|
+
ssr: false,
|
13
|
+
});
|
14
|
+
|
15
|
+
const InitIndicator = dynamic(() => import('./InitIndicator'), {
|
16
|
+
ssr: false,
|
17
|
+
});
|
18
|
+
|
19
|
+
interface InitClientDBProps {
|
20
|
+
bottom?: number;
|
21
|
+
}
|
22
|
+
|
23
|
+
const InitClientDB = memo<InitClientDBProps>(({ bottom }) => {
|
24
|
+
const isPgliteNotEnabled = useGlobalStore(systemStatusSelectors.isPgliteNotEnabled);
|
25
|
+
const isPgliteNotInited = useGlobalStore(systemStatusSelectors.isPgliteNotInited);
|
26
|
+
|
27
|
+
return (
|
28
|
+
<>
|
29
|
+
{/* 当用户没有设置启用 pglite 时,强弹窗引导用户来开启弹窗 */}
|
30
|
+
{isPgliteNotEnabled && <Modal open={isPgliteNotEnabled} />}
|
31
|
+
{/* 当用户已经启用 pglite 但没有初始化时,展示初始化指示器 */}
|
32
|
+
{isPgliteNotInited && <InitIndicator bottom={bottom} show={isPgliteNotInited} />}
|
33
|
+
</>
|
34
|
+
);
|
35
|
+
});
|
36
|
+
|
37
|
+
export default InitClientDB;
|
@@ -3,8 +3,8 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
|
3
3
|
import { useToolStore } from '@/store/tool';
|
4
4
|
|
5
5
|
export const useCheckPluginsIsInstalled = (plugins: string[]) => {
|
6
|
-
const
|
6
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
7
7
|
const checkPluginsIsInstalled = useToolStore((s) => s.useCheckPluginsIsInstalled);
|
8
8
|
|
9
|
-
checkPluginsIsInstalled(
|
9
|
+
checkPluginsIsInstalled(isDBInited, plugins);
|
10
10
|
};
|
@@ -3,8 +3,8 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
|
3
3
|
import { useToolStore } from '@/store/tool';
|
4
4
|
|
5
5
|
export const useFetchInstalledPlugins = () => {
|
6
|
-
const
|
6
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
7
7
|
const [useFetchInstalledPlugins] = useToolStore((s) => [s.useFetchInstalledPlugins]);
|
8
8
|
|
9
|
-
return useFetchInstalledPlugins(
|
9
|
+
return useFetchInstalledPlugins(isDBInited);
|
10
10
|
};
|
@@ -4,12 +4,12 @@ import { systemStatusSelectors } from '@/store/global/selectors';
|
|
4
4
|
import { useSessionStore } from '@/store/session';
|
5
5
|
|
6
6
|
export const useFetchMessages = () => {
|
7
|
-
const
|
7
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
8
8
|
const [sessionId] = useSessionStore((s) => [s.activeId]);
|
9
9
|
const [activeTopicId, useFetchMessages] = useChatStore((s) => [
|
10
10
|
s.activeTopicId,
|
11
11
|
s.useFetchMessages,
|
12
12
|
]);
|
13
13
|
|
14
|
-
useFetchMessages(
|
14
|
+
useFetchMessages(isDBInited, sessionId, activeTopicId);
|
15
15
|
};
|
@@ -5,9 +5,9 @@ import { useUserStore } from '@/store/user';
|
|
5
5
|
import { authSelectors } from '@/store/user/slices/auth/selectors';
|
6
6
|
|
7
7
|
export const useFetchSessions = () => {
|
8
|
-
const
|
8
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
9
9
|
const isLogin = useUserStore(authSelectors.isLogin);
|
10
10
|
const useFetchSessions = useSessionStore((s) => s.useFetchSessions);
|
11
11
|
|
12
|
-
useFetchSessions(
|
12
|
+
useFetchSessions(isDBInited, isLogin);
|
13
13
|
};
|
@@ -3,9 +3,9 @@ import { useGlobalStore } from '@/store/global';
|
|
3
3
|
import { systemStatusSelectors } from '@/store/global/selectors';
|
4
4
|
|
5
5
|
export const useFetchThreads = (activeTopicId?: string) => {
|
6
|
-
const
|
6
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
7
7
|
|
8
8
|
const [useFetchThreads] = useChatStore((s) => [s.useFetchThreads]);
|
9
9
|
|
10
|
-
useFetchThreads(
|
10
|
+
useFetchThreads(isDBInited, activeTopicId);
|
11
11
|
};
|
@@ -10,8 +10,8 @@ import { useSessionStore } from '@/store/session';
|
|
10
10
|
export const useFetchTopics = () => {
|
11
11
|
const [sessionId] = useSessionStore((s) => [s.activeId]);
|
12
12
|
const [activeTopicId, useFetchTopics] = useChatStore((s) => [s.activeTopicId, s.useFetchTopics]);
|
13
|
-
const
|
13
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
14
14
|
|
15
|
-
useFetchTopics(
|
15
|
+
useFetchTopics(isDBInited, sessionId);
|
16
16
|
useFetchThreads(activeTopicId);
|
17
17
|
};
|
@@ -51,8 +51,8 @@ const StoreInitialization = memo(() => {
|
|
51
51
|
* But during initialization, the value of `enableAuth` might be incorrect cause of the async fetch.
|
52
52
|
* So we need to use `isSignedIn` only to determine whether request for the default agent config and user state.
|
53
53
|
*/
|
54
|
-
const
|
55
|
-
const isLoginOnInit =
|
54
|
+
const isDBInited = useGlobalStore(systemStatusSelectors.isDBInited);
|
55
|
+
const isLoginOnInit = isDBInited && (enableNextAuth ? isSignedIn : isLogin);
|
56
56
|
|
57
57
|
// init inbox agent and default agent config
|
58
58
|
useInitAgentStore(isLoginOnInit, serverConfig.defaultAgent?.config);
|
package/src/services/debug.ts
CHANGED
@@ -1,39 +1,37 @@
|
|
1
|
-
import { DEBUG_MODEL } from '@/database/_deprecated/models/__DEBUG';
|
2
|
-
|
3
1
|
class DebugService {
|
4
2
|
async insertLargeDataToDB() {
|
5
|
-
await DEBUG_MODEL.createRandomData({
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
10
|
-
});
|
11
|
-
|
12
|
-
console.log('已插入10w');
|
13
|
-
|
14
|
-
await DEBUG_MODEL.createRandomData({
|
15
|
-
|
16
|
-
|
17
|
-
|
18
|
-
|
19
|
-
});
|
20
|
-
console.log('已插入40w');
|
21
|
-
|
22
|
-
await DEBUG_MODEL.createRandomData({
|
23
|
-
|
24
|
-
|
25
|
-
|
26
|
-
|
27
|
-
});
|
28
|
-
console.log('已插入70w');
|
29
|
-
|
30
|
-
await DEBUG_MODEL.createRandomData({
|
31
|
-
|
32
|
-
|
33
|
-
|
34
|
-
|
35
|
-
});
|
36
|
-
console.log('已插入100w');
|
3
|
+
// await DEBUG_MODEL.createRandomData({
|
4
|
+
// messageCount: 100_000,
|
5
|
+
// sessionCount: 40,
|
6
|
+
// startIndex: 0,
|
7
|
+
// topicCount: 200,
|
8
|
+
// });
|
9
|
+
//
|
10
|
+
// console.log('已插入10w');
|
11
|
+
//
|
12
|
+
// await DEBUG_MODEL.createRandomData({
|
13
|
+
// messageCount: 300_000,
|
14
|
+
// sessionCount: 40,
|
15
|
+
// startIndex: 100_001,
|
16
|
+
// topicCount: 200,
|
17
|
+
// });
|
18
|
+
// console.log('已插入40w');
|
19
|
+
//
|
20
|
+
// await DEBUG_MODEL.createRandomData({
|
21
|
+
// messageCount: 300_000,
|
22
|
+
// sessionCount: 40,
|
23
|
+
// startIndex: 400_001,
|
24
|
+
// topicCount: 200,
|
25
|
+
// });
|
26
|
+
// console.log('已插入70w');
|
27
|
+
//
|
28
|
+
// await DEBUG_MODEL.createRandomData({
|
29
|
+
// messageCount: 300_000,
|
30
|
+
// sessionCount: 40,
|
31
|
+
// startIndex: 700_001,
|
32
|
+
// topicCount: 200,
|
33
|
+
// });
|
34
|
+
// console.log('已插入100w');
|
37
35
|
}
|
38
36
|
}
|
39
37
|
|
@@ -1,5 +1,9 @@
|
|
1
|
-
import { ClientService } from './client';
|
1
|
+
import { ClientService as DeprecatedService } from './client';
|
2
|
+
import { ClientService } from './pglite';
|
2
3
|
import { ServerService } from './server';
|
3
4
|
|
5
|
+
const clientService =
|
6
|
+
process.env.NEXT_PUBLIC_CLIENT_DB === 'pglite' ? new ClientService() : new DeprecatedService();
|
7
|
+
|
4
8
|
export const fileService =
|
5
|
-
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() :
|
9
|
+
process.env.NEXT_PUBLIC_SERVICE_MODE === 'server' ? new ServerService() : clientService;
|
@@ -0,0 +1,198 @@
|
|
1
|
+
import { eq } from 'drizzle-orm';
|
2
|
+
import { beforeEach, describe, expect, it } from 'vitest';
|
3
|
+
|
4
|
+
import { clientDB, initializeDB } from '@/database/client/db';
|
5
|
+
import { files, globalFiles, users } from '@/database/schemas';
|
6
|
+
import { clientS3Storage } from '@/services/file/ClientS3';
|
7
|
+
import { UploadFileParams } from '@/types/files';
|
8
|
+
|
9
|
+
import { ClientService } from './pglite';
|
10
|
+
|
11
|
+
const userId = 'file-user';
|
12
|
+
|
13
|
+
const fileService = new ClientService(userId);
|
14
|
+
|
15
|
+
const mockFile = {
|
16
|
+
name: 'mock.png',
|
17
|
+
fileType: 'image/png',
|
18
|
+
size: 1,
|
19
|
+
url: '',
|
20
|
+
};
|
21
|
+
|
22
|
+
beforeEach(async () => {
|
23
|
+
await initializeDB();
|
24
|
+
|
25
|
+
await clientDB.delete(users);
|
26
|
+
await clientDB.delete(globalFiles);
|
27
|
+
// 创建测试数据
|
28
|
+
await clientDB.transaction(async (tx) => {
|
29
|
+
await tx.insert(users).values({ id: userId });
|
30
|
+
});
|
31
|
+
});
|
32
|
+
|
33
|
+
describe('FileService', () => {
|
34
|
+
describe('createFile', () => {
|
35
|
+
it('createFile should save the file to the database', async () => {
|
36
|
+
const localFile: UploadFileParams = {
|
37
|
+
name: 'test',
|
38
|
+
fileType: 'image/png',
|
39
|
+
url: '',
|
40
|
+
size: 1,
|
41
|
+
hash: '123',
|
42
|
+
};
|
43
|
+
|
44
|
+
await clientS3Storage.putObject(
|
45
|
+
'123',
|
46
|
+
new File([new ArrayBuffer(1)], 'test.png', { type: 'image/png' }),
|
47
|
+
);
|
48
|
+
|
49
|
+
const result = await fileService.createFile(localFile);
|
50
|
+
|
51
|
+
expect(result).toMatchObject({ url: 'data:image/png;base64,AA==' });
|
52
|
+
});
|
53
|
+
|
54
|
+
it('should throw error when file is not found in storage during base64 conversion', async () => {
|
55
|
+
const localFile: UploadFileParams = {
|
56
|
+
name: 'test',
|
57
|
+
fileType: 'image/png',
|
58
|
+
url: '',
|
59
|
+
size: 1,
|
60
|
+
hash: 'non-existing-hash',
|
61
|
+
};
|
62
|
+
|
63
|
+
// 不调用 clientS3Storage.putObject,模拟文件不存在的情况
|
64
|
+
|
65
|
+
const promise = fileService.createFile(localFile);
|
66
|
+
|
67
|
+
await expect(promise).rejects.toThrow('file not found');
|
68
|
+
});
|
69
|
+
});
|
70
|
+
|
71
|
+
it('removeFile should delete the file from the database', async () => {
|
72
|
+
const fileId = '1';
|
73
|
+
await clientDB.insert(files).values({ id: fileId, userId, ...mockFile });
|
74
|
+
|
75
|
+
await fileService.removeFile(fileId);
|
76
|
+
|
77
|
+
const result = await clientDB.query.files.findFirst({
|
78
|
+
where: eq(files.id, fileId),
|
79
|
+
});
|
80
|
+
|
81
|
+
expect(result).toBeUndefined();
|
82
|
+
});
|
83
|
+
|
84
|
+
describe('getFile', () => {
|
85
|
+
it('should retrieve and convert local file info to FilePreview', async () => {
|
86
|
+
const fileId = 'rwlijweled';
|
87
|
+
const file = {
|
88
|
+
fileType: 'image/png',
|
89
|
+
size: 1,
|
90
|
+
name: 'test.png',
|
91
|
+
url: 'idb://12312/abc.png',
|
92
|
+
hashId: '123tttt',
|
93
|
+
};
|
94
|
+
|
95
|
+
await clientDB.insert(globalFiles).values(file);
|
96
|
+
|
97
|
+
await clientDB.insert(files).values({
|
98
|
+
id: fileId,
|
99
|
+
userId,
|
100
|
+
...file,
|
101
|
+
createdAt: new Date(1),
|
102
|
+
updatedAt: new Date(2),
|
103
|
+
fileHash: file.hashId,
|
104
|
+
});
|
105
|
+
|
106
|
+
await clientS3Storage.putObject(
|
107
|
+
file.hashId,
|
108
|
+
new File([new ArrayBuffer(1)], file.name, { type: file.fileType }),
|
109
|
+
);
|
110
|
+
|
111
|
+
const result = await fileService.getFile(fileId);
|
112
|
+
|
113
|
+
expect(result).toMatchObject({
|
114
|
+
createdAt: new Date(1),
|
115
|
+
id: 'rwlijweled',
|
116
|
+
size: 1,
|
117
|
+
type: 'image/png',
|
118
|
+
name: 'test.png',
|
119
|
+
updatedAt: new Date(2),
|
120
|
+
});
|
121
|
+
});
|
122
|
+
|
123
|
+
it('should throw an error when the file is not found', async () => {
|
124
|
+
const fileId = 'non-existent';
|
125
|
+
|
126
|
+
const getFilePromise = fileService.getFile(fileId);
|
127
|
+
|
128
|
+
await expect(getFilePromise).rejects.toThrow('file not found');
|
129
|
+
});
|
130
|
+
});
|
131
|
+
|
132
|
+
describe('removeFiles', () => {
|
133
|
+
it('should delete multiple files from the database', async () => {
|
134
|
+
const fileIds = ['1', '2', '3'];
|
135
|
+
|
136
|
+
// 插入测试文件数据
|
137
|
+
await Promise.all(
|
138
|
+
fileIds.map((id) => clientDB.insert(files).values({ id, userId, ...mockFile })),
|
139
|
+
);
|
140
|
+
|
141
|
+
await fileService.removeFiles(fileIds);
|
142
|
+
|
143
|
+
// 验证所有文件都被删除
|
144
|
+
const remainingFiles = await clientDB.query.files.findMany({
|
145
|
+
where: (fields, { inArray }) => inArray(fields.id, fileIds),
|
146
|
+
});
|
147
|
+
|
148
|
+
expect(remainingFiles).toHaveLength(0);
|
149
|
+
});
|
150
|
+
});
|
151
|
+
|
152
|
+
describe('removeAllFiles', () => {
|
153
|
+
it('should clear all files for the user', async () => {
|
154
|
+
// 插入测试文件数据
|
155
|
+
await Promise.all([
|
156
|
+
clientDB.insert(files).values({ id: '1', userId, ...mockFile }),
|
157
|
+
clientDB.insert(files).values({ id: '2', userId, ...mockFile }),
|
158
|
+
]);
|
159
|
+
|
160
|
+
await fileService.removeAllFiles();
|
161
|
+
|
162
|
+
// 验证用户的所有文件都被删除
|
163
|
+
const remainingFiles = await clientDB.query.files.findMany({
|
164
|
+
where: eq(files.userId, userId),
|
165
|
+
});
|
166
|
+
|
167
|
+
expect(remainingFiles).toHaveLength(0);
|
168
|
+
});
|
169
|
+
});
|
170
|
+
|
171
|
+
describe('checkFileHash', () => {
|
172
|
+
it('should return true if file hash exists', async () => {
|
173
|
+
const hash = 'existing-hash';
|
174
|
+
await clientDB.insert(globalFiles).values({
|
175
|
+
...mockFile,
|
176
|
+
hashId: hash,
|
177
|
+
});
|
178
|
+
await clientDB.insert(files).values({
|
179
|
+
id: '1',
|
180
|
+
userId,
|
181
|
+
...mockFile,
|
182
|
+
fileHash: hash,
|
183
|
+
});
|
184
|
+
|
185
|
+
const exists = await fileService.checkFileHash(hash);
|
186
|
+
|
187
|
+
expect(exists).toMatchObject({ isExist: true });
|
188
|
+
});
|
189
|
+
|
190
|
+
it('should return false if file hash does not exist', async () => {
|
191
|
+
const hash = 'non-existing-hash';
|
192
|
+
|
193
|
+
const exists = await fileService.checkFileHash(hash);
|
194
|
+
|
195
|
+
expect(exists).toEqual({ isExist: false });
|
196
|
+
});
|
197
|
+
});
|
198
|
+
});
|
@@ -0,0 +1,84 @@
|
|
1
|
+
import { clientDB } from '@/database/client/db';
|
2
|
+
import { FileModel } from '@/database/server/models/file';
|
3
|
+
import { BaseClientService } from '@/services/baseClientService';
|
4
|
+
import { clientS3Storage } from '@/services/file/ClientS3';
|
5
|
+
import { FileItem, UploadFileParams } from '@/types/files';
|
6
|
+
|
7
|
+
import { IFileService } from './type';
|
8
|
+
|
9
|
+
export class ClientService extends BaseClientService implements IFileService {
|
10
|
+
private get fileModel(): FileModel {
|
11
|
+
return new FileModel(clientDB as any, this.userId);
|
12
|
+
}
|
13
|
+
|
14
|
+
async createFile(file: UploadFileParams) {
|
15
|
+
// save to local storage
|
16
|
+
// we may want to save to a remote server later
|
17
|
+
const res = await this.fileModel.create(
|
18
|
+
{
|
19
|
+
fileHash: file.hash,
|
20
|
+
fileType: file.fileType,
|
21
|
+
knowledgeBaseId: file.knowledgeBaseId,
|
22
|
+
metadata: file.metadata,
|
23
|
+
name: file.name,
|
24
|
+
size: file.size,
|
25
|
+
url: file.url!,
|
26
|
+
},
|
27
|
+
true,
|
28
|
+
);
|
29
|
+
|
30
|
+
// get file to base64 url
|
31
|
+
const base64 = await this.getBase64ByFileHash(file.hash!);
|
32
|
+
|
33
|
+
return {
|
34
|
+
id: res.id,
|
35
|
+
url: `data:${file.fileType};base64,${base64}`,
|
36
|
+
};
|
37
|
+
}
|
38
|
+
|
39
|
+
async getFile(id: string): Promise<FileItem> {
|
40
|
+
const item = await this.fileModel.findById(id);
|
41
|
+
if (!item) {
|
42
|
+
throw new Error('file not found');
|
43
|
+
}
|
44
|
+
|
45
|
+
// arrayBuffer to url
|
46
|
+
const fileItem = await clientS3Storage.getObject(item.fileHash!);
|
47
|
+
if (!fileItem) throw new Error('file not found');
|
48
|
+
|
49
|
+
const url = URL.createObjectURL(fileItem);
|
50
|
+
|
51
|
+
return {
|
52
|
+
createdAt: new Date(item.createdAt),
|
53
|
+
id,
|
54
|
+
name: item.name,
|
55
|
+
size: item.size,
|
56
|
+
type: item.fileType,
|
57
|
+
updatedAt: new Date(item.updatedAt),
|
58
|
+
url,
|
59
|
+
};
|
60
|
+
}
|
61
|
+
|
62
|
+
async removeFile(id: string) {
|
63
|
+
await this.fileModel.delete(id, false);
|
64
|
+
}
|
65
|
+
|
66
|
+
async removeFiles(ids: string[]) {
|
67
|
+
await this.fileModel.deleteMany(ids, false);
|
68
|
+
}
|
69
|
+
|
70
|
+
async removeAllFiles() {
|
71
|
+
return this.fileModel.clear();
|
72
|
+
}
|
73
|
+
|
74
|
+
async checkFileHash(hash: string) {
|
75
|
+
return this.fileModel.checkHash(hash);
|
76
|
+
}
|
77
|
+
|
78
|
+
private async getBase64ByFileHash(hash: string) {
|
79
|
+
const fileItem = await clientS3Storage.getObject(hash);
|
80
|
+
if (!fileItem) throw new Error('file not found');
|
81
|
+
|
82
|
+
return Buffer.from(await fileItem.arrayBuffer()).toString('base64');
|
83
|
+
}
|
84
|
+
}
|
@@ -1,12 +1,13 @@
|
|
1
|
-
import { FileItem, UploadFileParams } from '@/types/files';
|
1
|
+
import { CheckFileHashResult, FileItem, UploadFileParams } from '@/types/files';
|
2
2
|
|
3
3
|
export interface IFileService {
|
4
|
+
checkFileHash(hash: string): Promise<CheckFileHashResult>;
|
4
5
|
createFile(
|
5
6
|
file: UploadFileParams,
|
6
7
|
knowledgeBaseId?: string,
|
7
8
|
): Promise<{ id: string; url: string }>;
|
8
9
|
getFile(id: string): Promise<FileItem>;
|
9
10
|
removeAllFiles(): Promise<any>;
|
10
|
-
removeFile(id: string): Promise<
|
11
|
-
removeFiles(ids: string[]): Promise<
|
11
|
+
removeFile(id: string): Promise<void>;
|
12
|
+
removeFiles(ids: string[]): Promise<void>;
|
12
13
|
}
|