@alicloud/appflow-chat 0.0.4-alpha.2 → 0.0.4-alpha.4
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 +253 -159
- package/dist/appflow-chat.esm.js +7881 -7602
- package/dist/types/index.d.ts +17 -6
- package/package.json +1 -1
- package/src/App.tsx +182 -0
- package/src/components/ChatSender.tsx +253 -92
- package/src/components/MessageAttachments.tsx +227 -0
- package/src/components/MessageBubble.tsx +19 -5
- package/src/main.tsx +9 -0
- package/src/services/ChatService.ts +1 -0
|
@@ -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
|
|
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
|
-
/**
|
|
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:
|
|
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:
|
|
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
|
-
|
|
160
|
-
|
|
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 ??
|
|
232
|
-
const hasFileCapability = capabilities?.file ??
|
|
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
|
-
|
|
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
|
-
// ====================
|
|
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
|
-
|
|
388
|
-
|
|
389
|
-
|
|
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
|
|
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
|
-
{
|
|
424
|
-
<Tooltip title="
|
|
603
|
+
{/* 文件上传按钮 */}
|
|
604
|
+
{hasUploadCapability && (
|
|
605
|
+
<Tooltip title="上传文件">
|
|
425
606
|
<Button
|
|
426
607
|
type="text"
|
|
427
608
|
size="small"
|
|
428
|
-
icon={<
|
|
429
|
-
|
|
430
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
const { SendButton, LoadingButton } = info.components;
|
|
616
|
+
{/* 分隔线 */}
|
|
617
|
+
{hasUploadCapability && hasAudioCapability && (
|
|
618
|
+
<div className="appflow-chat-sender-separator" />
|
|
619
|
+
)}
|
|
446
620
|
|
|
447
|
-
|
|
448
|
-
|
|
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={
|
|
457
|
-
disabled={disabled
|
|
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
|
-
</
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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,
|
|
488
|
-
|
|
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={
|
|
666
|
+
allowSpeech={false}
|
|
506
667
|
header={renderHeader}
|
|
507
668
|
footer={renderFooter}
|
|
508
|
-
actions={
|
|
669
|
+
actions={false}
|
|
509
670
|
autoSize={{ minRows: 1, maxRows: 6 }}
|
|
510
671
|
readOnly={isUploading}
|
|
511
672
|
/>
|