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

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.
@@ -491,6 +491,7 @@ export declare interface HistoryMessage {
491
491
  name: string;
492
492
  url: string;
493
493
  }[];
494
+ audio?: string;
494
495
  }
495
496
 
496
497
  /**
@@ -623,6 +624,8 @@ export declare interface MessageBubbleProps {
623
624
  name: string;
624
625
  url: string;
625
626
  }[];
627
+ /** 语音消息URL */
628
+ audio?: string;
626
629
  /** 自定义类名 */
627
630
  className?: string;
628
631
  /** 自定义样式 */
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.7",
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>
@@ -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;
@@ -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';
@@ -52,6 +53,8 @@ export interface MessageBubbleProps {
52
53
  images?: string[];
53
54
  /** 文件列表(用户消息中上传的文件) */
54
55
  files?: { name: string; url: string }[];
56
+ /** 语音消息URL */
57
+ audio?: string;
55
58
  /** 自定义类名 */
56
59
  className?: string;
57
60
  /** 自定义样式 */
@@ -267,6 +270,7 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
267
270
  references = [],
268
271
  images,
269
272
  files,
273
+ audio,
270
274
  className,
271
275
  style,
272
276
  onReferenceClick,
@@ -340,6 +344,9 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
340
344
  const handleReferenceClick = onReferenceClick || defaultReferenceClick;
341
345
  const handleWebSearchClick = onWebSearchClick || defaultWebSearchClick;
342
346
 
347
+ // 纯语音消息:跳过 StyledBubble 包裹,直接渲染 AudioPlayer
348
+ const isAudioOnly = !!(audio && !content);
349
+
343
350
  return (
344
351
  <>
345
352
  <StyledContainer
@@ -347,50 +354,55 @@ export const MessageBubble: React.FC<MessageBubbleProps> = ({
347
354
  className={`appflow-sdk-message-bubble ${className || ''}`}
348
355
  style={style}
349
356
  >
350
- <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}
357
+ {isAudioOnly ? (
358
+ /* 纯语音消息:直接渲染播放器,无气泡包裹 */
359
+ <AudioPlayer src={audio} role={role} />
360
+ ) : (
361
+ <StyledBubble $role={role}>
362
+ {/* 使用核心组件渲染内容 */}
363
+ <BubbleContent
364
+ content={content}
365
+ status={status}
366
+ role={role}
367
+ >
368
+ {/* HumanVerify事件 - 人工审核表单 */}
369
+ {eventType === 'humanVerify' && humanVerifyData && (
370
+ <HumanVerify
371
+ data={humanVerifyData}
372
+ onSubmit={onHumanVerifySubmit}
373
+ />
374
+ )}
375
+
376
+ {/* HistoryCard 事件 - 历史对话中的审核卡片 */}
377
+ {eventType === 'historyCard' && historyCardData && (
378
+ <HistoryCard
379
+ data={historyCardData}
380
380
  />
381
- </ReferencesContainer>
382
- )}
383
- </BubbleContent>
384
-
385
- {/* 附件展示区域:图片和文件 */}
386
- <MessageAttachments
387
- role={role}
388
- images={images}
389
- files={files}
390
- />
391
-
392
- {contextHolder}
393
- </StyledBubble>
381
+ )}
382
+
383
+ {/* 参考资料 */}
384
+ {references && references.length > 0 && status === 'Success' && (
385
+ <ReferencesContainer>
386
+ <DocReferences
387
+ items={references}
388
+ status={status}
389
+ onItemClick={handleReferenceClick}
390
+ onWebSearchClick={handleWebSearchClick}
391
+ />
392
+ </ReferencesContainer>
393
+ )}
394
+ </BubbleContent>
395
+
396
+ {/* 附件展示区域:图片和文件 */}
397
+ <MessageAttachments
398
+ role={role}
399
+ images={images}
400
+ files={files}
401
+ />
402
+
403
+ {contextHolder}
404
+ </StyledBubble>
405
+ )}
394
406
  </StyledContainer>
395
407
 
396
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 {
@@ -729,15 +730,19 @@ class ChatService {
729
730
  content = item.message;
730
731
  }
731
732
 
733
+ // 语音消息:messageType === 'audio' 时,message 是音频文件 URL
734
+ const isAudioMessage = messageType === 'audio';
735
+
732
736
  messages.push({
733
737
  id: item.id || `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
734
738
  role: 'user',
735
- content,
739
+ content: isAudioMessage ? '' : content,
736
740
  messageType,
737
741
  gmtCreate: item.gmtCreate,
738
742
  sessionId: item.sessionId,
739
743
  images,
740
744
  files,
745
+ audio: isAudioMessage ? content : undefined,
741
746
  });
742
747
  }
743
748
 
@@ -757,7 +762,8 @@ class ChatService {
757
762
  }
758
763
  }
759
764
 
760
- return messages;
765
+ // 接口返回的数据是倒序的(最新的在前),需要反转为正序(最早的在前)
766
+ return messages.reverse();
761
767
  }
762
768
 
763
769
  return [];