@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.
@@ -0,0 +1,231 @@
1
+ import { getRecordAudioOfBlob, getRecordAudioOfPCM, MSender } from '@fe-free/ai';
2
+ import type { Meta, StoryObj } from '@storybook/react-vite';
3
+ import { App } from 'antd';
4
+ import { useCallback, useMemo, useState } from 'react';
5
+ import type { MSenderProps, MSenderValue } from './types';
6
+
7
+ const meta: Meta<typeof MSender> = {
8
+ title: '@fe-free/ai/MSender',
9
+ component: MSender,
10
+ tags: ['autodocs'],
11
+ };
12
+
13
+ type Story = StoryObj<typeof MSender>;
14
+
15
+ function Component(props: MSenderProps) {
16
+ const [v, setV] = useState<MSenderValue | undefined>(undefined);
17
+
18
+ return (
19
+ <MSender
20
+ value={v}
21
+ onChange={(v) => {
22
+ console.log('newValue', v);
23
+ setV(v);
24
+ }}
25
+ onSubmit={(value) => {
26
+ console.log('onSubmit', value);
27
+ }}
28
+ {...props}
29
+ />
30
+ );
31
+ }
32
+
33
+ export const Default: Story = {
34
+ render: (props) => <Component {...props} />,
35
+ args: {
36
+ onSubmit: (value) => {
37
+ console.log(value);
38
+ },
39
+ },
40
+ };
41
+
42
+ export const AutoFocus: Story = {
43
+ render: (props) => <Component {...props} />,
44
+ args: {
45
+ autoFocus: true,
46
+ onSubmit: (value) => {
47
+ console.log(value);
48
+ },
49
+ },
50
+ };
51
+
52
+ export const Loading: Story = {
53
+ args: {
54
+ loading: true,
55
+ onSubmit: (value) => {
56
+ console.log(value);
57
+ },
58
+ },
59
+ render: (props) => <Component {...props} />,
60
+ };
61
+
62
+ export const AllowSpeech: Story = {
63
+ render: (props) => {
64
+ const handleSubmit = (value: MSenderValue) => {
65
+ console.log('handleSubmit', value);
66
+ };
67
+
68
+ const handleRecordStart = useCallback(async () => {
69
+ // fake
70
+ }, []);
71
+
72
+ const handleRecordEnd = useCallback(
73
+ async (isSend) => {
74
+ console.log('handleRecordEnd isSend', isSend);
75
+
76
+ if (isSend) {
77
+ handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
78
+ }
79
+ },
80
+ [props.value],
81
+ );
82
+
83
+ return (
84
+ <div className="flex flex-col gap-10">
85
+ <Component
86
+ {...props}
87
+ allowSpeech={{
88
+ onRecordStart: handleRecordStart,
89
+ onRecordEnd: handleRecordEnd,
90
+ }}
91
+ />
92
+
93
+ <Component
94
+ value={{ text: 'test' } as MSenderValue}
95
+ {...props}
96
+ allowSpeech={{
97
+ onRecordStart: handleRecordStart,
98
+ onRecordEnd: handleRecordEnd,
99
+ }}
100
+ />
101
+
102
+ <div />
103
+
104
+ <Component
105
+ {...props}
106
+ defaultType="record"
107
+ allowSpeech={{
108
+ onRecordStart: handleRecordStart,
109
+ onRecordEnd: handleRecordEnd,
110
+ }}
111
+ />
112
+ </div>
113
+ );
114
+ },
115
+ };
116
+
117
+ export const AllowSpeechPCM: Story = {
118
+ render: (props) => {
119
+ const { message } = App.useApp();
120
+
121
+ const { start: startRecord, stop: stopRecord } = useMemo(() => {
122
+ return getRecordAudioOfPCM();
123
+ }, []);
124
+
125
+ const handleSubmit = (value: MSenderValue) => {
126
+ console.log('handleSubmit', value);
127
+ };
128
+
129
+ const handleRecordStart = useCallback(async () => {
130
+ // 假设这是录音的文本
131
+ try {
132
+ await startRecord({
133
+ onAudio: (data) => {
134
+ console.log('onAudio', data);
135
+ },
136
+ onError: (err) => {
137
+ message.error(err.message);
138
+ },
139
+ });
140
+ } catch (err) {
141
+ console.error(err);
142
+ }
143
+
144
+ return;
145
+ }, []);
146
+
147
+ const handleRecordEnd = useCallback(
148
+ async (isSend) => {
149
+ console.log('handleRecordEnd isSend', isSend);
150
+
151
+ const voiceData = await stopRecord();
152
+ console.log('voiceData', voiceData);
153
+
154
+ if (isSend) {
155
+ handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
156
+ }
157
+ },
158
+ [props.value, stopRecord],
159
+ );
160
+
161
+ return (
162
+ <Component
163
+ {...props}
164
+ defaultType="record"
165
+ allowSpeech={{
166
+ onRecordStart: handleRecordStart,
167
+ onRecordEnd: handleRecordEnd,
168
+ }}
169
+ />
170
+ );
171
+ },
172
+ };
173
+
174
+ export const AllowSpeechBlob: Story = {
175
+ render: (props) => {
176
+ const { message } = App.useApp();
177
+
178
+ const { start: startRecord, stop: stopRecord } = useMemo(() => {
179
+ return getRecordAudioOfBlob();
180
+ }, []);
181
+
182
+ const handleSubmit = (value: MSenderValue) => {
183
+ console.log('handleSubmit', value);
184
+ };
185
+
186
+ const handleRecordStart = useCallback(async () => {
187
+ // 假设这是录音的文本
188
+ try {
189
+ await startRecord({
190
+ onAudio: (data) => {
191
+ console.log('onAudio', data);
192
+ },
193
+ onError: (err) => {
194
+ message.error(err.message);
195
+ },
196
+ });
197
+ } catch (err) {
198
+ console.error(err);
199
+ }
200
+
201
+ return;
202
+ }, []);
203
+
204
+ const handleRecordEnd = useCallback(
205
+ async (isSend) => {
206
+ console.log('handleRecordEnd isSend', isSend);
207
+
208
+ const voiceData = await stopRecord();
209
+ console.log('voiceData', voiceData);
210
+
211
+ if (isSend) {
212
+ handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
213
+ }
214
+ },
215
+ [props.value, stopRecord],
216
+ );
217
+
218
+ return (
219
+ <Component
220
+ {...props}
221
+ defaultType="record"
222
+ allowSpeech={{
223
+ onRecordStart: handleRecordStart,
224
+ onRecordEnd: handleRecordEnd,
225
+ }}
226
+ />
227
+ );
228
+ },
229
+ };
230
+
231
+ export default meta;
@@ -0,0 +1,173 @@
1
+ import Icons from '@fe-free/icons';
2
+ import { Button } from 'antd';
3
+ import classNames from 'classnames';
4
+ import type { RefObject } from 'react';
5
+ import { useCallback, useEffect, useRef, useState } from 'react';
6
+ import { RecordLoading } from '../helper';
7
+ import IconKeyboard from '../svgs/keyboard.svg?react';
8
+ import type { MSenderProps } from './types';
9
+
10
+ function RecordAction(
11
+ props: MSenderProps & { setType; refText: RefObject<HTMLTextAreaElement | null> },
12
+ ) {
13
+ const { allowSpeech, setType, refText, loading } = props;
14
+
15
+ const containerRef = useRef<HTMLDivElement>(null);
16
+ const touchStartYRef = useRef<number>(0);
17
+
18
+ const [isRecording, setIsRecording] = useState(false);
19
+
20
+ const isCancelledRef = useRef(false);
21
+ const [isCancel, setIsCancel] = useState(false);
22
+
23
+ const handleTouchStart = useCallback(
24
+ async (e: TouchEvent) => {
25
+ // 阻止默认行为,避免触发文本选择、上下文菜单等
26
+ e.preventDefault();
27
+
28
+ if (loading) {
29
+ return;
30
+ }
31
+
32
+ await allowSpeech?.onRecordStart?.();
33
+
34
+ const touch = e.touches[0];
35
+ touchStartYRef.current = touch.clientY;
36
+
37
+ isCancelledRef.current = false;
38
+ setIsCancel(false);
39
+
40
+ setIsRecording(true);
41
+ },
42
+ [allowSpeech, loading],
43
+ );
44
+
45
+ const handleTouchMove = useCallback(
46
+ (e: TouchEvent) => {
47
+ // 阻止默认行为,避免页面滚动
48
+ e.preventDefault();
49
+
50
+ // 没有录音,不继续
51
+ if (!isRecording) {
52
+ return;
53
+ }
54
+
55
+ const touch = e.touches[0];
56
+ const deltaY = touchStartYRef.current - touch.clientY; // 向上移动为正
57
+
58
+ // 如果上移超过 50px,则判定为取消
59
+ if (deltaY > 50) {
60
+ if (!isCancelledRef.current) {
61
+ isCancelledRef.current = true;
62
+ setIsCancel(true);
63
+ }
64
+ } else {
65
+ if (isCancelledRef.current) {
66
+ isCancelledRef.current = false;
67
+ setIsCancel(false);
68
+ }
69
+ }
70
+ },
71
+ [isRecording],
72
+ );
73
+
74
+ const handleTouchEnd = useCallback(
75
+ async (e: TouchEvent) => {
76
+ e.preventDefault();
77
+
78
+ // 没有录音,不继续
79
+ if (!isRecording) {
80
+ return;
81
+ }
82
+
83
+ try {
84
+ await allowSpeech?.onRecordEnd?.(!isCancelledRef.current);
85
+ } catch (err) {
86
+ // nothing
87
+ console.error(err);
88
+ }
89
+
90
+ // 重置状态
91
+ setIsRecording(false);
92
+ setIsCancel(false);
93
+ isCancelledRef.current = false;
94
+ touchStartYRef.current = 0;
95
+ },
96
+ [allowSpeech, isRecording],
97
+ );
98
+
99
+ const handleTouchCancel = useCallback(
100
+ async (e: TouchEvent) => {
101
+ // touchcancel 在触摸被中断时触发(如系统手势、来电等)
102
+ e.preventDefault();
103
+
104
+ await allowSpeech?.onRecordEnd?.(false);
105
+
106
+ // 重置状态
107
+ setIsRecording(false);
108
+ setIsCancel(false);
109
+ isCancelledRef.current = false;
110
+ touchStartYRef.current = 0;
111
+ },
112
+ [allowSpeech],
113
+ );
114
+
115
+ // 使用原生事件监听器,设置 passive: false 以支持 preventDefault
116
+ useEffect(() => {
117
+ const container = containerRef.current;
118
+ if (!container) return;
119
+
120
+ // 使用 { passive: false } 选项,允许 preventDefault
121
+ container.addEventListener('touchstart', handleTouchStart, { passive: false });
122
+ container.addEventListener('touchmove', handleTouchMove, { passive: false });
123
+ container.addEventListener('touchend', handleTouchEnd, { passive: false });
124
+ container.addEventListener('touchcancel', handleTouchCancel, { passive: false });
125
+
126
+ return () => {
127
+ container.removeEventListener('touchstart', handleTouchStart);
128
+ container.removeEventListener('touchmove', handleTouchMove);
129
+ container.removeEventListener('touchend', handleTouchEnd);
130
+ container.removeEventListener('touchcancel', handleTouchCancel);
131
+ };
132
+ }, [handleTouchStart, handleTouchMove, handleTouchEnd, handleTouchCancel]);
133
+
134
+ return (
135
+ <div
136
+ className={classNames(
137
+ 'fea-m-sender-record absolute inset-0 flex items-center justify-center rounded-xl',
138
+ {
139
+ 'bg-red-500': isCancel,
140
+ 'bg-primary': !isCancel,
141
+ },
142
+ )}
143
+ >
144
+ {isRecording ? (
145
+ <>
146
+ <RecordLoading count={30} gap={4} />
147
+ <div className="text-03 absolute top-0 right-0 left-0 -mt-[50px] flex h-[50px] items-end justify-center bg-white pb-2">
148
+ {isCancel && <div className="text-red08">松开取消</div>}
149
+ {!isCancel && <div className="text-03">松开发送&nbsp;&nbsp;上移取消</div>}
150
+ </div>
151
+ </>
152
+ ) : (
153
+ <div className="text-base text-white">按住说话</div>
154
+ )}
155
+ <div className="absolute inset-0" ref={containerRef} />
156
+ {!isRecording && (
157
+ <Button
158
+ type="text"
159
+ shape="circle"
160
+ icon={<Icons component={IconKeyboard} className="h-[28px]! text-xl! text-white!" />}
161
+ onClick={() => {
162
+ setType('input');
163
+
164
+ refText.current?.focus();
165
+ }}
166
+ className="absolute! right-4!"
167
+ />
168
+ )}
169
+ </div>
170
+ );
171
+ }
172
+
173
+ export { RecordAction };
@@ -0,0 +1,32 @@
1
+ interface MSenderRef {
2
+ focus: () => void;
3
+ }
4
+
5
+ interface MSenderValue {
6
+ text?: string;
7
+ files?: string[];
8
+ }
9
+
10
+ interface MSenderProps {
11
+ value?: MSenderValue;
12
+ onChange?: (value?: MSenderValue) => void;
13
+
14
+ loading?: boolean;
15
+ onSubmit?: (value?: MSenderValue) => void | Promise<void>;
16
+
17
+ autoFocus?: boolean;
18
+ placeholder?: string;
19
+
20
+ /** 是否允许语音输入 */
21
+ allowSpeech?: {
22
+ /** 录音开始时回调,如果没权限,则 reject */
23
+ onRecordStart?: () => Promise<void>;
24
+ /** 录音结束时回调, isSend 为 true 则发送,否则取消 */
25
+ onRecordEnd?: (isSend: boolean) => Promise<void>;
26
+ };
27
+
28
+ defaultType?: 'input' | 'record';
29
+ statement?: string;
30
+ }
31
+
32
+ export type { MSenderProps, MSenderRef, MSenderValue };
@@ -0,0 +1,5 @@
1
+ export { MessageActions } from './message_actions';
2
+ export { MessageThink, MessageThinkOfDeepSeek } from './message_think';
3
+ export type { MessageThinkProps } from './message_think';
4
+ export { Messages } from './messages';
5
+ export type { MessagesProps } from './messages';
@@ -0,0 +1,166 @@
1
+ import { Copy } from '@fe-free/core';
2
+ import {
3
+ CopyFilled,
4
+ CopyOutlined,
5
+ DislikeFilled,
6
+ DislikeOutlined,
7
+ LikeFilled,
8
+ LikeOutlined,
9
+ } from '@fe-free/icons';
10
+ import { App, Button, Tooltip } from 'antd';
11
+ import classNames from 'classnames';
12
+ import { useCallback, useEffect, useState } from 'react';
13
+
14
+ function MessageActionOfCopy({
15
+ value,
16
+ onCopied,
17
+ className,
18
+ }: {
19
+ value: string;
20
+ onCopied?: () => void;
21
+ className?: string;
22
+ }) {
23
+ const [active, setActive] = useState(false);
24
+ const { message } = App.useApp();
25
+
26
+ const handleCopied = useCallback(async () => {
27
+ setActive(true);
28
+ onCopied?.();
29
+ message.success('复制成功');
30
+ }, [onCopied, message]);
31
+
32
+ return (
33
+ <Tooltip title="复制">
34
+ <Copy
35
+ value={value}
36
+ className={classNames('cursor-pointer text-03', className)}
37
+ onCopied={handleCopied}
38
+ >
39
+ <Button
40
+ type="text"
41
+ size="small"
42
+ className="text-03"
43
+ icon={active ? <CopyFilled /> : <CopyOutlined />}
44
+ />
45
+ </Copy>
46
+ </Tooltip>
47
+ );
48
+ }
49
+
50
+ function MessageActionOfLike({
51
+ active: propsActive,
52
+ onClick,
53
+ className,
54
+ }: {
55
+ active?: boolean;
56
+ onClick?: (active: boolean) => Promise<void>;
57
+ className?: string;
58
+ }) {
59
+ const { message } = App.useApp();
60
+ const [active, setActive] = useState(propsActive || false);
61
+
62
+ useEffect(() => {
63
+ setActive(!!propsActive);
64
+ }, [propsActive]);
65
+
66
+ const handleClick = useCallback(async () => {
67
+ await Promise.resolve(onClick?.(!active));
68
+ setActive(!active);
69
+ message.success(!active ? '点赞成功' : '取消点赞成功');
70
+ }, [onClick, active, message]);
71
+
72
+ return (
73
+ <Tooltip title={active ? '取消点赞' : '点赞'}>
74
+ <Button
75
+ type="text"
76
+ onClick={handleClick}
77
+ size="small"
78
+ className={classNames('text-03', className)}
79
+ icon={active ? <LikeFilled /> : <LikeOutlined />}
80
+ />
81
+ </Tooltip>
82
+ );
83
+ }
84
+
85
+ function MessageActionOfDislike({
86
+ active: propsActive,
87
+ onClick,
88
+ className,
89
+ }: {
90
+ active?: boolean;
91
+ onClick?: (active: boolean) => Promise<void>;
92
+ className?: string;
93
+ }) {
94
+ const [active, setActive] = useState(propsActive || false);
95
+ const { message } = App.useApp();
96
+
97
+ useEffect(() => {
98
+ setActive(!!propsActive);
99
+ }, [propsActive]);
100
+
101
+ const handleClick = useCallback(async () => {
102
+ await Promise.resolve(onClick?.(!active));
103
+ setActive(!active);
104
+ message.success(!active ? '点踩成功' : '取消点踩成功');
105
+ }, [onClick, active, message]);
106
+
107
+ return (
108
+ <Tooltip title={active ? '取消点踩' : '点踩'}>
109
+ <Button
110
+ type="text"
111
+ onClick={handleClick}
112
+ size="small"
113
+ className={classNames('text-03', className)}
114
+ icon={active ? <DislikeFilled /> : <DislikeOutlined />}
115
+ />
116
+ </Tooltip>
117
+ );
118
+ }
119
+
120
+ function MessageActionOfLikeAndDislike({
121
+ value: propsValue,
122
+ onChange,
123
+ className,
124
+ }: {
125
+ value?: -1 | 0 | 1;
126
+ onChange?: (value: -1 | 0 | 1) => void;
127
+ className?: string;
128
+ }) {
129
+ const [value, setValue] = useState<(-1 | 0 | 1) | undefined>(propsValue);
130
+
131
+ useEffect(() => {
132
+ setValue(propsValue);
133
+ }, [propsValue]);
134
+
135
+ return (
136
+ <>
137
+ <MessageActionOfLike
138
+ active={value === 1}
139
+ onClick={async () => {
140
+ const newValue = value === 1 ? 0 : 1;
141
+ await Promise.resolve(onChange?.(newValue));
142
+ setValue(newValue);
143
+ }}
144
+ className={className}
145
+ />
146
+ <MessageActionOfDislike
147
+ active={value === -1}
148
+ onClick={async () => {
149
+ const newValue = value === -1 ? 0 : -1;
150
+ await Promise.resolve(onChange?.(newValue));
151
+ setValue(newValue);
152
+ }}
153
+ className={className}
154
+ />
155
+ </>
156
+ );
157
+ }
158
+
159
+ const MessageActions = {
160
+ Copy: MessageActionOfCopy,
161
+ Like: MessageActionOfLike,
162
+ Dislike: MessageActionOfDislike,
163
+ LikeAndDislike: MessageActionOfLikeAndDislike,
164
+ };
165
+
166
+ export { MessageActions };
@@ -0,0 +1,69 @@
1
+ import { Think } from '@ant-design/x';
2
+ import Icons from '@fe-free/icons';
3
+ import classNames from 'classnames';
4
+ import { useCallback, useEffect, useState } from 'react';
5
+ import ThinkIcon from '../svgs/think.svg?react';
6
+
7
+ interface MessageThinkProps {
8
+ title: string;
9
+ icon?: React.ReactNode;
10
+ loading?: boolean;
11
+ children?: React.ReactNode;
12
+ expanded?: boolean;
13
+ onClick?: () => void;
14
+ className?: string;
15
+ }
16
+
17
+ function MessageThink({
18
+ title,
19
+ icon,
20
+ loading,
21
+ children,
22
+ expanded: propsExpanded,
23
+ onClick,
24
+ className,
25
+ }: MessageThinkProps) {
26
+ const [expanded, setExpanded] = useState(propsExpanded || false);
27
+
28
+ useEffect(() => {
29
+ setExpanded(propsExpanded || false);
30
+ }, [propsExpanded]);
31
+
32
+ useEffect(() => {
33
+ // 如果 propsExpanded 未定义,则根据 loading 状态设置 expanded
34
+ if (propsExpanded === undefined && loading !== undefined) {
35
+ setExpanded(loading ? true : false);
36
+ }
37
+ }, [propsExpanded, loading]);
38
+
39
+ const handleClick = useCallback(() => {
40
+ setExpanded(!expanded);
41
+ onClick?.();
42
+ }, [expanded, onClick]);
43
+
44
+ return (
45
+ <Think
46
+ title={title}
47
+ icon={icon || <Icons component={ThinkIcon} className="!text-sm" />}
48
+ loading={loading}
49
+ blink={loading}
50
+ expanded={expanded}
51
+ onClick={handleClick}
52
+ className={classNames('fea-message-think', className)}
53
+ >
54
+ {children}
55
+ </Think>
56
+ );
57
+ }
58
+
59
+ function MessageThinkOfDeepSeek(props: MessageThinkProps) {
60
+ return (
61
+ <MessageThink
62
+ {...props}
63
+ className={classNames('fea-message-think-deep-seek', props.className)}
64
+ />
65
+ );
66
+ }
67
+
68
+ export { MessageThink, MessageThinkOfDeepSeek };
69
+ export type { MessageThinkProps };