@alicloud/appflow-chat 0.0.4-alpha.8 → 0.0.4-alpha.9

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.
@@ -67,6 +67,8 @@ export declare interface ChatAttachment {
67
67
  thumbUrl?: string;
68
68
  /** 原始文件对象 */
69
69
  originFile?: File;
70
+ /** 文件ID(仅文件类型有) */
71
+ fileId?: string;
70
72
  }
71
73
 
72
74
  export declare interface ChatConfig {
@@ -86,7 +88,11 @@ export declare interface ChatConfig {
86
88
  export declare interface ChatMessage {
87
89
  text?: string;
88
90
  images?: string[];
89
- files?: string[];
91
+ files?: Array<{
92
+ url: string;
93
+ name: string;
94
+ fileId?: string;
95
+ }>;
90
96
  audio?: string;
91
97
  modelId?: string;
92
98
  webSearch?: boolean;
@@ -141,8 +147,11 @@ export declare interface ChatSenderProps {
141
147
  onSubmit?: (data: ChatSenderSubmitData) => void;
142
148
  /** 取消当前请求 */
143
149
  onCancel?: () => void;
144
- /** 文件上传方法,返回下载URL */
145
- onUpload?: (file: File) => Promise<string>;
150
+ /** 文件上传方法,返回下载URL和可选的fileId */
151
+ onUpload?: (file: File) => Promise<{
152
+ downloadUrl: string;
153
+ fileId?: string;
154
+ }>;
146
155
  /** 自定义类名 */
147
156
  className?: string;
148
157
  /** 自定义样式 */
@@ -155,10 +164,11 @@ export declare interface ChatSenderSubmitData {
155
164
  text: string;
156
165
  /** 图片URL列表 */
157
166
  images: string[];
158
- /** 文件列表(包含文件名和URL) */
167
+ /** 文件列表(包含文件名、URL和可选的fileId) */
159
168
  files: {
160
169
  name: string;
161
170
  url: string;
171
+ fileId?: string;
162
172
  }[];
163
173
  /** 语音文件URL(录音上传后的下载地址) */
164
174
  audio?: string;
@@ -198,10 +208,20 @@ export declare class ChatService {
198
208
  * 发送消息的内部实现
199
209
  */
200
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;
201
219
  /**
202
220
  * 上传文件
221
+ * 非图片文件会额外获取 fileId(用于服务端文件关联)
222
+ * @returns 上传结果,包含 downloadUrl 和可选的 fileId
203
223
  */
204
- upload(file: File): Promise<string>;
224
+ upload(file: File): Promise<UploadResult>;
205
225
  /**
206
226
  * 清除会话
207
227
  */
@@ -871,6 +891,14 @@ declare interface UploadRequestParams {
871
891
  content?: string;
872
892
  }
873
893
 
894
+ /** 上传结果 */
895
+ export declare interface UploadResult {
896
+ /** 文件下载URL */
897
+ downloadUrl: string;
898
+ /** 文件ID(仅文件类型有,图片无) */
899
+ fileId?: string;
900
+ }
901
+
874
902
  /**
875
903
  * 上传发送方法类型
876
904
  * 用于发送上传相关的事件请求
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@alicloud/appflow-chat",
3
- "version": "0.0.4-alpha.8",
3
+ "version": "0.0.4-alpha.9",
4
4
  "description": "Appflow-Chat AI聊天机器人组件库,提供聊天服务和UI组件",
5
5
  "type": "module",
6
6
  "main": "./dist/appflow-chat.cjs.js",
package/src/App.tsx CHANGED
@@ -13,14 +13,17 @@ const mockModels: ModelInfo[] = [
13
13
  ];
14
14
 
15
15
  // 模拟上传
16
- const mockUpload = async (file: File): Promise<string> => {
16
+ const mockUpload = async (file: File): Promise<{ downloadUrl: string; fileId?: string }> => {
17
17
  return new Promise((resolve) => {
18
18
  setTimeout(() => {
19
19
  // 音频文件返回 blob URL,以便本地测试播放
20
20
  if (file.type.startsWith('audio/')) {
21
- resolve(URL.createObjectURL(file));
21
+ resolve({ downloadUrl: URL.createObjectURL(file) });
22
+ } else if (file.type.startsWith('image/')) {
23
+ resolve({ downloadUrl: `https://example.com/files/${file.name}` });
22
24
  } else {
23
- resolve(`https://example.com/files/${file.name}`);
25
+ // 非图片文件模拟返回 fileId
26
+ resolve({ downloadUrl: `https://example.com/files/${file.name}`, fileId: `mock_fid_${Date.now()}` });
24
27
  }
25
28
  }, 1500);
26
29
  });
@@ -49,6 +49,8 @@ export interface ChatAttachment {
49
49
  thumbUrl?: string;
50
50
  /** 原始文件对象 */
51
51
  originFile?: File;
52
+ /** 文件ID(仅文件类型有) */
53
+ fileId?: string;
52
54
  }
53
55
 
54
56
  /** 提交时的消息数据 */
@@ -57,8 +59,8 @@ export interface ChatSenderSubmitData {
57
59
  text: string;
58
60
  /** 图片URL列表 */
59
61
  images: string[];
60
- /** 文件列表(包含文件名和URL) */
61
- files: { name: string; url: string }[];
62
+ /** 文件列表(包含文件名、URL和可选的fileId) */
63
+ files: { name: string; url: string; fileId?: string }[];
62
64
  /** 语音文件URL(录音上传后的下载地址) */
63
65
  audio?: string;
64
66
  /** 选中的模型ID */
@@ -102,8 +104,8 @@ export interface ChatSenderProps {
102
104
  onSubmit?: (data: ChatSenderSubmitData) => void;
103
105
  /** 取消当前请求 */
104
106
  onCancel?: () => void;
105
- /** 文件上传方法,返回下载URL */
106
- onUpload?: (file: File) => Promise<string>;
107
+ /** 文件上传方法,返回下载URL和可选的fileId */
108
+ onUpload?: (file: File) => Promise<{ downloadUrl: string; fileId?: string }>;
107
109
 
108
110
  // ==================== 样式 ====================
109
111
 
@@ -330,9 +332,9 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
330
332
  setHeaderOpen(true);
331
333
 
332
334
  try {
333
- const downloadUrl = await onUpload(file);
335
+ const result = await onUpload(file);
334
336
  setAttachments(prev =>
335
- prev.map(a => a.uid === uid ? { ...a, status: 'done' as const, url: downloadUrl } : a)
337
+ prev.map(a => a.uid === uid ? { ...a, status: 'done' as const, url: result.downloadUrl, fileId: result.fileId } : a)
336
338
  );
337
339
  } catch {
338
340
  setAttachments(prev =>
@@ -353,7 +355,7 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
353
355
 
354
356
  const files = attachments
355
357
  .filter(a => a.type === 'file' && a.status === 'done' && a.url)
356
- .map(a => ({ name: a.name, url: a.url! }));
358
+ .map(a => ({ name: a.name, url: a.url!, fileId: a.fileId }));
357
359
 
358
360
  onSubmit?.({
359
361
  text: text.trim(),
@@ -518,14 +520,14 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
518
520
 
519
521
  try {
520
522
  // 上传音频文件
521
- const audioUrl = await onUpload(audioFile);
523
+ const result = await onUpload(audioFile);
522
524
 
523
525
  // 发送语音消息(audio 优先级最高,服务端会忽略 text/images/files)
524
526
  onSubmit?.({
525
527
  text: '',
526
528
  images: [],
527
529
  files: [],
528
- audio: audioUrl,
530
+ audio: result.downloadUrl,
529
531
  modelId: currentModelId,
530
532
  webSearch: false,
531
533
  });
package/src/index.ts CHANGED
@@ -20,6 +20,7 @@ export type {
20
20
  ChatStream,
21
21
  HistoryMessage,
22
22
  ChatSession,
23
+ UploadResult,
23
24
  } from './services/ChatService';
24
25
 
25
26
  // ==================== UI 组件导出(简化接口,包含默认交互) ====================
@@ -53,10 +53,18 @@ export interface ModelCapabilities {
53
53
  };
54
54
  }
55
55
 
56
+ /** 上传结果 */
57
+ export interface UploadResult {
58
+ /** 文件下载URL */
59
+ downloadUrl: string;
60
+ /** 文件ID(仅文件类型有,图片无) */
61
+ fileId?: string;
62
+ }
63
+
56
64
  export interface ChatMessage {
57
65
  text?: string;
58
66
  images?: string[];
59
- files?: string[];
67
+ files?: Array<{ url: string; name: string; fileId?: string }>;
60
68
  audio?: string;
61
69
  modelId?: string;
62
70
  webSearch?: boolean;
@@ -330,8 +338,12 @@ class ChatService {
330
338
  richText.push({ type: 'image', mediaUrl: url });
331
339
  });
332
340
 
333
- message.files?.forEach(url => {
334
- richText.push({ type: 'file', mediaUrl: url });
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);
335
347
  });
336
348
 
337
349
  requestBody.richText = richText;
@@ -453,105 +465,120 @@ class ChatService {
453
465
  }
454
466
 
455
467
  /**
456
- * 上传文件
468
+ * 发送上传事件(通用方法)
469
+ * 封装向服务端发送上传相关事件的SSE请求逻辑,支持 uploadToken 和 uploadFile 两种事件类型
470
+ * @param eventType 事件类型:'uploadToken' 获取预签名URL | 'uploadFile' 获取文件ID
471
+ * @param fileName 文件名
472
+ * @param extraData 额外数据(如 uploadFile 时需要传 content: downloadUrl)
457
473
  */
458
- async upload(file: File): Promise<string> {
459
- if (!this.isInitialized) {
460
- throw new Error('请先调用 setup() 初始化SDK');
461
- }
462
-
474
+ private async sendUploadEvent(
475
+ eventType: 'uploadToken' | 'uploadFile',
476
+ fileName: string,
477
+ extraData?: Record<string, any>
478
+ ): Promise<any> {
463
479
  const domain = this.setupConfig?.domain || '';
464
480
  const integrateId = this.config?.integrateId || '';
481
+ const { token, ticket } = await this.getRequestToken();
465
482
 
466
- try {
467
- const { token, ticket } = await this.getRequestToken();
483
+ const eventContent: any = { fileName, ...extraData };
468
484
 
469
- // 获取上传预签名URL
470
- const uploadTokenResponse = await fetch(`${domain}/webhook/chatbot/chat/${integrateId}`, {
471
- method: 'POST',
472
- headers: {
473
- 'Content-Type': 'application/json',
474
- 'X-Request-Token': token,
475
- 'X-Account-Session-Ticket': ticket,
476
- },
477
- body: JSON.stringify({
478
- messageType: 'event',
479
- event: {
480
- eventType: 'uploadToken',
481
- content: JSON.stringify({ fileName: file.name })
482
- }
483
- })
484
- });
485
-
486
- // 解析SSE响应获取上传URL
487
- const reader = uploadTokenResponse.body?.getReader();
488
- if (!reader) throw new Error('无法读取响应');
489
-
490
- let uploadInfo: any = null;
491
- const decoder = new TextDecoder();
492
- let buffer = '';
493
-
494
- while (true) {
495
- const { done, value } = await reader.read();
496
- if (done) break;
497
-
498
- buffer += decoder.decode(value, { stream: true });
499
- const lines = buffer.split('\n');
500
-
501
- // 保留最后一行(可能不完整)
502
- buffer = lines.pop() || '';
485
+ const response = await fetch(`${domain}/webhook/chatbot/chat/${integrateId}`, {
486
+ method: 'POST',
487
+ headers: {
488
+ 'Content-Type': 'application/json',
489
+ 'X-Request-Token': token,
490
+ 'X-Account-Session-Ticket': ticket,
491
+ },
492
+ body: JSON.stringify({
493
+ messageType: 'event',
494
+ event: {
495
+ eventType,
496
+ content: JSON.stringify(eventContent)
497
+ }
498
+ })
499
+ });
503
500
 
504
- for (const line of lines) {
505
- const trimmedLine = line.trim();
506
- if (trimmedLine.startsWith('data:')) {
507
- try {
508
- const jsonStr = trimmedLine.slice(5).trim();
509
- if (jsonStr) {
510
- const data = JSON.parse(jsonStr);
511
- if (data.content) {
512
- // content 可能是字符串或对象
513
- const contentData = typeof data.content === 'string'
514
- ? JSON.parse(data.content)
515
- : data.content;
516
- if (contentData.uploadUrl) {
517
- uploadInfo = contentData;
518
- }
519
- }
501
+ // 解析SSE响应
502
+ const reader = response.body?.getReader();
503
+ if (!reader) throw new Error('无法读取响应');
504
+
505
+ let result: any = null;
506
+ const decoder = new TextDecoder();
507
+ let buffer = '';
508
+
509
+ while (true) {
510
+ const { done, value } = await reader.read();
511
+ if (done) break;
512
+
513
+ buffer += decoder.decode(value, { stream: true });
514
+ const lines = buffer.split('\n');
515
+
516
+ // 保留最后一行(可能不完整)
517
+ buffer = lines.pop() || '';
518
+
519
+ for (const line of lines) {
520
+ const trimmedLine = line.trim();
521
+ if (trimmedLine.startsWith('data:')) {
522
+ try {
523
+ const jsonStr = trimmedLine.slice(5).trim();
524
+ if (jsonStr) {
525
+ const data = JSON.parse(jsonStr);
526
+ if (data.content) {
527
+ // content 可能是字符串或对象
528
+ const contentData = typeof data.content === 'string'
529
+ ? JSON.parse(data.content)
530
+ : data.content;
531
+ result = contentData;
520
532
  }
521
- } catch (e) {
522
- // 忽略解析错误,继续处理下一行
523
- console.debug('解析SSE行失败:', line, e);
524
533
  }
534
+ } catch (e) {
535
+ console.debug(`解析SSE行失败(${eventType}):`, line, e);
525
536
  }
526
537
  }
527
538
  }
539
+ }
528
540
 
529
- // 处理buffer中剩余的数据
530
- if (buffer.trim().startsWith('data:')) {
531
- try {
532
- const jsonStr = buffer.trim().slice(5).trim();
533
- if (jsonStr) {
534
- const data = JSON.parse(jsonStr);
535
- if (data.content) {
536
- const contentData = typeof data.content === 'string'
537
- ? JSON.parse(data.content)
538
- : data.content;
539
- if (contentData.uploadUrl) {
540
- uploadInfo = contentData;
541
- }
542
- }
541
+ // 处理buffer中剩余的数据
542
+ if (buffer.trim().startsWith('data:')) {
543
+ try {
544
+ const jsonStr = buffer.trim().slice(5).trim();
545
+ if (jsonStr) {
546
+ const data = JSON.parse(jsonStr);
547
+ if (data.content) {
548
+ const contentData = typeof data.content === 'string'
549
+ ? JSON.parse(data.content)
550
+ : data.content;
551
+ result = contentData;
543
552
  }
544
- } catch (e) {
545
- console.debug('解析SSE剩余数据失败:', buffer, e);
546
553
  }
554
+ } catch (e) {
555
+ console.debug(`解析SSE剩余数据失败(${eventType}):`, buffer, e);
547
556
  }
557
+ }
558
+
559
+ return result;
560
+ }
561
+
562
+ /**
563
+ * 上传文件
564
+ * 非图片文件会额外获取 fileId(用于服务端文件关联)
565
+ * @returns 上传结果,包含 downloadUrl 和可选的 fileId
566
+ */
567
+ async upload(file: File): Promise<UploadResult> {
568
+ if (!this.isInitialized) {
569
+ throw new Error('请先调用 setup() 初始化SDK');
570
+ }
571
+
572
+ try {
573
+ // 1. 获取上传预签名URL
574
+ const uploadInfo = await this.sendUploadEvent('uploadToken', file.name);
548
575
 
549
576
  if (!uploadInfo?.uploadUrl) {
550
577
  console.error('获取上传URL失败,uploadInfo:', uploadInfo);
551
578
  throw new Error('获取上传URL失败');
552
579
  }
553
580
 
554
- // 上传文件
581
+ // 2. 上传文件到OSS
555
582
  const blob = new Blob([file], { type: file.type || 'application/octet-stream' });
556
583
  await fetchUploadApi(blob, uploadInfo.uploadUrl);
557
584
 
@@ -561,7 +588,21 @@ class ChatService {
561
588
  throw new Error('获取下载URL失败');
562
589
  }
563
590
 
564
- return uploadInfo.downloadUrl;
591
+ // 3. 非图片文件,额外获取 fileId
592
+ let fileId: string | undefined;
593
+ const isImage = file.type.startsWith('image/');
594
+ if (!isImage) {
595
+ try {
596
+ const fileIdResult = await this.sendUploadEvent('uploadFile', file.name, {
597
+ content: uploadInfo.downloadUrl,
598
+ });
599
+ fileId = fileIdResult?.fileId;
600
+ } catch (e) {
601
+ console.warn('获取fileId失败,将继续使用downloadUrl:', e);
602
+ }
603
+ }
604
+
605
+ return { downloadUrl: uploadInfo.downloadUrl, fileId };
565
606
  } catch (error) {
566
607
  console.error('上传文件失败:', error);
567
608
  throw error;