初始化模版工程

This commit is contained in:
Cloud Bot
2026-03-20 07:33:46 +00:00
commit 23717e0ecd
386 changed files with 51675 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
import { X, FileIcon, Loader2 } from 'lucide-react'
import type { UploadFile } from '../types'
import { cn } from '@/utils/cn'
import { ImagePreview } from '@/components/ui/image-preview'
import { Image } from '@/components/ui/image'
interface FilePreviewListProps {
files: UploadFile[]
onRemove: (uid: string) => void
disabled?: boolean
}
export function FilePreviewList({ files, onRemove, disabled }: FilePreviewListProps) {
if (files.length === 0) return null
return (
<div className="flex flex-wrap gap-2 border-b border-border/80 px-0 pb-3">
{files.map(file => {
const isImage = /\.(jpg|jpeg|png|gif|webp|svg|ico|bmp)$/i.test(file.name)
const isUploading = file.uploadStatus === 'uploading' || file.uploadStatus === 'pending'
const hasError = file.uploadStatus === 'error'
const showImagePreview = isImage && file.url
return (
<div
key={file.uid}
className={cn(
'relative group flex max-w-[200px] items-center gap-2 rounded-md border bg-card/82 px-3 py-1.5 text-xs backdrop-blur-sm',
hasError ? 'border-red-200 dark:border-red-800' : 'border-border/80',
file.url && !isUploading ? 'cursor-pointer hover:border-primary/50' : ''
)}
onClick={() => {
if (file.url && !isUploading && !hasError) {
if (!showImagePreview) {
window.open(file.url, '_blank')
}
}
}}
>
{/* Icon / Image Preview */}
<div
className="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded bg-accent/60 text-muted-foreground"
>
{isUploading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : showImagePreview ? (
<ImagePreview
src={file.url!}
alt={file.name}
className="w-full h-full flex items-center justify-center"
>
<Image
src={file.url!}
alt={file.name}
className="w-full h-full object-cover"
onError={(e) => {
e.currentTarget.style.display = 'none'
e.currentTarget.parentElement?.classList.add('fallback-icon')
}}
/>
</ImagePreview>
) : (
<FileIcon className="w-4 h-4" />
)}
</div>
<div className="flex min-w-0 flex-1 flex-col">
<span className="truncate font-medium text-foreground" title={file.name}>
{file.name}
</span>
<span className="text-[10px] text-muted-foreground">
{isUploading ? '上传中...' : hasError ? '上传失败' : formatFileSize(file.byte_size)}
</span>
</div>
<button
onClick={(e) => {
e.stopPropagation() // 防止触发打开文件
onRemove(file.uid)
}}
disabled={disabled}
className="shrink-0 rounded-full p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-accent"
>
<X className="h-3 w-3 text-muted-foreground" />
</button>
{/* Progress Bar */}
{isUploading && (
<div className="absolute bottom-0 left-0 h-0.5 w-full overflow-hidden rounded-b-md bg-accent">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${file.progress || 0}%` }}
/>
</div>
)}
</div>
)
})}
</div>
)
}
function formatFileSize(bytes?: number) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
const k = 1024
const sizes = ['KB', 'MB', 'GB']
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(k)) - 1,
sizes.length - 1,
)
return parseFloat((bytes / Math.pow(k, index + 1)).toFixed(1)) + ' ' + sizes[index]
}

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