@fe-free/ai 4.1.26 → 4.1.28

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @fe-free/ai
2
2
 
3
+ ## 4.1.28
4
+
5
+ ### Patch Changes
6
+
7
+ - feat: ai
8
+ - @fe-free/core@4.1.28
9
+ - @fe-free/icons@4.1.28
10
+ - @fe-free/tool@4.1.28
11
+
12
+ ## 4.1.27
13
+
14
+ ### Patch Changes
15
+
16
+ - feat: ai voice
17
+ - @fe-free/core@4.1.27
18
+ - @fe-free/icons@4.1.27
19
+ - @fe-free/tool@4.1.27
20
+
3
21
  ## 4.1.26
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fe-free/ai",
3
- "version": "4.1.26",
3
+ "version": "4.1.28",
4
4
  "description": "",
5
5
  "main": "./src/index.ts",
6
6
  "author": "",
@@ -19,7 +19,7 @@
19
19
  "lodash-es": "^4.17.21",
20
20
  "uuid": "^13.0.0",
21
21
  "zustand": "^4.5.7",
22
- "@fe-free/core": "4.1.26"
22
+ "@fe-free/core": "4.1.28"
23
23
  },
24
24
  "peerDependencies": {
25
25
  "antd": "^5.27.1",
@@ -29,8 +29,8 @@
29
29
  "i18next-icu": "^2.4.1",
30
30
  "react": "^19.2.0",
31
31
  "react-i18next": "^16.4.0",
32
- "@fe-free/icons": "4.1.26",
33
- "@fe-free/tool": "4.1.26"
32
+ "@fe-free/icons": "4.1.28",
33
+ "@fe-free/tool": "4.1.28"
34
34
  },
35
35
  "scripts": {
36
36
  "test": "echo \"Error: no test specified\" && exit 1",
@@ -5,6 +5,7 @@ import {
5
5
  EnumChatMessageStatus,
6
6
  EnumChatMessageType,
7
7
  generateUUID,
8
+ getRecordAudioOfPCM,
8
9
  Markdown,
9
10
  MessageActions,
10
11
  Messages,
@@ -12,7 +13,7 @@ import {
12
13
  } from '@fe-free/ai';
13
14
  import { sleep } from '@fe-free/tool';
14
15
  import type { Meta } from '@storybook/react-vite';
15
- import { Button, Divider } from 'antd';
16
+ import { App, Button, Divider } from 'antd';
16
17
  import { set } from 'lodash-es';
17
18
  import { useCallback, useEffect, useMemo } from 'react';
18
19
 
@@ -54,6 +55,8 @@ function Component() {
54
55
  const updateMessage = useChatStore((state) => state.updateMessage);
55
56
  const { chatStatus } = useChatStoreComputed();
56
57
 
58
+ const { message } = App.useApp();
59
+
57
60
  // init from cache
58
61
  useEffect(() => {
59
62
  const cacheMessages = localStorage.getItem('chatMessages');
@@ -103,6 +106,10 @@ function Component() {
103
106
  [addMessage, updateMessage],
104
107
  );
105
108
 
109
+ const { start: startRecord, stop: stopRecord } = useMemo(() => {
110
+ return getRecordAudioOfPCM();
111
+ }, []);
112
+
106
113
  return (
107
114
  <div>
108
115
  <div>
@@ -139,6 +146,28 @@ function Component() {
139
146
  onChange={(v) => setSenderValue(v)}
140
147
  loading={loading}
141
148
  onSubmit={handleSubmit}
149
+ allowSpeech={{
150
+ onRecordStart: async () => {
151
+ console.log('onRecordStart');
152
+ try {
153
+ await startRecord({
154
+ onAudio: (data) => {
155
+ console.log('onAudio', data);
156
+ },
157
+ onError: (err) => {
158
+ message.error(err.message);
159
+ },
160
+ });
161
+ } catch (err) {
162
+ console.error(err);
163
+ }
164
+ },
165
+ onRecordEnd: async (isSend) => {
166
+ console.log('onRecordEnd', isSend);
167
+ const voiceData = await stopRecord();
168
+ console.log('voiceData', voiceData);
169
+ },
170
+ }}
142
171
  />
143
172
  </div>
144
173
  }
package/src/index.ts CHANGED
@@ -15,4 +15,4 @@ export { EnumChatMessageStatus, EnumChatMessageType } from './store/types';
15
15
  export type { ChatMessage, ChatMessageOfAI, ChatMessageOfUser } from './store/types';
16
16
  export { fetchStream } from './stream';
17
17
  export { Tip } from './tip';
18
- export { recordAudioOfPCM } from './voice';
18
+ export { getRecordAudioOfBlob, getRecordAudioOfPCM } from './voice';
@@ -12,7 +12,7 @@ function Actions(
12
12
  setType: (type: 'input' | 'record') => void;
13
13
  },
14
14
  ) {
15
- const { refText, loading, onSubmit, value, onChange, setType, allowSpeech } = props;
15
+ const { loading, onSubmit, value, onChange, setType, allowSpeech } = props;
16
16
 
17
17
  const isLoading = loading;
18
18
 
@@ -33,10 +33,10 @@ function Actions(
33
33
  // reset
34
34
  onChange?.({});
35
35
 
36
- // focus
37
- refText.current?.focus();
36
+ // 移动端 不 focus
37
+ // refText.current?.focus();
38
38
  }
39
- }, [isLoading, value, onSubmit, onChange, refText]);
39
+ }, [isLoading, value, onSubmit, onChange]);
40
40
 
41
41
  return (
42
42
  <div className="mr-1 flex items-center gap-2">
@@ -44,7 +44,7 @@ function Actions(
44
44
  <Button
45
45
  type="primary"
46
46
  shape="circle"
47
- icon={<Icons component={IconRecord} />}
47
+ icon={<Icons component={IconRecord} className="!text-lg" />}
48
48
  onClick={() => {
49
49
  setType('record');
50
50
  }}
@@ -53,7 +53,7 @@ function Actions(
53
53
  <Button
54
54
  type="primary"
55
55
  shape="circle"
56
- icon={<Icons component={SendIcon} />}
56
+ icon={<Icons component={SendIcon} className="!text-lg" />}
57
57
  loading={isLoading}
58
58
  onClick={handleSubmit}
59
59
  />
@@ -1,7 +1,7 @@
1
- import { MSender } from '@fe-free/ai';
2
- import { sleep } from '@fe-free/tool';
1
+ import { getRecordAudioOfBlob, getRecordAudioOfPCM, MSender } from '@fe-free/ai';
3
2
  import type { Meta, StoryObj } from '@storybook/react-vite';
4
- import { useCallback, useState } from 'react';
3
+ import { App } from 'antd';
4
+ import { useCallback, useMemo, useState } from 'react';
5
5
  import type { MSenderProps, MSenderValue } from './types';
6
6
 
7
7
  const meta: Meta<typeof MSender> = {
@@ -61,31 +61,23 @@ export const Loading: Story = {
61
61
 
62
62
  export const AllowSpeech: Story = {
63
63
  render: (props) => {
64
- // 假设是字符串,实则是 buffer
65
- const [recordVoice, setRecordVoice] = useState<string | undefined>(undefined);
66
-
67
64
  const handleSubmit = (value: MSenderValue) => {
68
65
  console.log('handleSubmit', value);
69
66
  };
70
67
 
71
68
  const handleRecordStart = useCallback(async () => {
72
- // 假设这是录音的文本
73
- setRecordVoice('这是录音的文本');
74
-
75
- return;
69
+ // fake
76
70
  }, []);
77
71
 
78
72
  const handleRecordEnd = useCallback(
79
- async (isSend: boolean) => {
73
+ async (isSend) => {
80
74
  console.log('handleRecordEnd isSend', isSend);
81
- if (isSend) {
82
- await sleep(1000);
83
- const recordResult = recordVoice;
84
75
 
85
- handleSubmit({ ...(props.value || {}), text: recordResult });
76
+ if (isSend) {
77
+ handleSubmit({ ...(props.value || {}), text: '假设这是识别的文字' });
86
78
  }
87
79
  },
88
- [props.value, recordVoice],
80
+ [props.value],
89
81
  );
90
82
 
91
83
  return (
@@ -122,4 +114,118 @@ export const AllowSpeech: Story = {
122
114
  },
123
115
  };
124
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
+
125
231
  export default meta;
@@ -8,9 +8,18 @@ import {
8
8
  LikeOutlined,
9
9
  } from '@fe-free/icons';
10
10
  import { App, Button, Tooltip } from 'antd';
11
+ import classNames from 'classnames';
11
12
  import { useCallback, useEffect, useState } from 'react';
12
13
 
13
- function MessageActionOfCopy({ value, onCopied }: { value: string; onCopied?: () => void }) {
14
+ function MessageActionOfCopy({
15
+ value,
16
+ onCopied,
17
+ className,
18
+ }: {
19
+ value: string;
20
+ onCopied?: () => void;
21
+ className?: string;
22
+ }) {
14
23
  const [active, setActive] = useState(false);
15
24
  const { message } = App.useApp();
16
25
 
@@ -22,7 +31,11 @@ function MessageActionOfCopy({ value, onCopied }: { value: string; onCopied?: ()
22
31
 
23
32
  return (
24
33
  <Tooltip title="复制">
25
- <Copy value={value} className="cursor-pointer text-03" onCopied={handleCopied}>
34
+ <Copy
35
+ value={value}
36
+ className={classNames('cursor-pointer text-03', className)}
37
+ onCopied={handleCopied}
38
+ >
26
39
  <Button
27
40
  type="text"
28
41
  size="small"
@@ -37,9 +50,11 @@ function MessageActionOfCopy({ value, onCopied }: { value: string; onCopied?: ()
37
50
  function MessageActionOfLike({
38
51
  active: propsActive,
39
52
  onClick,
53
+ className,
40
54
  }: {
41
55
  active?: boolean;
42
56
  onClick?: (active: boolean) => Promise<void>;
57
+ className?: string;
43
58
  }) {
44
59
  const { message } = App.useApp();
45
60
  const [active, setActive] = useState(propsActive || false);
@@ -60,7 +75,7 @@ function MessageActionOfLike({
60
75
  type="text"
61
76
  onClick={handleClick}
62
77
  size="small"
63
- className="text-03"
78
+ className={classNames('text-03', className)}
64
79
  icon={active ? <LikeFilled /> : <LikeOutlined />}
65
80
  />
66
81
  </Tooltip>
@@ -70,9 +85,11 @@ function MessageActionOfLike({
70
85
  function MessageActionOfDislike({
71
86
  active: propsActive,
72
87
  onClick,
88
+ className,
73
89
  }: {
74
90
  active?: boolean;
75
91
  onClick?: (active: boolean) => Promise<void>;
92
+ className?: string;
76
93
  }) {
77
94
  const [active, setActive] = useState(propsActive || false);
78
95
  const { message } = App.useApp();
@@ -93,7 +110,7 @@ function MessageActionOfDislike({
93
110
  type="text"
94
111
  onClick={handleClick}
95
112
  size="small"
96
- className="text-03"
113
+ className={classNames('text-03', className)}
97
114
  icon={active ? <DislikeFilled /> : <DislikeOutlined />}
98
115
  />
99
116
  </Tooltip>
@@ -103,9 +120,11 @@ function MessageActionOfDislike({
103
120
  function MessageActionOfLinkAndDislike({
104
121
  value: propsValue,
105
122
  onChange,
123
+ className,
106
124
  }: {
107
125
  value?: -1 | 0 | 1;
108
126
  onChange?: (value: -1 | 0 | 1) => void;
127
+ className?: string;
109
128
  }) {
110
129
  const [value, setValue] = useState<(-1 | 0 | 1) | undefined>(propsValue);
111
130
 
@@ -122,6 +141,7 @@ function MessageActionOfLinkAndDislike({
122
141
  await Promise.resolve(onChange?.(newValue));
123
142
  setValue(newValue);
124
143
  }}
144
+ className={className}
125
145
  />
126
146
  <MessageActionOfDislike
127
147
  active={value === -1}
@@ -130,6 +150,7 @@ function MessageActionOfLinkAndDislike({
130
150
  await Promise.resolve(onChange?.(newValue));
131
151
  setValue(newValue);
132
152
  }}
153
+ className={className}
133
154
  />
134
155
  </>
135
156
  );
@@ -1,33 +1,188 @@
1
- async function recordAudioOfPCM({ onAudio }: { onAudio: (data: ArrayBufferLike) => void }) {
2
- // --- 初始化音频 ---
3
- const micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
4
- const audioContext = new AudioContext({ sampleRate: 16000 });
5
- const sourceNode = audioContext.createMediaStreamSource(micStream);
6
- // ScriptProcessorNode(4096 是稳定 buffer)
7
- const processorNode = audioContext.createScriptProcessor(4096, 1, 1);
8
-
9
- processorNode.onaudioprocess = function (event) {
10
- const float32Data = event.inputBuffer.getChannelData(0); // float32
11
-
12
- // === 转成 Int16 PCM ===
13
- const pcm16 = new Int16Array(float32Data.length);
14
- for (let i = 0; i < float32Data.length; i++) {
15
- const s = Math.max(-1, Math.min(1, float32Data[i]));
16
- pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
17
- }
1
+ function getRecordAudioOfPCM() {
2
+ let processorNode: ScriptProcessorNode | null = null;
3
+ let sourceNode: MediaStreamAudioSourceNode | null = null;
4
+ let audioContext: AudioContext | null = null;
5
+ let micStream: MediaStream | null = null;
18
6
 
19
- onAudio(pcm16.buffer);
20
- };
7
+ let data: ArrayBufferLike[] = [];
8
+
9
+ async function start({
10
+ onAudio,
11
+ onError,
12
+ }: {
13
+ onAudio: (data: ArrayBufferLike) => void;
14
+ onError?: (error: Error) => void;
15
+ }): Promise<void> {
16
+ try {
17
+ // --- 初始化音频 ---
18
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
19
+ audioContext = new AudioContext({ sampleRate: 16000 });
20
+ sourceNode = audioContext.createMediaStreamSource(micStream);
21
+ // ScriptProcessorNode(4096 是稳定 buffer)
22
+ processorNode = audioContext.createScriptProcessor(4096, 1, 1);
23
+
24
+ data = [];
25
+
26
+ processorNode.onaudioprocess = function (event) {
27
+ const float32Data = event.inputBuffer.getChannelData(0); // float32
28
+
29
+ // === 转成 Int16 PCM ===
30
+ const pcm16 = new Int16Array(float32Data.length);
31
+ for (let i = 0; i < float32Data.length; i++) {
32
+ const s = Math.max(-1, Math.min(1, float32Data[i]));
33
+ pcm16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
34
+ }
35
+
36
+ data.push(pcm16.buffer);
37
+
38
+ onAudio(pcm16.buffer);
39
+ };
21
40
 
22
- sourceNode.connect(processorNode);
23
- processorNode.connect(audioContext.destination);
41
+ sourceNode.connect(processorNode);
42
+ processorNode.connect(audioContext.destination);
43
+ } catch (err) {
44
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
45
+ onError?.(new Error('请允许麦克风权限'));
46
+ } else if (err instanceof DOMException && err.name === 'NotFoundError') {
47
+ onError?.(new Error('未找到麦克风设备'));
48
+ } else if (err instanceof DOMException && err.name === 'NotReadableError') {
49
+ onError?.(new Error('麦克风被其他应用占用'));
50
+ } else {
51
+ onError?.(new Error('启动录音失败'));
52
+ }
24
53
 
25
- return () => {
54
+ throw err;
55
+ }
56
+ }
57
+
58
+ async function stop(): Promise<{ data: ArrayBufferLike[] }> {
26
59
  if (processorNode) processorNode.disconnect();
27
60
  if (sourceNode) sourceNode.disconnect();
28
61
  if (audioContext) audioContext.close();
29
62
  if (micStream) micStream.getTracks().forEach((track) => track.stop());
63
+
64
+ const result = data;
65
+ data = [];
66
+
67
+ return { data: result };
68
+ }
69
+
70
+ return {
71
+ start,
72
+ stop,
73
+ };
74
+ }
75
+
76
+ function getRecordAudioOfBlob() {
77
+ let mediaRecorder: MediaRecorder | null = null;
78
+ let micStream: MediaStream | null = null;
79
+ let chunks: Blob[] = [];
80
+
81
+ async function start({
82
+ onAudio,
83
+ onError,
84
+ mimeType = 'audio/webm',
85
+ }: {
86
+ onAudio?: (blob: Blob) => void;
87
+ onError?: (error: Error) => void;
88
+ mimeType?: string;
89
+ }): Promise<void> {
90
+ try {
91
+ // 获取麦克风权限
92
+ micStream = await navigator.mediaDevices.getUserMedia({ audio: true });
93
+
94
+ // 检查浏览器是否支持指定的 MIME 类型
95
+ let finalMimeType = mimeType;
96
+ if (!MediaRecorder.isTypeSupported(mimeType)) {
97
+ // 如果不支持,尝试使用默认类型
98
+ finalMimeType = '';
99
+ console.warn(`不支持的 MIME 类型: ${mimeType},使用默认类型`);
100
+ }
101
+
102
+ // 创建 MediaRecorder 实例
103
+ mediaRecorder = new MediaRecorder(micStream, {
104
+ mimeType: finalMimeType || undefined,
105
+ });
106
+
107
+ chunks = [];
108
+
109
+ // 监听数据可用事件
110
+ mediaRecorder.ondataavailable = (event) => {
111
+ if (event.data && event.data.size > 0) {
112
+ chunks.push(event.data);
113
+ onAudio?.(event.data);
114
+ }
115
+ };
116
+
117
+ // 监听错误事件
118
+ mediaRecorder.onerror = () => {
119
+ const error = new Error('MediaRecorder 录音错误');
120
+ onError?.(error);
121
+ };
122
+
123
+ // 开始录音
124
+ mediaRecorder.start(100); // 每 100ms 触发一次 dataavailable 事件
125
+ } catch (err) {
126
+ if (err instanceof DOMException && err.name === 'NotAllowedError') {
127
+ onError?.(new Error('请允许麦克风权限'));
128
+ } else if (err instanceof DOMException && err.name === 'NotFoundError') {
129
+ onError?.(new Error('未找到麦克风设备'));
130
+ } else if (err instanceof DOMException && err.name === 'NotReadableError') {
131
+ onError?.(new Error('麦克风被其他应用占用'));
132
+ } else {
133
+ onError?.(new Error('启动录音失败'));
134
+ }
135
+
136
+ throw err;
137
+ }
138
+ }
139
+
140
+ async function stop(): Promise<{ data: Blob; base64: string }> {
141
+ return new Promise((resolve, reject) => {
142
+ if (!mediaRecorder) {
143
+ reject(new Error('MediaRecorder 未初始化'));
144
+ return;
145
+ }
146
+
147
+ function doStop() {
148
+ const blob = new Blob(chunks, { type: mediaRecorder?.mimeType || 'audio/webm' });
149
+ chunks = [];
150
+
151
+ // 停止所有轨道
152
+ if (micStream) {
153
+ micStream.getTracks().forEach((track) => track.stop());
154
+ }
155
+
156
+ // 将 Blob 转换为 base64
157
+ const reader = new FileReader();
158
+ reader.onloadend = () => {
159
+ const base64String = reader.result as string;
160
+ resolve({ data: blob, base64: base64String });
161
+ };
162
+ reader.onerror = () => {
163
+ reject(new Error('转换为 base64 失败'));
164
+ };
165
+ reader.readAsDataURL(blob);
166
+ }
167
+
168
+ // 监听停止事件
169
+ mediaRecorder.onstop = () => {
170
+ doStop();
171
+ };
172
+
173
+ // 如果正在录音,则停止
174
+ if (mediaRecorder.state === 'recording') {
175
+ mediaRecorder.stop();
176
+ } else {
177
+ doStop();
178
+ }
179
+ });
180
+ }
181
+
182
+ return {
183
+ start,
184
+ stop,
30
185
  };
31
186
  }
32
187
 
33
- export { recordAudioOfPCM };
188
+ export { getRecordAudioOfBlob, getRecordAudioOfPCM };