| 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 |