@alicloud/appflow-chat 0.0.3 → 0.0.4-alpha.10
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 +383 -164
- package/dist/appflow-chat.esm.js +12015 -11276
- package/dist/types/index.d.ts +135 -2
- package/package.json +3 -2
- package/src/App.tsx +193 -0
- package/src/components/AudioPlayer.tsx +246 -0
- package/src/components/ChatSender.tsx +679 -0
- package/src/components/MessageAttachments.tsx +227 -0
- package/src/components/MessageBubble.tsx +66 -40
- package/src/index.ts +3 -0
- package/src/main.tsx +9 -0
- package/src/markdown/index.tsx +21 -2
- package/src/services/ChatService.ts +168 -101
package/dist/types/index.d.ts
CHANGED
|
@@ -51,6 +51,26 @@ export declare interface BubbleContentProps {
|
|
|
51
51
|
waitingMessage?: string;
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/** 附件信息(上传完成后携带下载URL) */
|
|
55
|
+
export declare interface ChatAttachment {
|
|
56
|
+
/** 文件唯一标识 */
|
|
57
|
+
uid: string;
|
|
58
|
+
/** 文件名 */
|
|
59
|
+
name: string;
|
|
60
|
+
/** 上传状态 */
|
|
61
|
+
status: 'uploading' | 'done' | 'error';
|
|
62
|
+
/** 文件类型:image 或 file */
|
|
63
|
+
type: 'image' | 'file';
|
|
64
|
+
/** 上传后的下载URL */
|
|
65
|
+
url?: string;
|
|
66
|
+
/** 本地预览URL(图片) */
|
|
67
|
+
thumbUrl?: string;
|
|
68
|
+
/** 原始文件对象 */
|
|
69
|
+
originFile?: File;
|
|
70
|
+
/** 文件ID(仅文件类型有) */
|
|
71
|
+
fileId?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
54
74
|
export declare interface ChatConfig {
|
|
55
75
|
welcome: string;
|
|
56
76
|
questions: string[];
|
|
@@ -68,12 +88,96 @@ export declare interface ChatConfig {
|
|
|
68
88
|
export declare interface ChatMessage {
|
|
69
89
|
text?: string;
|
|
70
90
|
images?: string[];
|
|
71
|
-
files?:
|
|
91
|
+
files?: Array<{
|
|
92
|
+
url: string;
|
|
93
|
+
name: string;
|
|
94
|
+
fileId?: string;
|
|
95
|
+
}>;
|
|
72
96
|
audio?: string;
|
|
73
97
|
modelId?: string;
|
|
74
98
|
webSearch?: boolean;
|
|
75
99
|
}
|
|
76
100
|
|
|
101
|
+
/**
|
|
102
|
+
* ChatSender - 聊天输入框组件
|
|
103
|
+
*
|
|
104
|
+
* 集成了文本输入、文件/图片上传、语音输入、联网搜索、模型选择等功能。
|
|
105
|
+
* 根据模型能力自动控制功能按钮的显隐。
|
|
106
|
+
*
|
|
107
|
+
* @example
|
|
108
|
+
* ```tsx
|
|
109
|
+
* <ChatSender
|
|
110
|
+
* loading={isLoading}
|
|
111
|
+
* models={config.models}
|
|
112
|
+
* capabilities={chatService.getModelCapabilities(modelId)}
|
|
113
|
+
* onSubmit={({ text, images, files, modelId, webSearch }) => {
|
|
114
|
+
* chatService.chat({ text, images, files, modelId, webSearch });
|
|
115
|
+
* }}
|
|
116
|
+
* onCancel={() => chatService.cancel()}
|
|
117
|
+
* onClear={() => chatService.clear()}
|
|
118
|
+
* onUpload={(file) => chatService.upload(file)}
|
|
119
|
+
* />
|
|
120
|
+
* ```
|
|
121
|
+
*/
|
|
122
|
+
export declare const ChatSender: default_2.FC<ChatSenderProps>;
|
|
123
|
+
|
|
124
|
+
export declare interface ChatSenderProps {
|
|
125
|
+
/** 是否处于加载状态(AI正在回复) */
|
|
126
|
+
loading?: boolean;
|
|
127
|
+
/** 是否禁用 */
|
|
128
|
+
disabled?: boolean;
|
|
129
|
+
/** 输入框占位文本 */
|
|
130
|
+
placeholder?: string;
|
|
131
|
+
/** 提交方式:'enter' 回车发送 | 'shiftEnter' Shift+回车发送 */
|
|
132
|
+
submitType?: 'enter' | 'shiftEnter';
|
|
133
|
+
/** 可用模型列表,传入且长度>1时显示模型选择下拉框 */
|
|
134
|
+
models?: ModelInfo[];
|
|
135
|
+
/** 当前选中的模型ID */
|
|
136
|
+
modelId?: string;
|
|
137
|
+
/** 默认选中的模型ID(非受控) */
|
|
138
|
+
defaultModelId?: string;
|
|
139
|
+
/** 模型切换回调 */
|
|
140
|
+
onModelChange?: (modelId: string) => void;
|
|
141
|
+
/**
|
|
142
|
+
* 模型能力配置,控制功能按钮的显隐
|
|
143
|
+
* 不传时默认所有功能关闭
|
|
144
|
+
*/
|
|
145
|
+
capabilities?: ModelCapabilities;
|
|
146
|
+
/** 提交消息回调 */
|
|
147
|
+
onSubmit?: (data: ChatSenderSubmitData) => void;
|
|
148
|
+
/** 取消当前请求 */
|
|
149
|
+
onCancel?: () => void;
|
|
150
|
+
/** 文件上传方法,返回下载URL和可选的fileId */
|
|
151
|
+
onUpload?: (file: File) => Promise<{
|
|
152
|
+
downloadUrl: string;
|
|
153
|
+
fileId?: string;
|
|
154
|
+
}>;
|
|
155
|
+
/** 自定义类名 */
|
|
156
|
+
className?: string;
|
|
157
|
+
/** 自定义样式 */
|
|
158
|
+
style?: default_2.CSSProperties;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** 提交时的消息数据 */
|
|
162
|
+
export declare interface ChatSenderSubmitData {
|
|
163
|
+
/** 文本内容 */
|
|
164
|
+
text: string;
|
|
165
|
+
/** 图片URL列表 */
|
|
166
|
+
images: string[];
|
|
167
|
+
/** 文件列表(包含文件名、URL和可选的fileId) */
|
|
168
|
+
files: {
|
|
169
|
+
name: string;
|
|
170
|
+
url: string;
|
|
171
|
+
fileId?: string;
|
|
172
|
+
}[];
|
|
173
|
+
/** 语音文件URL(录音上传后的下载地址) */
|
|
174
|
+
audio?: string;
|
|
175
|
+
/** 选中的模型ID */
|
|
176
|
+
modelId?: string;
|
|
177
|
+
/** 是否启用联网搜索 */
|
|
178
|
+
webSearch: boolean;
|
|
179
|
+
}
|
|
180
|
+
|
|
77
181
|
export declare class ChatService {
|
|
78
182
|
private config;
|
|
79
183
|
private setupConfig;
|
|
@@ -104,10 +208,20 @@ export declare class ChatService {
|
|
|
104
208
|
* 发送消息的内部实现
|
|
105
209
|
*/
|
|
106
210
|
private sendMessage;
|
|
211
|
+
/**
|
|
212
|
+
* 发送上传事件(通用方法)
|
|
213
|
+
* 封装向服务端发送上传相关事件的SSE请求逻辑,支持 uploadToken 和 uploadFile 两种事件类型
|
|
214
|
+
* @param eventType 事件类型:'uploadToken' 获取预签名URL | 'uploadFile' 获取文件ID
|
|
215
|
+
* @param fileName 文件名
|
|
216
|
+
* @param extraData 额外数据(如 uploadFile 时需要传 content: downloadUrl)
|
|
217
|
+
*/
|
|
218
|
+
private sendUploadEvent;
|
|
107
219
|
/**
|
|
108
220
|
* 上传文件
|
|
221
|
+
* 非图片文件会额外获取 fileId(用于服务端文件关联)
|
|
222
|
+
* @returns 上传结果,包含 downloadUrl 和可选的 fileId
|
|
109
223
|
*/
|
|
110
|
-
upload(file: File): Promise<
|
|
224
|
+
upload(file: File): Promise<UploadResult>;
|
|
111
225
|
/**
|
|
112
226
|
* 清除会话
|
|
113
227
|
*/
|
|
@@ -397,6 +511,7 @@ export declare interface HistoryMessage {
|
|
|
397
511
|
name: string;
|
|
398
512
|
url: string;
|
|
399
513
|
}[];
|
|
514
|
+
audio?: string;
|
|
400
515
|
}
|
|
401
516
|
|
|
402
517
|
/**
|
|
@@ -522,6 +637,15 @@ export declare interface MessageBubbleProps {
|
|
|
522
637
|
status?: 'Running' | 'Success' | 'Error';
|
|
523
638
|
/** 参考资料列表 */
|
|
524
639
|
references?: DocReferenceItem[];
|
|
640
|
+
/** 图片URL列表(用户消息中上传的图片) */
|
|
641
|
+
images?: string[];
|
|
642
|
+
/** 文件列表(用户消息中上传的文件) */
|
|
643
|
+
files?: {
|
|
644
|
+
name: string;
|
|
645
|
+
url: string;
|
|
646
|
+
}[];
|
|
647
|
+
/** 语音消息URL */
|
|
648
|
+
audio?: string;
|
|
525
649
|
/** 自定义类名 */
|
|
526
650
|
className?: string;
|
|
527
651
|
/** 自定义样式 */
|
|
@@ -558,6 +682,7 @@ export declare interface ModelInfo {
|
|
|
558
682
|
config?: {
|
|
559
683
|
image?: boolean;
|
|
560
684
|
file?: boolean;
|
|
685
|
+
audio?: boolean;
|
|
561
686
|
webSearch?: boolean;
|
|
562
687
|
fileConfig?: string;
|
|
563
688
|
};
|
|
@@ -766,6 +891,14 @@ declare interface UploadRequestParams {
|
|
|
766
891
|
content?: string;
|
|
767
892
|
}
|
|
768
893
|
|
|
894
|
+
/** 上传结果 */
|
|
895
|
+
export declare interface UploadResult {
|
|
896
|
+
/** 文件下载URL */
|
|
897
|
+
downloadUrl: string;
|
|
898
|
+
/** 文件ID(仅文件类型有,图片无) */
|
|
899
|
+
fileId?: string;
|
|
900
|
+
}
|
|
901
|
+
|
|
769
902
|
/**
|
|
770
903
|
* 上传发送方法类型
|
|
771
904
|
* 用于发送上传相关的事件请求
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alicloud/appflow-chat",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.4-alpha.10",
|
|
4
4
|
"description": "Appflow-Chat AI聊天机器人组件库,提供聊天服务和UI组件",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/appflow-chat.cjs.js",
|
|
@@ -60,7 +60,8 @@
|
|
|
60
60
|
"peerDependencies": {
|
|
61
61
|
"antd": "^5.0.0",
|
|
62
62
|
"react": "^18.0.0",
|
|
63
|
-
"react-dom": "^18.0.0"
|
|
63
|
+
"react-dom": "^18.0.0",
|
|
64
|
+
"@ant-design/x": "1.6.1"
|
|
64
65
|
},
|
|
65
66
|
"files": [
|
|
66
67
|
"dist",
|
package/src/App.tsx
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { ConfigProvider } from 'antd';
|
|
3
|
+
import { ChatSender } from './components/ChatSender';
|
|
4
|
+
import { MessageBubble } from './components/MessageBubble';
|
|
5
|
+
import type { ChatSenderSubmitData } from './components/ChatSender';
|
|
6
|
+
import type { ModelInfo, ModelCapabilities } from './services/ChatService';
|
|
7
|
+
|
|
8
|
+
// 模拟模型列表
|
|
9
|
+
const mockModels: ModelInfo[] = [
|
|
10
|
+
{ id: 'model-1', name: '通义千问-VL', config: { image: true, file: true, webSearch: true , audio: true } },
|
|
11
|
+
{ id: 'model-2', name: '通义千问-Max', config: { image: false, file: false, webSearch: true } },
|
|
12
|
+
{ id: 'model-3', name: 'DeepSeek-R1', config: { image: false, file: false, webSearch: false } },
|
|
13
|
+
];
|
|
14
|
+
|
|
15
|
+
// 模拟上传
|
|
16
|
+
const mockUpload = async (file: File): Promise<{ downloadUrl: string; fileId?: string }> => {
|
|
17
|
+
return new Promise((resolve) => {
|
|
18
|
+
setTimeout(() => {
|
|
19
|
+
// 音频文件返回 blob URL,以便本地测试播放
|
|
20
|
+
if (file.type.startsWith('audio/')) {
|
|
21
|
+
resolve({ downloadUrl: URL.createObjectURL(file) });
|
|
22
|
+
} else if (file.type.startsWith('image/')) {
|
|
23
|
+
resolve({ downloadUrl: `https://example.com/files/${file.name}` });
|
|
24
|
+
} else {
|
|
25
|
+
// 非图片文件模拟返回 fileId
|
|
26
|
+
resolve({ downloadUrl: `https://example.com/files/${file.name}`, fileId: `mock_fid_${Date.now()}` });
|
|
27
|
+
}
|
|
28
|
+
}, 1500);
|
|
29
|
+
});
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function App() {
|
|
33
|
+
const [loading, setLoading] = useState(false);
|
|
34
|
+
const [selectedModelId, setSelectedModelId] = useState(mockModels[0].id);
|
|
35
|
+
const [logs, setLogs] = useState<string[]>([]);
|
|
36
|
+
|
|
37
|
+
// 根据选中模型计算能力
|
|
38
|
+
const currentModel = mockModels.find(m => m.id === selectedModelId) || mockModels[0];
|
|
39
|
+
const capabilities: ModelCapabilities = {
|
|
40
|
+
image: currentModel.config?.image ?? false,
|
|
41
|
+
file: currentModel.config?.file ?? false,
|
|
42
|
+
audio: currentModel.config?.audio ?? false,
|
|
43
|
+
webSearch: currentModel.config?.webSearch ?? false,
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const addLog = (message: string) => {
|
|
47
|
+
setLogs(prev => [`[${new Date().toLocaleTimeString()}] ${message}`, ...prev].slice(0, 50));
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// 消息列表
|
|
51
|
+
interface Message {
|
|
52
|
+
id: string;
|
|
53
|
+
role: 'user' | 'bot';
|
|
54
|
+
content: string;
|
|
55
|
+
status: 'Running' | 'Success' | 'Error';
|
|
56
|
+
images?: string[];
|
|
57
|
+
files?: { name: string; url: string }[];
|
|
58
|
+
audio?: string;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const [messages, setMessages] = useState<Message[]>([]);
|
|
62
|
+
|
|
63
|
+
const handleSubmit = (data: ChatSenderSubmitData) => {
|
|
64
|
+
addLog(`发送消息: text="${data.text}", model=${data.modelId}, images=${data.images.length}, files=${data.files.length}, audio=${data.audio || 'none'}, webSearch=${data.webSearch}`);
|
|
65
|
+
|
|
66
|
+
// 构建用户消息(包含图片、文件和语音)
|
|
67
|
+
const userMsg: Message = {
|
|
68
|
+
id: `msg-${Date.now()}`,
|
|
69
|
+
role: 'user',
|
|
70
|
+
content: data.audio ? '' : data.text,
|
|
71
|
+
status: 'Success',
|
|
72
|
+
images: data.images.length > 0 ? data.images : undefined,
|
|
73
|
+
files: data.files.length > 0 ? data.files : undefined,
|
|
74
|
+
audio: data.audio || undefined,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// 构建 bot 占位消息
|
|
78
|
+
const botMsg: Message = {
|
|
79
|
+
id: `msg-${Date.now() + 1}`,
|
|
80
|
+
role: 'bot',
|
|
81
|
+
content: '',
|
|
82
|
+
status: 'Running',
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
setMessages(prev => [...prev, userMsg, botMsg]);
|
|
86
|
+
setLoading(true);
|
|
87
|
+
|
|
88
|
+
// 模拟 AI 回复
|
|
89
|
+
setTimeout(() => {
|
|
90
|
+
setMessages(prev => prev.map(m =>
|
|
91
|
+
m.id === botMsg.id
|
|
92
|
+
? { ...m, content: '收到你的消息!这是一条模拟的 AI 回复。', status: 'Success' as const }
|
|
93
|
+
: m
|
|
94
|
+
));
|
|
95
|
+
setLoading(false);
|
|
96
|
+
addLog('AI 回复完成');
|
|
97
|
+
}, 2000);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const handleCancel = () => {
|
|
101
|
+
setLoading(false);
|
|
102
|
+
addLog('取消请求');
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// 切换能力的控制面板
|
|
106
|
+
const [showUpload, setShowUpload] = useState(true);
|
|
107
|
+
const [showAudio, setShowAudio] = useState(false);
|
|
108
|
+
|
|
109
|
+
const adjustedCapabilities: ModelCapabilities = {
|
|
110
|
+
...capabilities,
|
|
111
|
+
image: showUpload && capabilities.image,
|
|
112
|
+
file: showUpload && capabilities.file,
|
|
113
|
+
audio: showAudio,
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<ConfigProvider>
|
|
118
|
+
<div style={{ maxWidth: 800, margin: '40px auto', padding: '0 20px', fontFamily: '-apple-system, BlinkMacSystemFont, sans-serif' }}>
|
|
119
|
+
<h2 style={{ marginBottom: 24, color: '#333' }}>ChatSender 组件预览</h2>
|
|
120
|
+
|
|
121
|
+
{/* 控制面板 */}
|
|
122
|
+
<div style={{ marginBottom: 24, padding: 16, background: '#f5f5f5', borderRadius: 8 }}>
|
|
123
|
+
<h4 style={{ margin: '0 0 12px 0', color: '#666' }}>功能开关(模拟 capabilities 控制)</h4>
|
|
124
|
+
<div style={{ display: 'flex', gap: 16, flexWrap: 'wrap' }}>
|
|
125
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
|
126
|
+
<input type="checkbox" checked={showUpload} onChange={e => setShowUpload(e.target.checked)} />
|
|
127
|
+
文件/图片上传
|
|
128
|
+
</label>
|
|
129
|
+
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
|
|
130
|
+
<input type="checkbox" checked={showAudio} onChange={e => setShowAudio(e.target.checked)} />
|
|
131
|
+
语音输入
|
|
132
|
+
</label>
|
|
133
|
+
<span style={{ color: '#999', fontSize: 13 }}>
|
|
134
|
+
当前模型: <strong>{currentModel.name}</strong>
|
|
135
|
+
{capabilities.image && ' | 支持图片'}
|
|
136
|
+
{capabilities.file && ' | 支持文件'}
|
|
137
|
+
</span>
|
|
138
|
+
</div>
|
|
139
|
+
</div>
|
|
140
|
+
|
|
141
|
+
{/* 消息列表 */}
|
|
142
|
+
<div style={{ marginBottom: 24, padding: 16, background: '#fff', borderRadius: 8, minHeight: 200, maxHeight: 500, overflow: 'auto', border: '1px solid #f0f0f0' }}>
|
|
143
|
+
{messages.length === 0 && (
|
|
144
|
+
<div style={{ color: '#999', textAlign: 'center', padding: 40 }}>
|
|
145
|
+
发送消息后,消息气泡将在此展示(支持图片和文件)
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
|
|
149
|
+
{messages.map(msg => (
|
|
150
|
+
<MessageBubble
|
|
151
|
+
key={msg.id}
|
|
152
|
+
content={msg.content}
|
|
153
|
+
role={msg.role}
|
|
154
|
+
status={msg.status}
|
|
155
|
+
images={msg.images}
|
|
156
|
+
files={msg.files}
|
|
157
|
+
audio={msg.audio}
|
|
158
|
+
/>
|
|
159
|
+
))}
|
|
160
|
+
</div>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
{/* ChatSender 组件 */}
|
|
164
|
+
<div style={{ marginBottom: 24 }}>
|
|
165
|
+
<ChatSender
|
|
166
|
+
loading={loading}
|
|
167
|
+
models={mockModels}
|
|
168
|
+
modelId={selectedModelId}
|
|
169
|
+
onModelChange={setSelectedModelId}
|
|
170
|
+
capabilities={adjustedCapabilities}
|
|
171
|
+
placeholder="输入消息,按 Enter 发送..."
|
|
172
|
+
onSubmit={handleSubmit}
|
|
173
|
+
onCancel={handleCancel}
|
|
174
|
+
onUpload={mockUpload}
|
|
175
|
+
/>
|
|
176
|
+
</div>
|
|
177
|
+
|
|
178
|
+
{/* 日志区域 */}
|
|
179
|
+
<div style={{ padding: 16, background: '#1e1e1e', borderRadius: 8, maxHeight: 300, overflow: 'auto' }}>
|
|
180
|
+
<h4 style={{ margin: '0 0 8px 0', color: '#888', fontSize: 13 }}>事件日志</h4>
|
|
181
|
+
{logs.length === 0 && <div style={{ color: '#666', fontSize: 13 }}>暂无日志,尝试发送消息或上传文件...</div>}
|
|
182
|
+
{logs.map((log, index) => (
|
|
183
|
+
<div key={index} style={{ color: '#4ec9b0', fontSize: 13, lineHeight: 1.6, fontFamily: 'monospace' }}>
|
|
184
|
+
{log}
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
</div>
|
|
189
|
+
</ConfigProvider>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export default App;
|
|
@@ -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;
|