@alicloud/appflow-chat 0.0.4-alpha.2 → 0.0.4-alpha.3

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.
@@ -1,18 +1,17 @@
1
1
  /**
2
2
  * ChatSender - 聊天输入框组件
3
- * 封装 @ant-design/x 的 Sender + Attachments,集成文件上传、语音输入、联网搜索、模型选择等能力
4
3
  */
5
4
 
6
- import React, { useState, useRef, useCallback, useMemo } from 'react';
5
+ import React, { useState, useRef, useCallback, useMemo, useEffect } from 'react';
7
6
  import { Sender, Attachments } from '@ant-design/x';
8
7
  import type { AttachmentsProps } from '@ant-design/x';
9
- import { Select, Switch, Button, Tooltip, Badge } from 'antd';
8
+ import { Select, Switch, Button, Tooltip } from 'antd';
10
9
  import {
11
10
  PaperClipOutlined,
12
11
  PictureOutlined,
13
12
  GlobalOutlined,
14
- DeleteOutlined,
15
13
  CloudUploadOutlined,
14
+ AudioOutlined,
16
15
  } from '@ant-design/icons';
17
16
  import styled from 'styled-components';
18
17
  import type { ModelInfo, ModelCapabilities } from '../services/ChatService';
@@ -58,8 +57,10 @@ export interface ChatSenderSubmitData {
58
57
  text: string;
59
58
  /** 图片URL列表 */
60
59
  images: string[];
61
- /** 文件URL列表 */
62
- files: string[];
60
+ /** 文件列表(包含文件名和URL */
61
+ files: { name: string; url: string }[];
62
+ /** 语音文件URL(录音上传后的下载地址) */
63
+ audio?: string;
63
64
  /** 选中的模型ID */
64
65
  modelId?: string;
65
66
  /** 是否启用联网搜索 */
@@ -78,7 +79,7 @@ export interface ChatSenderProps {
78
79
 
79
80
  // ==================== 模型相关 ====================
80
81
 
81
- /** 可用模型列表 */
82
+ /** 可用模型列表,传入且长度>1时显示模型选择下拉框 */
82
83
  models?: ModelInfo[];
83
84
  /** 当前选中的模型ID */
84
85
  modelId?: string;
@@ -91,7 +92,7 @@ export interface ChatSenderProps {
91
92
 
92
93
  /**
93
94
  * 模型能力配置,控制功能按钮的显隐
94
- * 如果不传,所有功能按钮都显示
95
+ * 不传时默认所有功能关闭
95
96
  */
96
97
  capabilities?: ModelCapabilities;
97
98
 
@@ -101,8 +102,6 @@ export interface ChatSenderProps {
101
102
  onSubmit?: (data: ChatSenderSubmitData) => void;
102
103
  /** 取消当前请求 */
103
104
  onCancel?: () => void;
104
- /** 清除会话回调 */
105
- onClear?: () => void;
106
105
  /** 文件上传方法,返回下载URL */
107
106
  onUpload?: (file: File) => Promise<string>;
108
107
 
@@ -117,18 +116,6 @@ export interface ChatSenderProps {
117
116
  // ==================== 样式组件 ====================
118
117
 
119
118
  const SenderWrapper = styled.div`
120
- .appflow-chat-sender-prefix {
121
- display: flex;
122
- align-items: center;
123
- gap: 4px;
124
- }
125
-
126
- .appflow-chat-sender-actions {
127
- display: flex;
128
- align-items: center;
129
- gap: 4px;
130
- }
131
-
132
119
  .appflow-chat-sender-footer {
133
120
  display: flex;
134
121
  align-items: center;
@@ -139,28 +126,104 @@ const SenderWrapper = styled.div`
139
126
  .appflow-chat-sender-footer-left {
140
127
  display: flex;
141
128
  align-items: center;
142
- gap: 8px;
129
+ gap: 4px;
143
130
  }
144
131
 
145
132
  .appflow-chat-sender-footer-right {
146
133
  display: flex;
147
134
  align-items: center;
148
- gap: 8px;
135
+ gap: 4px;
136
+ }
137
+
138
+ .appflow-chat-sender-model-select {
139
+ min-width: 100px;
140
+ }
141
+
142
+ .appflow-chat-sender-separator {
143
+ width: 1px;
144
+ height: 16px;
145
+ background: #e0e0e0;
146
+ margin: 0 2px;
149
147
  }
150
148
 
151
149
  .appflow-chat-sender-web-search {
152
- display: flex;
150
+ display: inline-flex;
153
151
  align-items: center;
154
152
  gap: 4px;
155
153
  font-size: 13px;
156
154
  color: #666;
157
- }
158
-
159
- .appflow-chat-sender-model-select {
160
- min-width: 120px;
155
+ padding: 2px 6px;
156
+ height: 28px;
157
+ border-radius: 6px;
158
+ cursor: pointer;
159
+ transition: background 0.2s;
160
+ user-select: none;
161
+
162
+ &:hover {
163
+ background: rgba(0, 0, 0, 0.04);
164
+ }
161
165
  }
162
166
  `;
163
167
 
168
+ // ==================== 录音中波形图标 ====================
169
+
170
+ /** 录音中的波形动画图标(复用 @ant-design/x 的 RecordingIcon 设计) */
171
+ const RecordingIcon: React.FC = () => {
172
+ const SIZE = 1000;
173
+ const COUNT = 4;
174
+ const RECT_WIDTH = 140;
175
+ const RECT_RADIUS = RECT_WIDTH / 2;
176
+ const RECT_HEIGHT_MIN = 250;
177
+ const RECT_HEIGHT_MAX = 500;
178
+ const DURATION = 0.8;
179
+
180
+ return (
181
+ <svg
182
+ width="1em"
183
+ height="1em"
184
+ viewBox={`0 0 ${SIZE} ${SIZE}`}
185
+ xmlns="http://www.w3.org/2000/svg"
186
+ fill="currentColor"
187
+ >
188
+ {Array.from({ length: COUNT }).map((_, index) => {
189
+ const dest = (SIZE - RECT_WIDTH * COUNT) / (COUNT - 1);
190
+ const x = index * (dest + RECT_WIDTH);
191
+ const yMin = SIZE / 2 - RECT_HEIGHT_MIN / 2;
192
+ const yMax = SIZE / 2 - RECT_HEIGHT_MAX / 2;
193
+ return (
194
+ <rect
195
+ key={index}
196
+ fill="currentColor"
197
+ rx={RECT_RADIUS}
198
+ ry={RECT_RADIUS}
199
+ height={RECT_HEIGHT_MIN}
200
+ width={RECT_WIDTH}
201
+ x={x}
202
+ y={yMin}
203
+ >
204
+ <animate
205
+ attributeName="height"
206
+ values={`${RECT_HEIGHT_MIN}; ${RECT_HEIGHT_MAX}; ${RECT_HEIGHT_MIN}`}
207
+ keyTimes="0; 0.5; 1"
208
+ dur={`${DURATION}s`}
209
+ begin={`${(DURATION / COUNT) * index}s`}
210
+ repeatCount="indefinite"
211
+ />
212
+ <animate
213
+ attributeName="y"
214
+ values={`${yMin}; ${yMax}; ${yMin}`}
215
+ keyTimes="0; 0.5; 1"
216
+ dur={`${DURATION}s`}
217
+ begin={`${(DURATION / COUNT) * index}s`}
218
+ repeatCount="indefinite"
219
+ />
220
+ </rect>
221
+ );
222
+ })}
223
+ </svg>
224
+ );
225
+ };
226
+
164
227
  // ==================== 工具函数 ====================
165
228
 
166
229
  function generateUid(): string {
@@ -206,7 +269,6 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
206
269
  capabilities,
207
270
  onSubmit,
208
271
  onCancel,
209
- onClear,
210
272
  onUpload,
211
273
  className,
212
274
  style,
@@ -218,18 +280,23 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
218
280
  defaultModelId || models[0]?.id
219
281
  );
220
282
  const [webSearchEnabled, setWebSearchEnabled] = useState(false);
283
+ const [speechRecording, setSpeechRecording] = useState(false);
221
284
  const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
222
285
  const [headerOpen, setHeaderOpen] = useState(false);
223
286
 
224
287
  const attachmentsRef = useRef<AttachmentsRefType>(null);
288
+ const mediaRecorderRef = useRef<MediaRecorder | null>(null);
289
+ const audioChunksRef = useRef<Blob[]>([]);
290
+ const mediaStreamRef = useRef<MediaStream | null>(null);
291
+ const speechRecordingRef = useRef(false);
225
292
 
226
293
  // 受控/非受控模型ID
227
294
  const currentModelId = controlledModelId ?? internalModelId;
228
295
 
229
296
  // ==================== 能力判断 ====================
230
297
 
231
- const hasImageCapability = capabilities?.image ?? true;
232
- const hasFileCapability = capabilities?.file ?? true;
298
+ const hasImageCapability = capabilities?.image ?? false;
299
+ const hasFileCapability = capabilities?.file ?? false;
233
300
  const hasAudioCapability = capabilities?.audio ?? false;
234
301
  const hasWebSearchCapability = capabilities?.webSearch ?? false;
235
302
 
@@ -286,12 +353,13 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
286
353
 
287
354
  const files = attachments
288
355
  .filter(a => a.type === 'file' && a.status === 'done' && a.url)
289
- .map(a => a.url!);
356
+ .map(a => ({ name: a.name, url: a.url! }));
290
357
 
291
358
  onSubmit?.({
292
359
  text: text.trim(),
293
360
  images,
294
361
  files,
362
+ audio: undefined,
295
363
  modelId: currentModelId,
296
364
  webSearch: webSearchEnabled,
297
365
  });
@@ -362,7 +430,13 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
362
430
  <Attachments
363
431
  ref={attachmentsRef as any}
364
432
  items={attachmentItems as AttachmentsProps['items']}
365
- beforeUpload={() => false}
433
+ accept={acceptTypes}
434
+ multiple
435
+ customRequest={({ file }) => {
436
+ if (file instanceof File) {
437
+ handleFileUpload(file);
438
+ }
439
+ }}
366
440
  onRemove={(file) => {
367
441
  setAttachments(prev => {
368
442
  const updated = prev.filter(a => a.uid !== file.uid);
@@ -382,11 +456,115 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
382
456
  );
383
457
  }, [hasUploadCapability, headerOpen, attachmentItems]);
384
458
 
385
- // ==================== 渲染:Footer(模型选择 + 联网搜索 + 清除) ====================
459
+ // ==================== 触发文件选择 ====================
460
+
461
+ const triggerFileSelect = useCallback(() => {
462
+ const input = document.createElement('input');
463
+ input.type = 'file';
464
+ input.accept = acceptTypes;
465
+ input.multiple = true;
466
+ input.onchange = (event) => {
467
+ const fileList = (event.target as HTMLInputElement).files;
468
+ if (fileList) {
469
+ Array.from(fileList).forEach(handleFileUpload);
470
+ }
471
+ };
472
+ input.click();
473
+ }, [acceptTypes, handleFileUpload]);
474
+
475
+ // ==================== 语音录音管理 ====================
476
+ // 使用 MediaRecorder 录制音频文件,录音结束后上传并发送
477
+
478
+ const stopMediaStream = useCallback(() => {
479
+ mediaStreamRef.current?.getTracks().forEach(track => track.stop());
480
+ mediaStreamRef.current = null;
481
+ }, []);
482
+
483
+ const toggleSpeechRecording = useCallback(async () => {
484
+ if (speechRecordingRef.current) {
485
+ // 停止录音 — MediaRecorder.onstop 中会处理上传和发送
486
+ mediaRecorderRef.current?.stop();
487
+ speechRecordingRef.current = false;
488
+ setSpeechRecording(false);
489
+ } else {
490
+ // 开始录音
491
+ try {
492
+ const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
493
+ mediaStreamRef.current = stream;
494
+ audioChunksRef.current = [];
495
+
496
+ const mediaRecorder = new MediaRecorder(stream);
497
+
498
+ mediaRecorder.ondataavailable = (event) => {
499
+ if (event.data.size > 0) {
500
+ audioChunksRef.current.push(event.data);
501
+ }
502
+ };
503
+
504
+ mediaRecorder.onstop = async () => {
505
+ // 停止麦克风流
506
+ stopMediaStream();
507
+
508
+ const chunks = audioChunksRef.current;
509
+ if (chunks.length === 0 || !onUpload) return;
510
+
511
+ // 合并音频 chunks 为 Blob
512
+ const audioBlob = new Blob(chunks, { type: 'audio/webm' });
513
+ const audioFile = new File(
514
+ [audioBlob],
515
+ `recording_${Date.now()}.webm`,
516
+ { type: 'audio/webm' }
517
+ );
518
+
519
+ try {
520
+ // 上传音频文件
521
+ const audioUrl = await onUpload(audioFile);
522
+
523
+ // 发送语音消息(audio 优先级最高,服务端会忽略 text/images/files)
524
+ onSubmit?.({
525
+ text: '',
526
+ images: [],
527
+ files: [],
528
+ audio: audioUrl,
529
+ modelId: currentModelId,
530
+ webSearch: false,
531
+ });
532
+ } catch (err) {
533
+ console.error('语音上传失败:', err);
534
+ }
535
+ };
536
+
537
+ mediaRecorder.onerror = () => {
538
+ speechRecordingRef.current = false;
539
+ setSpeechRecording(false);
540
+ stopMediaStream();
541
+ };
542
+
543
+ mediaRecorderRef.current = mediaRecorder;
544
+ mediaRecorder.start();
545
+ speechRecordingRef.current = true;
546
+ setSpeechRecording(true);
547
+ } catch (err) {
548
+ console.error('无法获取麦克风权限:', err);
549
+ speechRecordingRef.current = false;
550
+ setSpeechRecording(false);
551
+ }
552
+ }
553
+ }, [onUpload, onSubmit, inputValue, currentModelId, webSearchEnabled, stopMediaStream]);
554
+
555
+ // 组件卸载时停止录音和麦克风流
556
+ useEffect(() => {
557
+ return () => {
558
+ mediaRecorderRef.current?.stop();
559
+ stopMediaStream();
560
+ };
561
+ }, [stopMediaStream]);
386
562
 
387
- const renderFooter = useMemo(() => {
388
- const hasFooterContent = models.length > 1 || hasWebSearchCapability || onClear;
389
- if (!hasFooterContent) return undefined;
563
+ // ==================== 渲染:Footer(功能栏) ====================
564
+ // 布局:左侧 [模型选择] [联网搜索] 右侧 [文件上传] [语音] [发送/停止]
565
+
566
+ const renderFooter = useCallback((info: any) => {
567
+ const { SendButton, LoadingButton } = info.components;
390
568
 
391
569
  return (
392
570
  <div className="appflow-chat-sender-footer">
@@ -406,7 +584,10 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
406
584
 
407
585
  {/* 联网搜索开关 */}
408
586
  {hasWebSearchCapability && (
409
- <div className="appflow-chat-sender-web-search">
587
+ <div
588
+ className="appflow-chat-sender-web-search"
589
+ onClick={() => setWebSearchEnabled(prev => !prev)}
590
+ >
410
591
  <GlobalOutlined />
411
592
  <Switch
412
593
  size="small"
@@ -419,73 +600,53 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
419
600
  </div>
420
601
 
421
602
  <div className="appflow-chat-sender-footer-right">
422
- {/* 清除会话 */}
423
- {onClear && (
424
- <Tooltip title="清除会话">
603
+ {/* 文件上传按钮 */}
604
+ {hasUploadCapability && (
605
+ <Tooltip title="上传文件">
425
606
  <Button
426
607
  type="text"
427
608
  size="small"
428
- icon={<DeleteOutlined />}
429
- onClick={onClear}
430
- disabled={loading}
609
+ icon={hasImageCapability && !hasFileCapability ? <PictureOutlined /> : <PaperClipOutlined />}
610
+ disabled={disabled || loading}
611
+ onClick={triggerFileSelect}
431
612
  />
432
613
  </Tooltip>
433
614
  )}
434
- </div>
435
- </div>
436
- );
437
- }, [models, currentModelId, handleModelChange, hasWebSearchCapability, webSearchEnabled, onClear, loading]);
438
-
439
- // ==================== 渲染:Actions(上传按钮) ====================
440
615
 
441
- const renderActions = useCallback((
442
- _oriNode: React.ReactNode,
443
- info: { components: { SendButton: React.ComponentType<any>; ClearButton: React.ComponentType<any>; LoadingButton: React.ComponentType<any> } }
444
- ) => {
445
- const { SendButton, LoadingButton } = info.components;
616
+ {/* 分隔线 */}
617
+ {hasUploadCapability && hasAudioCapability && (
618
+ <div className="appflow-chat-sender-separator" />
619
+ )}
446
620
 
447
- return (
448
- <div className="appflow-chat-sender-actions">
449
- {/* 上传按钮 */}
450
- {hasUploadCapability && (
451
- <Tooltip title={hasAttachments ? `${attachments.length} 个附件` : '上传文件'}>
452
- <Badge count={attachments.length} size="small" offset={[-4, 4]}>
621
+ {/* 语音按钮 */}
622
+ {hasAudioCapability && (
623
+ <Tooltip title={speechRecording ? '停止录音' : '语音输入'}>
453
624
  <Button
454
625
  type="text"
455
626
  size="small"
456
- icon={hasImageCapability && !hasFileCapability ? <PictureOutlined /> : <PaperClipOutlined />}
457
- disabled={disabled || loading}
458
- onClick={() => {
459
- // 触发文件选择
460
- const input = document.createElement('input');
461
- input.type = 'file';
462
- input.accept = acceptTypes;
463
- input.multiple = true;
464
- input.onchange = (e) => {
465
- const fileList = (e.target as HTMLInputElement).files;
466
- if (fileList) {
467
- Array.from(fileList).forEach(handleFileUpload);
468
- }
469
- };
470
- input.click();
471
- }}
627
+ icon={speechRecording ? <RecordingIcon /> : <AudioOutlined />}
628
+ disabled={disabled}
629
+ onClick={toggleSpeechRecording}
472
630
  />
473
- </Badge>
474
- </Tooltip>
475
- )}
476
-
477
- {/* 发送/停止按钮 */}
478
- {loading ? (
479
- <LoadingButton />
480
- ) : (
481
- <SendButton disabled={disabled || isUploading || (!inputValue.trim() && !hasAttachments)} />
482
- )}
631
+ </Tooltip>
632
+ )}
633
+
634
+ {/* 发送/停止按钮 */}
635
+ {loading ? (
636
+ <LoadingButton />
637
+ ) : (
638
+ <SendButton disabled={disabled || isUploading || (!inputValue.trim() && !hasAttachments)} />
639
+ )}
640
+ </div>
483
641
  </div>
484
642
  );
485
643
  }, [
486
644
  hasUploadCapability, hasImageCapability, hasFileCapability,
487
- hasAttachments, attachments.length, acceptTypes,
488
- disabled, loading, isUploading, inputValue, handleFileUpload,
645
+ hasAttachments, attachments.length, triggerFileSelect,
646
+ models, currentModelId, handleModelChange,
647
+ hasWebSearchCapability, webSearchEnabled,
648
+ hasAudioCapability, speechRecording, toggleSpeechRecording,
649
+ disabled, loading, isUploading, inputValue,
489
650
  ]);
490
651
 
491
652
  // ==================== 主渲染 ====================
@@ -502,10 +663,10 @@ export const ChatSender: React.FC<ChatSenderProps> = ({
502
663
  submitType={submitType}
503
664
  onCancel={onCancel}
504
665
  onPasteFile={hasUploadCapability ? handlePasteFile : undefined}
505
- allowSpeech={hasAudioCapability}
666
+ allowSpeech={false}
506
667
  header={renderHeader}
507
668
  footer={renderFooter}
508
- actions={renderActions}
669
+ actions={false}
509
670
  autoSize={{ minRows: 1, maxRows: 6 }}
510
671
  readOnly={isUploading}
511
672
  />