@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.
- package/dist/appflow-chat.cjs.js +236 -149
- package/dist/appflow-chat.esm.js +5961 -5796
- package/dist/types/index.d.ts +2 -0
- package/package.json +1 -1
- package/src/App.tsx +11 -3
- package/src/components/MessageAttachments.tsx +224 -4
- package/src/components/MessageBubble.tsx +40 -34
- package/src/services/ChatService.ts +2 -1
package/dist/types/index.d.ts
CHANGED
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
382
|
-
|
|
383
|
-
|
|
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}
|