@alicloud/appflow-chat 0.0.4-alpha.6 → 0.0.4-alpha.8
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/dist/appflow-chat.cjs.js +47 -47
- package/dist/appflow-chat.esm.js +1137 -1135
- package/dist/types/index.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/AudioPlayer.tsx +246 -0
- package/src/components/MessageAttachments.tsx +4 -224
- package/src/components/MessageBubble.tsx +19 -13
- package/src/services/ChatService.ts +23 -15
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AudioPlayer - 语音消息播放器组件
|
|
3
|
+
* 用于在消息气泡中展示语音消息,支持播放/暂停、进度条、波形动画、时长显示
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
|
7
|
+
import styled, { keyframes, css } from 'styled-components';
|
|
8
|
+
import {
|
|
9
|
+
PlayCircleOutlined,
|
|
10
|
+
PauseCircleOutlined,
|
|
11
|
+
} from '@ant-design/icons';
|
|
12
|
+
|
|
13
|
+
// ==================== 类型定义 ====================
|
|
14
|
+
|
|
15
|
+
export interface AudioPlayerProps {
|
|
16
|
+
/** 音频文件URL */
|
|
17
|
+
src: string;
|
|
18
|
+
/** 消息角色,影响配色 */
|
|
19
|
+
role?: 'user' | 'bot';
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ==================== 样式组件 ====================
|
|
23
|
+
|
|
24
|
+
const waveAnimation = keyframes`
|
|
25
|
+
0%, 100% { height: 4px; }
|
|
26
|
+
50% { height: 16px; }
|
|
27
|
+
`;
|
|
28
|
+
|
|
29
|
+
const AudioCard = styled.div<{ $role: 'user' | 'bot' }>`
|
|
30
|
+
display: flex;
|
|
31
|
+
align-items: center;
|
|
32
|
+
gap: 10px;
|
|
33
|
+
background: ${props => props.$role === 'user' ? '#e5effe' : '#e8f4fd'};
|
|
34
|
+
border-radius: 12px;
|
|
35
|
+
padding: 10px 14px;
|
|
36
|
+
max-width: 100%;
|
|
37
|
+
box-sizing: border-box;
|
|
38
|
+
min-width: 200px;
|
|
39
|
+
`;
|
|
40
|
+
|
|
41
|
+
const PlayButton = styled.span<{ $role: 'user' | 'bot' }>`
|
|
42
|
+
font-size: 28px;
|
|
43
|
+
cursor: pointer;
|
|
44
|
+
flex-shrink: 0;
|
|
45
|
+
color: ${props => props.$role === 'user' ? '#667eea' : '#1677ff'};
|
|
46
|
+
display: flex;
|
|
47
|
+
align-items: center;
|
|
48
|
+
transition: opacity 0.2s;
|
|
49
|
+
|
|
50
|
+
&:hover {
|
|
51
|
+
opacity: 0.8;
|
|
52
|
+
}
|
|
53
|
+
`;
|
|
54
|
+
|
|
55
|
+
const AudioBody = styled.div`
|
|
56
|
+
flex: 1;
|
|
57
|
+
display: flex;
|
|
58
|
+
flex-direction: column;
|
|
59
|
+
gap: 6px;
|
|
60
|
+
min-width: 0;
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
const WaveformContainer = styled.div`
|
|
64
|
+
display: flex;
|
|
65
|
+
align-items: center;
|
|
66
|
+
gap: 2px;
|
|
67
|
+
height: 20px;
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
const WaveBar = styled.div<{ $active: boolean; $playing: boolean; $delay: number }>`
|
|
71
|
+
width: 3px;
|
|
72
|
+
border-radius: 2px;
|
|
73
|
+
background: ${props => props.$active ? '#667eea' : '#c0c8d8'};
|
|
74
|
+
transition: background 0.15s;
|
|
75
|
+
|
|
76
|
+
${props => props.$playing && props.$active ? css`
|
|
77
|
+
animation: ${waveAnimation} 0.6s ease-in-out infinite;
|
|
78
|
+
animation-delay: ${props.$delay}s;
|
|
79
|
+
` : css`
|
|
80
|
+
height: ${props.$active ? '12px' : `${4 + Math.random() * 10}px`};
|
|
81
|
+
`}
|
|
82
|
+
`;
|
|
83
|
+
|
|
84
|
+
const ProgressBar = styled.div`
|
|
85
|
+
position: relative;
|
|
86
|
+
height: 3px;
|
|
87
|
+
background: rgba(0, 0, 0, 0.08);
|
|
88
|
+
border-radius: 2px;
|
|
89
|
+
cursor: pointer;
|
|
90
|
+
overflow: visible;
|
|
91
|
+
`;
|
|
92
|
+
|
|
93
|
+
const ProgressFill = styled.div<{ $progress: number }>`
|
|
94
|
+
height: 100%;
|
|
95
|
+
background: #667eea;
|
|
96
|
+
border-radius: 2px;
|
|
97
|
+
width: ${props => props.$progress}%;
|
|
98
|
+
transition: width 0.1s linear;
|
|
99
|
+
position: relative;
|
|
100
|
+
|
|
101
|
+
&::after {
|
|
102
|
+
content: '';
|
|
103
|
+
position: absolute;
|
|
104
|
+
right: -4px;
|
|
105
|
+
top: 50%;
|
|
106
|
+
transform: translateY(-50%);
|
|
107
|
+
width: 8px;
|
|
108
|
+
height: 8px;
|
|
109
|
+
border-radius: 50%;
|
|
110
|
+
background: #667eea;
|
|
111
|
+
opacity: 0;
|
|
112
|
+
transition: opacity 0.2s;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
${ProgressBar}:hover &::after {
|
|
116
|
+
opacity: 1;
|
|
117
|
+
}
|
|
118
|
+
`;
|
|
119
|
+
|
|
120
|
+
const AudioDuration = styled.span`
|
|
121
|
+
font-size: 12px;
|
|
122
|
+
color: #888;
|
|
123
|
+
flex-shrink: 0;
|
|
124
|
+
min-width: 32px;
|
|
125
|
+
text-align: right;
|
|
126
|
+
font-variant-numeric: tabular-nums;
|
|
127
|
+
`;
|
|
128
|
+
|
|
129
|
+
// ==================== 工具函数 ====================
|
|
130
|
+
|
|
131
|
+
/** 格式化秒数为 m:ss */
|
|
132
|
+
function formatDuration(seconds: number): string {
|
|
133
|
+
if (!seconds || !isFinite(seconds)) return '0:00';
|
|
134
|
+
const m = Math.floor(seconds / 60);
|
|
135
|
+
const s = Math.floor(seconds % 60);
|
|
136
|
+
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 生成固定的波形条高度 */
|
|
140
|
+
const WAVE_BARS = Array.from({ length: 20 }, (_, i) => {
|
|
141
|
+
// 用正弦函数生成自然的波形高度
|
|
142
|
+
const base = Math.sin((i / 20) * Math.PI) * 12 + 4;
|
|
143
|
+
return Math.max(4, Math.min(18, base + (Math.random() * 4 - 2)));
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// ==================== 组件实现 ====================
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* AudioPlayer - 语音消息播放器
|
|
150
|
+
*
|
|
151
|
+
* 紧凑的播放器组件,包含播放/暂停按钮 + 波形条 + 进度条 + 时长显示。
|
|
152
|
+
* 风格类似钉钉/微信的聊天语音消息,可独立使用无需外层气泡包裹。
|
|
153
|
+
*
|
|
154
|
+
* @example
|
|
155
|
+
* ```tsx
|
|
156
|
+
* <AudioPlayer src="https://example.com/audio.webm" role="user" />
|
|
157
|
+
* ```
|
|
158
|
+
*/
|
|
159
|
+
export const AudioPlayer: React.FC<AudioPlayerProps> = ({ src, role = 'user' }) => {
|
|
160
|
+
const audioRef = useRef<HTMLAudioElement>(null);
|
|
161
|
+
const progressRef = useRef<HTMLDivElement>(null);
|
|
162
|
+
const [isPlaying, setIsPlaying] = useState(false);
|
|
163
|
+
const [currentTime, setCurrentTime] = useState(0);
|
|
164
|
+
const [duration, setDuration] = useState(0);
|
|
165
|
+
|
|
166
|
+
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
167
|
+
|
|
168
|
+
useEffect(() => {
|
|
169
|
+
const audio = audioRef.current;
|
|
170
|
+
if (!audio) return;
|
|
171
|
+
|
|
172
|
+
const onLoadedMetadata = () => setDuration(audio.duration);
|
|
173
|
+
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
|
|
174
|
+
const onEnded = () => {
|
|
175
|
+
setIsPlaying(false);
|
|
176
|
+
setCurrentTime(0);
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
180
|
+
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
181
|
+
audio.addEventListener('ended', onEnded);
|
|
182
|
+
|
|
183
|
+
return () => {
|
|
184
|
+
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
185
|
+
audio.removeEventListener('timeupdate', onTimeUpdate);
|
|
186
|
+
audio.removeEventListener('ended', onEnded);
|
|
187
|
+
};
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
const togglePlay = useCallback(() => {
|
|
191
|
+
const audio = audioRef.current;
|
|
192
|
+
if (!audio) return;
|
|
193
|
+
|
|
194
|
+
if (isPlaying) {
|
|
195
|
+
audio.pause();
|
|
196
|
+
setIsPlaying(false);
|
|
197
|
+
} else {
|
|
198
|
+
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
|
199
|
+
}
|
|
200
|
+
}, [isPlaying]);
|
|
201
|
+
|
|
202
|
+
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
203
|
+
const audio = audioRef.current;
|
|
204
|
+
const bar = progressRef.current;
|
|
205
|
+
if (!audio || !bar || !duration) return;
|
|
206
|
+
|
|
207
|
+
const rect = bar.getBoundingClientRect();
|
|
208
|
+
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
209
|
+
audio.currentTime = ratio * duration;
|
|
210
|
+
setCurrentTime(audio.currentTime);
|
|
211
|
+
}, [duration]);
|
|
212
|
+
|
|
213
|
+
return (
|
|
214
|
+
<AudioCard $role={role}>
|
|
215
|
+
<audio ref={audioRef} src={src} preload="metadata" />
|
|
216
|
+
<PlayButton $role={role} onClick={togglePlay}>
|
|
217
|
+
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
218
|
+
</PlayButton>
|
|
219
|
+
<AudioBody>
|
|
220
|
+
<WaveformContainer>
|
|
221
|
+
{WAVE_BARS.map((h, i) => {
|
|
222
|
+
const barProgress = (i / WAVE_BARS.length) * 100;
|
|
223
|
+
const isActive = barProgress <= progress;
|
|
224
|
+
return (
|
|
225
|
+
<WaveBar
|
|
226
|
+
key={i}
|
|
227
|
+
$active={isActive}
|
|
228
|
+
$playing={isPlaying}
|
|
229
|
+
$delay={i * 0.05}
|
|
230
|
+
style={!isPlaying || !isActive ? { height: `${h}px` } : undefined}
|
|
231
|
+
/>
|
|
232
|
+
);
|
|
233
|
+
})}
|
|
234
|
+
</WaveformContainer>
|
|
235
|
+
<ProgressBar ref={progressRef} onClick={handleProgressClick}>
|
|
236
|
+
<ProgressFill $progress={progress} />
|
|
237
|
+
</ProgressBar>
|
|
238
|
+
</AudioBody>
|
|
239
|
+
<AudioDuration>
|
|
240
|
+
{isPlaying || currentTime > 0 ? formatDuration(currentTime) : formatDuration(duration)}
|
|
241
|
+
</AudioDuration>
|
|
242
|
+
</AudioCard>
|
|
243
|
+
);
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
export default AudioPlayer;
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* MessageAttachments - 消息附件展示组件
|
|
3
|
-
*
|
|
3
|
+
* 用于在消息气泡中展示上传的图片和文件
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
|
-
import React
|
|
7
|
-
import styled
|
|
6
|
+
import React from 'react';
|
|
7
|
+
import styled from 'styled-components';
|
|
8
8
|
import { Image } from 'antd';
|
|
9
9
|
import {
|
|
10
10
|
FileWordOutlined,
|
|
@@ -12,8 +12,6 @@ import {
|
|
|
12
12
|
FileExcelOutlined,
|
|
13
13
|
FileTextOutlined,
|
|
14
14
|
FileOutlined,
|
|
15
|
-
PlayCircleOutlined,
|
|
16
|
-
PauseCircleOutlined,
|
|
17
15
|
} from '@ant-design/icons';
|
|
18
16
|
|
|
19
17
|
// ==================== 类型定义 ====================
|
|
@@ -25,8 +23,6 @@ export interface MessageAttachmentsProps {
|
|
|
25
23
|
images?: string[];
|
|
26
24
|
/** 文件列表 */
|
|
27
25
|
files?: { name: string; url: string }[];
|
|
28
|
-
/** 语音消息URL */
|
|
29
|
-
audio?: string;
|
|
30
26
|
}
|
|
31
27
|
|
|
32
28
|
// ==================== 样式组件 ====================
|
|
@@ -127,217 +123,6 @@ const FileCard = styled.a<{ $role: 'user' | 'bot' }>`
|
|
|
127
123
|
}
|
|
128
124
|
`;
|
|
129
125
|
|
|
130
|
-
// ==================== 语音播放器样式 ====================
|
|
131
|
-
|
|
132
|
-
const waveAnimation = keyframes`
|
|
133
|
-
0%, 100% { height: 4px; }
|
|
134
|
-
50% { height: 16px; }
|
|
135
|
-
`;
|
|
136
|
-
|
|
137
|
-
const AudioCard = styled.div<{ $role: 'user' | 'bot' }>`
|
|
138
|
-
display: flex;
|
|
139
|
-
align-items: center;
|
|
140
|
-
gap: 10px;
|
|
141
|
-
background: ${props => props.$role === 'user' ? '#eef0ff' : '#e8f4fd'};
|
|
142
|
-
border-radius: 8px;
|
|
143
|
-
padding: 10px 14px;
|
|
144
|
-
max-width: 100%;
|
|
145
|
-
box-sizing: border-box;
|
|
146
|
-
min-width: 200px;
|
|
147
|
-
`;
|
|
148
|
-
|
|
149
|
-
const PlayButton = styled.span<{ $role: 'user' | 'bot' }>`
|
|
150
|
-
font-size: 28px;
|
|
151
|
-
cursor: pointer;
|
|
152
|
-
flex-shrink: 0;
|
|
153
|
-
color: ${props => props.$role === 'user' ? '#667eea' : '#1677ff'};
|
|
154
|
-
display: flex;
|
|
155
|
-
align-items: center;
|
|
156
|
-
transition: opacity 0.2s;
|
|
157
|
-
|
|
158
|
-
&:hover {
|
|
159
|
-
opacity: 0.8;
|
|
160
|
-
}
|
|
161
|
-
`;
|
|
162
|
-
|
|
163
|
-
const AudioBody = styled.div`
|
|
164
|
-
flex: 1;
|
|
165
|
-
display: flex;
|
|
166
|
-
flex-direction: column;
|
|
167
|
-
gap: 6px;
|
|
168
|
-
min-width: 0;
|
|
169
|
-
`;
|
|
170
|
-
|
|
171
|
-
const WaveformContainer = styled.div`
|
|
172
|
-
display: flex;
|
|
173
|
-
align-items: center;
|
|
174
|
-
gap: 2px;
|
|
175
|
-
height: 20px;
|
|
176
|
-
`;
|
|
177
|
-
|
|
178
|
-
const WaveBar = styled.div<{ $active: boolean; $playing: boolean; $delay: number }>`
|
|
179
|
-
width: 3px;
|
|
180
|
-
border-radius: 2px;
|
|
181
|
-
background: ${props => props.$active ? '#667eea' : '#c0c8d8'};
|
|
182
|
-
transition: background 0.15s;
|
|
183
|
-
|
|
184
|
-
${props => props.$playing && props.$active ? css`
|
|
185
|
-
animation: ${waveAnimation} 0.6s ease-in-out infinite;
|
|
186
|
-
animation-delay: ${props.$delay}s;
|
|
187
|
-
` : css`
|
|
188
|
-
height: ${props.$active ? '12px' : `${4 + Math.random() * 10}px`};
|
|
189
|
-
`}
|
|
190
|
-
`;
|
|
191
|
-
|
|
192
|
-
const ProgressBar = styled.div`
|
|
193
|
-
position: relative;
|
|
194
|
-
height: 3px;
|
|
195
|
-
background: rgba(0, 0, 0, 0.08);
|
|
196
|
-
border-radius: 2px;
|
|
197
|
-
cursor: pointer;
|
|
198
|
-
overflow: visible;
|
|
199
|
-
`;
|
|
200
|
-
|
|
201
|
-
const ProgressFill = styled.div<{ $progress: number }>`
|
|
202
|
-
height: 100%;
|
|
203
|
-
background: #667eea;
|
|
204
|
-
border-radius: 2px;
|
|
205
|
-
width: ${props => props.$progress}%;
|
|
206
|
-
transition: width 0.1s linear;
|
|
207
|
-
position: relative;
|
|
208
|
-
|
|
209
|
-
&::after {
|
|
210
|
-
content: '';
|
|
211
|
-
position: absolute;
|
|
212
|
-
right: -4px;
|
|
213
|
-
top: 50%;
|
|
214
|
-
transform: translateY(-50%);
|
|
215
|
-
width: 8px;
|
|
216
|
-
height: 8px;
|
|
217
|
-
border-radius: 50%;
|
|
218
|
-
background: #667eea;
|
|
219
|
-
opacity: 0;
|
|
220
|
-
transition: opacity 0.2s;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
${ProgressBar}:hover &::after {
|
|
224
|
-
opacity: 1;
|
|
225
|
-
}
|
|
226
|
-
`;
|
|
227
|
-
|
|
228
|
-
const AudioDuration = styled.span`
|
|
229
|
-
font-size: 12px;
|
|
230
|
-
color: #888;
|
|
231
|
-
flex-shrink: 0;
|
|
232
|
-
min-width: 32px;
|
|
233
|
-
text-align: right;
|
|
234
|
-
font-variant-numeric: tabular-nums;
|
|
235
|
-
`;
|
|
236
|
-
|
|
237
|
-
// ==================== 语音播放器组件 ====================
|
|
238
|
-
|
|
239
|
-
/** 格式化秒数为 m:ss */
|
|
240
|
-
function formatDuration(seconds: number): string {
|
|
241
|
-
if (!seconds || !isFinite(seconds)) return '0:00';
|
|
242
|
-
const m = Math.floor(seconds / 60);
|
|
243
|
-
const s = Math.floor(seconds % 60);
|
|
244
|
-
return `${m}:${s.toString().padStart(2, '0')}`;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
/** 生成固定的波形条高度 */
|
|
248
|
-
const WAVE_BARS = Array.from({ length: 20 }, (_, i) => {
|
|
249
|
-
// 用正弦函数生成自然的波形高度
|
|
250
|
-
const base = Math.sin((i / 20) * Math.PI) * 12 + 4;
|
|
251
|
-
return Math.max(4, Math.min(18, base + (Math.random() * 4 - 2)));
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
const AudioPlayer: React.FC<{ src: string; role: 'user' | 'bot' }> = ({ src, role }) => {
|
|
255
|
-
const audioRef = useRef<HTMLAudioElement>(null);
|
|
256
|
-
const progressRef = useRef<HTMLDivElement>(null);
|
|
257
|
-
const [isPlaying, setIsPlaying] = useState(false);
|
|
258
|
-
const [currentTime, setCurrentTime] = useState(0);
|
|
259
|
-
const [duration, setDuration] = useState(0);
|
|
260
|
-
|
|
261
|
-
const progress = duration > 0 ? (currentTime / duration) * 100 : 0;
|
|
262
|
-
|
|
263
|
-
useEffect(() => {
|
|
264
|
-
const audio = audioRef.current;
|
|
265
|
-
if (!audio) return;
|
|
266
|
-
|
|
267
|
-
const onLoadedMetadata = () => setDuration(audio.duration);
|
|
268
|
-
const onTimeUpdate = () => setCurrentTime(audio.currentTime);
|
|
269
|
-
const onEnded = () => {
|
|
270
|
-
setIsPlaying(false);
|
|
271
|
-
setCurrentTime(0);
|
|
272
|
-
};
|
|
273
|
-
|
|
274
|
-
audio.addEventListener('loadedmetadata', onLoadedMetadata);
|
|
275
|
-
audio.addEventListener('timeupdate', onTimeUpdate);
|
|
276
|
-
audio.addEventListener('ended', onEnded);
|
|
277
|
-
|
|
278
|
-
return () => {
|
|
279
|
-
audio.removeEventListener('loadedmetadata', onLoadedMetadata);
|
|
280
|
-
audio.removeEventListener('timeupdate', onTimeUpdate);
|
|
281
|
-
audio.removeEventListener('ended', onEnded);
|
|
282
|
-
};
|
|
283
|
-
}, []);
|
|
284
|
-
|
|
285
|
-
const togglePlay = useCallback(() => {
|
|
286
|
-
const audio = audioRef.current;
|
|
287
|
-
if (!audio) return;
|
|
288
|
-
|
|
289
|
-
if (isPlaying) {
|
|
290
|
-
audio.pause();
|
|
291
|
-
setIsPlaying(false);
|
|
292
|
-
} else {
|
|
293
|
-
audio.play().then(() => setIsPlaying(true)).catch(() => {});
|
|
294
|
-
}
|
|
295
|
-
}, [isPlaying]);
|
|
296
|
-
|
|
297
|
-
const handleProgressClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
|
|
298
|
-
const audio = audioRef.current;
|
|
299
|
-
const bar = progressRef.current;
|
|
300
|
-
if (!audio || !bar || !duration) return;
|
|
301
|
-
|
|
302
|
-
const rect = bar.getBoundingClientRect();
|
|
303
|
-
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
304
|
-
audio.currentTime = ratio * duration;
|
|
305
|
-
setCurrentTime(audio.currentTime);
|
|
306
|
-
}, [duration]);
|
|
307
|
-
|
|
308
|
-
return (
|
|
309
|
-
<AudioCard $role={role}>
|
|
310
|
-
<audio ref={audioRef} src={src} preload="metadata" />
|
|
311
|
-
<PlayButton $role={role} onClick={togglePlay}>
|
|
312
|
-
{isPlaying ? <PauseCircleOutlined /> : <PlayCircleOutlined />}
|
|
313
|
-
</PlayButton>
|
|
314
|
-
<AudioBody>
|
|
315
|
-
<WaveformContainer>
|
|
316
|
-
{WAVE_BARS.map((h, i) => {
|
|
317
|
-
const barProgress = (i / WAVE_BARS.length) * 100;
|
|
318
|
-
const isActive = barProgress <= progress;
|
|
319
|
-
return (
|
|
320
|
-
<WaveBar
|
|
321
|
-
key={i}
|
|
322
|
-
$active={isActive}
|
|
323
|
-
$playing={isPlaying}
|
|
324
|
-
$delay={i * 0.05}
|
|
325
|
-
style={!isPlaying || !isActive ? { height: `${h}px` } : undefined}
|
|
326
|
-
/>
|
|
327
|
-
);
|
|
328
|
-
})}
|
|
329
|
-
</WaveformContainer>
|
|
330
|
-
<ProgressBar ref={progressRef} onClick={handleProgressClick}>
|
|
331
|
-
<ProgressFill $progress={progress} />
|
|
332
|
-
</ProgressBar>
|
|
333
|
-
</AudioBody>
|
|
334
|
-
<AudioDuration>
|
|
335
|
-
{isPlaying || currentTime > 0 ? formatDuration(currentTime) : formatDuration(duration)}
|
|
336
|
-
</AudioDuration>
|
|
337
|
-
</AudioCard>
|
|
338
|
-
);
|
|
339
|
-
};
|
|
340
|
-
|
|
341
126
|
// ==================== 工具函数 ====================
|
|
342
127
|
|
|
343
128
|
/** 根据文件名获取文件扩展名 */
|
|
@@ -387,19 +172,14 @@ export const MessageAttachments: React.FC<MessageAttachmentsProps> = ({
|
|
|
387
172
|
role = 'user',
|
|
388
173
|
images,
|
|
389
174
|
files,
|
|
390
|
-
audio,
|
|
391
175
|
}) => {
|
|
392
176
|
const hasImages = images && images.length > 0;
|
|
393
177
|
const hasFiles = files && files.length > 0;
|
|
394
|
-
const hasAudio = !!audio;
|
|
395
178
|
|
|
396
|
-
if (!hasImages && !hasFiles
|
|
179
|
+
if (!hasImages && !hasFiles) return null;
|
|
397
180
|
|
|
398
181
|
return (
|
|
399
182
|
<AttachmentsArea>
|
|
400
|
-
{/* 语音播放器 */}
|
|
401
|
-
{hasAudio && <AudioPlayer src={audio} role={role} />}
|
|
402
|
-
|
|
403
183
|
{/* 图片列表 */}
|
|
404
184
|
{hasImages && (
|
|
405
185
|
<ImagesRow>
|
|
@@ -8,6 +8,7 @@ import React, { useEffect, useState, useCallback } from 'react';
|
|
|
8
8
|
import styled from 'styled-components';
|
|
9
9
|
import { Modal, Image, Space } from 'antd';
|
|
10
10
|
import { MessageAttachments } from './MessageAttachments';
|
|
11
|
+
import { AudioPlayer } from './AudioPlayer';
|
|
11
12
|
import { loadEchartsScript } from '@/utils/loadEcharts';
|
|
12
13
|
import { DocReferences, DocReferenceItem } from './DocReferences';
|
|
13
14
|
import { WebSearchPanel } from './WebSearchPanel';
|
|
@@ -343,6 +344,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
343
344
|
const handleReferenceClick = onReferenceClick || defaultReferenceClick;
|
|
344
345
|
const handleWebSearchClick = onWebSearchClick || defaultWebSearchClick;
|
|
345
346
|
|
|
347
|
+
// 纯语音消息:跳过 StyledBubble 包裹,直接渲染 AudioPlayer
|
|
348
|
+
const isAudioOnly = !!(audio && !content);
|
|
349
|
+
|
|
346
350
|
return (
|
|
347
351
|
<>
|
|
348
352
|
<StyledContainer
|
|
@@ -350,9 +354,12 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
350
354
|
className={`appflow-sdk-message-bubble ${className || ''}`}
|
|
351
355
|
style={style}
|
|
352
356
|
>
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
{
|
|
357
|
+
{isAudioOnly ? (
|
|
358
|
+
/* 纯语音消息:直接渲染播放器,无气泡包裹 */
|
|
359
|
+
<AudioPlayer src={audio} role={role} />
|
|
360
|
+
) : (
|
|
361
|
+
<StyledBubble $role={role}>
|
|
362
|
+
{/* 使用核心组件渲染内容 */}
|
|
356
363
|
<BubbleContent
|
|
357
364
|
content={content}
|
|
358
365
|
status={status}
|
|
@@ -385,18 +392,17 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
|
|
|
385
392
|
</ReferencesContainer>
|
|
386
393
|
)}
|
|
387
394
|
</BubbleContent>
|
|
388
|
-
)}
|
|
389
395
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
/>
|
|
396
|
+
{/* 附件展示区域:图片和文件 */}
|
|
397
|
+
<MessageAttachments
|
|
398
|
+
role={role}
|
|
399
|
+
images={images}
|
|
400
|
+
files={files}
|
|
401
|
+
/>
|
|
397
402
|
|
|
398
|
-
|
|
399
|
-
|
|
403
|
+
{contextHolder}
|
|
404
|
+
</StyledBubble>
|
|
405
|
+
)}
|
|
400
406
|
</StyledContainer>
|
|
401
407
|
|
|
402
408
|
{/* 默认的网页搜索抽屉(仅在用户未传入onWebSearchClick时使用) */}
|
|
@@ -81,6 +81,7 @@ export interface HistoryMessage {
|
|
|
81
81
|
sessionId?: string;
|
|
82
82
|
images?: string[];
|
|
83
83
|
files?: { name: string; url: string }[];
|
|
84
|
+
audio?: string;
|
|
84
85
|
}
|
|
85
86
|
|
|
86
87
|
export interface ChatSession {
|
|
@@ -695,6 +696,23 @@ class ChatService {
|
|
|
695
696
|
// 处理消息列表
|
|
696
697
|
if (Array.isArray(data)) {
|
|
697
698
|
for (const item of data) {
|
|
699
|
+
// 注意:接口返回数据是倒序的(最新在前),最终会 reverse()
|
|
700
|
+
// 因此这里先推入 AI 回复,再推入用户消息,reverse() 后顺序变为 user → assistant
|
|
701
|
+
|
|
702
|
+
// AI回复 - 在 assistant 数组中
|
|
703
|
+
if (item.assistant && Array.isArray(item.assistant) && item.assistant.length > 0) {
|
|
704
|
+
// 取第一个assistant回复
|
|
705
|
+
const assistantMsg = item.assistant[0];
|
|
706
|
+
messages.push({
|
|
707
|
+
id: assistantMsg.messageId || `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
708
|
+
role: 'assistant',
|
|
709
|
+
content: assistantMsg.content || '',
|
|
710
|
+
messageType: assistantMsg.messageType || 'text',
|
|
711
|
+
gmtCreate: item.gmtCreate,
|
|
712
|
+
sessionId: assistantMsg.sessionId || item.sessionId,
|
|
713
|
+
});
|
|
714
|
+
}
|
|
715
|
+
|
|
698
716
|
// 用户消息 - 在 message 字段中
|
|
699
717
|
if (item.message !== undefined && item.message !== null) {
|
|
700
718
|
const messageType = item.messageType || 'text';
|
|
@@ -729,29 +747,19 @@ class ChatService {
|
|
|
729
747
|
content = item.message;
|
|
730
748
|
}
|
|
731
749
|
|
|
750
|
+
// 语音消息:messageType === 'audio' 时,message 是音频文件 URL
|
|
751
|
+
const isAudioMessage = messageType === 'audio';
|
|
752
|
+
|
|
732
753
|
messages.push({
|
|
733
754
|
id: item.id || `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
734
755
|
role: 'user',
|
|
735
|
-
content,
|
|
756
|
+
content: isAudioMessage ? '' : content,
|
|
736
757
|
messageType,
|
|
737
758
|
gmtCreate: item.gmtCreate,
|
|
738
759
|
sessionId: item.sessionId,
|
|
739
760
|
images,
|
|
740
761
|
files,
|
|
741
|
-
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// AI回复 - 在 assistant 数组中
|
|
745
|
-
if (item.assistant && Array.isArray(item.assistant) && item.assistant.length > 0) {
|
|
746
|
-
// 取第一个assistant回复
|
|
747
|
-
const assistantMsg = item.assistant[0];
|
|
748
|
-
messages.push({
|
|
749
|
-
id: assistantMsg.messageId || `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
750
|
-
role: 'assistant',
|
|
751
|
-
content: assistantMsg.content || '',
|
|
752
|
-
messageType: assistantMsg.messageType || 'text',
|
|
753
|
-
gmtCreate: item.gmtCreate,
|
|
754
|
-
sessionId: assistantMsg.sessionId || item.sessionId,
|
|
762
|
+
audio: isAudioMessage ? content : undefined,
|
|
755
763
|
});
|
|
756
764
|
}
|
|
757
765
|
}
|