@lobehub/chat 0.119.10 → 0.119.12
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 +50 -0
- package/README.md +8 -8
- package/README.zh-CN.md +8 -8
- package/locales/de-DE/chat.json +9 -4
- package/locales/en-US/chat.json +9 -4
- package/locales/es-ES/chat.json +9 -4
- package/locales/fr-FR/chat.json +9 -4
- package/locales/it-IT/chat.json +9 -4
- package/locales/ja-JP/chat.json +9 -4
- package/locales/ko-KR/chat.json +9 -4
- package/locales/nl-NL/chat.json +9 -4
- package/locales/pl-PL/chat.json +9 -4
- package/locales/pt-BR/chat.json +9 -4
- package/locales/ru-RU/chat.json +9 -4
- package/locales/tr-TR/chat.json +9 -4
- package/locales/vi-VN/chat.json +9 -4
- package/locales/zh-CN/chat.json +9 -4
- package/locales/zh-TW/chat.json +9 -4
- package/package.json +1 -1
- package/src/app/chat/(desktop)/features/ChatInput/Footer/index.tsx +164 -0
- package/src/app/chat/(desktop)/features/ChatInput/Header/index.tsx +33 -0
- package/src/app/chat/(desktop)/features/ChatInput/TextArea.test.tsx +316 -0
- package/src/app/chat/(desktop)/features/ChatInput/TextArea.tsx +122 -0
- package/src/app/chat/(desktop)/features/ChatInput/index.tsx +41 -110
- package/src/app/chat/(desktop)/features/SideBar.tsx +1 -0
- package/src/app/chat/(mobile)/features/ChatHeaderTitle.tsx +1 -2
- package/src/app/chat/features/ChatHeader/SettingButton.tsx +1 -1
- package/src/app/chat/features/ChatHeader/ShareButton/useScreenshot.ts +1 -1
- package/src/features/ChatInput/useChatInput.ts +2 -8
- package/src/features/ChatInput/useSend.ts +9 -2
- package/src/locales/default/chat.ts +8 -3
- package/src/store/chat/slices/message/action.test.ts +7 -7
- package/src/store/chat/slices/message/action.ts +11 -2
- package/src/store/chat/slices/message/initialState.ts +0 -3
- package/src/store/global/slices/common/initialState.ts +5 -0
- package/src/store/global/slices/common/selectors.ts +4 -1
- package/src/utils/keyboard.ts +13 -0
- package/src/utils/platform.ts +4 -0
- /package/src/app/chat/(desktop)/features/ChatInput/{LocalFiles.tsx → Footer/LocalFiles.tsx} +0 -0
- /package/src/app/chat/(desktop)/features/ChatInput/{DragUpload.tsx → Header/DragUpload.tsx} +0 -0
package/locales/zh-CN/chat.json
CHANGED
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
"desc": "开启大脑集群,激发思维火花。你的智能助理,在这里与你交流一切",
|
|
15
15
|
"title": "随便聊聊"
|
|
16
16
|
},
|
|
17
|
+
"input": {
|
|
18
|
+
"onlyAdd": "仅添加消息",
|
|
19
|
+
"send": "发送",
|
|
20
|
+
"sendWithCmdEnter": "按 {{meta}} + Enter 键发送",
|
|
21
|
+
"sendWithEnter": "按 Enter 键发送",
|
|
22
|
+
"stop": "停止",
|
|
23
|
+
"warp": "换行"
|
|
24
|
+
},
|
|
17
25
|
"messageAction": {
|
|
18
26
|
"delAndRegenerate": "删除并重新生成",
|
|
19
27
|
"regenerate": "重新生成"
|
|
@@ -25,7 +33,6 @@
|
|
|
25
33
|
"regenerate": "重新生成",
|
|
26
34
|
"roleAndArchive": "角色与记录",
|
|
27
35
|
"searchAgentPlaceholder": "搜索助手和对话...",
|
|
28
|
-
"send": "发送",
|
|
29
36
|
"sendPlaceholder": "输入聊天内容...",
|
|
30
37
|
"sessionList": "助手列表",
|
|
31
38
|
"shareModal": {
|
|
@@ -39,7 +46,6 @@
|
|
|
39
46
|
"withPluginInfo": "包含插件信息",
|
|
40
47
|
"withSystemRole": "包含助手角色设定"
|
|
41
48
|
},
|
|
42
|
-
"stop": "停止",
|
|
43
49
|
"stt": {
|
|
44
50
|
"action": "语音输入",
|
|
45
51
|
"loading": "识别中...",
|
|
@@ -86,6 +92,5 @@
|
|
|
86
92
|
"actionTooltip": "上传图片",
|
|
87
93
|
"dragDesc": "拖拽文件到这里,支持上传多个图片。按住 Shift 直接发送图片",
|
|
88
94
|
"dragTitle": "上传图片"
|
|
89
|
-
}
|
|
90
|
-
"warp": "换行"
|
|
95
|
+
}
|
|
91
96
|
}
|
package/locales/zh-TW/chat.json
CHANGED
|
@@ -14,6 +14,14 @@
|
|
|
14
14
|
"desc": "開啟大腦集群,激發思維火花。你的智能助理,在這裡與你交流一切",
|
|
15
15
|
"title": "隨便聊聊"
|
|
16
16
|
},
|
|
17
|
+
"input": {
|
|
18
|
+
"onlyAdd": "僅新增訊息",
|
|
19
|
+
"send": "發送",
|
|
20
|
+
"sendWithCmdEnter": "按 {{meta}} + Enter 鍵發送",
|
|
21
|
+
"sendWithEnter": "按 Enter 鍵發送",
|
|
22
|
+
"stop": "停止",
|
|
23
|
+
"warp": "換行"
|
|
24
|
+
},
|
|
17
25
|
"messageAction": {
|
|
18
26
|
"delAndRegenerate": "刪除並重新生成",
|
|
19
27
|
"regenerate": "重新生成"
|
|
@@ -25,7 +33,6 @@
|
|
|
25
33
|
"regenerate": "重新生成",
|
|
26
34
|
"roleAndArchive": "角色與記錄",
|
|
27
35
|
"searchAgentPlaceholder": "搜索助手和對話...",
|
|
28
|
-
"send": "發送",
|
|
29
36
|
"sendPlaceholder": "輸入聊天內容...",
|
|
30
37
|
"sessionList": "助手列表",
|
|
31
38
|
"shareModal": {
|
|
@@ -39,7 +46,6 @@
|
|
|
39
46
|
"withPluginInfo": "包含插件信息",
|
|
40
47
|
"withSystemRole": "包含助手角色設定"
|
|
41
48
|
},
|
|
42
|
-
"stop": "停止",
|
|
43
49
|
"stt": {
|
|
44
50
|
"action": "語音輸入",
|
|
45
51
|
"loading": "識別中...",
|
|
@@ -86,6 +92,5 @@
|
|
|
86
92
|
"actionTooltip": "上傳圖片",
|
|
87
93
|
"dragDesc": "拖拽文件到這裡,支持上傳多個圖片。按住 Shift 直接發送圖片",
|
|
88
94
|
"dragTitle": "上傳圖片"
|
|
89
|
-
}
|
|
90
|
-
"warp": "換行"
|
|
95
|
+
}
|
|
91
96
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lobehub/chat",
|
|
3
|
-
"version": "0.119.
|
|
3
|
+
"version": "0.119.12",
|
|
4
4
|
"description": "Lobe Chat - an open-source, high-performance chatbot framework that supports speech synthesis, multimodal, and extensible Function Call plugin system. Supports one-click free deployment of your private ChatGPT/LLM web application.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"framework",
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { Icon } from '@lobehub/ui';
|
|
2
|
+
import { Button, Dropdown, Space } from 'antd';
|
|
3
|
+
import { createStyles } from 'antd-style';
|
|
4
|
+
import {
|
|
5
|
+
ChevronUp,
|
|
6
|
+
CornerDownLeft,
|
|
7
|
+
Loader2,
|
|
8
|
+
LucideCheck,
|
|
9
|
+
LucideChevronDown,
|
|
10
|
+
LucideCommand,
|
|
11
|
+
LucidePlus,
|
|
12
|
+
} from 'lucide-react';
|
|
13
|
+
import { memo } from 'react';
|
|
14
|
+
import { useTranslation } from 'react-i18next';
|
|
15
|
+
import { Center, Flexbox } from 'react-layout-kit';
|
|
16
|
+
|
|
17
|
+
import SaveTopic from '@/features/ChatInput/Topic';
|
|
18
|
+
import { useSendMessage } from '@/features/ChatInput/useSend';
|
|
19
|
+
import { useChatStore } from '@/store/chat';
|
|
20
|
+
import { useGlobalStore } from '@/store/global';
|
|
21
|
+
import { preferenceSelectors } from '@/store/global/selectors';
|
|
22
|
+
import { useSessionStore } from '@/store/session';
|
|
23
|
+
import { agentSelectors } from '@/store/session/selectors';
|
|
24
|
+
import { isMacOS } from '@/utils/platform';
|
|
25
|
+
|
|
26
|
+
import { LocalFiles } from './LocalFiles';
|
|
27
|
+
|
|
28
|
+
const useStyles = createStyles(({ css, prefixCls }) => {
|
|
29
|
+
return {
|
|
30
|
+
arrow: css`
|
|
31
|
+
&.${prefixCls}-btn.${prefixCls}-btn-icon-only {
|
|
32
|
+
width: 28px;
|
|
33
|
+
}
|
|
34
|
+
`,
|
|
35
|
+
overrideAntdIcon: css`
|
|
36
|
+
.${prefixCls}-btn.${prefixCls}-btn-icon-only {
|
|
37
|
+
display: flex;
|
|
38
|
+
align-items: center;
|
|
39
|
+
justify-content: center;
|
|
40
|
+
}
|
|
41
|
+
`,
|
|
42
|
+
};
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const isMac = isMacOS();
|
|
46
|
+
|
|
47
|
+
const Footer = memo(() => {
|
|
48
|
+
const { t } = useTranslation('chat');
|
|
49
|
+
|
|
50
|
+
const { theme, styles } = useStyles();
|
|
51
|
+
const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
|
|
52
|
+
const [loading, stopGenerateMessage] = useChatStore((s) => [
|
|
53
|
+
!!s.chatLoadingId,
|
|
54
|
+
s.stopGenerateMessage,
|
|
55
|
+
]);
|
|
56
|
+
const [useCmdEnterToSend, updatePreference] = useGlobalStore((s) => [
|
|
57
|
+
preferenceSelectors.useCmdEnterToSend(s),
|
|
58
|
+
s.updatePreference,
|
|
59
|
+
]);
|
|
60
|
+
const sendMessage = useSendMessage();
|
|
61
|
+
|
|
62
|
+
const cmdEnter = (
|
|
63
|
+
<Flexbox gap={2} horizontal>
|
|
64
|
+
<Icon icon={isMac ? LucideCommand : ChevronUp} />
|
|
65
|
+
<Icon icon={CornerDownLeft} />
|
|
66
|
+
</Flexbox>
|
|
67
|
+
);
|
|
68
|
+
|
|
69
|
+
const enter = (
|
|
70
|
+
<Center>
|
|
71
|
+
<Icon icon={CornerDownLeft} />
|
|
72
|
+
</Center>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const sendShortcut = useCmdEnterToSend ? cmdEnter : enter;
|
|
76
|
+
|
|
77
|
+
const wrapperShortcut = useCmdEnterToSend ? enter : cmdEnter;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<Flexbox
|
|
81
|
+
align={'end'}
|
|
82
|
+
className={styles.overrideAntdIcon}
|
|
83
|
+
distribution={'space-between'}
|
|
84
|
+
flex={'none'}
|
|
85
|
+
gap={8}
|
|
86
|
+
horizontal
|
|
87
|
+
padding={'0 24px'}
|
|
88
|
+
>
|
|
89
|
+
<Flexbox align={'center'} gap={8} horizontal>
|
|
90
|
+
{canUpload && <LocalFiles />}
|
|
91
|
+
</Flexbox>
|
|
92
|
+
<Flexbox align={'center'} gap={8} horizontal>
|
|
93
|
+
<Flexbox
|
|
94
|
+
gap={4}
|
|
95
|
+
horizontal
|
|
96
|
+
style={{ color: theme.colorTextDescription, fontSize: 12, marginRight: 12 }}
|
|
97
|
+
>
|
|
98
|
+
{sendShortcut}
|
|
99
|
+
<span>{t('input.send')}</span>
|
|
100
|
+
<span>/</span>
|
|
101
|
+
{wrapperShortcut}
|
|
102
|
+
<span>{t('input.warp')}</span>
|
|
103
|
+
</Flexbox>
|
|
104
|
+
<SaveTopic />
|
|
105
|
+
<Flexbox style={{ minWidth: 92 }}>
|
|
106
|
+
{loading ? (
|
|
107
|
+
<Button icon={loading && <Icon icon={Loader2} spin />} onClick={stopGenerateMessage}>
|
|
108
|
+
{t('input.stop')}
|
|
109
|
+
</Button>
|
|
110
|
+
) : (
|
|
111
|
+
<Space.Compact>
|
|
112
|
+
<Button onClick={() => sendMessage()} type={'primary'}>
|
|
113
|
+
{t('input.send')}
|
|
114
|
+
</Button>
|
|
115
|
+
<Dropdown
|
|
116
|
+
menu={{
|
|
117
|
+
items: [
|
|
118
|
+
{
|
|
119
|
+
icon: !useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
|
|
120
|
+
key: 'sendWithEnter',
|
|
121
|
+
label: t('input.sendWithEnter'),
|
|
122
|
+
onClick: () => {
|
|
123
|
+
updatePreference({ useCmdEnterToSend: false });
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
{
|
|
127
|
+
icon: useCmdEnterToSend ? <Icon icon={LucideCheck} /> : <div />,
|
|
128
|
+
key: 'sendWithCmdEnter',
|
|
129
|
+
label: t('input.sendWithCmdEnter', {
|
|
130
|
+
meta: isMac ? 'Cmd' : 'Ctrl',
|
|
131
|
+
}),
|
|
132
|
+
onClick: () => {
|
|
133
|
+
updatePreference({ useCmdEnterToSend: true });
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
{ type: 'divider' },
|
|
137
|
+
{
|
|
138
|
+
icon: <Icon icon={LucidePlus} />,
|
|
139
|
+
key: 'onlyAdd',
|
|
140
|
+
label: t('input.onlyAdd'),
|
|
141
|
+
onClick: () => {
|
|
142
|
+
sendMessage(true);
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
],
|
|
146
|
+
}}
|
|
147
|
+
placement={'topRight'}
|
|
148
|
+
trigger={['hover']}
|
|
149
|
+
>
|
|
150
|
+
<Button
|
|
151
|
+
className={styles.arrow}
|
|
152
|
+
icon={<Icon icon={LucideChevronDown} />}
|
|
153
|
+
type={'primary'}
|
|
154
|
+
/>
|
|
155
|
+
</Dropdown>
|
|
156
|
+
</Space.Compact>
|
|
157
|
+
)}
|
|
158
|
+
</Flexbox>
|
|
159
|
+
</Flexbox>
|
|
160
|
+
</Flexbox>
|
|
161
|
+
);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
export default Footer;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { ActionIcon } from '@lobehub/ui';
|
|
2
|
+
import { Maximize2, Minimize2 } from 'lucide-react';
|
|
3
|
+
import { memo } from 'react';
|
|
4
|
+
|
|
5
|
+
import ActionBar from '@/features/ChatInput/ActionBar';
|
|
6
|
+
import { useSessionStore } from '@/store/session';
|
|
7
|
+
import { agentSelectors } from '@/store/session/selectors';
|
|
8
|
+
|
|
9
|
+
import DragUpload from './DragUpload';
|
|
10
|
+
|
|
11
|
+
const Header = memo<{ expand: boolean; setExpand: (expand: boolean) => void }>(
|
|
12
|
+
({ expand, setExpand }) => {
|
|
13
|
+
const canUpload = useSessionStore(agentSelectors.modelHasVisionAbility);
|
|
14
|
+
|
|
15
|
+
return (
|
|
16
|
+
<>
|
|
17
|
+
{canUpload && <DragUpload />}
|
|
18
|
+
<ActionBar
|
|
19
|
+
rightAreaEndRender={
|
|
20
|
+
<ActionIcon
|
|
21
|
+
icon={expand ? Minimize2 : Maximize2}
|
|
22
|
+
onClick={() => {
|
|
23
|
+
setExpand(!expand);
|
|
24
|
+
}}
|
|
25
|
+
/>
|
|
26
|
+
}
|
|
27
|
+
/>
|
|
28
|
+
</>
|
|
29
|
+
);
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
export default Header;
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { act, fireEvent, render, screen } from '@testing-library/react';
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
|
|
5
|
+
import { useChatStore } from '@/store/chat';
|
|
6
|
+
import { useGlobalStore } from '@/store/global';
|
|
7
|
+
|
|
8
|
+
import InputArea from './TextArea';
|
|
9
|
+
|
|
10
|
+
let setExpandMock: (expand: boolean) => void;
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
setExpandMock = vi.fn();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
describe('<InputArea />', () => {
|
|
17
|
+
it('renders the TextArea component correctly', () => {
|
|
18
|
+
render(<InputArea />);
|
|
19
|
+
const textArea = screen.getByRole('textbox');
|
|
20
|
+
expect(textArea).toBeInTheDocument();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('auto-focuses the TextArea component on mount', () => {
|
|
24
|
+
render(<InputArea />);
|
|
25
|
+
const textArea = screen.getByRole('textbox');
|
|
26
|
+
|
|
27
|
+
// The document's active element should be the textarea if it was auto-focused
|
|
28
|
+
expect(document.activeElement).toBe(textArea);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('renders with correct placeholder text', () => {
|
|
32
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
33
|
+
const textArea = screen.getByPlaceholderText('sendPlaceholder');
|
|
34
|
+
expect(textArea).toBeInTheDocument();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('has the correct initial value', () => {
|
|
38
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
39
|
+
const textArea = screen.getByRole('textbox');
|
|
40
|
+
expect(textArea).toHaveValue('');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
describe('input behavior', () => {
|
|
44
|
+
it('calls updateInputMessage on input change', async () => {
|
|
45
|
+
const updateInputMessageMock = vi.fn();
|
|
46
|
+
act(() => {
|
|
47
|
+
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
render(<InputArea />);
|
|
51
|
+
|
|
52
|
+
const textArea = screen.getByRole('textbox');
|
|
53
|
+
const newValue = 'New message';
|
|
54
|
+
fireEvent.change(textArea, { target: { value: newValue } });
|
|
55
|
+
|
|
56
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith(newValue);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('handles composition events for IME input correctly', () => {
|
|
60
|
+
const updateInputMessageMock = vi.fn();
|
|
61
|
+
act(() => {
|
|
62
|
+
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
render(<InputArea />);
|
|
66
|
+
const textArea = screen.getByRole('textbox');
|
|
67
|
+
|
|
68
|
+
// Start composition (IME input starts)
|
|
69
|
+
fireEvent.compositionStart(textArea);
|
|
70
|
+
fireEvent.change(textArea, { target: { value: '正在' } });
|
|
71
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith('正在');
|
|
72
|
+
|
|
73
|
+
// End composition (IME input ends)
|
|
74
|
+
fireEvent.compositionEnd(textArea);
|
|
75
|
+
fireEvent.change(textArea, { target: { value: '正在输入' } });
|
|
76
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith('正在输入');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('does not send a message when Enter is pressed during IME composition', () => {
|
|
80
|
+
const updateInputMessageMock = vi.fn();
|
|
81
|
+
act(() => {
|
|
82
|
+
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
86
|
+
const textArea = screen.getByRole('textbox');
|
|
87
|
+
|
|
88
|
+
// Start composition (IME input starts)
|
|
89
|
+
fireEvent.compositionStart(textArea);
|
|
90
|
+
|
|
91
|
+
// Simulate pressing Enter during IME composition
|
|
92
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
|
|
93
|
+
|
|
94
|
+
// Since we are in the middle of IME composition, the message should not be sent
|
|
95
|
+
expect(setExpandMock).not.toHaveBeenCalled();
|
|
96
|
+
expect(updateInputMessageMock).not.toHaveBeenCalled();
|
|
97
|
+
|
|
98
|
+
// End composition (IME input ends)
|
|
99
|
+
fireEvent.compositionEnd(textArea);
|
|
100
|
+
|
|
101
|
+
// Now simulate pressing Enter after IME composition
|
|
102
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
|
|
103
|
+
|
|
104
|
+
// Since IME composition has ended, now the message should be sent
|
|
105
|
+
expect(setExpandMock).toHaveBeenCalled();
|
|
106
|
+
expect(updateInputMessageMock).toHaveBeenCalled();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('updates the input message when TextArea loses focus', () => {
|
|
110
|
+
const updateInputMessageMock = vi.fn();
|
|
111
|
+
act(() => {
|
|
112
|
+
useChatStore.setState({ updateInputMessage: updateInputMessageMock });
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
116
|
+
const textArea = screen.getByRole('textbox');
|
|
117
|
+
const newText = 'New input text';
|
|
118
|
+
|
|
119
|
+
fireEvent.change(textArea, { target: { value: newText } });
|
|
120
|
+
fireEvent.blur(textArea);
|
|
121
|
+
|
|
122
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith(newText);
|
|
123
|
+
});
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
describe('leaving protect', () => {
|
|
127
|
+
it('triggers a warning when trying to leave the page with unsaved input', () => {
|
|
128
|
+
const beforeUnloadEvent = new Event('beforeunload', { cancelable: true });
|
|
129
|
+
|
|
130
|
+
act(() => {
|
|
131
|
+
useChatStore.setState({ inputMessage: 'Unsaved input' });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
render(<InputArea />);
|
|
135
|
+
|
|
136
|
+
// trigger beforeunload
|
|
137
|
+
window.dispatchEvent(beforeUnloadEvent);
|
|
138
|
+
|
|
139
|
+
// 检查 returnValue 是否被设置为 true,这是触发警告的标识
|
|
140
|
+
expect(beforeUnloadEvent.returnValue).toBeTruthy();
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('does not trigger a warning when trying to leave the page with empty input', () => {
|
|
144
|
+
act(() => {
|
|
145
|
+
useChatStore.setState({ inputMessage: '' });
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// 模拟 window.addEventListener 来捕获 beforeunload 事件处理程序
|
|
149
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
|
150
|
+
const beforeUnloadHandler = vi.fn();
|
|
151
|
+
|
|
152
|
+
addEventListenerSpy.mockImplementation((event, handler) => {
|
|
153
|
+
if (event === 'beforeunload') {
|
|
154
|
+
beforeUnloadHandler.mockImplementation(handler as any);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// 渲染组件
|
|
159
|
+
render(<InputArea />);
|
|
160
|
+
|
|
161
|
+
// 触发 beforeunload 事件
|
|
162
|
+
const event = new Event('beforeunload', { cancelable: true });
|
|
163
|
+
window.dispatchEvent(event);
|
|
164
|
+
|
|
165
|
+
// 检查 beforeunload 事件的处理程序是否没有被调用
|
|
166
|
+
expect(beforeUnloadHandler).not.toHaveBeenCalled();
|
|
167
|
+
|
|
168
|
+
// 清理模拟
|
|
169
|
+
addEventListenerSpy.mockRestore();
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('cleanup', () => {
|
|
173
|
+
it('removes beforeunload listener on unmount', () => {
|
|
174
|
+
const addEventListenerSpy = vi.spyOn(window, 'addEventListener');
|
|
175
|
+
const removeEventListenerSpy = vi.spyOn(window, 'removeEventListener');
|
|
176
|
+
|
|
177
|
+
// 渲染并立即卸载组件
|
|
178
|
+
const { unmount } = render(<InputArea />);
|
|
179
|
+
unmount();
|
|
180
|
+
|
|
181
|
+
// 检查是否为 beforeunload 事件添加了监听器
|
|
182
|
+
expect(addEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
|
183
|
+
|
|
184
|
+
// 检查是否移除了对应的监听器
|
|
185
|
+
expect(removeEventListenerSpy).toHaveBeenCalledWith('beforeunload', expect.any(Function));
|
|
186
|
+
|
|
187
|
+
// 清理 spy
|
|
188
|
+
addEventListenerSpy.mockRestore();
|
|
189
|
+
removeEventListenerSpy.mockRestore();
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe('message sending behavior', () => {
|
|
195
|
+
it('does not send message when loading or shift key is pressed', () => {
|
|
196
|
+
const sendMessageMock = vi.fn();
|
|
197
|
+
act(() => {
|
|
198
|
+
useChatStore.setState({ chatLoadingId: '123', sendMessage: sendMessageMock });
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
202
|
+
const textArea = screen.getByRole('textbox');
|
|
203
|
+
|
|
204
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', shiftKey: true });
|
|
205
|
+
expect(sendMessageMock).not.toHaveBeenCalled();
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('sends message on Enter press when not loading and no shift key', () => {
|
|
209
|
+
const sendMessageMock = vi.fn();
|
|
210
|
+
act(() => {
|
|
211
|
+
useChatStore.setState({
|
|
212
|
+
chatLoadingId: '',
|
|
213
|
+
inputMessage: 'abc',
|
|
214
|
+
sendMessage: sendMessageMock,
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
render(<InputArea />);
|
|
219
|
+
const textArea = screen.getByRole('textbox');
|
|
220
|
+
fireEvent.change(textArea, { target: { value: 'Test message' } });
|
|
221
|
+
|
|
222
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter' });
|
|
223
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe('metaKey behavior for sending messages', () => {
|
|
227
|
+
it('windows: sends message on ctrl + enter when useCmdEnterToSend is true', () => {
|
|
228
|
+
const sendMessageMock = vi.fn();
|
|
229
|
+
act(() => {
|
|
230
|
+
useChatStore.setState({
|
|
231
|
+
chatLoadingId: '',
|
|
232
|
+
inputMessage: '123',
|
|
233
|
+
sendMessage: sendMessageMock,
|
|
234
|
+
});
|
|
235
|
+
useGlobalStore.getState().updatePreference({ useCmdEnterToSend: true });
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
239
|
+
const textArea = screen.getByRole('textbox');
|
|
240
|
+
|
|
241
|
+
fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
|
|
242
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it('windows: inserts a new line on ctrl + enter when useCmdEnterToSend is false', () => {
|
|
246
|
+
const sendMessageMock = vi.fn();
|
|
247
|
+
const updateInputMessageMock = vi.fn();
|
|
248
|
+
act(() => {
|
|
249
|
+
useChatStore.setState({
|
|
250
|
+
chatLoadingId: '',
|
|
251
|
+
inputMessage: 'Test',
|
|
252
|
+
sendMessage: sendMessageMock,
|
|
253
|
+
updateInputMessage: updateInputMessageMock,
|
|
254
|
+
});
|
|
255
|
+
useGlobalStore.getState().updatePreference({ useCmdEnterToSend: false });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
259
|
+
const textArea = screen.getByRole('textbox');
|
|
260
|
+
|
|
261
|
+
fireEvent.keyDown(textArea, { code: 'Enter', ctrlKey: true, key: 'Enter' });
|
|
262
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
|
|
263
|
+
expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('macOS: sends message on cmd + enter when useCmdEnterToSend is true', () => {
|
|
267
|
+
vi.stubGlobal('navigator', {
|
|
268
|
+
userAgent:
|
|
269
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
270
|
+
});
|
|
271
|
+
const sendMessageMock = vi.fn();
|
|
272
|
+
act(() => {
|
|
273
|
+
useChatStore.setState({
|
|
274
|
+
chatLoadingId: '',
|
|
275
|
+
inputMessage: '123',
|
|
276
|
+
sendMessage: sendMessageMock,
|
|
277
|
+
});
|
|
278
|
+
useGlobalStore.getState().updatePreference({ useCmdEnterToSend: true });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
282
|
+
const textArea = screen.getByRole('textbox');
|
|
283
|
+
|
|
284
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
|
|
285
|
+
expect(sendMessageMock).toHaveBeenCalled();
|
|
286
|
+
vi.restoreAllMocks();
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
it('macOS: inserts a new line on cmd + enter when useCmdEnterToSend is false', () => {
|
|
290
|
+
vi.stubGlobal('navigator', {
|
|
291
|
+
userAgent:
|
|
292
|
+
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
|
293
|
+
});
|
|
294
|
+
const sendMessageMock = vi.fn();
|
|
295
|
+
const updateInputMessageMock = vi.fn();
|
|
296
|
+
act(() => {
|
|
297
|
+
useChatStore.setState({
|
|
298
|
+
chatLoadingId: '',
|
|
299
|
+
inputMessage: 'Test',
|
|
300
|
+
sendMessage: sendMessageMock,
|
|
301
|
+
updateInputMessage: updateInputMessageMock,
|
|
302
|
+
});
|
|
303
|
+
useGlobalStore.getState().updatePreference({ useCmdEnterToSend: false });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
render(<InputArea setExpand={setExpandMock} />);
|
|
307
|
+
const textArea = screen.getByRole('textbox');
|
|
308
|
+
|
|
309
|
+
fireEvent.keyDown(textArea, { code: 'Enter', key: 'Enter', metaKey: true });
|
|
310
|
+
expect(updateInputMessageMock).toHaveBeenCalledWith('Test\n');
|
|
311
|
+
expect(sendMessageMock).not.toHaveBeenCalled(); // sendMessage should not be called
|
|
312
|
+
vi.restoreAllMocks();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
});
|