Files
test1/components/nova-sdk/message-input/index.tsx
2026-03-20 07:33:46 +00:00

444 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useCallback, useRef, type KeyboardEvent } from 'react'
import { ArrowUp, StopCircle, Paperclip, Wrench } from 'lucide-react'
import { cn } from '@/utils/cn'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { SendMessagePayload } from '../types'
import { Button } from '@/components/ui'
import { useNovaKit } from '../context/useNovaKit'
import { useFileUploader } from '../hooks/useFileUploader'
import type { UploadFile } from '../types'
import { FilePreviewList } from './FilePreviewList'
import { request } from '@/http/request'
import {
McpStorePopover,
SkillForm,
type SkillFormState,
MCPJsonEditor,
} from '../tools'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { toast } from 'sonner'
import { TaskStatus } from '../types'
import { getProjectId, getUserId } from '@/utils/getAuth'
interface InputToolsProps {
showUpload?: boolean
fileInputRef: React.RefObject<HTMLInputElement | null>
accept?: string
onFileSelect: (e: React.ChangeEvent<HTMLInputElement>) => void
onOpenSkillManager: () => void
onOpenMcpManager: () => void
}
const InputTools: React.FC<InputToolsProps> = ({
showUpload,
fileInputRef,
accept,
onFileSelect,
onOpenSkillManager,
onOpenMcpManager,
}) => {
if (!showUpload) return null
return (
<TooltipProvider>
<div className="flex items-center gap-2">
{/* 上传文件 */}
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onClick={() => fileInputRef.current?.click()}
className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200"
>
<Paperclip className="w-3.5 h-3.5" />
</button>
</TooltipTrigger>
<TooltipContent>
<p></p>
</TooltipContent>
</Tooltip>
<input
type="file"
multiple
className="hidden"
ref={fileInputRef}
onChange={onFileSelect}
accept={accept}
/>
{/* 中间按钮Popover提供 Skill / MCP 选项 */}
<Popover>
<Tooltip>
<TooltipTrigger asChild>
<PopoverTrigger asChild>
<button
type="button"
className="inline-flex items-center justify-center rounded-full w-7 h-7 bg-gray-100 text-gray-900 transition-colors hover:bg-gray-200"
>
<Wrench className="w-3.5 h-3.5" />
</button>
</PopoverTrigger>
</TooltipTrigger>
<TooltipContent>
</TooltipContent>
</Tooltip>
<PopoverContent
align="start"
className="w-40 p-1 bg-popover border border-border"
>
<button
type="button"
className="w-full cursor-pointer rounded-md px-3 py-3 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={onOpenSkillManager}
>
Skill
</button>
<button
type="button"
className="mt-0.5 w-full cursor-pointer rounded-md px-3 py-3 text-left text-xs text-foreground transition-colors hover:bg-accent"
onClick={onOpenMcpManager}
>
MCP
</button>
</PopoverContent>
</Popover>
{/* MCP 市场入口 */}
<McpStorePopover />
</div>
</TooltipProvider>
)
}
export interface MessageInputProps {
/** 占位符文本 */
placeholder?: string
/** 是否禁用 */
disabled?: boolean
/** 是否正在加载 */
loading?: boolean
/** 任务状态 */
taskStatus?: TaskStatus
/** 发送消息回调 */
onSend?: (payload: SendMessagePayload) => void
/** 终止消息回调 */
onStop?: () => void
/** 文件列表变化回调 */
onFilesChange?: (files: UploadFile[]) => void
/** 自定义类名 */
className?: string
/** 是否显示文件上传按钮 */
showUpload?: boolean
}
/**
* 消息输入框组件
*/
export function MessageInput({
placeholder = '请输入消息...',
taskStatus = TaskStatus.PENDING,
onSend,
className,
showUpload = true,
}: MessageInputProps) {
const { api: { stopChat }, loading, agentId } = useNovaKit()
const [content, setContent] = useState('')
const [files, setFiles] = useState<UploadFile[]>([])
const textareaRef = useRef<HTMLTextAreaElement>(null)
const fileInputRef = useRef<HTMLInputElement>(null)
const [skillDialogOpen, setSkillDialogOpen] = useState(false)
const [mcpDialogOpen, setMcpDialogOpen] = useState(false)
const [mcpJson, setMcpJson] = useState('')
const handleUploadEnd = useCallback((file: UploadFile) => {
setFiles(prev => prev.map(f => f.uid === file.uid ? file : f))
}, [])
const handleUploadStart = useCallback((file: UploadFile) => {
setFiles(prev => [...prev, file])
}, [])
const handleFileUpdate = useCallback((file: UploadFile) => {
setFiles(prev => prev.map(f => f.uid === file.uid ? file : f))
}, [])
const { uploadFile, accept } = useFileUploader({
onUploadStart: handleUploadStart,
onFileUpdate: handleFileUpdate,
onUploadEnd: handleUploadEnd,
})
// Filter valid files and check loading status
const uploading = files.some(f => f.uploadStatus === 'uploading' || f.uploadStatus === 'pending')
const contentEmpty = !content.trim() && files.length === 0
const showStopButton =
(loading || taskStatus === TaskStatus.IN_PROGRESS) &&
taskStatus !== TaskStatus.PAUSED
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
const selectedFiles = Array.from(e.target.files)
for (const file of selectedFiles) {
await uploadFile(file)
}
// Reset input
if (fileInputRef.current) fileInputRef.current.value = ''
}
}, [uploadFile])
const removeFile = useCallback((uid: string) => {
setFiles(prev => prev.filter(f => f.uid !== uid))
}, [])
// 发送消息
const handleSend = useCallback(() => {
if (contentEmpty) return
// Check if any files are still uploading
if (uploading) {
console.warn('请等待文件上传完成')
return
}
// Filter out failed uploads
const validFiles = files.filter(f => f.uploadStatus === 'success')
const fileIds = validFiles.map(f => f.upload_file_id).filter(Boolean) as string[]
const payload: SendMessagePayload = {
content: content.trim(),
upload_file_ids: fileIds.length > 0 ? fileIds : undefined
}
// Ensure we are sending the file IDs as requested
console.log('Sending message payload:', payload)
onSend?.(payload)
setContent('')
setFiles([])
// 清空已上传的文件
}, [content, contentEmpty, onSend, files, uploading])
// 处理键盘事件
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
}
},
[handleSend]
)
// 自动调整高度
const handleInput = useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
const textarea = e.target
setContent(textarea.value)
// 自动调整高度
textarea.style.height = 'auto'
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`
}, [])
return (
<>
<div className={cn('w-full', className)}>
<div className="max-w-2xl mx-auto w-full">
<div className="relative group/input">
{/* 渐变光晕背景层 */}
{/* 主输入容器:上方 textarea下方工具栏 / 发送按钮 */}
<div className="relative flex w-full flex-col rounded-2xl border border-gray-200 bg-white px-4 pt-3 pb-3">
{/* 文件预览区域 */}
<FilePreviewList
files={files}
onRemove={removeFile}
disabled={loading}
/>
{/* 文本输入区域(顶部整行) */}
<div className="flex-1 relative">
<Textarea
ref={textareaRef}
value={content}
onChange={handleInput}
onKeyDown={handleKeyDown}
placeholder={placeholder}
rows={1}
className={cn(
'w-full resize-none border-0 shadow-none focus-visible:ring-0 focus-visible:ring-offset-0',
'bg-transparent min-h-[40px] max-h-[160px] px-0',
'placeholder:text-muted-foreground/70',
'text-foreground text-sm leading-relaxed',
'overflow-y-auto transition-all duration-200',
'[&::-webkit-scrollbar]:w-1 [&::-webkit-scrollbar-thumb]:bg-muted-foreground/40 [&::-webkit-scrollbar-thumb]:rounded-full'
)}
/>
</div>
{/* 底部工具栏 + 发送按钮(参考截图布局) */}
<div className="mt-2 pt-2 flex items-center justify-between text-[11px] text-muted-foreground">
{/* 左侧Agent Input + 工具区 */}
<div className="flex items-center gap-3 text-muted-foreground">
<InputTools
showUpload={showUpload}
fileInputRef={fileInputRef}
accept={accept}
onFileSelect={handleFileSelect}
onOpenSkillManager={() => {
setSkillDialogOpen(true)
}}
onOpenMcpManager={() => {
setMcpDialogOpen(true)
}}
/>
</div>
{/* 右侧:终止 + 发送按钮(发送为黑色圆形按钮) */}
<div className="flex items-center gap-2 shrink-0">
{showStopButton && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
void stopChat().catch(() => {})
}}
title="终止"
className={cn(
'h-8 rounded-full px-2.5 pr-3 text-[11px] font-medium transition-all duration-200',
'border border-rose-200/80 bg-rose-50/90 text-rose-600 hover:bg-rose-100 hover:text-rose-700 hover:border-rose-300',
'dark:border-rose-500/40 dark:bg-rose-500/10 dark:text-rose-100 dark:hover:bg-rose-500/20 dark:hover:border-rose-400'
)}
>
<StopCircle className="w-3.5 h-3.5" />
</Button>
)}
<button
type="button"
onClick={handleSend}
disabled={contentEmpty}
title="发送"
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full bg-gray-900 text-white transition-all',
'hover:scale-105 active:scale-95 hover:bg-black',
contentEmpty && 'opacity-30 cursor-not-allowed hover:scale-100 hover:bg-gray-900'
)}
>
<ArrowUp className="w-4 h-4" strokeWidth={2.3} />
</button>
</div>
</div>
</div>
</div>
</div>
</div>
{/* Skill 管理弹窗 */}
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogContent className="max-w-2xl border-border bg-card">
<DialogHeader>
<DialogTitle> Skill</DialogTitle>
</DialogHeader>
<div className="mt-4 overflow-hidden rounded-lg">
<SkillForm
teamId={agentId!}
onBack={() => setSkillDialogOpen(false)}
onSave={async (values: SkillFormState) => {
if (!agentId) {
toast.error('缺少 agentId无法创建 Skill')
return
}
if (!values.file?.file) {
toast.error('请先上传 Skill zip 文件')
return
}
try {
const formData = new FormData()
formData.append('name', values.name)
formData.append('agent_id', agentId)
formData.append('file', values.file.file)
/** 存在projectId的时候上传external_app_id */
if (getProjectId()) {
formData.append('external_app_id', getProjectId()!)
}
const userId = await getUserId()
/** 存在userId的时候上传external_user_id */
if (userId) {
formData.append('external_user_id', userId)
}
if (values.description) {
formData.append('description', values.description)
}
await request.post('/plugins/skill/upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
})
toast.success('Skill 添加成功')
setSkillDialogOpen(false)
} catch (error) {
console.error('创建 Skill 失败:', error)
toast.error('创建 Skill 失败')
}
}}
/>
</div>
</DialogContent>
</Dialog>
{/* MCP 管理弹窗 */}
<Dialog open={mcpDialogOpen} onOpenChange={setMcpDialogOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle> MCP</DialogTitle>
</DialogHeader>
<div className="mt-4">
<MCPJsonEditor
value={mcpJson}
onChange={setMcpJson}
onSave={async (parsedConfig: Record<string, unknown>) => {
console.log('保存 MCP', { agentId, parsedConfig })
if (!agentId) {
toast.error('缺少 agentId无法创建 MCP')
return
}
try {
await request.post(`/team/${agentId}/plugins`, {
name: parsedConfig.name,
code: parsedConfig.code,
plugin_type: 'MCP',
source: 'CLOUD',
config: parsedConfig,
})
toast.success('MCP 添加成功')
setMcpJson('')
setMcpDialogOpen(false)
} catch (error) {
console.error('创建 MCP 失败:', error)
toast.error('创建 MCP 失败')
}
}}
/>
</div>
</DialogContent>
</Dialog>
</>
)
}
export default React.memo(MessageInput)