@alicloud/appflow-chat 0.0.4-alpha.5 → 0.0.4-alpha.6

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.
@@ -623,6 +623,8 @@ export declare interface MessageBubbleProps {
623
623
  name: string;
624
624
  url: string;
625
625
  }[];
626
+ /** 语音消息URL */
627
+ audio?: string;
626
628
  /** 自定义类名 */
627
629
  className?: string;
628
630
  /** 自定义样式 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alicloud/appflow-chat",
3
- "version": "0.0.4-alpha.5",
3
+ "version": "0.0.4-alpha.6",
4
4
  "description": "Appflow-Chat AI聊天机器人组件库,提供聊天服务和UI组件",
5
5
  "type": "module",
6
6
  "main": "./dist/appflow-chat.cjs.js",
package/src/App.tsx CHANGED
@@ -16,7 +16,12 @@ const mockModels: ModelInfo[] = [
16
16
  const mockUpload = async (file: File): Promise<string> => {
17
17
  return new Promise((resolve) => {
18
18
  setTimeout(() => {
19
- resolve(`https://example.com/files/${file.name}`);
19
+ // 音频文件返回 blob URL,以便本地测试播放
20
+ if (file.type.startsWith('audio/')) {
21
+ resolve(URL.createObjectURL(file));
22
+ } else {
23
+ resolve(`https://example.com/files/${file.name}`);
24
+ }
20
25
  }, 1500);
21
26
  });
22
27
  };
@@ -47,6 +52,7 @@ function App() {
47
52
  status: 'Running' | 'Success' | 'Error';
48
53
  images?: string[];
49
54
  files?: { name: string; url: string }[];
55
+ audio?: string;
50
56
  }
51
57
 
52
58
  const [messages, setMessages] = useState<Message[]>([]);
@@ -54,14 +60,15 @@ function App() {
54
60
  const handleSubmit = (data: ChatSenderSubmitData) => {
55
61
  addLog(`发送消息: text="${data.text}", model=${data.modelId}, images=${data.images.length}, files=${data.files.length}, audio=${data.audio || 'none'}, webSearch=${data.webSearch}`);
56
62
 
57
- // 构建用户消息(包含图片和文件)
63
+ // 构建用户消息(包含图片、文件和语音)
58
64
  const userMsg: Message = {
59
65
  id: `msg-${Date.now()}`,
60
66
  role: 'user',
61
- content: data.text,
67
+ content: data.audio ? '' : data.text,
62
68
  status: 'Success',
63
69
  images: data.images.length > 0 ? data.images : undefined,
64
70
  files: data.files.length > 0 ? data.files : undefined,
71
+ audio: data.audio || undefined,
65
72
  };
66
73
 
67
74
  // 构建 bot 占位消息
@@ -144,6 +151,7 @@ function App() {
144
151
  status={msg.status}
145
152
  images={msg.images}
146
153
  files={msg.files}
154
+ audio={msg.audio}
147
155
  />
148
156
  ))}
149
157
  </div>
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * MessageAttachments - 消息附件展示组件
3
- * 用于在消息气泡中展示上传的图片和文件
3
+ * 用于在消息气泡中展示上传的图片、文件和语音消息
4
4
  */
5
5
 
6
- import React from 'react';
7
- import styled from 'styled-components';
6
+ import React, { useRef, useState, useCallback, useEffect } from 'react';
7
+ import styled, { keyframes, css } from 'styled-components';
8
8
  import { Image } from 'antd';
9
9
  import {
10
10
  FileWordOutlined,
@@ -12,6 +12,8 @@ import {
12
12
  FileExcelOutlined,
13
13
  FileTextOutlined,
14
14
  FileOutlined,
15
+ PlayCircleOutlined,
16
+ PauseCircleOutlined,
15
17
  } from '@ant-design/icons';
16
18
 
17
19
  // ==================== 类型定义 ====================
@@ -23,6 +25,8 @@ export interface MessageAttachmentsProps {
23
25
  images?: string[];
24
26
  /** 文件列表 */
25
27
  files?: { name: string; url: string }[];
28
+ /** 语音消息URL */
29
+ audio?: string;
26
30
  }
27
31
 
28
32
  // ==================== 样式组件 ====================
@@ -123,6 +127,217 @@ const FileCard = styled.a<{ $role: 'user' | 'bot' }>`
123
127
  }
124
128
  `;
125
129
 
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
+
126
341
  // ==================== 工具函数 ====================
127
342
 
128
343
  /** 根据文件名获取文件扩展名 */
@@ -172,14 +387,19 @@ export const MessageAttachments: React.FC<MessageAttachmentsProps> = ({
172
387
  role = 'user',
173
388
  images,
174
389
  files,
390
+ audio,
175
391
  }) => {
176
392
  const hasImages = images && images.length > 0;
177
393
  const hasFiles = files && files.length > 0;
394
+ const hasAudio = !!audio;
178
395
 
179
- if (!hasImages && !hasFiles) return null;
396
+ if (!hasImages && !hasFiles && !hasAudio) return null;
180
397
 
181
398
  return (
182
399
  <AttachmentsArea>
400
+ {/* 语音播放器 */}
401
+ {hasAudio && <AudioPlayer src={audio} role={role} />}
402
+
183
403
  {/* 图片列表 */}
184
404
  {hasImages && (
185
405
  <ImagesRow>
@@ -52,6 +52,8 @@ export interface MessageBubbleProps {
52
52
  images?: string[];
53
53
  /** 文件列表(用户消息中上传的文件) */
54
54
  files?: { name: string; url: string }[];
55
+ /** 语音消息URL */
56
+ audio?: string;
55
57
  /** 自定义类名 */
56
58
  className?: string;
57
59
  /** 自定义样式 */
@@ -267,6 +269,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
267
269
  references = [],
268
270
  images,
269
271
  files,
272
+ audio,
270
273
  className,
271
274
  style,
272
275
  onReferenceClick,
@@ -348,45 +351,48 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
348
351
  style={style}
349
352
  >
350
353
  <StyledBubble $role={role}>
351
- {/* 使用核心组件渲染内容 */}
352
- <BubbleContent
353
- content={content}
354
- status={status}
355
- role={role}
356
- >
357
- {/* HumanVerify事件 - 人工审核表单 */}
358
- {eventType === 'humanVerify' && humanVerifyData && (
359
- <HumanVerify
360
- data={humanVerifyData}
361
- onSubmit={onHumanVerifySubmit}
362
- />
363
- )}
364
-
365
- {/* HistoryCard 事件 - 历史对话中的审核卡片 */}
366
- {eventType === 'historyCard' && historyCardData && (
367
- <HistoryCard
368
- data={historyCardData}
369
- />
370
- )}
371
-
372
- {/* 参考资料 */}
373
- {references && references.length > 0 && status === 'Success' && (
374
- <ReferencesContainer>
375
- <DocReferences
376
- items={references}
377
- status={status}
378
- onItemClick={handleReferenceClick}
379
- onWebSearchClick={handleWebSearchClick}
354
+ {/* 使用核心组件渲染内容(语音消息且无文本时跳过,避免显示加载动画) */}
355
+ {!(audio && !content) && (
356
+ <BubbleContent
357
+ content={content}
358
+ status={status}
359
+ role={role}
360
+ >
361
+ {/* HumanVerify事件 - 人工审核表单 */}
362
+ {eventType === 'humanVerify' && humanVerifyData && (
363
+ <HumanVerify
364
+ data={humanVerifyData}
365
+ onSubmit={onHumanVerifySubmit}
380
366
  />
381
- </ReferencesContainer>
382
- )}
383
- </BubbleContent>
367
+ )}
368
+
369
+ {/* HistoryCard 事件 - 历史对话中的审核卡片 */}
370
+ {eventType === 'historyCard' && historyCardData && (
371
+ <HistoryCard
372
+ data={historyCardData}
373
+ />
374
+ )}
375
+
376
+ {/* 参考资料 */}
377
+ {references && references.length > 0 && status === 'Success' && (
378
+ <ReferencesContainer>
379
+ <DocReferences
380
+ items={references}
381
+ status={status}
382
+ onItemClick={handleReferenceClick}
383
+ onWebSearchClick={handleWebSearchClick}
384
+ />
385
+ </ReferencesContainer>
386
+ )}
387
+ </BubbleContent>
388
+ )}
384
389
 
385
- {/* 附件展示区域:图片和文件 */}
390
+ {/* 附件展示区域:语音、图片和文件 */}
386
391
  <MessageAttachments
387
392
  role={role}
388
393
  images={images}
389
- files={files}
394
+ files={files}
395
+ audio={audio}
390
396
  />
391
397
 
392
398
  {contextHolder}
@@ -757,7 +757,8 @@ class ChatService {
757
757
  }
758
758
  }
759
759
 
760
- return messages;
760
+ // 接口返回的数据是倒序的(最新的在前),需要反转为正序(最早的在前)
761
+ return messages.reverse();
761
762
  }
762
763
 
763
764
  return [];