初始化模版工程
This commit is contained in:
443
components/nova-sdk/message-input/index.tsx
Normal file
443
components/nova-sdk/message-input/index.tsx
Normal file
@@ -0,0 +1,443 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user