@alicloud/appflow-chat 0.0.3 → 0.0.4-alpha.2
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 +234 -163
- package/dist/appflow-chat.esm.js +16753 -12123
- package/dist/types/index.d.ts +91 -0
- package/package.json +2 -1
- package/src/components/ChatSender.tsx +516 -0
- package/src/index.ts +2 -0
- package/src/markdown/index.tsx +21 -2
package/dist/types/index.d.ts
CHANGED
|
@@ -51,6 +51,24 @@ 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
|
+
}
|
|
71
|
+
|
|
54
72
|
export declare interface ChatConfig {
|
|
55
73
|
welcome: string;
|
|
56
74
|
questions: string[];
|
|
@@ -74,6 +92,79 @@ export declare interface ChatMessage {
|
|
|
74
92
|
webSearch?: boolean;
|
|
75
93
|
}
|
|
76
94
|
|
|
95
|
+
/**
|
|
96
|
+
* ChatSender - 聊天输入框组件
|
|
97
|
+
*
|
|
98
|
+
* 集成了文本输入、文件/图片上传、语音输入、联网搜索、模型选择等功能。
|
|
99
|
+
* 根据模型能力自动控制功能按钮的显隐。
|
|
100
|
+
*
|
|
101
|
+
* @example
|
|
102
|
+
* ```tsx
|
|
103
|
+
* <ChatSender
|
|
104
|
+
* loading={isLoading}
|
|
105
|
+
* models={config.models}
|
|
106
|
+
* capabilities={chatService.getModelCapabilities(modelId)}
|
|
107
|
+
* onSubmit={({ text, images, files, modelId, webSearch }) => {
|
|
108
|
+
* chatService.chat({ text, images, files, modelId, webSearch });
|
|
109
|
+
* }}
|
|
110
|
+
* onCancel={() => chatService.cancel()}
|
|
111
|
+
* onClear={() => chatService.clear()}
|
|
112
|
+
* onUpload={(file) => chatService.upload(file)}
|
|
113
|
+
* />
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
export declare const ChatSender: default_2.FC<ChatSenderProps>;
|
|
117
|
+
|
|
118
|
+
export declare interface ChatSenderProps {
|
|
119
|
+
/** 是否处于加载状态(AI正在回复) */
|
|
120
|
+
loading?: boolean;
|
|
121
|
+
/** 是否禁用 */
|
|
122
|
+
disabled?: boolean;
|
|
123
|
+
/** 输入框占位文本 */
|
|
124
|
+
placeholder?: string;
|
|
125
|
+
/** 提交方式:'enter' 回车发送 | 'shiftEnter' Shift+回车发送 */
|
|
126
|
+
submitType?: 'enter' | 'shiftEnter';
|
|
127
|
+
/** 可用模型列表 */
|
|
128
|
+
models?: ModelInfo[];
|
|
129
|
+
/** 当前选中的模型ID */
|
|
130
|
+
modelId?: string;
|
|
131
|
+
/** 默认选中的模型ID(非受控) */
|
|
132
|
+
defaultModelId?: string;
|
|
133
|
+
/** 模型切换回调 */
|
|
134
|
+
onModelChange?: (modelId: string) => void;
|
|
135
|
+
/**
|
|
136
|
+
* 模型能力配置,控制功能按钮的显隐
|
|
137
|
+
* 如果不传,所有功能按钮都显示
|
|
138
|
+
*/
|
|
139
|
+
capabilities?: ModelCapabilities;
|
|
140
|
+
/** 提交消息回调 */
|
|
141
|
+
onSubmit?: (data: ChatSenderSubmitData) => void;
|
|
142
|
+
/** 取消当前请求 */
|
|
143
|
+
onCancel?: () => void;
|
|
144
|
+
/** 清除会话回调 */
|
|
145
|
+
onClear?: () => void;
|
|
146
|
+
/** 文件上传方法,返回下载URL */
|
|
147
|
+
onUpload?: (file: File) => Promise<string>;
|
|
148
|
+
/** 自定义类名 */
|
|
149
|
+
className?: string;
|
|
150
|
+
/** 自定义样式 */
|
|
151
|
+
style?: default_2.CSSProperties;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 提交时的消息数据 */
|
|
155
|
+
export declare interface ChatSenderSubmitData {
|
|
156
|
+
/** 文本内容 */
|
|
157
|
+
text: string;
|
|
158
|
+
/** 图片URL列表 */
|
|
159
|
+
images: string[];
|
|
160
|
+
/** 文件URL列表 */
|
|
161
|
+
files: string[];
|
|
162
|
+
/** 选中的模型ID */
|
|
163
|
+
modelId?: string;
|
|
164
|
+
/** 是否启用联网搜索 */
|
|
165
|
+
webSearch: boolean;
|
|
166
|
+
}
|
|
167
|
+
|
|
77
168
|
export declare class ChatService {
|
|
78
169
|
private config;
|
|
79
170
|
private setupConfig;
|
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.2",
|
|
4
4
|
"description": "Appflow-Chat AI聊天机器人组件库,提供聊天服务和UI组件",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/appflow-chat.cjs.js",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
"@fortawesome/react-fontawesome": "^0.2.0"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
+
"@ant-design/x": "^1.6.1",
|
|
43
44
|
"@eslint/js": "^9.13.0",
|
|
44
45
|
"@types/js-cookie": "^3.0.6",
|
|
45
46
|
"@types/lodash-es": "^4.17.12",
|
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ChatSender - 聊天输入框组件
|
|
3
|
+
* 封装 @ant-design/x 的 Sender + Attachments,集成文件上传、语音输入、联网搜索、模型选择等能力
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import React, { useState, useRef, useCallback, useMemo } from 'react';
|
|
7
|
+
import { Sender, Attachments } from '@ant-design/x';
|
|
8
|
+
import type { AttachmentsProps } from '@ant-design/x';
|
|
9
|
+
import { Select, Switch, Button, Tooltip, Badge } from 'antd';
|
|
10
|
+
import {
|
|
11
|
+
PaperClipOutlined,
|
|
12
|
+
PictureOutlined,
|
|
13
|
+
GlobalOutlined,
|
|
14
|
+
DeleteOutlined,
|
|
15
|
+
CloudUploadOutlined,
|
|
16
|
+
} from '@ant-design/icons';
|
|
17
|
+
import styled from 'styled-components';
|
|
18
|
+
import type { ModelInfo, ModelCapabilities } from '../services/ChatService';
|
|
19
|
+
|
|
20
|
+
/** Attachments 组件的 ref 类型 */
|
|
21
|
+
interface AttachmentsRefType {
|
|
22
|
+
nativeElement: HTMLDivElement | null;
|
|
23
|
+
upload: (file: File) => void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 附件文件项类型(兼容 antd Upload fileList 项) */
|
|
27
|
+
interface AttachmentFileItem {
|
|
28
|
+
uid: string;
|
|
29
|
+
name: string;
|
|
30
|
+
status?: 'uploading' | 'done' | 'error' | 'removed';
|
|
31
|
+
thumbUrl?: string;
|
|
32
|
+
url?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// ==================== 类型定义 ====================
|
|
36
|
+
|
|
37
|
+
/** 附件信息(上传完成后携带下载URL) */
|
|
38
|
+
export interface ChatAttachment {
|
|
39
|
+
/** 文件唯一标识 */
|
|
40
|
+
uid: string;
|
|
41
|
+
/** 文件名 */
|
|
42
|
+
name: string;
|
|
43
|
+
/** 上传状态 */
|
|
44
|
+
status: 'uploading' | 'done' | 'error';
|
|
45
|
+
/** 文件类型:image 或 file */
|
|
46
|
+
type: 'image' | 'file';
|
|
47
|
+
/** 上传后的下载URL */
|
|
48
|
+
url?: string;
|
|
49
|
+
/** 本地预览URL(图片) */
|
|
50
|
+
thumbUrl?: string;
|
|
51
|
+
/** 原始文件对象 */
|
|
52
|
+
originFile?: File;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** 提交时的消息数据 */
|
|
56
|
+
export interface ChatSenderSubmitData {
|
|
57
|
+
/** 文本内容 */
|
|
58
|
+
text: string;
|
|
59
|
+
/** 图片URL列表 */
|
|
60
|
+
images: string[];
|
|
61
|
+
/** 文件URL列表 */
|
|
62
|
+
files: string[];
|
|
63
|
+
/** 选中的模型ID */
|
|
64
|
+
modelId?: string;
|
|
65
|
+
/** 是否启用联网搜索 */
|
|
66
|
+
webSearch: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface ChatSenderProps {
|
|
70
|
+
/** 是否处于加载状态(AI正在回复) */
|
|
71
|
+
loading?: boolean;
|
|
72
|
+
/** 是否禁用 */
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
/** 输入框占位文本 */
|
|
75
|
+
placeholder?: string;
|
|
76
|
+
/** 提交方式:'enter' 回车发送 | 'shiftEnter' Shift+回车发送 */
|
|
77
|
+
submitType?: 'enter' | 'shiftEnter';
|
|
78
|
+
|
|
79
|
+
// ==================== 模型相关 ====================
|
|
80
|
+
|
|
81
|
+
/** 可用模型列表 */
|
|
82
|
+
models?: ModelInfo[];
|
|
83
|
+
/** 当前选中的模型ID */
|
|
84
|
+
modelId?: string;
|
|
85
|
+
/** 默认选中的模型ID(非受控) */
|
|
86
|
+
defaultModelId?: string;
|
|
87
|
+
/** 模型切换回调 */
|
|
88
|
+
onModelChange?: (modelId: string) => void;
|
|
89
|
+
|
|
90
|
+
// ==================== 能力配置 ====================
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* 模型能力配置,控制功能按钮的显隐
|
|
94
|
+
* 如果不传,所有功能按钮都显示
|
|
95
|
+
*/
|
|
96
|
+
capabilities?: ModelCapabilities;
|
|
97
|
+
|
|
98
|
+
// ==================== 事件回调 ====================
|
|
99
|
+
|
|
100
|
+
/** 提交消息回调 */
|
|
101
|
+
onSubmit?: (data: ChatSenderSubmitData) => void;
|
|
102
|
+
/** 取消当前请求 */
|
|
103
|
+
onCancel?: () => void;
|
|
104
|
+
/** 清除会话回调 */
|
|
105
|
+
onClear?: () => void;
|
|
106
|
+
/** 文件上传方法,返回下载URL */
|
|
107
|
+
onUpload?: (file: File) => Promise<string>;
|
|
108
|
+
|
|
109
|
+
// ==================== 样式 ====================
|
|
110
|
+
|
|
111
|
+
/** 自定义类名 */
|
|
112
|
+
className?: string;
|
|
113
|
+
/** 自定义样式 */
|
|
114
|
+
style?: React.CSSProperties;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ==================== 样式组件 ====================
|
|
118
|
+
|
|
119
|
+
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
|
+
.appflow-chat-sender-footer {
|
|
133
|
+
display: flex;
|
|
134
|
+
align-items: center;
|
|
135
|
+
justify-content: space-between;
|
|
136
|
+
padding: 4px 0;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.appflow-chat-sender-footer-left {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 8px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.appflow-chat-sender-footer-right {
|
|
146
|
+
display: flex;
|
|
147
|
+
align-items: center;
|
|
148
|
+
gap: 8px;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.appflow-chat-sender-web-search {
|
|
152
|
+
display: flex;
|
|
153
|
+
align-items: center;
|
|
154
|
+
gap: 4px;
|
|
155
|
+
font-size: 13px;
|
|
156
|
+
color: #666;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
.appflow-chat-sender-model-select {
|
|
160
|
+
min-width: 120px;
|
|
161
|
+
}
|
|
162
|
+
`;
|
|
163
|
+
|
|
164
|
+
// ==================== 工具函数 ====================
|
|
165
|
+
|
|
166
|
+
function generateUid(): string {
|
|
167
|
+
return `file_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function isImageFile(file: File): boolean {
|
|
171
|
+
return file.type.startsWith('image/');
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ==================== 组件实现 ====================
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* ChatSender - 聊天输入框组件
|
|
178
|
+
*
|
|
179
|
+
* 集成了文本输入、文件/图片上传、语音输入、联网搜索、模型选择等功能。
|
|
180
|
+
* 根据模型能力自动控制功能按钮的显隐。
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ```tsx
|
|
184
|
+
* <ChatSender
|
|
185
|
+
* loading={isLoading}
|
|
186
|
+
* models={config.models}
|
|
187
|
+
* capabilities={chatService.getModelCapabilities(modelId)}
|
|
188
|
+
* onSubmit={({ text, images, files, modelId, webSearch }) => {
|
|
189
|
+
* chatService.chat({ text, images, files, modelId, webSearch });
|
|
190
|
+
* }}
|
|
191
|
+
* onCancel={() => chatService.cancel()}
|
|
192
|
+
* onClear={() => chatService.clear()}
|
|
193
|
+
* onUpload={(file) => chatService.upload(file)}
|
|
194
|
+
* />
|
|
195
|
+
* ```
|
|
196
|
+
*/
|
|
197
|
+
export const ChatSender: React.FC<ChatSenderProps> = ({
|
|
198
|
+
loading = false,
|
|
199
|
+
disabled = false,
|
|
200
|
+
placeholder = '',
|
|
201
|
+
submitType = 'enter',
|
|
202
|
+
models = [],
|
|
203
|
+
modelId: controlledModelId,
|
|
204
|
+
defaultModelId,
|
|
205
|
+
onModelChange,
|
|
206
|
+
capabilities,
|
|
207
|
+
onSubmit,
|
|
208
|
+
onCancel,
|
|
209
|
+
onClear,
|
|
210
|
+
onUpload,
|
|
211
|
+
className,
|
|
212
|
+
style,
|
|
213
|
+
}) => {
|
|
214
|
+
// ==================== 状态管理 ====================
|
|
215
|
+
|
|
216
|
+
const [inputValue, setInputValue] = useState('');
|
|
217
|
+
const [internalModelId, setInternalModelId] = useState<string | undefined>(
|
|
218
|
+
defaultModelId || models[0]?.id
|
|
219
|
+
);
|
|
220
|
+
const [webSearchEnabled, setWebSearchEnabled] = useState(false);
|
|
221
|
+
const [attachments, setAttachments] = useState<ChatAttachment[]>([]);
|
|
222
|
+
const [headerOpen, setHeaderOpen] = useState(false);
|
|
223
|
+
|
|
224
|
+
const attachmentsRef = useRef<AttachmentsRefType>(null);
|
|
225
|
+
|
|
226
|
+
// 受控/非受控模型ID
|
|
227
|
+
const currentModelId = controlledModelId ?? internalModelId;
|
|
228
|
+
|
|
229
|
+
// ==================== 能力判断 ====================
|
|
230
|
+
|
|
231
|
+
const hasImageCapability = capabilities?.image ?? true;
|
|
232
|
+
const hasFileCapability = capabilities?.file ?? true;
|
|
233
|
+
const hasAudioCapability = capabilities?.audio ?? false;
|
|
234
|
+
const hasWebSearchCapability = capabilities?.webSearch ?? false;
|
|
235
|
+
|
|
236
|
+
const hasUploadCapability = hasImageCapability || hasFileCapability;
|
|
237
|
+
const hasAttachments = attachments.length > 0;
|
|
238
|
+
const uploadingCount = attachments.filter(a => a.status === 'uploading').length;
|
|
239
|
+
const isUploading = uploadingCount > 0;
|
|
240
|
+
|
|
241
|
+
// ==================== 文件上传处理 ====================
|
|
242
|
+
|
|
243
|
+
const handleFileUpload = useCallback(async (file: File) => {
|
|
244
|
+
if (!onUpload) return;
|
|
245
|
+
|
|
246
|
+
const uid = generateUid();
|
|
247
|
+
const fileType = isImageFile(file) ? 'image' : 'file';
|
|
248
|
+
|
|
249
|
+
// 检查能力限制
|
|
250
|
+
if (fileType === 'image' && !hasImageCapability) return;
|
|
251
|
+
if (fileType === 'file' && !hasFileCapability) return;
|
|
252
|
+
|
|
253
|
+
const newAttachment: ChatAttachment = {
|
|
254
|
+
uid,
|
|
255
|
+
name: file.name,
|
|
256
|
+
status: 'uploading',
|
|
257
|
+
type: fileType,
|
|
258
|
+
thumbUrl: fileType === 'image' ? URL.createObjectURL(file) : undefined,
|
|
259
|
+
originFile: file,
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
setAttachments(prev => [...prev, newAttachment]);
|
|
263
|
+
setHeaderOpen(true);
|
|
264
|
+
|
|
265
|
+
try {
|
|
266
|
+
const downloadUrl = await onUpload(file);
|
|
267
|
+
setAttachments(prev =>
|
|
268
|
+
prev.map(a => a.uid === uid ? { ...a, status: 'done' as const, url: downloadUrl } : a)
|
|
269
|
+
);
|
|
270
|
+
} catch {
|
|
271
|
+
setAttachments(prev =>
|
|
272
|
+
prev.map(a => a.uid === uid ? { ...a, status: 'error' as const } : a)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
}, [onUpload, hasImageCapability, hasFileCapability]);
|
|
276
|
+
|
|
277
|
+
// ==================== 提交处理 ====================
|
|
278
|
+
|
|
279
|
+
const handleSubmit = useCallback((text: string) => {
|
|
280
|
+
if (!text.trim() && !hasAttachments) return;
|
|
281
|
+
if (isUploading) return;
|
|
282
|
+
|
|
283
|
+
const images = attachments
|
|
284
|
+
.filter(a => a.type === 'image' && a.status === 'done' && a.url)
|
|
285
|
+
.map(a => a.url!);
|
|
286
|
+
|
|
287
|
+
const files = attachments
|
|
288
|
+
.filter(a => a.type === 'file' && a.status === 'done' && a.url)
|
|
289
|
+
.map(a => a.url!);
|
|
290
|
+
|
|
291
|
+
onSubmit?.({
|
|
292
|
+
text: text.trim(),
|
|
293
|
+
images,
|
|
294
|
+
files,
|
|
295
|
+
modelId: currentModelId,
|
|
296
|
+
webSearch: webSearchEnabled,
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
// 清空状态
|
|
300
|
+
setInputValue('');
|
|
301
|
+
setAttachments([]);
|
|
302
|
+
setHeaderOpen(false);
|
|
303
|
+
}, [attachments, hasAttachments, isUploading, currentModelId, webSearchEnabled, onSubmit]);
|
|
304
|
+
|
|
305
|
+
// ==================== 模型切换 ====================
|
|
306
|
+
|
|
307
|
+
const handleModelChange = useCallback((value: string) => {
|
|
308
|
+
setInternalModelId(value);
|
|
309
|
+
onModelChange?.(value);
|
|
310
|
+
}, [onModelChange]);
|
|
311
|
+
|
|
312
|
+
// ==================== 粘贴文件 ====================
|
|
313
|
+
|
|
314
|
+
const handlePasteFile = useCallback((firstFile: File) => {
|
|
315
|
+
handleFileUpload(firstFile);
|
|
316
|
+
}, [handleFileUpload]);
|
|
317
|
+
|
|
318
|
+
// ==================== Attachments 配置 ====================
|
|
319
|
+
|
|
320
|
+
const attachmentItems: AttachmentFileItem[] = useMemo(() => {
|
|
321
|
+
return attachments.map(a => ({
|
|
322
|
+
uid: a.uid,
|
|
323
|
+
name: a.name,
|
|
324
|
+
status: a.status === 'done' ? 'done' as const : a.status === 'error' ? 'error' as const : 'uploading' as const,
|
|
325
|
+
thumbUrl: a.thumbUrl,
|
|
326
|
+
url: a.url,
|
|
327
|
+
}));
|
|
328
|
+
}, [attachments]);
|
|
329
|
+
|
|
330
|
+
// 文件类型限制
|
|
331
|
+
const acceptTypes = useMemo(() => {
|
|
332
|
+
const types: string[] = [];
|
|
333
|
+
if (hasImageCapability) {
|
|
334
|
+
types.push('image/*');
|
|
335
|
+
}
|
|
336
|
+
if (hasFileCapability) {
|
|
337
|
+
const fileConfig = capabilities?.fileConfig;
|
|
338
|
+
if (fileConfig?.supportFileTypes?.length) {
|
|
339
|
+
types.push(...fileConfig.supportFileTypes.map(t => `.${t}`));
|
|
340
|
+
} else {
|
|
341
|
+
types.push('.pdf', '.doc', '.docx', '.txt', '.csv', '.xlsx', '.xls', '.md', '.json');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return types.join(',');
|
|
345
|
+
}, [hasImageCapability, hasFileCapability, capabilities?.fileConfig]);
|
|
346
|
+
|
|
347
|
+
// ==================== 渲染:Header(附件区域) ====================
|
|
348
|
+
|
|
349
|
+
const renderHeader = useMemo(() => {
|
|
350
|
+
if (!hasUploadCapability) return undefined;
|
|
351
|
+
|
|
352
|
+
return (
|
|
353
|
+
<Sender.Header
|
|
354
|
+
title="附件"
|
|
355
|
+
open={headerOpen}
|
|
356
|
+
onOpenChange={setHeaderOpen}
|
|
357
|
+
closable
|
|
358
|
+
styles={{
|
|
359
|
+
content: { padding: 4 },
|
|
360
|
+
}}
|
|
361
|
+
>
|
|
362
|
+
<Attachments
|
|
363
|
+
ref={attachmentsRef as any}
|
|
364
|
+
items={attachmentItems as AttachmentsProps['items']}
|
|
365
|
+
beforeUpload={() => false}
|
|
366
|
+
onRemove={(file) => {
|
|
367
|
+
setAttachments(prev => {
|
|
368
|
+
const updated = prev.filter(a => a.uid !== file.uid);
|
|
369
|
+
if (updated.length === 0) {
|
|
370
|
+
setHeaderOpen(false);
|
|
371
|
+
}
|
|
372
|
+
return updated;
|
|
373
|
+
});
|
|
374
|
+
}}
|
|
375
|
+
placeholder={{
|
|
376
|
+
icon: <CloudUploadOutlined />,
|
|
377
|
+
title: '拖拽文件到此处',
|
|
378
|
+
description: '支持图片和文件',
|
|
379
|
+
}}
|
|
380
|
+
/>
|
|
381
|
+
</Sender.Header>
|
|
382
|
+
);
|
|
383
|
+
}, [hasUploadCapability, headerOpen, attachmentItems]);
|
|
384
|
+
|
|
385
|
+
// ==================== 渲染:Footer(模型选择 + 联网搜索 + 清除) ====================
|
|
386
|
+
|
|
387
|
+
const renderFooter = useMemo(() => {
|
|
388
|
+
const hasFooterContent = models.length > 1 || hasWebSearchCapability || onClear;
|
|
389
|
+
if (!hasFooterContent) return undefined;
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<div className="appflow-chat-sender-footer">
|
|
393
|
+
<div className="appflow-chat-sender-footer-left">
|
|
394
|
+
{/* 模型选择 */}
|
|
395
|
+
{models.length > 1 && (
|
|
396
|
+
<Select
|
|
397
|
+
className="appflow-chat-sender-model-select"
|
|
398
|
+
size="small"
|
|
399
|
+
value={currentModelId}
|
|
400
|
+
onChange={handleModelChange}
|
|
401
|
+
options={models.map(m => ({ label: m.name, value: m.id }))}
|
|
402
|
+
variant="borderless"
|
|
403
|
+
popupMatchSelectWidth={false}
|
|
404
|
+
/>
|
|
405
|
+
)}
|
|
406
|
+
|
|
407
|
+
{/* 联网搜索开关 */}
|
|
408
|
+
{hasWebSearchCapability && (
|
|
409
|
+
<div className="appflow-chat-sender-web-search">
|
|
410
|
+
<GlobalOutlined />
|
|
411
|
+
<Switch
|
|
412
|
+
size="small"
|
|
413
|
+
checked={webSearchEnabled}
|
|
414
|
+
onChange={setWebSearchEnabled}
|
|
415
|
+
/>
|
|
416
|
+
<span>联网搜索</span>
|
|
417
|
+
</div>
|
|
418
|
+
)}
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
<div className="appflow-chat-sender-footer-right">
|
|
422
|
+
{/* 清除会话 */}
|
|
423
|
+
{onClear && (
|
|
424
|
+
<Tooltip title="清除会话">
|
|
425
|
+
<Button
|
|
426
|
+
type="text"
|
|
427
|
+
size="small"
|
|
428
|
+
icon={<DeleteOutlined />}
|
|
429
|
+
onClick={onClear}
|
|
430
|
+
disabled={loading}
|
|
431
|
+
/>
|
|
432
|
+
</Tooltip>
|
|
433
|
+
)}
|
|
434
|
+
</div>
|
|
435
|
+
</div>
|
|
436
|
+
);
|
|
437
|
+
}, [models, currentModelId, handleModelChange, hasWebSearchCapability, webSearchEnabled, onClear, loading]);
|
|
438
|
+
|
|
439
|
+
// ==================== 渲染:Actions(上传按钮) ====================
|
|
440
|
+
|
|
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;
|
|
446
|
+
|
|
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]}>
|
|
453
|
+
<Button
|
|
454
|
+
type="text"
|
|
455
|
+
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
|
+
}}
|
|
472
|
+
/>
|
|
473
|
+
</Badge>
|
|
474
|
+
</Tooltip>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{/* 发送/停止按钮 */}
|
|
478
|
+
{loading ? (
|
|
479
|
+
<LoadingButton />
|
|
480
|
+
) : (
|
|
481
|
+
<SendButton disabled={disabled || isUploading || (!inputValue.trim() && !hasAttachments)} />
|
|
482
|
+
)}
|
|
483
|
+
</div>
|
|
484
|
+
);
|
|
485
|
+
}, [
|
|
486
|
+
hasUploadCapability, hasImageCapability, hasFileCapability,
|
|
487
|
+
hasAttachments, attachments.length, acceptTypes,
|
|
488
|
+
disabled, loading, isUploading, inputValue, handleFileUpload,
|
|
489
|
+
]);
|
|
490
|
+
|
|
491
|
+
// ==================== 主渲染 ====================
|
|
492
|
+
|
|
493
|
+
return (
|
|
494
|
+
<SenderWrapper className={`appflow-chat-sender ${className || ''}`} style={style}>
|
|
495
|
+
<Sender
|
|
496
|
+
value={inputValue}
|
|
497
|
+
onChange={setInputValue}
|
|
498
|
+
onSubmit={handleSubmit}
|
|
499
|
+
loading={loading}
|
|
500
|
+
disabled={disabled}
|
|
501
|
+
placeholder={isUploading ? '文件上传中...' : placeholder}
|
|
502
|
+
submitType={submitType}
|
|
503
|
+
onCancel={onCancel}
|
|
504
|
+
onPasteFile={hasUploadCapability ? handlePasteFile : undefined}
|
|
505
|
+
allowSpeech={hasAudioCapability}
|
|
506
|
+
header={renderHeader}
|
|
507
|
+
footer={renderFooter}
|
|
508
|
+
actions={renderActions}
|
|
509
|
+
autoSize={{ minRows: 1, maxRows: 6 }}
|
|
510
|
+
readOnly={isUploading}
|
|
511
|
+
/>
|
|
512
|
+
</SenderWrapper>
|
|
513
|
+
);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
export default ChatSender;
|
package/src/index.ts
CHANGED
|
@@ -28,6 +28,7 @@ export { MessageBubble } from './components/MessageBubble';
|
|
|
28
28
|
export { RichMessageBubble } from './components/RichMessageBubble';
|
|
29
29
|
export { DocReferences } from './components/DocReferences';
|
|
30
30
|
export { WebSearchPanel } from './components/WebSearchPanel';
|
|
31
|
+
export { ChatSender } from './components/ChatSender';
|
|
31
32
|
|
|
32
33
|
// ==================== UI 组件类型导出 ====================
|
|
33
34
|
export type { MarkdownRendererProps } from './components/MarkdownRenderer';
|
|
@@ -40,6 +41,7 @@ export type {
|
|
|
40
41
|
export type { RichMessageBubbleProps } from './components/RichMessageBubble';
|
|
41
42
|
export type { DocReferencesProps, DocReferenceItem } from './components/DocReferences';
|
|
42
43
|
export type { WebSearchPanelProps, WebSearchItem } from './components/WebSearchPanel';
|
|
44
|
+
export type { ChatSenderProps, ChatSenderSubmitData, ChatAttachment } from './components/ChatSender';
|
|
43
45
|
|
|
44
46
|
// ==================== Core 组件导出(纯展示组件,供高级定制) ====================
|
|
45
47
|
export { BubbleContent } from './core/BubbleContent';
|
package/src/markdown/index.tsx
CHANGED
|
@@ -53,9 +53,28 @@ export const MarkdownView: React.FC<MarkdownViewProps> = ({
|
|
|
53
53
|
/>
|
|
54
54
|
);
|
|
55
55
|
},
|
|
56
|
-
code: ({ node, className, ...props }: any) => {
|
|
56
|
+
code: ({ node, inline, className, ...props }: any) => {
|
|
57
|
+
// 判断是否为行内代码
|
|
58
|
+
// 行内代码没有 className,且 inline 为 true
|
|
59
|
+
const isInline = inline || (!className && !node?.properties?.className);
|
|
60
|
+
|
|
61
|
+
if (isInline) {
|
|
62
|
+
// 行内代码 - 使用简单的 <code> 标签样式
|
|
63
|
+
return (
|
|
64
|
+
<code style={{
|
|
65
|
+
backgroundColor: 'rgba(175, 184, 193, 0.2)',
|
|
66
|
+
padding: '0.2em 0.4em',
|
|
67
|
+
borderRadius: '3px',
|
|
68
|
+
fontSize: '85%',
|
|
69
|
+
fontFamily: 'Consolas, Monaco, "Andale Mono", monospace',
|
|
70
|
+
}}>
|
|
71
|
+
{props.children}
|
|
72
|
+
</code>
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
|
|
57
76
|
// 元信息中解析语言类型
|
|
58
|
-
const languageType = /language-(\w+)/.exec(node?.properties?.className || '')?.[0];
|
|
77
|
+
const languageType = /language-(\w+)/.exec(node?.properties?.className || className || '')?.[0];
|
|
59
78
|
const langFromMeta = node?.position?.start?.line === 2 ? 'language-file' : null;
|
|
60
79
|
const finalLang = languageType || langFromMeta;
|
|
61
80
|
|