@lobehub/chat 1.75.4 → 1.76.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +1 -1
  3. package/README.zh-CN.md +1 -1
  4. package/changelog/v1.json +18 -0
  5. package/docs/developer/database-schema.dbml +1 -0
  6. package/docs/self-hosting/advanced/model-list.mdx +5 -3
  7. package/docs/self-hosting/advanced/model-list.zh-CN.mdx +5 -3
  8. package/docs/usage/providers/infiniai.zh-CN.mdx +4 -0
  9. package/locales/ar/hotkey.json +46 -0
  10. package/locales/ar/models.json +51 -54
  11. package/locales/ar/providers.json +3 -0
  12. package/locales/ar/setting.json +12 -0
  13. package/locales/bg-BG/hotkey.json +46 -0
  14. package/locales/bg-BG/models.json +51 -54
  15. package/locales/bg-BG/providers.json +3 -0
  16. package/locales/bg-BG/setting.json +12 -0
  17. package/locales/de-DE/hotkey.json +46 -0
  18. package/locales/de-DE/models.json +51 -54
  19. package/locales/de-DE/providers.json +3 -0
  20. package/locales/de-DE/setting.json +12 -0
  21. package/locales/en-US/hotkey.json +46 -0
  22. package/locales/en-US/models.json +51 -54
  23. package/locales/en-US/providers.json +3 -0
  24. package/locales/en-US/setting.json +12 -0
  25. package/locales/es-ES/hotkey.json +46 -0
  26. package/locales/es-ES/models.json +51 -54
  27. package/locales/es-ES/providers.json +3 -0
  28. package/locales/es-ES/setting.json +12 -0
  29. package/locales/fa-IR/hotkey.json +46 -0
  30. package/locales/fa-IR/models.json +51 -54
  31. package/locales/fa-IR/providers.json +3 -0
  32. package/locales/fa-IR/setting.json +12 -0
  33. package/locales/fr-FR/hotkey.json +46 -0
  34. package/locales/fr-FR/models.json +51 -54
  35. package/locales/fr-FR/providers.json +3 -0
  36. package/locales/fr-FR/setting.json +12 -0
  37. package/locales/it-IT/hotkey.json +46 -0
  38. package/locales/it-IT/models.json +51 -54
  39. package/locales/it-IT/providers.json +3 -0
  40. package/locales/it-IT/setting.json +12 -0
  41. package/locales/ja-JP/hotkey.json +46 -0
  42. package/locales/ja-JP/models.json +51 -54
  43. package/locales/ja-JP/providers.json +3 -0
  44. package/locales/ja-JP/setting.json +12 -0
  45. package/locales/ko-KR/hotkey.json +46 -0
  46. package/locales/ko-KR/models.json +51 -54
  47. package/locales/ko-KR/providers.json +3 -0
  48. package/locales/ko-KR/setting.json +12 -0
  49. package/locales/nl-NL/hotkey.json +46 -0
  50. package/locales/nl-NL/models.json +51 -54
  51. package/locales/nl-NL/providers.json +3 -0
  52. package/locales/nl-NL/setting.json +12 -0
  53. package/locales/pl-PL/hotkey.json +46 -0
  54. package/locales/pl-PL/models.json +51 -54
  55. package/locales/pl-PL/providers.json +3 -0
  56. package/locales/pl-PL/setting.json +12 -0
  57. package/locales/pt-BR/hotkey.json +46 -0
  58. package/locales/pt-BR/models.json +51 -54
  59. package/locales/pt-BR/providers.json +3 -0
  60. package/locales/pt-BR/setting.json +12 -0
  61. package/locales/ru-RU/hotkey.json +46 -0
  62. package/locales/ru-RU/models.json +51 -54
  63. package/locales/ru-RU/providers.json +3 -0
  64. package/locales/ru-RU/setting.json +12 -0
  65. package/locales/tr-TR/hotkey.json +46 -0
  66. package/locales/tr-TR/models.json +51 -54
  67. package/locales/tr-TR/providers.json +3 -0
  68. package/locales/tr-TR/setting.json +12 -0
  69. package/locales/vi-VN/hotkey.json +46 -0
  70. package/locales/vi-VN/models.json +51 -54
  71. package/locales/vi-VN/providers.json +3 -0
  72. package/locales/vi-VN/setting.json +12 -0
  73. package/locales/zh-CN/hotkey.json +46 -0
  74. package/locales/zh-CN/models.json +55 -58
  75. package/locales/zh-CN/providers.json +3 -0
  76. package/locales/zh-CN/setting.json +12 -0
  77. package/locales/zh-TW/hotkey.json +46 -0
  78. package/locales/zh-TW/models.json +51 -54
  79. package/locales/zh-TW/providers.json +3 -0
  80. package/locales/zh-TW/setting.json +12 -0
  81. package/package.json +3 -3
  82. package/src/app/[variants]/(main)/(mobile)/me/(home)/features/Category.tsx +1 -1
  83. package/src/app/[variants]/(main)/(mobile)/me/(home)/layout.tsx +3 -2
  84. package/src/app/[variants]/(main)/(mobile)/me/data/features/Category.tsx +1 -1
  85. package/src/app/[variants]/(main)/(mobile)/me/profile/features/Category.tsx +1 -1
  86. package/src/app/[variants]/(main)/(mobile)/me/settings/features/Category.tsx +1 -1
  87. package/src/app/[variants]/(main)/_layout/Desktop/RegisterHotkeys.tsx +11 -0
  88. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/PinList/index.tsx +6 -23
  89. package/src/app/[variants]/(main)/_layout/Desktop/SideBar/TopActions.test.tsx +2 -0
  90. package/src/app/[variants]/(main)/_layout/Desktop/index.tsx +11 -4
  91. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/Footer/SendMore.tsx +6 -21
  92. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/Footer/ShortcutHint.tsx +13 -34
  93. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ChatInput/Desktop/Footer/index.tsx +1 -1
  94. package/src/app/[variants]/(main)/chat/(workspace)/@conversation/features/ZenModeToast/Toast.tsx +7 -4
  95. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/HeaderAction.tsx +12 -8
  96. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/ChatHeader/Main.tsx +24 -30
  97. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/index.tsx +0 -2
  98. package/src/app/[variants]/(main)/chat/(workspace)/features/SettingButton.tsx +12 -7
  99. package/src/app/[variants]/(main)/chat/@session/features/SessionSearchBar.tsx +5 -1
  100. package/src/app/[variants]/(main)/chat/_layout/Desktop/RegisterHotkeys.tsx +10 -0
  101. package/src/app/[variants]/(main)/chat/_layout/Desktop/index.tsx +5 -0
  102. package/src/app/[variants]/(main)/chat/_layout/Mobile.tsx +1 -1
  103. package/src/app/[variants]/(main)/discover/features/StoreSearchBar.tsx +5 -1
  104. package/src/app/[variants]/(main)/settings/hooks/useCategory.tsx +31 -21
  105. package/src/app/[variants]/(main)/settings/hotkey/features/HotkeySetting.tsx +80 -0
  106. package/src/app/[variants]/(main)/settings/hotkey/index.tsx +9 -0
  107. package/src/app/[variants]/(main)/settings/hotkey/page.tsx +15 -0
  108. package/src/app/[variants]/layout.tsx +16 -13
  109. package/src/config/aiModels/infiniai.ts +52 -55
  110. package/src/config/aiModels/siliconcloud.ts +17 -1
  111. package/src/config/aiModels/tencentcloud.ts +17 -0
  112. package/src/const/hotkeys.ts +80 -10
  113. package/src/const/settings/hotkey.ts +10 -0
  114. package/src/const/settings/index.ts +3 -0
  115. package/src/database/client/migrations.json +46 -32
  116. package/src/database/migrations/0019_add_hotkey_user_settings.sql +2 -0
  117. package/src/database/migrations/meta/0019_snapshot.json +4218 -0
  118. package/src/database/migrations/meta/_journal.json +7 -0
  119. package/src/database/schemas/user.ts +1 -0
  120. package/src/database/server/models/user.ts +2 -0
  121. package/src/features/ChatInput/Desktop/InputArea/index.tsx +8 -0
  122. package/src/features/ChatInput/Desktop/index.tsx +0 -1
  123. package/src/features/ChatInput/Topic/index.tsx +10 -15
  124. package/src/features/FileManager/Header/FilesSearchBar.tsx +6 -2
  125. package/src/features/HotkeyHelperPanel/HotkeyContent.tsx +62 -0
  126. package/src/features/HotkeyHelperPanel/index.tsx +59 -0
  127. package/src/hooks/useHotkeys/chatScope.ts +105 -0
  128. package/src/hooks/useHotkeys/globalScope.ts +69 -0
  129. package/src/hooks/useHotkeys/index.ts +2 -0
  130. package/src/hooks/useHotkeys/useHotkeyById.test.ts +194 -0
  131. package/src/hooks/useHotkeys/useHotkeyById.ts +57 -0
  132. package/src/libs/agent-runtime/infiniai/index.ts +38 -3
  133. package/src/locales/default/hotkey.ts +50 -0
  134. package/src/locales/default/index.ts +2 -0
  135. package/src/locales/default/setting.ts +12 -0
  136. package/src/store/global/initialState.ts +3 -0
  137. package/src/store/user/slices/settings/selectors/__snapshots__/settings.test.ts.snap +79 -0
  138. package/src/store/user/slices/settings/selectors/settings.test.ts +131 -0
  139. package/src/store/user/slices/settings/selectors/settings.ts +6 -0
  140. package/src/types/hotkey.ts +59 -0
  141. package/src/types/user/settings/hotkey.ts +3 -0
  142. package/src/types/user/settings/index.ts +3 -0
  143. package/src/utils/format.ts +1 -1
  144. package/src/utils/parseModels.test.ts +14 -0
  145. package/src/utils/parseModels.ts +4 -0
  146. package/src/app/[variants]/(main)/chat/(workspace)/_layout/Desktop/HotKeys.tsx +0 -44
  147. package/src/components/HotKeys/index.tsx +0 -77
@@ -133,6 +133,13 @@
133
133
  "when": 1742616026643,
134
134
  "tag": "0018_add_client_id_for_entities",
135
135
  "breakpoints": true
136
+ },
137
+ {
138
+ "idx": 19,
139
+ "version": "7",
140
+ "when": 1742806552131,
141
+ "tag": "0019_add_hotkey_user_settings",
142
+ "breakpoints": true
136
143
  }
137
144
  ],
138
145
  "version": "6"
@@ -39,6 +39,7 @@ export const userSettings = pgTable('user_settings', {
39
39
  .primaryKey(),
40
40
 
41
41
  tts: jsonb('tts'),
42
+ hotkey: jsonb('hotkey'),
42
43
  keyVaults: text('key_vaults'),
43
44
  general: jsonb('general'),
44
45
  languageModel: jsonb('language_model'),
@@ -67,6 +67,7 @@ export class UserModel {
67
67
 
68
68
  settingsDefaultAgent: userSettings.defaultAgent,
69
69
  settingsGeneral: userSettings.general,
70
+ settingsHotkey: userSettings.hotkey,
70
71
  settingsKeyVaults: userSettings.keyVaults,
71
72
  settingsLanguageModel: userSettings.languageModel,
72
73
  settingsSystemAgent: userSettings.systemAgent,
@@ -89,6 +90,7 @@ export class UserModel {
89
90
  const settings: DeepPartial<UserSettings> = {
90
91
  defaultAgent: state.settingsDefaultAgent || {},
91
92
  general: state.settingsGeneral || {},
93
+ hotkey: state.settingsHotkey || {},
92
94
  keyVaults: decryptKeyVaults,
93
95
  languageModel: state.settingsLanguageModel || {},
94
96
  systemAgent: state.settingsSystemAgent || {},
@@ -2,10 +2,12 @@ import { TextArea } from '@lobehub/ui';
2
2
  import { createStyles } from 'antd-style';
3
3
  import { TextAreaRef } from 'antd/es/input/TextArea';
4
4
  import { RefObject, memo, useEffect, useRef } from 'react';
5
+ import { useHotkeysContext } from 'react-hotkeys-hook';
5
6
  import { useTranslation } from 'react-i18next';
6
7
 
7
8
  import { useUserStore } from '@/store/user';
8
9
  import { preferenceSelectors } from '@/store/user/selectors';
10
+ import { HotkeyEnum } from '@/types/hotkey';
9
11
  import { isCommandPressed } from '@/utils/keyboard';
10
12
 
11
13
  import { useAutoFocus } from '../useAutoFocus';
@@ -38,8 +40,10 @@ interface InputAreaProps {
38
40
  }
39
41
 
40
42
  const InputArea = memo<InputAreaProps>(({ onSend, value, loading, onChange }) => {
43
+ const { enableScope, disableScope } = useHotkeysContext();
41
44
  const { t } = useTranslation('chat');
42
45
  const { styles } = useStyles();
46
+
43
47
  const ref = useRef<TextAreaRef>(null);
44
48
  const isChineseInput = useRef(false);
45
49
 
@@ -71,6 +75,7 @@ const InputArea = memo<InputAreaProps>(({ onSend, value, loading, onChange }) =>
71
75
  className={styles.textarea}
72
76
  onBlur={(e) => {
73
77
  onChange?.(e.target.value);
78
+ disableScope(HotkeyEnum.AddUserMessage);
74
79
  }}
75
80
  onChange={(e) => {
76
81
  onChange?.(e.target.value);
@@ -81,6 +86,9 @@ const InputArea = memo<InputAreaProps>(({ onSend, value, loading, onChange }) =>
81
86
  onCompositionStart={() => {
82
87
  isChineseInput.current = true;
83
88
  }}
89
+ onFocus={() => {
90
+ enableScope(HotkeyEnum.AddUserMessage);
91
+ }}
84
92
  onPressEnter={(e) => {
85
93
  if (loading || e.altKey || e.shiftKey || isChineseInput.current) return;
86
94
 
@@ -34,7 +34,6 @@ const DesktopChatInput = memo<DesktopChatInputProps>(
34
34
  renderFooter,
35
35
  }) => {
36
36
  const [expand, setExpand] = useState<boolean>(false);
37
-
38
37
  const onSend = useCallback(() => {
39
38
  setExpand(false);
40
39
  }, []);
@@ -1,17 +1,19 @@
1
- import { ActionIcon, Icon, Tooltip } from '@lobehub/ui';
1
+ import { ActionIcon, Hotkey, Icon, Tooltip } from '@lobehub/ui';
2
2
  import { Button, Popconfirm } from 'antd';
3
3
  import { LucideGalleryVerticalEnd, LucideMessageSquarePlus } from 'lucide-react';
4
4
  import { memo, useState } from 'react';
5
- import { useHotkeys } from 'react-hotkeys-hook';
6
5
  import { useTranslation } from 'react-i18next';
6
+ import { Flexbox } from 'react-layout-kit';
7
7
 
8
- import HotKeys from '@/components/HotKeys';
9
- import { ALT_KEY, SAVE_TOPIC_KEY } from '@/const/hotkeys';
10
8
  import { useActionSWR } from '@/libs/swr';
11
9
  import { useChatStore } from '@/store/chat';
10
+ import { useUserStore } from '@/store/user';
11
+ import { settingsSelectors } from '@/store/user/selectors';
12
+ import { HotkeyEnum } from '@/types/hotkey';
12
13
 
13
14
  const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
14
15
  const { t } = useTranslation('chat');
16
+ const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.SaveTopic));
15
17
  const [hasTopic, openNewTopicOrSaveTopic] = useChatStore((s) => [
16
18
  !!s.activeTopicId,
17
19
  s.openNewTopicOrSaveTopic,
@@ -24,13 +26,6 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
24
26
  const icon = hasTopic ? LucideMessageSquarePlus : LucideGalleryVerticalEnd;
25
27
  const desc = t(hasTopic ? 'topic.openNewTopic' : 'topic.saveCurrentMessages');
26
28
 
27
- const hotkeys = [ALT_KEY, SAVE_TOPIC_KEY].join('+');
28
-
29
- useHotkeys(hotkeys, () => mutate(), {
30
- enableOnFormTags: true,
31
- preventDefault: true,
32
- });
33
-
34
29
  if (mobile) {
35
30
  return (
36
31
  <Popconfirm
@@ -41,12 +36,12 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
41
36
  open={confirmOpened}
42
37
  placement={'top'}
43
38
  title={
44
- <div style={{ alignItems: 'center', display: 'flex', marginBottom: '8px' }}>
39
+ <Flexbox align={'center'} horizontal style={{ marginBottom: 8 }}>
45
40
  <div style={{ marginRight: '16px', whiteSpace: 'pre-line', wordBreak: 'break-word' }}>
46
41
  {t(hasTopic ? 'topic.checkOpenNewTopic' : 'topic.checkSaveCurrentMessages')}
47
42
  </div>
48
- <HotKeys inverseTheme={false} keys={hotkeys} />
49
- </div>
43
+ <Hotkey keys={hotkey} />
44
+ </Flexbox>
50
45
  }
51
46
  >
52
47
  <ActionIcon
@@ -59,7 +54,7 @@ const SaveTopic = memo<{ mobile?: boolean }>(({ mobile }) => {
59
54
  );
60
55
  } else {
61
56
  return (
62
- <Tooltip title={<HotKeys desc={desc} inverseTheme keys={hotkeys} />}>
57
+ <Tooltip hotkey={hotkey} title={desc}>
63
58
  <Button
64
59
  aria-label={desc}
65
60
  icon={<Icon icon={icon} />}
@@ -5,9 +5,13 @@ import { useQueryState } from 'nuqs';
5
5
  import { memo, useState } from 'react';
6
6
  import { useTranslation } from 'react-i18next';
7
7
 
8
+ import { useUserStore } from '@/store/user';
9
+ import { settingsSelectors } from '@/store/user/selectors';
10
+ import { HotkeyEnum } from '@/types/hotkey';
11
+
8
12
  const FilesSearchBar = memo<{ mobile?: boolean }>(({ mobile }) => {
9
13
  const { t } = useTranslation('file');
10
-
14
+ const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.Search));
11
15
  const [keywords, setKeywords] = useState<string>('');
12
16
 
13
17
  const [, setQuery] = useQueryState('q', {
@@ -30,7 +34,7 @@ const FilesSearchBar = memo<{ mobile?: boolean }>(({ mobile }) => {
30
34
  setQuery(keywords);
31
35
  }}
32
36
  placeholder={t('searchFilePlaceholder')}
33
- shortKey={'k'}
37
+ shortKey={hotkey}
34
38
  spotlight={!mobile}
35
39
  style={{ width: 320 }}
36
40
  value={keywords}
@@ -0,0 +1,62 @@
1
+ import { Hotkey } from '@lobehub/ui';
2
+ import { createStyles } from 'antd-style';
3
+ import isEqual from 'fast-deep-equal';
4
+ import { memo } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import { Flexbox } from 'react-layout-kit';
7
+
8
+ import { HOTKEYS_REGISTRATION } from '@/const/hotkeys';
9
+ import hotkeyMeta from '@/locales/default/hotkey';
10
+ import { useUserStore } from '@/store/user';
11
+ import { settingsSelectors } from '@/store/user/slices/settings/selectors';
12
+ import { HotkeyGroupId } from '@/types/hotkey';
13
+
14
+ const useStyle = createStyles(({ css, token }) => ({
15
+ desc: css`
16
+ font-size: 12px;
17
+ line-height: 1.3;
18
+ color: ${token.colorTextDescription};
19
+ `,
20
+ hotkey: css`
21
+ gap: 4px;
22
+
23
+ kbd {
24
+ min-width: 26px;
25
+ height: 26px;
26
+ border: 1px solid ${token.colorBorder};
27
+ }
28
+ `,
29
+ }));
30
+
31
+ interface HotkeyContentProps {
32
+ groupId: HotkeyGroupId;
33
+ }
34
+
35
+ const HotkeyContent = memo<HotkeyContentProps>(({ groupId }) => {
36
+ const settings = useUserStore(settingsSelectors.currentSettings, isEqual);
37
+ const { t } = useTranslation('hotkey');
38
+ const { styles } = useStyle();
39
+ return (
40
+ <>
41
+ {HOTKEYS_REGISTRATION.filter((item) => item.group === groupId).map((item) => (
42
+ <Flexbox align={'flex-start'} gap={16} horizontal key={item.id} width={'100%'}>
43
+ <Flexbox flex={1} gap={4} justify={'space-between'}>
44
+ <span>{t(`${item.id}.title`)}</span>
45
+ {hotkeyMeta[item.id].desc ? (
46
+ <span className={styles.desc}>{t(`${item.id}.desc`)}</span>
47
+ ) : null}
48
+ </Flexbox>
49
+ <Hotkey
50
+ className={styles.hotkey}
51
+ keys={settings.hotkey[item.id]}
52
+ style={{
53
+ zoom: 1.1,
54
+ }}
55
+ />
56
+ </Flexbox>
57
+ ))}
58
+ </>
59
+ );
60
+ });
61
+
62
+ export default HotkeyContent;
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+
3
+ import { Drawer, Grid, TabsNav } from '@lobehub/ui';
4
+ import { memo, useState } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+
7
+ import { useGlobalStore } from '@/store/global';
8
+ import { HotkeyGroupEnum, HotkeyGroupId } from '@/types/hotkey';
9
+
10
+ import HotkeyContent from './HotkeyContent';
11
+
12
+ const HotkeyHelperPanel = memo(() => {
13
+ const [open, updateSystemStatus] = useGlobalStore((s) => [
14
+ s.status.showHotkeyHelper,
15
+ s.updateSystemStatus,
16
+ ]);
17
+ const [active, setActive] = useState<HotkeyGroupId>(HotkeyGroupEnum.Essential);
18
+ const { t } = useTranslation('setting');
19
+
20
+ const handleClose = () => updateSystemStatus({ showHotkeyHelper: false });
21
+
22
+ return (
23
+ <Drawer
24
+ height={240}
25
+ mask={false}
26
+ maskClosable={false}
27
+ onClose={handleClose}
28
+ open={open}
29
+ placement={'bottom'}
30
+ styles={{
31
+ bodyContent: { paddingBlock: 24 },
32
+ title: { paddingBlock: 0 },
33
+ }}
34
+ title={
35
+ <TabsNav
36
+ activeKey={active}
37
+ items={[
38
+ {
39
+ key: HotkeyGroupEnum.Essential,
40
+ label: t('hotkey.group.essential'),
41
+ },
42
+ {
43
+ key: HotkeyGroupEnum.Conversation,
44
+ label: t('hotkey.group.conversation'),
45
+ },
46
+ ]}
47
+ onChange={(key) => setActive(key as HotkeyGroupId)}
48
+ variant={'compact'}
49
+ />
50
+ }
51
+ >
52
+ <Grid gap={32}>
53
+ <HotkeyContent groupId={active} />
54
+ </Grid>
55
+ </Drawer>
56
+ );
57
+ });
58
+
59
+ export default HotkeyHelperPanel;
@@ -0,0 +1,105 @@
1
+ import isEqual from 'fast-deep-equal';
2
+ import { parseAsBoolean, useQueryState } from 'nuqs';
3
+ import { useEffect } from 'react';
4
+ import { useHotkeysContext } from 'react-hotkeys-hook';
5
+
6
+ import { useSendMessage } from '@/features/ChatInput/useSend';
7
+ import { useOpenChatSettings } from '@/hooks/useInterceptingRoutes';
8
+ import { useActionSWR } from '@/libs/swr';
9
+ import { useChatStore } from '@/store/chat';
10
+ import { chatSelectors } from '@/store/chat/selectors';
11
+ import { useGlobalStore } from '@/store/global';
12
+ import { systemStatusSelectors } from '@/store/global/selectors';
13
+ import { HotkeyEnum, HotkeyScopeEnum } from '@/types/hotkey';
14
+
15
+ import { useHotkeyById } from './useHotkeyById';
16
+
17
+ export const useSaveTopicHotkey = () => {
18
+ const openNewTopicOrSaveTopic = useChatStore((s) => s.openNewTopicOrSaveTopic);
19
+ const { mutate } = useActionSWR('openNewTopicOrSaveTopic', openNewTopicOrSaveTopic);
20
+ return useHotkeyById(HotkeyEnum.SaveTopic, () => mutate());
21
+ };
22
+
23
+ export const useToggleZenModeHotkey = () => {
24
+ const toggleZenMode = useGlobalStore((s) => s.toggleZenMode);
25
+ return useHotkeyById(HotkeyEnum.ToggleZenMode, toggleZenMode);
26
+ };
27
+
28
+ export const useOpenChatSettingsHotkey = () => {
29
+ const openChatSettings = useOpenChatSettings();
30
+ return useHotkeyById(HotkeyEnum.OpenChatSettings, openChatSettings);
31
+ };
32
+
33
+ export const useRegenerateMessageHotkey = () => {
34
+ const regenerateMessage = useChatStore((s) => s.regenerateMessage);
35
+ const lastMessage = useChatStore(chatSelectors.latestMessage, isEqual);
36
+
37
+ const disable = !lastMessage || lastMessage.id === 'default' || lastMessage.role === 'system';
38
+
39
+ return useHotkeyById(
40
+ HotkeyEnum.RegenerateMessage,
41
+ () => !disable && regenerateMessage(lastMessage.id),
42
+ {
43
+ enabled: !disable,
44
+ },
45
+ );
46
+ };
47
+
48
+ export const useToggleLeftPanelHotkey = () => {
49
+ const isZenMode = useGlobalStore((s) => s.status.zenMode);
50
+ const [isPinned] = useQueryState('pinned', parseAsBoolean);
51
+ const showSessionPanel = useGlobalStore(systemStatusSelectors.showSessionPanel);
52
+ const updateSystemStatus = useGlobalStore((s) => s.updateSystemStatus);
53
+
54
+ return useHotkeyById(
55
+ HotkeyEnum.ToggleLeftPanel,
56
+ () =>
57
+ updateSystemStatus({
58
+ sessionsWidth: showSessionPanel ? 0 : 320,
59
+ showSessionPanel: !showSessionPanel,
60
+ }),
61
+ {
62
+ enabled: !isZenMode && !isPinned,
63
+ },
64
+ );
65
+ };
66
+
67
+ export const useToggleRightPanelHotkey = () => {
68
+ const isZenMode = useGlobalStore((s) => s.status.zenMode);
69
+ const toggleConfig = useGlobalStore((s) => s.toggleChatSideBar);
70
+
71
+ return useHotkeyById(HotkeyEnum.ToggleRightPanel, () => toggleConfig(), {
72
+ enabled: !isZenMode,
73
+ });
74
+ };
75
+
76
+ export const useAddUserMessageHotkey = () => {
77
+ const { send } = useSendMessage();
78
+ return useHotkeyById(HotkeyEnum.AddUserMessage, () => send({ onlyAddUserMessage: true }));
79
+ };
80
+
81
+ // 注册聚合
82
+
83
+ export const useRegisterChatHotkeys = () => {
84
+ const { enableScope, disableScope } = useHotkeysContext();
85
+
86
+ // System
87
+ useOpenChatSettingsHotkey();
88
+
89
+ // Layout
90
+ useToggleLeftPanelHotkey();
91
+ useToggleRightPanelHotkey();
92
+ useToggleZenModeHotkey();
93
+
94
+ // Conversation
95
+ useRegenerateMessageHotkey();
96
+ useSaveTopicHotkey();
97
+ useAddUserMessageHotkey();
98
+
99
+ useEffect(() => {
100
+ enableScope(HotkeyScopeEnum.Chat);
101
+ return () => disableScope(HotkeyScopeEnum.Chat);
102
+ }, []);
103
+
104
+ return null;
105
+ };
@@ -0,0 +1,69 @@
1
+ import isEqual from 'fast-deep-equal';
2
+ import { parseAsBoolean, useQueryState } from 'nuqs';
3
+ import { useHotkeys } from 'react-hotkeys-hook';
4
+
5
+ import { useSwitchSession } from '@/hooks/useSwitchSession';
6
+ import { useGlobalStore } from '@/store/global';
7
+ import { featureFlagsSelectors, useServerConfigStore } from '@/store/serverConfig';
8
+ import { useSessionStore } from '@/store/session';
9
+ import { sessionSelectors } from '@/store/session/selectors';
10
+ import { useUserStore } from '@/store/user';
11
+ import { settingsSelectors } from '@/store/user/selectors';
12
+ import { HotkeyEnum, HotkeyScopeEnum, KeyEnum } from '@/types/hotkey';
13
+
14
+ import { useHotkeyById } from './useHotkeyById';
15
+
16
+ export const useSwitchAgentHotkey = () => {
17
+ const { showPinList } = useServerConfigStore(featureFlagsSelectors);
18
+ const list = useSessionStore(sessionSelectors.pinnedSessions, isEqual);
19
+ const hotkey = useUserStore(settingsSelectors.getHotkeyById(HotkeyEnum.SwitchAgent));
20
+ const switchSession = useSwitchSession();
21
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
22
+ const [_, setPinned] = useQueryState('pinned', parseAsBoolean);
23
+
24
+ const switchAgent = (id: string) => {
25
+ switchSession(id);
26
+ setPinned(true);
27
+ };
28
+
29
+ const ref = useHotkeys(
30
+ list.slice(0, 9).map((e, i) => hotkey.replaceAll(KeyEnum.Number, String(i + 1))),
31
+ (_, hotkeysEvent) => {
32
+ if (!hotkeysEvent.keys?.[0]) return;
33
+ const index = parseInt(hotkeysEvent.keys?.[0]) - 1;
34
+ const item = list[index];
35
+ if (!item) return;
36
+ switchAgent(item.id);
37
+ },
38
+ {
39
+ enableOnFormTags: true,
40
+ enabled: showPinList,
41
+ preventDefault: true,
42
+ scopes: [HotkeyScopeEnum.Global, HotkeyEnum.SwitchAgent],
43
+ },
44
+ );
45
+
46
+ return {
47
+ id: HotkeyEnum.SwitchAgent,
48
+ ref,
49
+ };
50
+ };
51
+
52
+ export const useOpenHotkeyHelperHotkey = () => {
53
+ const [open, updateSystemStatus] = useGlobalStore((s) => [
54
+ s.status.showHotkeyHelper,
55
+ s.updateSystemStatus,
56
+ ]);
57
+
58
+ return useHotkeyById(HotkeyEnum.OpenHotkeyHelper, () =>
59
+ updateSystemStatus({ showHotkeyHelper: !open }),
60
+ );
61
+ };
62
+
63
+ // 注册聚合
64
+
65
+ export const useRegisterGlobalHotkeys = () => {
66
+ // 全局自动注册不需要 enableScope
67
+ useSwitchAgentHotkey();
68
+ useOpenHotkeyHelperHotkey();
69
+ };
@@ -0,0 +1,2 @@
1
+ export * from './chatScope';
2
+ export * from './globalScope';
@@ -0,0 +1,194 @@
1
+ import { renderHook } from '@testing-library/react';
2
+ import { uniq } from 'lodash-es';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { HOTKEYS_REGISTRATION } from '@/const/hotkeys';
6
+ import { HotkeyEnum, HotkeyScopeEnum } from '@/types/hotkey';
7
+
8
+ import { useHotkeyById } from './useHotkeyById';
9
+
10
+ // Mock dependencies
11
+ vi.mock('lodash-es', async (importOriginal) => {
12
+ const actual: any = await importOriginal();
13
+ return {
14
+ ...actual,
15
+ uniq: vi.fn((arr) => [...new Set(arr)]),
16
+ };
17
+ });
18
+
19
+ // Mock react-hotkeys-hook
20
+ const mockRef = { current: document.createElement('div') };
21
+ const mockUseHotkeys = vi.fn(() => mockRef);
22
+
23
+ vi.mock('react-hotkeys-hook', () => ({
24
+ // @ts-ignore
25
+ useHotkeys: (...args: any[]) => mockUseHotkeys(...args),
26
+ }));
27
+
28
+ // Mock store and environment
29
+ let isMobileValue = false;
30
+ const mockHotkey = 'mod+k';
31
+
32
+ vi.mock('@/store/serverConfig', () => ({
33
+ useServerConfigStore: (selector: (state: any) => any) => selector({ isMobile: isMobileValue }),
34
+ }));
35
+
36
+ vi.mock('@/store/user', () => ({
37
+ useUserStore: (selector: any) => selector(),
38
+ }));
39
+
40
+ vi.mock('@/store/user/selectors', () => ({
41
+ settingsSelectors: {
42
+ getHotkeyById: () => () => mockHotkey,
43
+ },
44
+ }));
45
+
46
+ vi.mock('@/utils/env', () => ({
47
+ isDev: false,
48
+ }));
49
+
50
+ describe('useHotkeyById', () => {
51
+ const mockCallback = vi.fn();
52
+
53
+ beforeEach(() => {
54
+ vi.clearAllMocks();
55
+ isMobileValue = false;
56
+ });
57
+
58
+ it('should register hotkey with correct parameters', () => {
59
+ const { result } = renderHook(() => useHotkeyById(HotkeyEnum.Search, mockCallback));
60
+
61
+ // 验证 useHotkeys 被调用,但不检查具体函数引用
62
+ expect(mockUseHotkeys).toHaveBeenCalled();
63
+
64
+ // 获取实际调用参数
65
+ const callArgs: any = mockUseHotkeys.mock.calls[0];
66
+
67
+ // 验证第一个参数是正确的热键
68
+ expect(callArgs[0]).toBe(mockHotkey);
69
+
70
+ // 验证第二个参数是函数
71
+ expect(typeof callArgs[1]).toBe('function');
72
+
73
+ // 验证第三个参数包含预期的属性
74
+ expect(callArgs[2]).toMatchObject({
75
+ enableOnFormTags: true,
76
+ preventDefault: true,
77
+ scopes: [HotkeyEnum.Search, HotkeyScopeEnum.Global],
78
+ });
79
+
80
+ // 验证第四个参数是 undefined
81
+ expect(callArgs[3]).toBeUndefined();
82
+
83
+ // 验证返回值
84
+ expect(result.current).toEqual({
85
+ id: HotkeyEnum.Search,
86
+ ref: mockRef,
87
+ });
88
+ });
89
+
90
+ it('should disable hotkey on mobile devices', () => {
91
+ // Set as mobile
92
+ isMobileValue = true;
93
+
94
+ renderHook(() => useHotkeyById(HotkeyEnum.Search, mockCallback, { enabled: true }));
95
+
96
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
97
+ mockHotkey,
98
+ expect.any(Function),
99
+ expect.objectContaining({
100
+ enabled: false, // Should be disabled on mobile
101
+ }),
102
+ undefined,
103
+ );
104
+ });
105
+
106
+ it('should handle options parameter correctly', () => {
107
+ const options = {
108
+ enabled: true,
109
+ scopes: ['customScope'],
110
+ };
111
+
112
+ renderHook(() => useHotkeyById(HotkeyEnum.Search, mockCallback, options));
113
+
114
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
115
+ mockHotkey,
116
+ expect.any(Function),
117
+ expect.objectContaining({
118
+ enabled: true,
119
+ scopes: expect.arrayContaining([HotkeyEnum.Search, HotkeyScopeEnum.Global, 'customScope']),
120
+ }),
121
+ undefined,
122
+ );
123
+
124
+ expect(uniq).toHaveBeenCalled();
125
+ });
126
+
127
+ it('should handle dependencies parameter correctly', () => {
128
+ const deps = [1, 2, 3];
129
+
130
+ renderHook(() => useHotkeyById(HotkeyEnum.Search, mockCallback, deps));
131
+
132
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
133
+ mockHotkey,
134
+ expect.any(Function),
135
+ expect.any(Object),
136
+ deps,
137
+ );
138
+ });
139
+
140
+ it('should handle both options and dependencies correctly', () => {
141
+ const options = { enabled: true };
142
+ const deps = [1, 2, 3];
143
+
144
+ renderHook(() => useHotkeyById(HotkeyEnum.Search, mockCallback, options, deps));
145
+
146
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
147
+ mockHotkey,
148
+ expect.any(Function),
149
+ expect.objectContaining({
150
+ enabled: true,
151
+ }),
152
+ deps,
153
+ );
154
+ });
155
+
156
+ it('should combine scopes from registration and options', () => {
157
+ const testHotkeyId = HotkeyEnum.ToggleLeftPanel;
158
+ const registrationItem = HOTKEYS_REGISTRATION.find((item) => item.id === testHotkeyId);
159
+ const options = {
160
+ scopes: ['customScope'],
161
+ };
162
+
163
+ renderHook(() => useHotkeyById(testHotkeyId, mockCallback, options));
164
+
165
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
166
+ mockHotkey,
167
+ expect.any(Function),
168
+ expect.objectContaining({
169
+ scopes: expect.arrayContaining([
170
+ testHotkeyId,
171
+ ...(registrationItem?.scopes || []),
172
+ 'customScope',
173
+ ]),
174
+ }),
175
+ undefined,
176
+ );
177
+ });
178
+
179
+ it('should handle case when no registration item is found', () => {
180
+ // Using a non-existent ID to test this case
181
+ const nonExistentId = 'nonExistentId' as any;
182
+
183
+ renderHook(() => useHotkeyById(nonExistentId, mockCallback));
184
+
185
+ expect(mockUseHotkeys).toHaveBeenCalledWith(
186
+ mockHotkey,
187
+ expect.any(Function),
188
+ expect.objectContaining({
189
+ scopes: expect.arrayContaining([nonExistentId]),
190
+ }),
191
+ undefined,
192
+ );
193
+ });
194
+ });