@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
|
@@ -35,6 +35,7 @@ export interface ModelInfo {
|
|
|
35
35
|
config?: {
|
|
36
36
|
image?: boolean;
|
|
37
37
|
file?: boolean;
|
|
38
|
+
audio?: boolean;
|
|
38
39
|
webSearch?: boolean;
|
|
39
40
|
fileConfig?: string;
|
|
40
41
|
};
|
|
@@ -52,10 +53,18 @@ export interface ModelCapabilities {
|
|
|
52
53
|
};
|
|
53
54
|
}
|
|
54
55
|
|
|
56
|
+
/** 上传结果 */
|
|
57
|
+
export interface UploadResult {
|
|
58
|
+
/** 文件下载URL */
|
|
59
|
+
downloadUrl: string;
|
|
60
|
+
/** 文件ID(仅文件类型有,图片无) */
|
|
61
|
+
fileId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
55
64
|
export interface ChatMessage {
|
|
56
65
|
text?: string;
|
|
57
66
|
images?: string[];
|
|
58
|
-
files?: string
|
|
67
|
+
files?: Array<{ url: string; name: string; fileId?: string }>;
|
|
59
68
|
audio?: string;
|
|
60
69
|
modelId?: string;
|
|
61
70
|
webSearch?: boolean;
|
|
@@ -80,6 +89,7 @@ export interface HistoryMessage {
|
|
|
80
89
|
sessionId?: string;
|
|
81
90
|
images?: string[];
|
|
82
91
|
files?: { name: string; url: string }[];
|
|
92
|
+
audio?: string;
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
export interface ChatSession {
|
|
@@ -269,16 +279,18 @@ class ChatService {
|
|
|
269
279
|
this.sendMessage(message, callbacks);
|
|
270
280
|
|
|
271
281
|
// 返回链式调用对象
|
|
272
|
-
|
|
282
|
+
const stream: ChatStream = {
|
|
273
283
|
onMessage: (callback) => {
|
|
274
284
|
callbacks.onMessage = callback;
|
|
275
|
-
return
|
|
285
|
+
return stream;
|
|
276
286
|
},
|
|
277
287
|
onError: (callback) => {
|
|
278
288
|
callbacks.onError = callback;
|
|
279
|
-
return
|
|
289
|
+
return stream;
|
|
280
290
|
}
|
|
281
291
|
};
|
|
292
|
+
|
|
293
|
+
return stream;
|
|
282
294
|
}
|
|
283
295
|
|
|
284
296
|
/**
|
|
@@ -326,8 +338,12 @@ class ChatService {
|
|
|
326
338
|
richText.push({ type: 'image', mediaUrl: url });
|
|
327
339
|
});
|
|
328
340
|
|
|
329
|
-
message.files?.forEach(
|
|
330
|
-
|
|
341
|
+
message.files?.forEach(fileItem => {
|
|
342
|
+
const entry: any = { type: 'file', mediaUrl: fileItem.url };
|
|
343
|
+
if (fileItem.fileId) {
|
|
344
|
+
entry.mediaId = fileItem.fileId;
|
|
345
|
+
}
|
|
346
|
+
richText.push(entry);
|
|
331
347
|
});
|
|
332
348
|
|
|
333
349
|
requestBody.richText = richText;
|
|
@@ -449,105 +465,134 @@ class ChatService {
|
|
|
449
465
|
}
|
|
450
466
|
|
|
451
467
|
/**
|
|
452
|
-
*
|
|
468
|
+
* 发送上传事件(通用方法)
|
|
469
|
+
* 封装向服务端发送上传相关事件的SSE请求逻辑,支持 uploadToken 和 uploadFile 两种事件类型
|
|
470
|
+
* @param eventType 事件类型:'uploadToken' 获取预签名URL | 'uploadFile' 获取文件ID
|
|
471
|
+
* @param fileName 文件名
|
|
472
|
+
* @param extraData 额外数据(如 uploadFile 时需要传 content: downloadUrl)
|
|
453
473
|
*/
|
|
454
|
-
async
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
474
|
+
private async sendUploadEvent(
|
|
475
|
+
eventType: 'uploadToken' | 'uploadFile',
|
|
476
|
+
fileName: string,
|
|
477
|
+
extraData?: Record<string, any>
|
|
478
|
+
): Promise<any> {
|
|
459
479
|
const domain = this.setupConfig?.domain || '';
|
|
460
480
|
const integrateId = this.config?.integrateId || '';
|
|
481
|
+
const { token, ticket } = await this.getRequestToken();
|
|
482
|
+
|
|
483
|
+
// uploadToken 事件:content 中包含 fileName
|
|
484
|
+
// uploadFile 事件:content 中包含 fileUrl(extraData 传入),不需要 fileName
|
|
485
|
+
const eventContent: any = eventType === 'uploadToken'
|
|
486
|
+
? { fileName, ...extraData }
|
|
487
|
+
: { ...extraData };
|
|
488
|
+
|
|
489
|
+
const requestBody: any = {
|
|
490
|
+
messageType: 'event',
|
|
491
|
+
event: {
|
|
492
|
+
eventType,
|
|
493
|
+
content: JSON.stringify(eventContent)
|
|
494
|
+
}
|
|
495
|
+
};
|
|
461
496
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
'Content-Type': 'application/json',
|
|
470
|
-
'X-Request-Token': token,
|
|
471
|
-
'X-Account-Session-Ticket': ticket,
|
|
472
|
-
},
|
|
473
|
-
body: JSON.stringify({
|
|
474
|
-
messageType: 'event',
|
|
475
|
-
event: {
|
|
476
|
-
eventType: 'uploadToken',
|
|
477
|
-
content: JSON.stringify({ fileName: file.name })
|
|
478
|
-
}
|
|
479
|
-
})
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
// 解析SSE响应获取上传URL
|
|
483
|
-
const reader = uploadTokenResponse.body?.getReader();
|
|
484
|
-
if (!reader) throw new Error('无法读取响应');
|
|
485
|
-
|
|
486
|
-
let uploadInfo: any = null;
|
|
487
|
-
const decoder = new TextDecoder();
|
|
488
|
-
let buffer = '';
|
|
489
|
-
|
|
490
|
-
while (true) {
|
|
491
|
-
const { done, value } = await reader.read();
|
|
492
|
-
if (done) break;
|
|
493
|
-
|
|
494
|
-
buffer += decoder.decode(value, { stream: true });
|
|
495
|
-
const lines = buffer.split('\n');
|
|
497
|
+
// 携带 sessionId 和 chatbotModelId
|
|
498
|
+
if (this.sessionId) {
|
|
499
|
+
requestBody.sessionId = this.sessionId;
|
|
500
|
+
}
|
|
501
|
+
if (this.config?.models?.length) {
|
|
502
|
+
requestBody.chatbotModelId = this.config.models[0]?.id || '';
|
|
503
|
+
}
|
|
496
504
|
|
|
497
|
-
|
|
498
|
-
|
|
505
|
+
const response = await fetch(`${domain}/webhook/chatbot/chat/${integrateId}`, {
|
|
506
|
+
method: 'POST',
|
|
507
|
+
headers: {
|
|
508
|
+
'Content-Type': 'application/json',
|
|
509
|
+
'X-Request-Token': token,
|
|
510
|
+
'X-Account-Session-Ticket': ticket,
|
|
511
|
+
},
|
|
512
|
+
body: JSON.stringify(requestBody)
|
|
513
|
+
});
|
|
499
514
|
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
515
|
+
// 解析SSE响应
|
|
516
|
+
const reader = response.body?.getReader();
|
|
517
|
+
if (!reader) throw new Error('无法读取响应');
|
|
518
|
+
|
|
519
|
+
let result: any = null;
|
|
520
|
+
const decoder = new TextDecoder();
|
|
521
|
+
let buffer = '';
|
|
522
|
+
|
|
523
|
+
while (true) {
|
|
524
|
+
const { done, value } = await reader.read();
|
|
525
|
+
if (done) break;
|
|
526
|
+
|
|
527
|
+
buffer += decoder.decode(value, { stream: true });
|
|
528
|
+
const lines = buffer.split('\n');
|
|
529
|
+
|
|
530
|
+
// 保留最后一行(可能不完整)
|
|
531
|
+
buffer = lines.pop() || '';
|
|
532
|
+
|
|
533
|
+
for (const line of lines) {
|
|
534
|
+
const trimmedLine = line.trim();
|
|
535
|
+
if (trimmedLine.startsWith('data:')) {
|
|
536
|
+
try {
|
|
537
|
+
const jsonStr = trimmedLine.slice(5).trim();
|
|
538
|
+
if (jsonStr) {
|
|
539
|
+
const data = JSON.parse(jsonStr);
|
|
540
|
+
if (data.content) {
|
|
541
|
+
// content 可能是字符串或对象
|
|
542
|
+
const contentData = typeof data.content === 'string'
|
|
543
|
+
? JSON.parse(data.content)
|
|
544
|
+
: data.content;
|
|
545
|
+
result = contentData;
|
|
516
546
|
}
|
|
517
|
-
} catch (e) {
|
|
518
|
-
// 忽略解析错误,继续处理下一行
|
|
519
|
-
console.debug('解析SSE行失败:', line, e);
|
|
520
547
|
}
|
|
548
|
+
} catch (e) {
|
|
549
|
+
console.debug(`解析SSE行失败(${eventType}):`, line, e);
|
|
521
550
|
}
|
|
522
551
|
}
|
|
523
552
|
}
|
|
553
|
+
}
|
|
524
554
|
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
uploadInfo = contentData;
|
|
537
|
-
}
|
|
538
|
-
}
|
|
555
|
+
// 处理buffer中剩余的数据
|
|
556
|
+
if (buffer.trim().startsWith('data:')) {
|
|
557
|
+
try {
|
|
558
|
+
const jsonStr = buffer.trim().slice(5).trim();
|
|
559
|
+
if (jsonStr) {
|
|
560
|
+
const data = JSON.parse(jsonStr);
|
|
561
|
+
if (data.content) {
|
|
562
|
+
const contentData = typeof data.content === 'string'
|
|
563
|
+
? JSON.parse(data.content)
|
|
564
|
+
: data.content;
|
|
565
|
+
result = contentData;
|
|
539
566
|
}
|
|
540
|
-
} catch (e) {
|
|
541
|
-
console.debug('解析SSE剩余数据失败:', buffer, e);
|
|
542
567
|
}
|
|
568
|
+
} catch (e) {
|
|
569
|
+
console.debug(`解析SSE剩余数据失败(${eventType}):`, buffer, e);
|
|
543
570
|
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
return result;
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* 上传文件
|
|
578
|
+
* 非图片文件会额外获取 fileId(用于服务端文件关联)
|
|
579
|
+
* @returns 上传结果,包含 downloadUrl 和可选的 fileId
|
|
580
|
+
*/
|
|
581
|
+
async upload(file: File): Promise<UploadResult> {
|
|
582
|
+
if (!this.isInitialized) {
|
|
583
|
+
throw new Error('请先调用 setup() 初始化SDK');
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
try {
|
|
587
|
+
// 1. 获取上传预签名URL
|
|
588
|
+
const uploadInfo = await this.sendUploadEvent('uploadToken', file.name);
|
|
544
589
|
|
|
545
590
|
if (!uploadInfo?.uploadUrl) {
|
|
546
591
|
console.error('获取上传URL失败,uploadInfo:', uploadInfo);
|
|
547
592
|
throw new Error('获取上传URL失败');
|
|
548
593
|
}
|
|
549
594
|
|
|
550
|
-
//
|
|
595
|
+
// 2. 上传文件到OSS
|
|
551
596
|
const blob = new Blob([file], { type: file.type || 'application/octet-stream' });
|
|
552
597
|
await fetchUploadApi(blob, uploadInfo.uploadUrl);
|
|
553
598
|
|
|
@@ -557,7 +602,21 @@ class ChatService {
|
|
|
557
602
|
throw new Error('获取下载URL失败');
|
|
558
603
|
}
|
|
559
604
|
|
|
560
|
-
|
|
605
|
+
// 3. 非图片文件,额外获取 fileId
|
|
606
|
+
let fileId: string | undefined;
|
|
607
|
+
const isImage = file.type.startsWith('image/');
|
|
608
|
+
if (!isImage) {
|
|
609
|
+
try {
|
|
610
|
+
const fileIdResult = await this.sendUploadEvent('uploadFile', file.name, {
|
|
611
|
+
fileUrl: uploadInfo.downloadUrl,
|
|
612
|
+
});
|
|
613
|
+
fileId = fileIdResult?.fileId;
|
|
614
|
+
} catch (e) {
|
|
615
|
+
console.warn('获取fileId失败,将继续使用downloadUrl:', e);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
return { downloadUrl: uploadInfo.downloadUrl, fileId };
|
|
561
620
|
} catch (error) {
|
|
562
621
|
console.error('上传文件失败:', error);
|
|
563
622
|
throw error;
|
|
@@ -692,6 +751,23 @@ class ChatService {
|
|
|
692
751
|
// 处理消息列表
|
|
693
752
|
if (Array.isArray(data)) {
|
|
694
753
|
for (const item of data) {
|
|
754
|
+
// 注意:接口返回数据是倒序的(最新在前),最终会 reverse()
|
|
755
|
+
// 因此这里先推入 AI 回复,再推入用户消息,reverse() 后顺序变为 user → assistant
|
|
756
|
+
|
|
757
|
+
// AI回复 - 在 assistant 数组中
|
|
758
|
+
if (item.assistant && Array.isArray(item.assistant) && item.assistant.length > 0) {
|
|
759
|
+
// 取第一个assistant回复
|
|
760
|
+
const assistantMsg = item.assistant[0];
|
|
761
|
+
messages.push({
|
|
762
|
+
id: assistantMsg.messageId || `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
763
|
+
role: 'assistant',
|
|
764
|
+
content: assistantMsg.content || '',
|
|
765
|
+
messageType: assistantMsg.messageType || 'text',
|
|
766
|
+
gmtCreate: item.gmtCreate,
|
|
767
|
+
sessionId: assistantMsg.sessionId || item.sessionId,
|
|
768
|
+
});
|
|
769
|
+
}
|
|
770
|
+
|
|
695
771
|
// 用户消息 - 在 message 字段中
|
|
696
772
|
if (item.message !== undefined && item.message !== null) {
|
|
697
773
|
const messageType = item.messageType || 'text';
|
|
@@ -726,35 +802,26 @@ class ChatService {
|
|
|
726
802
|
content = item.message;
|
|
727
803
|
}
|
|
728
804
|
|
|
805
|
+
// 语音消息:messageType === 'audio' 时,message 是音频文件 URL
|
|
806
|
+
const isAudioMessage = messageType === 'audio';
|
|
807
|
+
|
|
729
808
|
messages.push({
|
|
730
809
|
id: item.id || `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
731
810
|
role: 'user',
|
|
732
|
-
content,
|
|
811
|
+
content: isAudioMessage ? '' : content,
|
|
733
812
|
messageType,
|
|
734
813
|
gmtCreate: item.gmtCreate,
|
|
735
814
|
sessionId: item.sessionId,
|
|
736
815
|
images,
|
|
737
816
|
files,
|
|
738
|
-
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
// AI回复 - 在 assistant 数组中
|
|
742
|
-
if (item.assistant && Array.isArray(item.assistant) && item.assistant.length > 0) {
|
|
743
|
-
// 取第一个assistant回复
|
|
744
|
-
const assistantMsg = item.assistant[0];
|
|
745
|
-
messages.push({
|
|
746
|
-
id: assistantMsg.messageId || `assistant_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
747
|
-
role: 'assistant',
|
|
748
|
-
content: assistantMsg.content || '',
|
|
749
|
-
messageType: assistantMsg.messageType || 'text',
|
|
750
|
-
gmtCreate: item.gmtCreate,
|
|
751
|
-
sessionId: assistantMsg.sessionId || item.sessionId,
|
|
817
|
+
audio: isAudioMessage ? content : undefined,
|
|
752
818
|
});
|
|
753
819
|
}
|
|
754
820
|
}
|
|
755
821
|
}
|
|
756
822
|
|
|
757
|
-
|
|
823
|
+
// 接口返回的数据是倒序的(最新的在前),需要反转为正序(最早的在前)
|
|
824
|
+
return messages.reverse();
|
|
758
825
|
}
|
|
759
826
|
|
|
760
827
|
return [];
|