返回 AiToEarn
index.tsx
1 /**
2 * ChatInput - 聊天输入组件
3 * 功能:文本输入、媒体上传、发送消息
4 * 支持首页大尺寸和对话详情页固定底部两种模式
5 */
6
7 'use client'
8
9 import type { ClipboardEvent, KeyboardEvent } from 'react'
10 import type { IUploadedMedia } from '../MediaUpload'
11 import { ArrowUp, Loader2, Square } from 'lucide-react'
12 import { useEffect, useRef, useState } from 'react'
13 import { useTransClient } from '@/app/i18n/client'
14 import {
15 Tooltip,
16 TooltipContent,
17 TooltipProvider,
18 TooltipTrigger,
19 } from '@/components/ui/tooltip'
20 import { useSystemStore } from '@/store/system'
21 import { cn } from '@/utils/className'
22 import { MediaUpload } from '../MediaUpload'
23 import { AgentTestingNoticeDialog } from './AgentTestingNoticeDialog'
24
25 export interface IChatInputProps {
26 /** 输入内容 */
27 value: string
28 /** 内容变更回调 */
29 onChange: (value: string) => void
30 /** 发送回调 */
31 onSend: () => void
32 /** 停止生成回调 */
33 onStop?: () => void
34 /** 已上传的媒体 */
35 medias?: IUploadedMedia[]
36 /** 媒体文件变更回调 */
37 onMediasChange?: (files: FileList) => void
38 /** 移除媒体回调 */
39 onMediaRemove?: (index: number) => void
40 /** 更新媒体回调(编辑后替换) */
41 onMediaUpdate?: (index: number, newUrl: string) => void
42 /** 最大媒体上传数量 */
43 maxMediaCount?: number
44 /** 最大输入字符数 */
45 maxLength?: number
46 /** 是否正在生成 */
47 isGenerating?: boolean
48 /** 是否正在上传 */
49 isUploading?: boolean
50 /** 是否禁用 */
51 disabled?: boolean
52 /** 占位文本 */
53 placeholder?: string
54 /** 显示模式:large-首页大尺寸,compact-对话详情页 */
55 mode?: 'large' | 'compact'
56 /** 自定义类名 */
57 className?: string
58 /** 是否允许空输入发送(用于首页使用 placeholder 作为默认值的场景) */
59 allowEmptySubmit?: boolean
60 }
61
62 /**
63 * ChatInput - 聊天输入组件
64 */
65 export function ChatInput({
66 value,
67 onChange,
68 onSend,
69 onStop,
70 medias = [],
71 onMediasChange,
72 onMediaRemove,
73 onMediaUpdate,
74 maxMediaCount = 5,
75 maxLength = 4000,
76 isGenerating = false,
77 isUploading = false,
78 disabled = false,
79 placeholder = '输入你想创作的内容...',
80 mode = 'large',
81 className,
82 allowEmptySubmit = false,
83 }: IChatInputProps) {
84 const { t } = useTransClient('chat')
85 const textareaRef = useRef<HTMLTextAreaElement>(null)
86 const [isFocused, setIsFocused] = useState(false)
87 const [noticeOpen, setNoticeOpen] = useState(false)
88 const disableAgentTestingNotice = useSystemStore(state => state.disableAgentTestingNotice)
89 const setDisableAgentTestingNotice = useSystemStore(state => state.setDisableAgentTestingNotice)
90
91 /** 当前字符数 */
92 const currentLength = value.length
93 /** 是否超出限制 */
94 const isOverLimit = currentLength > maxLength
95
96 /**
97 * 自动调整高度
98 * - large 模式:根据内容自适应高度(最多 200px)
99 * - compact 模式:保持单行起步,允许根据内容适度增高
100 */
101 useEffect(() => {
102 if (!textareaRef.current)
103 return
104
105 if (mode === 'large') {
106 textareaRef.current.style.height = 'auto'
107 const maxHeight = 200
108 textareaRef.current.style.height = `${Math.min(textareaRef.current.scrollHeight, maxHeight)}px`
109 }
110 else {
111 textareaRef.current.style.height = 'auto'
112 }
113 }, [value, mode])
114
115 /**
116 * 处理粘贴事件
117 * 支持从剪贴板粘贴图片,受 maxMediaCount 限制
118 */
119 const handlePaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
120 const items = e.clipboardData?.items
121 if (!items || !onMediasChange)
122 return
123
124 // 收集剪贴板中的图片文件
125 const imageFiles: File[] = []
126 for (let i = 0; i < items.length; i++) {
127 const item = items[i]
128 // 检查是否为图片类型
129 if (item.type.startsWith('image/')) {
130 const file = item.getAsFile()
131 if (file) {
132 imageFiles.push(file)
133 }
134 }
135 }
136
137 // 如果有图片,阻止默认粘贴行为并上传
138 if (imageFiles.length > 0) {
139 e.preventDefault()
140
141 // 计算剩余可上传数量
142 const remaining = Math.max(0, maxMediaCount - medias.length)
143 if (remaining <= 0)
144 return
145
146 // 将 File[] 转换为 FileList 格式传递给 onMediasChange
147 const dataTransfer = new DataTransfer()
148 // 只取剩余可上传数量的图片
149 const filesToUpload = imageFiles.slice(0, remaining)
150 filesToUpload.forEach(file => dataTransfer.items.add(file))
151 onMediasChange(dataTransfer.files)
152 }
153 }
154
155 /** 处理键盘事件 */
156 const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
157 // Enter 发送,Shift+Enter 换行
158 if (e.key === 'Enter' && !e.shiftKey) {
159 e.preventDefault()
160 if (!disabled && !isGenerating && !isUploading && (allowEmptySubmit || value.trim())) {
161 handleSendRequest()
162 }
163 }
164 }
165
166 /** 请求发送:提交前展示 Agent 测试阶段提示 */
167 const handleSendRequest = () => {
168 if (disableAgentTestingNotice) {
169 onSend()
170 return
171 }
172
173 setNoticeOpen(true)
174 }
175
176 /** 确认提示后继续发送 */
177 const handleNoticeConfirm = (doNotShowAgain: boolean) => {
178 if (doNotShowAgain) {
179 setDisableAgentTestingNotice(true)
180 }
181
182 setNoticeOpen(false)
183 onSend()
184 }
185
186 /** 处理发送/停止按钮点击 */
187 const handleButtonClick = () => {
188 if (isGenerating) {
189 onStop?.()
190 }
191 else if (canSend) {
192 handleSendRequest()
193 }
194 }
195
196 // 是否可以发送(仅当没有在生成时才检查内容)
197 const canSend = !disabled && !isUploading && !isGenerating && (allowEmptySubmit || value.trim())
198
199 return (
200 <>
201 <div
202 style={{
203 position: 'relative',
204 zIndex: 2,
205 }}
206 className={cn(
207 'w-full rounded-2xl border bg-card transition-all duration-300 border-border shadow-sm hover:border-border/80 hover:shadow-md',
208 mode === 'large' ? 'p-4' : 'p-3',
209 // 详情页(compact 模式)允许输入区域根据父容器拉伸
210 mode === 'compact' && 'h-full flex flex-col',
211 className,
212 )}
213 >
214 {/* 第一层:媒体预览区域(只有有媒体时展示) */}
215 {medias.length > 0 && (
216 <div className="mb-3">
217 <MediaUpload
218 medias={medias}
219 isUploading={isUploading}
220 disabled={disabled || isGenerating}
221 onFilesChange={onMediasChange}
222 onRemove={onMediaRemove}
223 onMediaUpdate={onMediaUpdate}
224 maxCount={maxMediaCount}
225 showUploadButton={false}
226 />
227 </div>
228 )}
229
230 {/* 第二层:文本输入区域 */}
231 <div className={cn('flex-1', mode === 'compact' && 'w-full')}>
232 <textarea
233 ref={textareaRef}
234 value={value}
235 onChange={e => onChange(e.target.value)}
236 onKeyDown={handleKeyDown}
237 onPaste={handlePaste}
238 onFocus={() => setIsFocused(true)}
239 onBlur={() => setIsFocused(false)}
240 placeholder={placeholder}
241 disabled={disabled || isGenerating}
242 rows={mode === 'large' ? 3 : 1}
243 className={cn(
244 'w-full resize-none border-none outline-none focus:outline-none bg-transparent text-foreground placeholder:text-muted-foreground',
245 'disabled:opacity-50 disabled:cursor-not-allowed',
246 mode === 'large' ? 'text-base min-h-[80px]' : 'text-sm min-h-[40px]',
247 )}
248 />
249 </div>
250
251 {/* 第三层:操作栏(左侧其他操作,右侧发送按钮) */}
252 <div className="mt-3 flex items-center justify-between gap-2">
253 {/* 左侧:其它操作(上传按钮 + 字数提示) */}
254 <div className="flex items-center gap-2">
255 {/* 上传按钮(包裹 Tooltip) */}
256 <TooltipProvider>
257 <Tooltip>
258 <TooltipTrigger asChild>
259 <div>
260 <MediaUpload
261 medias={medias}
262 isUploading={isUploading}
263 disabled={disabled || isGenerating}
264 onFilesChange={onMediasChange}
265 onRemove={onMediaRemove}
266 maxCount={maxMediaCount}
267 showList={false}
268 buttonVariant="icon"
269 />
270 </div>
271 </TooltipTrigger>
272 <TooltipContent side="top">
273 {t('input.upload')}
274 </TooltipContent>
275 </Tooltip>
276 </TooltipProvider>
277 {/* 字数限制提示:只在超出时显示并标红 */}
278 {isOverLimit && (
279 <div className="text-xs text-destructive">
280 {currentLength}
281 /
282 {maxLength}
283 </div>
284 )}
285 </div>
286
287 {/* 右侧:发送/停止按钮 */}
288 <button
289 onClick={handleButtonClick}
290 disabled={!isGenerating && !canSend}
291 className={cn(
292 'shrink-0 flex items-center justify-center rounded-full transition-all',
293 mode === 'large' ? 'w-10 h-10' : 'w-8 h-8',
294 // 生成中或无法发送时都显示灰色
295 !isGenerating && !canSend
296 ? 'bg-muted text-muted-foreground cursor-not-allowed'
297 : 'bg-gradient-back text-gradient-foreground shadow-sm shadow-primary/20 hover:shadow-md hover:shadow-primary/25 active:scale-95',
298 )}
299 >
300 {isGenerating ? (
301 <Square className={cn(mode === 'large' ? 'w-4 h-4' : 'w-3 h-3')} fill="currentColor" />
302 ) : isUploading ? (
303 <Loader2 className={cn('animate-spin', mode === 'large' ? 'w-5 h-5' : 'w-4 h-4')} />
304 ) : (
305 <ArrowUp className={cn(mode === 'large' ? 'w-5 h-5' : 'w-4 h-4')} />
306 )}
307 </button>
308 </div>
309 </div>
310
311 <AgentTestingNoticeDialog
312 open={noticeOpen}
313 onOpenChange={setNoticeOpen}
314 onConfirm={handleNoticeConfirm}
315 />
316 </>
317 )
318 }
319
320 export default ChatInput
321
321 lines Plain Text