@fe-free/ai 5.0.0 → 6.0.2
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 +486 -4
- package/package.json +10 -4
- package/src/ai.stories.tsx +228 -0
- package/src/chat/index.tsx +32 -0
- package/src/files/files.stories.tsx +22 -0
- package/src/files/index.tsx +56 -0
- package/src/files/style.scss +7 -0
- package/src/helper/complete.ts +90 -0
- package/src/helper/index.tsx +45 -0
- package/src/index.ts +17 -0
- package/src/m_sender/actions.tsx +65 -0
- package/src/m_sender/index.tsx +74 -0
- package/src/m_sender/m_sender.stories.tsx +231 -0
- package/src/m_sender/record.tsx +173 -0
- package/src/m_sender/types.ts +32 -0
- package/src/messages/index.tsx +5 -0
- package/src/messages/message_actions.tsx +166 -0
- package/src/messages/message_think.tsx +69 -0
- package/src/messages/messages.stories.tsx +44 -0
- package/src/messages/messages.tsx +169 -0
- package/src/sender/actions.tsx +10 -47
- package/src/sender/files.tsx +7 -13
- package/src/sender/index.tsx +78 -20
- package/src/sender/record.tsx +3 -7
- package/src/sender/sender.stories.tsx +7 -0
- package/src/sender/types.ts +4 -2
- package/src/store/index.ts +166 -0
- package/src/store/types.ts +46 -0
- package/src/stream/index.ts +41 -0
- package/src/style.scss +100 -0
- package/src/svgs/keyboard.svg +3 -0
- package/src/svgs/record.svg +3 -0
- package/src/svgs/think.svg +3 -0
- package/src/voice/index.ts +188 -0
- package/src/sender/style.scss +0 -75
- /package/src/{sender → m_sender}/helper.tsx +0 -0
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { MessageThink } from '@fe-free/ai';
|
|
2
|
+
import { CheckCircleOutlined } from '@fe-free/icons';
|
|
3
|
+
import type { Meta, StoryObj } from '@storybook/react-vite';
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof MessageThink> = {
|
|
6
|
+
title: '@fe-free/ai/MessageThink',
|
|
7
|
+
component: MessageThink,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Story = StoryObj<typeof MessageThink>;
|
|
12
|
+
|
|
13
|
+
export const Default: Story = {
|
|
14
|
+
args: {
|
|
15
|
+
title: '思考',
|
|
16
|
+
children: '这是 Think 的内容',
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const Loading: Story = {
|
|
21
|
+
args: {
|
|
22
|
+
title: '思考中...',
|
|
23
|
+
loading: true,
|
|
24
|
+
children: '这是 Think 的内容',
|
|
25
|
+
},
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const Icon: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
title: '思考',
|
|
31
|
+
children: '这是 Think 的内容',
|
|
32
|
+
icon: <CheckCircleOutlined className="text-green08" />,
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export const DeepSeek: Story = {
|
|
37
|
+
args: {
|
|
38
|
+
title: '思考中...',
|
|
39
|
+
loading: true,
|
|
40
|
+
children: '这是 Think 的内容',
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export default meta;
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { PageLayout, ScrollFixed } from '@fe-free/core';
|
|
2
|
+
import { ArrowDownOutlined } from '@fe-free/icons';
|
|
3
|
+
import { useMemoizedFn } from 'ahooks';
|
|
4
|
+
import { Button } from 'antd';
|
|
5
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
6
|
+
import { EnumChatMessageType, type ChatMessage } from '../store/types';
|
|
7
|
+
|
|
8
|
+
interface MessagesProps<UserData, AIData> {
|
|
9
|
+
refList?: React.RefObject<HTMLDivElement | null>;
|
|
10
|
+
messages?: ChatMessage<UserData, AIData>[];
|
|
11
|
+
/** 含所有 */
|
|
12
|
+
renderMessage?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
|
|
13
|
+
/** 系统消息 */
|
|
14
|
+
renderMessageOfSystem?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
|
|
15
|
+
/** 用户消息 */
|
|
16
|
+
renderMessageOfUser?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
|
|
17
|
+
/** AI消息 */
|
|
18
|
+
renderMessageOfAI?: (props: { message: ChatMessage<UserData, AIData> }) => React.ReactNode;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function useScrollToBottom({ ref }) {
|
|
22
|
+
const [showScrollBottom, setShowScrollBottom] = useState(false);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
const handleScroll = () => {
|
|
26
|
+
if (!ref.current) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const { scrollTop, clientHeight, scrollHeight } = ref.current;
|
|
31
|
+
|
|
32
|
+
const isNearBottom =
|
|
33
|
+
scrollHeight > clientHeight && scrollTop + clientHeight + 200 <= scrollHeight;
|
|
34
|
+
setShowScrollBottom(isNearBottom);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
if (ref.current) {
|
|
38
|
+
ref.current.addEventListener('scroll', handleScroll);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// first
|
|
42
|
+
handleScroll();
|
|
43
|
+
|
|
44
|
+
return () => {
|
|
45
|
+
ref.current?.removeEventListener('scroll', handleScroll);
|
|
46
|
+
};
|
|
47
|
+
}, [ref]);
|
|
48
|
+
|
|
49
|
+
return showScrollBottom;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function Messages<UserData, AIData>(props: MessagesProps<UserData, AIData>) {
|
|
53
|
+
const {
|
|
54
|
+
refList,
|
|
55
|
+
messages,
|
|
56
|
+
renderMessage,
|
|
57
|
+
renderMessageOfSystem,
|
|
58
|
+
renderMessageOfUser,
|
|
59
|
+
renderMessageOfAI,
|
|
60
|
+
} = props;
|
|
61
|
+
|
|
62
|
+
const innerRef = useRef<HTMLDivElement>(null);
|
|
63
|
+
const ref = refList || innerRef;
|
|
64
|
+
|
|
65
|
+
const lastMessage = useMemo(() => {
|
|
66
|
+
return messages?.[messages.length - 1];
|
|
67
|
+
}, [messages]);
|
|
68
|
+
|
|
69
|
+
const scrollToBottom = useMemoizedFn(() => {
|
|
70
|
+
if (!lastMessage?.uuid) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 延迟下,因为 markdown 可能没渲染出来
|
|
75
|
+
setTimeout(() => {
|
|
76
|
+
const element = document.querySelector(`[data-uuid="${lastMessage.uuid}"]`);
|
|
77
|
+
if (element) {
|
|
78
|
+
element.scrollIntoView({ behavior: 'smooth', block: 'end' });
|
|
79
|
+
}
|
|
80
|
+
}, 100);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// 首次和更新时滚动到最新消息
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (lastMessage?.uuid) {
|
|
86
|
+
scrollToBottom();
|
|
87
|
+
}
|
|
88
|
+
}, [scrollToBottom, lastMessage?.uuid]);
|
|
89
|
+
|
|
90
|
+
// 数据更新是,如果 dom 处于可视区域,则滚动
|
|
91
|
+
useEffect(() => {
|
|
92
|
+
if (!lastMessage?.updatedAt || !lastMessage?.uuid || !ref.current) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 延迟下,因为 markdown 可能没渲染出来
|
|
97
|
+
setTimeout(() => {
|
|
98
|
+
const element = document.querySelector(`[data-uuid="${lastMessage.uuid}"]`);
|
|
99
|
+
if (!element) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const { top: listTop, bottom: listBottom } = ref.current!.getBoundingClientRect();
|
|
104
|
+
const { top, bottom } = element.getBoundingClientRect();
|
|
105
|
+
|
|
106
|
+
// 如果最后一个元素可见,则滚动到底部
|
|
107
|
+
const isVisible = top < listBottom && bottom > listTop;
|
|
108
|
+
if (isVisible) {
|
|
109
|
+
scrollToBottom();
|
|
110
|
+
}
|
|
111
|
+
}, 100);
|
|
112
|
+
}, [lastMessage?.updatedAt, lastMessage?.uuid, ref, scrollToBottom]);
|
|
113
|
+
|
|
114
|
+
const showScrollBottom = useScrollToBottom({ ref });
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<PageLayout>
|
|
118
|
+
<ScrollFixed
|
|
119
|
+
refScroll={ref}
|
|
120
|
+
className="fea-messages-scroll relative flex h-full flex-col overflow-x-hidden overflow-y-auto"
|
|
121
|
+
style={{
|
|
122
|
+
transform: `translateZ(0)`,
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{messages?.map((message) => {
|
|
126
|
+
return (
|
|
127
|
+
<div key={message.uuid} data-uuid={message.uuid} className="flex flex-col">
|
|
128
|
+
{renderMessage ? (
|
|
129
|
+
renderMessage?.({ message })
|
|
130
|
+
) : (
|
|
131
|
+
<>
|
|
132
|
+
{message.type === EnumChatMessageType.SYSTEM && message.system && (
|
|
133
|
+
<div className="flex justify-center">
|
|
134
|
+
{renderMessageOfSystem?.({ message })}
|
|
135
|
+
</div>
|
|
136
|
+
)}
|
|
137
|
+
{message.type !== EnumChatMessageType.SYSTEM && message.user && (
|
|
138
|
+
<div className="flex justify-end">{renderMessageOfUser?.({ message })}</div>
|
|
139
|
+
)}
|
|
140
|
+
{message.type !== EnumChatMessageType.SYSTEM && message.ai && (
|
|
141
|
+
<div className="flex justify-start">{renderMessageOfAI?.({ message })}</div>
|
|
142
|
+
)}
|
|
143
|
+
</>
|
|
144
|
+
)}
|
|
145
|
+
</div>
|
|
146
|
+
);
|
|
147
|
+
})}
|
|
148
|
+
<div className="pointer-events-none sticky bottom-2 mx-auto flex justify-center">
|
|
149
|
+
<Button
|
|
150
|
+
shape="circle"
|
|
151
|
+
icon={<ArrowDownOutlined />}
|
|
152
|
+
onClick={() => {
|
|
153
|
+
scrollToBottom();
|
|
154
|
+
}}
|
|
155
|
+
className="pointer-events-auto! bg-white! text-2xl! shadow-[0px_1px_12px_0px_#2921391F]!"
|
|
156
|
+
style={{
|
|
157
|
+
transform: `translateY(${showScrollBottom ? 0 : 30}px) scale(${showScrollBottom ? 1 : 0})`,
|
|
158
|
+
width: 44,
|
|
159
|
+
height: 44,
|
|
160
|
+
}}
|
|
161
|
+
/>
|
|
162
|
+
</div>
|
|
163
|
+
</ScrollFixed>
|
|
164
|
+
</PageLayout>
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export { Messages };
|
|
169
|
+
export type { MessagesProps };
|
package/src/sender/actions.tsx
CHANGED
|
@@ -1,74 +1,37 @@
|
|
|
1
1
|
import Icons from '@fe-free/icons';
|
|
2
2
|
import type { UploadFile } from 'antd';
|
|
3
|
-
import { Button
|
|
4
|
-
import {
|
|
3
|
+
import { Button } from 'antd';
|
|
4
|
+
import type { RefObject } from 'react';
|
|
5
5
|
import SendIcon from '../svgs/send.svg?react';
|
|
6
6
|
import { FileAction } from './files';
|
|
7
7
|
import { RecordAction } from './record';
|
|
8
|
-
import './style.scss';
|
|
9
8
|
import type { SenderProps } from './types';
|
|
10
9
|
|
|
11
10
|
function Actions(
|
|
12
11
|
props: SenderProps & {
|
|
13
|
-
refText: RefObject<HTMLTextAreaElement>;
|
|
14
|
-
refUpload: RefObject<HTMLDivElement>;
|
|
12
|
+
refText: RefObject<HTMLTextAreaElement | null>;
|
|
13
|
+
refUpload: RefObject<HTMLDivElement | null>;
|
|
15
14
|
isUploading: boolean;
|
|
16
15
|
fileList: UploadFile[];
|
|
17
16
|
setFileList: (fileList: UploadFile[]) => void;
|
|
18
17
|
fileUrls: string[];
|
|
19
18
|
setFileUrls: (fileUrls: string[]) => void;
|
|
19
|
+
onSubmit: () => Promise<void>;
|
|
20
20
|
},
|
|
21
21
|
) {
|
|
22
22
|
const {
|
|
23
|
-
refText,
|
|
24
|
-
loading,
|
|
25
|
-
onSubmit,
|
|
26
|
-
value,
|
|
27
|
-
onChange,
|
|
28
23
|
refUpload,
|
|
29
24
|
isUploading,
|
|
30
|
-
setFileList,
|
|
31
25
|
fileUrls,
|
|
32
26
|
setFileUrls,
|
|
33
27
|
allowUpload,
|
|
34
28
|
allowSpeech,
|
|
29
|
+
loading,
|
|
30
|
+
onSubmit,
|
|
35
31
|
} = props;
|
|
36
32
|
|
|
37
33
|
const isLoading = loading || isUploading;
|
|
38
34
|
|
|
39
|
-
const handleSubmit = useCallback(async () => {
|
|
40
|
-
if (isLoading || allowSpeech?.recording) {
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const newValue = {
|
|
45
|
-
...value,
|
|
46
|
-
text: value?.text?.trim(),
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// 有内容才提交
|
|
50
|
-
if (newValue.text || (newValue.files && newValue.files.length > 0)) {
|
|
51
|
-
await Promise.resolve(onSubmit?.(newValue));
|
|
52
|
-
|
|
53
|
-
// reset
|
|
54
|
-
setFileList([]);
|
|
55
|
-
setFileUrls([]);
|
|
56
|
-
onChange?.({});
|
|
57
|
-
|
|
58
|
-
// focus
|
|
59
|
-
refText.current?.focus();
|
|
60
|
-
}
|
|
61
|
-
}, [
|
|
62
|
-
isLoading,
|
|
63
|
-
allowSpeech?.recording,
|
|
64
|
-
value,
|
|
65
|
-
onSubmit,
|
|
66
|
-
setFileList,
|
|
67
|
-
setFileUrls,
|
|
68
|
-
onChange,
|
|
69
|
-
refText,
|
|
70
|
-
]);
|
|
71
|
-
|
|
72
35
|
return (
|
|
73
36
|
<div className="flex items-center gap-2">
|
|
74
37
|
<div className="flex flex-1 gap-1">
|
|
@@ -81,16 +44,16 @@ function Actions(
|
|
|
81
44
|
/>
|
|
82
45
|
)}
|
|
83
46
|
</div>
|
|
84
|
-
<Divider type="vertical" />
|
|
47
|
+
{/* <Divider type="vertical" /> */}
|
|
85
48
|
<div className="flex items-center gap-2">
|
|
86
49
|
{allowSpeech && <RecordAction {...props} />}
|
|
87
50
|
<Button
|
|
88
51
|
type="primary"
|
|
89
52
|
shape="circle"
|
|
90
|
-
icon={<Icons component={SendIcon} className="!text-lg" />}
|
|
53
|
+
icon={<Icons component={SendIcon} className="h-[28px]! text-lg!" />}
|
|
91
54
|
loading={isLoading}
|
|
92
55
|
// disabled={loading}
|
|
93
|
-
onClick={
|
|
56
|
+
onClick={onSubmit}
|
|
94
57
|
/>
|
|
95
58
|
</div>
|
|
96
59
|
</div>
|
package/src/sender/files.tsx
CHANGED
|
@@ -5,12 +5,13 @@ import { App, Button, Dropdown, Input, Modal, Upload } from 'antd';
|
|
|
5
5
|
import type { RefObject } from 'react';
|
|
6
6
|
import { useState } from 'react';
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
|
+
import { FileView } from '../files';
|
|
8
9
|
import FilesIcon from '../svgs/files.svg?react';
|
|
9
10
|
import type { SenderProps } from './types';
|
|
10
11
|
|
|
11
12
|
function FileAction(
|
|
12
13
|
props: SenderProps & {
|
|
13
|
-
refUpload: RefObject<HTMLDivElement>;
|
|
14
|
+
refUpload: RefObject<HTMLDivElement | null>;
|
|
14
15
|
fileUrls: string[];
|
|
15
16
|
setFileUrls: (fileUrls: string[]) => void;
|
|
16
17
|
},
|
|
@@ -88,7 +89,7 @@ function FileAction(
|
|
|
88
89
|
|
|
89
90
|
function FileUpload(
|
|
90
91
|
props: SenderProps & {
|
|
91
|
-
refUpload: RefObject<HTMLDivElement>;
|
|
92
|
+
refUpload: RefObject<HTMLDivElement | null>;
|
|
92
93
|
fileList: UploadFile[];
|
|
93
94
|
setFileList: (fileList: UploadFile[]) => void;
|
|
94
95
|
uploadMaxCount?: number;
|
|
@@ -136,16 +137,9 @@ function UploadFileItem({ file, onDelete }: { file: UploadFile; onDelete: () =>
|
|
|
136
137
|
return (
|
|
137
138
|
<div className="group relative">
|
|
138
139
|
{isImage ? (
|
|
139
|
-
<
|
|
140
|
-
src={file.originFileObj && URL.createObjectURL(file.originFileObj)}
|
|
141
|
-
className="h-[53px] w-[53px] rounded-lg border border-01 bg-01 object-cover"
|
|
142
|
-
/>
|
|
140
|
+
<FileView url={URL.createObjectURL(file.originFileObj!)} isImage={isImage} />
|
|
143
141
|
) : (
|
|
144
|
-
<
|
|
145
|
-
<div className="min-w-0">
|
|
146
|
-
<FileCard name={file.name} size={file.size} />
|
|
147
|
-
</div>
|
|
148
|
-
</div>
|
|
142
|
+
<FileView url={file.name} />
|
|
149
143
|
)}
|
|
150
144
|
{!isDone && (
|
|
151
145
|
<div className="absolute inset-0 flex items-center justify-center bg-01/80">
|
|
@@ -163,7 +157,7 @@ function UploadFileItem({ file, onDelete }: { file: UploadFile; onDelete: () =>
|
|
|
163
157
|
function UrlFileItem({ url, onDelete }: { url: string; onDelete: () => void }) {
|
|
164
158
|
return (
|
|
165
159
|
<div className="group relative">
|
|
166
|
-
<div className="flex h-[
|
|
160
|
+
<div className="flex h-[60px] w-[250px] items-center rounded bg-01 px-2">
|
|
167
161
|
<div className="line-clamp-2">{url}</div>
|
|
168
162
|
</div>
|
|
169
163
|
<CloseOutlined
|
|
@@ -186,7 +180,7 @@ function Files(
|
|
|
186
180
|
|
|
187
181
|
return (
|
|
188
182
|
<>
|
|
189
|
-
{fileList && fileList.length > 0 && (
|
|
183
|
+
{((fileList && fileList.length > 0) || (fileUrls && fileUrls.length > 0)) && (
|
|
190
184
|
<div className="scrollbar-hide mb-2 flex gap-2 overflow-x-auto">
|
|
191
185
|
{fileList.map((file) => (
|
|
192
186
|
<UploadFileItem
|
package/src/sender/index.tsx
CHANGED
|
@@ -7,11 +7,31 @@ import { useCallback, useMemo, useRef, useState } from 'react';
|
|
|
7
7
|
import { useTranslation } from 'react-i18next';
|
|
8
8
|
import { Actions } from './actions';
|
|
9
9
|
import { FileUpload, Files } from './files';
|
|
10
|
-
import './style.scss';
|
|
11
10
|
import type { SenderProps, SenderRef } from './types';
|
|
12
11
|
|
|
13
|
-
function Text(
|
|
14
|
-
|
|
12
|
+
function Text(
|
|
13
|
+
props: SenderProps & {
|
|
14
|
+
refText: RefObject<HTMLTextAreaElement | null>;
|
|
15
|
+
onSubmit?: () => void;
|
|
16
|
+
},
|
|
17
|
+
) {
|
|
18
|
+
const { value, onChange, placeholder, refText, onSubmit } = props;
|
|
19
|
+
|
|
20
|
+
const handleKeyDown = useCallback(
|
|
21
|
+
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
22
|
+
// Shift + Enter: 换行(默认行为)
|
|
23
|
+
if (e.key === 'Enter' && e.shiftKey) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Enter: 提交
|
|
28
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
onSubmit?.();
|
|
31
|
+
}
|
|
32
|
+
},
|
|
33
|
+
[onSubmit],
|
|
34
|
+
);
|
|
15
35
|
|
|
16
36
|
return (
|
|
17
37
|
<Input.TextArea
|
|
@@ -20,6 +40,7 @@ function Text(props: SenderProps & { refText: RefObject<HTMLTextAreaElement> })
|
|
|
20
40
|
onChange={(e) => {
|
|
21
41
|
onChange?.({ ...value, text: e.target.value });
|
|
22
42
|
}}
|
|
43
|
+
onKeyDown={handleKeyDown}
|
|
23
44
|
placeholder={placeholder}
|
|
24
45
|
autoSize={{ minRows: 2, maxRows: 8 }}
|
|
25
46
|
className="mb-1 px-1 py-0"
|
|
@@ -33,21 +54,22 @@ function Sender(originProps: SenderProps) {
|
|
|
33
54
|
const props = useMemo(() => {
|
|
34
55
|
return {
|
|
35
56
|
placeholder:
|
|
36
|
-
originProps.placeholder ??
|
|
57
|
+
originProps.placeholder ??
|
|
58
|
+
t('@fe-free/ai.sender.describeYourQuestion', '描述你的问题, shift + enter 换行'),
|
|
37
59
|
...originProps,
|
|
38
60
|
};
|
|
39
61
|
}, [originProps, t]);
|
|
40
62
|
|
|
41
63
|
const refText = useRef<HTMLTextAreaElement>(null);
|
|
42
64
|
|
|
43
|
-
const { value, onChange, allowUpload } = props;
|
|
65
|
+
const { value, onChange, allowUpload, onSubmit, loading, allowSpeech } = props;
|
|
44
66
|
const { filesMaxCount } = allowUpload || {};
|
|
45
67
|
|
|
46
68
|
const refContainer = useRef<HTMLDivElement>(null);
|
|
47
69
|
const refUpload = useRef<HTMLDivElement>(null);
|
|
48
70
|
const [dragHover, setDragHover] = useState(false);
|
|
49
71
|
|
|
50
|
-
useDrop(refContainer, {
|
|
72
|
+
useDrop(allowUpload ? refContainer : null, {
|
|
51
73
|
onDragEnter: () => {
|
|
52
74
|
setDragHover(true);
|
|
53
75
|
},
|
|
@@ -95,16 +117,54 @@ function Sender(originProps: SenderProps) {
|
|
|
95
117
|
return fileList.some((file) => !file.response?.data?.url);
|
|
96
118
|
}, [fileList]);
|
|
97
119
|
|
|
120
|
+
const handleSubmit = useCallback(async () => {
|
|
121
|
+
const isLoading = loading || isUploading;
|
|
122
|
+
|
|
123
|
+
if (isLoading || allowSpeech?.recording) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const newValue = {
|
|
128
|
+
...value,
|
|
129
|
+
text: value?.text?.trim(),
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
// 有内容才提交
|
|
133
|
+
if (newValue.text || (newValue.files && newValue.files.length > 0)) {
|
|
134
|
+
await Promise.resolve(onSubmit?.(newValue));
|
|
135
|
+
|
|
136
|
+
// reset
|
|
137
|
+
setFileList([]);
|
|
138
|
+
setFileUrls([]);
|
|
139
|
+
onChange?.({});
|
|
140
|
+
|
|
141
|
+
// focus
|
|
142
|
+
refText.current?.focus();
|
|
143
|
+
}
|
|
144
|
+
}, [
|
|
145
|
+
loading,
|
|
146
|
+
isUploading,
|
|
147
|
+
allowSpeech?.recording,
|
|
148
|
+
value,
|
|
149
|
+
onSubmit,
|
|
150
|
+
setFileList,
|
|
151
|
+
setFileUrls,
|
|
152
|
+
onChange,
|
|
153
|
+
]);
|
|
154
|
+
|
|
98
155
|
return (
|
|
99
156
|
<div className="fea-sender-wrap">
|
|
100
157
|
<div
|
|
101
158
|
ref={refContainer}
|
|
102
159
|
className={classNames(
|
|
103
|
-
'fea-sender relative flex flex-col rounded-
|
|
160
|
+
'fea-sender relative flex flex-col rounded-xl border border-01 bg-white p-2',
|
|
104
161
|
{
|
|
105
162
|
'fea-sender-drag-hover': dragHover,
|
|
106
163
|
},
|
|
107
164
|
)}
|
|
165
|
+
style={{
|
|
166
|
+
boxShadow: '0px 2px 12px 0px #00000014',
|
|
167
|
+
}}
|
|
108
168
|
>
|
|
109
169
|
<Files
|
|
110
170
|
{...props}
|
|
@@ -114,7 +174,7 @@ function Sender(originProps: SenderProps) {
|
|
|
114
174
|
setFileUrls={setFileUrls}
|
|
115
175
|
/>
|
|
116
176
|
<div className="flex">
|
|
117
|
-
<Text {...props} refText={refText} />
|
|
177
|
+
<Text {...props} refText={refText} onSubmit={handleSubmit} />
|
|
118
178
|
</div>
|
|
119
179
|
<Actions
|
|
120
180
|
{...props}
|
|
@@ -125,21 +185,19 @@ function Sender(originProps: SenderProps) {
|
|
|
125
185
|
setFileList={setFileList}
|
|
126
186
|
fileUrls={fileUrls}
|
|
127
187
|
setFileUrls={setFileUrls}
|
|
188
|
+
onSubmit={handleSubmit}
|
|
128
189
|
/>
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
<div className="mt-1 text-center text-xs text-03">
|
|
138
|
-
{t(
|
|
139
|
-
'@fe-free/ai.sender.aiGeneratedDisclaimer',
|
|
140
|
-
'内容由 AI 生成,无法确保信息的真实准确,仅供参考',
|
|
190
|
+
{allowUpload && (
|
|
191
|
+
<FileUpload
|
|
192
|
+
{...props}
|
|
193
|
+
refUpload={refUpload}
|
|
194
|
+
fileList={fileList}
|
|
195
|
+
setFileList={setFileList}
|
|
196
|
+
uploadMaxCount={filesMaxCount ? filesMaxCount - fileUrls.length : undefined}
|
|
197
|
+
/>
|
|
141
198
|
)}
|
|
142
199
|
</div>
|
|
200
|
+
{props.statement && <div className="mt-1 text-center text-xs text-03">{props.statement}</div>}
|
|
143
201
|
</div>
|
|
144
202
|
);
|
|
145
203
|
}
|
package/src/sender/record.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AudioOutlined } from '@fe-free/icons';
|
|
2
2
|
import { Button } from 'antd';
|
|
3
|
+
import { RecordLoading } from '../helper';
|
|
3
4
|
import type { SenderProps } from './types';
|
|
4
5
|
|
|
5
6
|
function RecordAction(props: SenderProps) {
|
|
@@ -9,12 +10,7 @@ function RecordAction(props: SenderProps) {
|
|
|
9
10
|
if (recording) {
|
|
10
11
|
return (
|
|
11
12
|
<Button type="text" shape="circle" onClick={() => onRecordingChange?.(false)}>
|
|
12
|
-
<
|
|
13
|
-
<div className="fea-sender-spinner-line fea-sender-spinner-line1" />
|
|
14
|
-
<div className="fea-sender-spinner-line fea-sender-spinner-line2" />
|
|
15
|
-
<div className="fea-sender-spinner-line fea-sender-spinner-line3" />
|
|
16
|
-
<div className="fea-sender-spinner-line fea-sender-spinner-line4" />
|
|
17
|
-
</div>
|
|
13
|
+
<RecordLoading count={4} color="primary" />
|
|
18
14
|
</Button>
|
|
19
15
|
);
|
|
20
16
|
}
|
|
@@ -23,7 +19,7 @@ function RecordAction(props: SenderProps) {
|
|
|
23
19
|
<Button
|
|
24
20
|
type="text"
|
|
25
21
|
shape="circle"
|
|
26
|
-
icon={<AudioOutlined className="
|
|
22
|
+
icon={<AudioOutlined className="text-lg!" />}
|
|
27
23
|
onClick={() => onRecordingChange?.(true)}
|
|
28
24
|
/>
|
|
29
25
|
);
|
package/src/sender/types.ts
CHANGED
|
@@ -9,10 +9,10 @@ interface SenderValue {
|
|
|
9
9
|
|
|
10
10
|
interface SenderProps {
|
|
11
11
|
value?: SenderValue;
|
|
12
|
-
onChange
|
|
12
|
+
onChange?: (value?: SenderValue) => void;
|
|
13
13
|
|
|
14
14
|
loading?: boolean;
|
|
15
|
-
onSubmit
|
|
15
|
+
onSubmit?: (value?: SenderValue) => void | Promise<void>;
|
|
16
16
|
|
|
17
17
|
placeholder?: string;
|
|
18
18
|
|
|
@@ -30,6 +30,8 @@ interface SenderProps {
|
|
|
30
30
|
/** 录音状态变化时回调 */
|
|
31
31
|
onRecordingChange?: (recording: boolean) => void;
|
|
32
32
|
};
|
|
33
|
+
|
|
34
|
+
statement?: string;
|
|
33
35
|
}
|
|
34
36
|
|
|
35
37
|
export type { SenderProps, SenderRef, SenderValue };
|