@lobehub/chat 0.70.4 → 0.71.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 CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## [Version 0.71.0](https://github.com/lobehub/lobe-chat/compare/v0.70.4...v0.71.0)
6
+
7
+ <sup>Released on **2023-09-09**</sup>
8
+
9
+ #### ✨ Features
10
+
11
+ - **misc**: Migrate localStorage to indexedDB.
12
+
13
+ <br/>
14
+
15
+ <details>
16
+ <summary><kbd>Improvements and Fixes</kbd></summary>
17
+
18
+ #### What's improved
19
+
20
+ - **misc**: Migrate localStorage to indexedDB, closes [#160](https://github.com/lobehub/lobe-chat/issues/160) ([7f96deb](https://github.com/lobehub/lobe-chat/commit/7f96deb))
21
+
22
+ </details>
23
+
24
+ <div align="right">
25
+
26
+ [![](https://img.shields.io/badge/-BACK_TO_TOP-151515?style=flat-square)](#readme-top)
27
+
28
+ </div>
29
+
5
30
  ### [Version 0.70.4](https://github.com/lobehub/lobe-chat/compare/v0.70.3...v0.70.4)
6
31
 
7
32
  <sup>Released on **2023-09-09**</sup>
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@lobehub/chat",
3
- "version": "0.70.4",
4
- "description": "Lobe Chat is an open-source chatbot client using LangChain, Typescript and Next.js",
3
+ "version": "0.71.0",
4
+ "description": "Lobe Chat is an open-source chatbot client using Vercel AI SDK, Typescript and Next.js",
5
5
  "keywords": [
6
6
  "chatbot",
7
7
  "ChatGPT",
8
- "LangChain",
8
+ "LobeHub",
9
9
  "typescript",
10
- "Next.js"
10
+ "Vercel AI SDK",
11
+ "Next.js",
12
+ "OpenAI"
11
13
  ],
12
14
  "homepage": "https://github.com/lobehub/lobe-chat",
13
15
  "bugs": {
@@ -80,12 +82,13 @@
80
82
  "fast-deep-equal": "^3",
81
83
  "gpt-tokenizer": "^2",
82
84
  "i18next": "^23",
85
+ "idb-keyval": "^6",
83
86
  "immer": "^10",
84
87
  "lodash-es": "^4",
85
88
  "lucide-react": "latest",
86
89
  "nanoid": "^4",
87
90
  "next": "13.4.7",
88
- "openai": "^4.4.0",
91
+ "openai": "^4.5.0",
89
92
  "polished": "^4",
90
93
  "react": "^18",
91
94
  "react-dom": "^18",
@@ -9,11 +9,9 @@ export const useGlobalHydrated = () => {
9
9
  const [isInit, setInit] = useState(hasInited);
10
10
 
11
11
  useEffect(() => {
12
- const hasRehydrated = useGlobalStore.persist.hasHydrated();
13
-
14
- if (hasRehydrated && !isInit) {
15
- setInit(true);
16
- }
12
+ useGlobalStore.persist.onFinishHydration(() => {
13
+ if (!isInit) setInit(true);
14
+ });
17
15
  }, []);
18
16
 
19
17
  return isInit;
@@ -9,10 +9,8 @@ import { GlobalStore, useGlobalStore } from '../store';
9
9
  */
10
10
  export const useOnFinishHydrationGlobal = (fn: (state: GlobalStore) => void, deps: any[] = []) => {
11
11
  useEffect(() => {
12
- const hasRehydrated = useGlobalStore.persist.hasHydrated();
13
- // 只有当水合完毕后再开始做操作
14
- if (!hasRehydrated) return;
15
-
16
- fn(useGlobalStore.getState());
12
+ useGlobalStore.persist.onFinishHydration(() => {
13
+ fn(useGlobalStore.getState());
14
+ });
17
15
  }, deps);
18
16
  };
@@ -1,28 +1,40 @@
1
- import { isEmpty } from 'lodash-es';
2
1
  import { PersistStorage } from 'zustand/middleware';
3
2
  import { StorageValue } from 'zustand/middleware/persist';
4
3
 
4
+ import { createIndexedDB } from './indexedDB';
5
5
  import { createKeyMapper } from './keyMapper';
6
+ import { createLocalStorage } from './localStorage';
6
7
  import { HyperStorageOptions } from './type';
7
- import { createUrlSearch } from './urlSearch';
8
+ import { creatUrlStorage } from './urlStorage';
8
9
 
9
10
  export const createHyperStorage = <T extends object>(
10
11
  options: HyperStorageOptions,
11
12
  ): PersistStorage<T> => {
12
13
  const hasUrl = !!options.url;
13
-
14
- const { setUrlSearch, getUrlSearch } = createUrlSearch(options.url?.mode);
14
+ const useIndexedDB = options.localStorage?.mode === 'indexedDB';
15
+ const dbName = options.localStorage?.dbName || 'indexedDB';
15
16
 
16
17
  const { mapStateKeyToStorageKey, getStateKeyFromStorageKey } = createKeyMapper(options);
17
18
 
19
+ const indexedDB = createIndexedDB(dbName);
20
+ const localStorage = createLocalStorage();
21
+ const urlStorage = creatUrlStorage(options.url?.mode);
18
22
  return {
19
- getItem: (name): StorageValue<T> => {
23
+ getItem: async (name): Promise<StorageValue<T>> => {
20
24
  const state: any = {};
21
25
  let version: number | undefined;
26
+
22
27
  // ============== 处理 Local Storage ============== //
23
- const string = localStorage.getItem(name);
24
- if (string) {
25
- const localState: StorageValue<T> = JSON.parse(string);
28
+ let localState: StorageValue<T> | undefined;
29
+
30
+ // 如果使用 indexedDB,优先从 indexedDB 中获取
31
+ if (useIndexedDB) {
32
+ localState = await indexedDB.getItem(name);
33
+ }
34
+ // 如果 indexedDB 中不存在,则再试试 localStorage
35
+ if (!localState) localState = localStorage.getItem(name);
36
+
37
+ if (localState) {
26
38
  version = localState.version;
27
39
  for (const [k, v] of Object.entries(localState.state)) {
28
40
  const key = getStateKeyFromStorageKey(k, 'localStorage');
@@ -33,31 +45,39 @@ export const createHyperStorage = <T extends object>(
33
45
  // ============== 处理 URL Storage ============== //
34
46
  // 不从 URL 中获取 version,由于 url 状态是临时态 不作为版本控制的依据
35
47
  if (hasUrl) {
36
- const searchParameters = new URLSearchParams(getUrlSearch());
37
-
38
- for (const [k, v] of searchParameters.entries()) {
39
- const key = getStateKeyFromStorageKey(k, 'url');
40
- // 当存在 UrlSelector 逻辑,且 key 有效时,才将状态加入终态 state
41
- if (hasUrl && key) {
42
- state[key] = v;
48
+ const urlState = urlStorage.getItem();
49
+
50
+ if (urlState) {
51
+ for (const [k, v] of Object.entries(urlState.state)) {
52
+ const key = getStateKeyFromStorageKey(k, 'url');
53
+ // 当存在 UrlSelector 逻辑,且 key 有效时,才将状态加入终态 state
54
+ if (hasUrl && key) {
55
+ state[key] = v;
56
+ }
43
57
  }
44
58
  }
45
59
  }
46
60
 
47
61
  return { state, version };
48
62
  },
49
- removeItem: (key): void => {
63
+ removeItem: async (key) => {
64
+ // ============== 处理 Local Storage ============== //
65
+
66
+ if (useIndexedDB) {
67
+ await indexedDB.removeItem(key);
68
+ } else {
69
+ localStorage.removeItem(key);
70
+ }
71
+
50
72
  // ============== 处理 URL Storage ============== //
51
73
  if (hasUrl) {
52
- const searchParameters = new URLSearchParams(getUrlSearch());
53
-
54
74
  const storageKey = getStateKeyFromStorageKey(key, 'url');
55
- if (storageKey) searchParameters.delete(storageKey);
56
75
 
57
- setUrlSearch(searchParameters);
76
+ urlStorage.removeItem(storageKey);
58
77
  }
59
78
  },
60
- setItem: (name, newValue) => {
79
+
80
+ setItem: async (name, newValue) => {
61
81
  // ============== 处理 Local Storage ============== //
62
82
  const localState: Record<string, any> = {};
63
83
  for (const [k, v] of Object.entries(newValue.state)) {
@@ -65,30 +85,24 @@ export const createHyperStorage = <T extends object>(
65
85
  if (localKey) localState[localKey] = v;
66
86
  }
67
87
 
68
- localStorage.setItem(name, JSON.stringify({ ...newValue, state: localState }));
88
+ if (useIndexedDB) {
89
+ await indexedDB.setItem(name, localState, newValue.version);
90
+ } else {
91
+ localStorage.setItem(name, localState, newValue.version);
92
+ }
69
93
 
70
94
  // ============== 处理 URL Storage ============== //
71
95
  if (hasUrl) {
72
- const searchParameters = new URLSearchParams(getUrlSearch());
73
-
74
- for (const [k, v] of Object.entries(newValue.state)) {
75
- const urlKey = mapStateKeyToStorageKey(k, 'url');
76
-
77
- if (!urlKey) continue;
78
-
79
- if (isEmpty(v)) {
80
- searchParameters.delete(urlKey);
81
- continue;
82
- }
83
-
84
- if (typeof v === 'string') {
85
- searchParameters.set(urlKey, v);
86
- } else {
87
- searchParameters.set(urlKey, JSON.stringify(v));
88
- }
89
- }
90
-
91
- setUrlSearch(searchParameters);
96
+ // 转换 key 为目标 key
97
+ const finalEntries = Object.entries(newValue.state)
98
+ .map(([k, v]) => {
99
+ const urlKey = mapStateKeyToStorageKey(k, 'url');
100
+ if (!urlKey) return undefined;
101
+ return [urlKey, v];
102
+ })
103
+ .filter(Boolean) as [string, any][];
104
+
105
+ urlStorage.setItem(name, Object.fromEntries(finalEntries));
92
106
  }
93
107
  },
94
108
  };
@@ -0,0 +1,26 @@
1
+ import { createStore, delMany, getMany, setMany } from 'idb-keyval';
2
+ import { StorageValue } from 'zustand/middleware/persist';
3
+
4
+ export const createIndexedDB = <State extends any>(dbName: string = 'indexedDB') => ({
5
+ getItem: async <T extends State>(name: string): Promise<StorageValue<T> | undefined> => {
6
+ const [version, state] = await getMany(['version', 'state'], createStore(dbName, name));
7
+
8
+ if (!state) return undefined;
9
+
10
+ return { state, version };
11
+ },
12
+ removeItem: async (name: string) => {
13
+ await delMany(['version', 'state'], createStore(dbName, name));
14
+ },
15
+ setItem: async (name: string, state: any, version: number | undefined) => {
16
+ const store = createStore(dbName, name);
17
+
18
+ await setMany(
19
+ [
20
+ ['version', version],
21
+ ['state', state],
22
+ ],
23
+ store,
24
+ );
25
+ },
26
+ });
@@ -0,0 +1,17 @@
1
+ import { StorageValue } from 'zustand/middleware/persist';
2
+
3
+ export const createLocalStorage = <State extends any>() => ({
4
+ getItem: <T extends State>(name: string): StorageValue<T> | undefined => {
5
+ const string = localStorage.getItem(name);
6
+
7
+ if (string) return JSON.parse(string) as StorageValue<T>;
8
+
9
+ return undefined;
10
+ },
11
+ removeItem: (name: string) => {
12
+ localStorage.removeItem(name);
13
+ },
14
+ setItem: <T extends State>(name: string, state: T, version: number | undefined) => {
15
+ localStorage.setItem(name, JSON.stringify({ state, version }));
16
+ },
17
+ });
@@ -2,6 +2,11 @@ export type StorageSelector = string | Record<string, string>;
2
2
 
3
3
  export interface HyperStorageOptions {
4
4
  localStorage?: {
5
+ dbName?: string;
6
+ /**
7
+ * @default 'localStorage'
8
+ */
9
+ mode?: 'indexedDB' | 'localStorage';
5
10
  selectors: StorageSelector[];
6
11
  };
7
12
  url?: {
@@ -0,0 +1,60 @@
1
+ import { isEmpty } from 'lodash-es';
2
+ import { StorageValue } from 'zustand/middleware/persist';
3
+
4
+ interface UrlSearchHelper {
5
+ getUrlSearch: () => string;
6
+ setUrlSearch: (params: URLSearchParams) => void;
7
+ }
8
+
9
+ const createUrlSearch = (mode: 'search' | 'hash' = 'hash'): UrlSearchHelper => {
10
+ if (mode === 'hash')
11
+ return {
12
+ getUrlSearch: () => location.hash.slice(1),
13
+ setUrlSearch: (params: URLSearchParams) => (location.hash = params.toString()),
14
+ };
15
+
16
+ return {
17
+ getUrlSearch: () => location.search.slice(1),
18
+ setUrlSearch: (params: URLSearchParams) => (location.search = params.toString()),
19
+ };
20
+ };
21
+
22
+ export const creatUrlStorage = <State extends object>(mode: 'hash' | 'search' = 'hash') => {
23
+ const { setUrlSearch, getUrlSearch } = createUrlSearch(mode);
24
+
25
+ return {
26
+ getItem: <T extends State>(): StorageValue<T> | undefined => {
27
+ const searchParameters = new URLSearchParams(getUrlSearch());
28
+
29
+ if (searchParameters.size === 0) return undefined;
30
+
31
+ const state = Object.fromEntries(searchParameters.entries()) as T;
32
+
33
+ return { state };
34
+ },
35
+ removeItem: (key?: string) => {
36
+ const searchParameters = new URLSearchParams(getUrlSearch());
37
+ if (key) searchParameters.delete(key);
38
+
39
+ setUrlSearch(searchParameters);
40
+ },
41
+ setItem: <T extends State>(name: string, state: T) => {
42
+ const searchParameters = new URLSearchParams(getUrlSearch());
43
+
44
+ for (const [urlKey, v] of Object.entries(state)) {
45
+ if (isEmpty(v)) {
46
+ searchParameters.delete(urlKey);
47
+ continue;
48
+ }
49
+
50
+ if (typeof v === 'string') {
51
+ searchParameters.set(urlKey, v);
52
+ } else {
53
+ searchParameters.set(urlKey, JSON.stringify(v));
54
+ }
55
+ }
56
+
57
+ setUrlSearch(searchParameters);
58
+ },
59
+ };
60
+ };
@@ -12,10 +12,9 @@ export const useOnFinishHydrationSession = (
12
12
  deps: any[] = [],
13
13
  ) => {
14
14
  useEffect(() => {
15
- const hasRehydrated = useSessionStore.persist.hasHydrated();
16
15
  // 只有当水合完毕后再开始做操作
17
- if (!hasRehydrated) return;
18
-
19
- fn(useSessionStore.getState());
16
+ useSessionStore.persist.onFinishHydration(() => {
17
+ fn(useSessionStore.getState());
18
+ });
20
19
  }, deps);
21
20
  };
@@ -5,14 +5,13 @@ import { useSessionStore } from '../store';
5
5
  export const useSessionHydrated = () => {
6
6
  // 根据 sessions 是否有值来判断是否已经初始化
7
7
  const hasInited = !!Object.values(useSessionStore.getState().sessions).length;
8
+
8
9
  const [isInit, setInit] = useState(hasInited);
9
10
 
10
11
  useEffect(() => {
11
- const hasRehydrated = useSessionStore.persist.hasHydrated();
12
-
13
- if (hasRehydrated && !isInit) {
14
- setInit(true);
15
- }
12
+ useSessionStore.persist.onFinishHydration(() => {
13
+ if (!isInit) setInit(true);
14
+ });
16
15
  }, []);
17
16
 
18
17
  return isInit;
@@ -33,6 +33,8 @@ const persistOptions: PersistOptions<SessionStore> = {
33
33
 
34
34
  storage: createHyperStorage({
35
35
  localStorage: {
36
+ dbName: 'LobeHub',
37
+ mode: 'indexedDB',
36
38
  selectors: ['inbox', 'sessions'],
37
39
  },
38
40
  url: {
@@ -1,17 +0,0 @@
1
- interface UrlSearchHelper {
2
- getUrlSearch: () => string;
3
- setUrlSearch: (params: URLSearchParams) => void;
4
- }
5
-
6
- export const createUrlSearch = (mode: 'search' | 'hash' = 'hash'): UrlSearchHelper => {
7
- if (mode === 'hash')
8
- return {
9
- getUrlSearch: () => location.hash.slice(1),
10
- setUrlSearch: (params: URLSearchParams) => (location.hash = params.toString()),
11
- };
12
-
13
- return {
14
- getUrlSearch: () => location.search.slice(1),
15
- setUrlSearch: (params: URLSearchParams) => (location.search = params.toString()),
16
- };
17
- };