'use client'
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import { Send, Paperclip, Mic, Image as ImageIcon, X, FileText, StopCircle } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useChatStore } from '@/lib/store'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'
import type { AgentSkill } from '@/lib/types'
import { skillService } from '@/services/skill-service'
import { AgentSelector } from './agent-selector'
import { useToast } from '@/hooks/use-toast'
const models = [
{ id: 'gpt-4o', name: 'GPT-4o', provider: 'OpenAI' },
{ id: 'claude-3-opus', name: 'Claude 3 Opus', provider: 'Anthropic' },
{ id: 'gemini-pro', name: 'Gemini Pro', provider: 'Google' },
{ id: 'llama-3', name: 'Llama 3 70B', provider: 'Meta' },
]
export interface PromptSuggestion {
id: string
icon?: React.ElementType
title: string
description?: string
prompt: string
}
interface ChatInputProps {
onSend: (content: string, attachments?: File[]) => void
presetPrompts?: PromptSuggestion[]
onRemovePresetPrompt?: (promptId: string) => void
onClearPresetPrompts?: () => void
}
export function ChatInput({
onSend,
presetPrompts = [],
onRemovePresetPrompt,
onClearPresetPrompts,
}: ChatInputProps) {
const { toast } = useToast()
const [skills, setSkills] = useState<AgentSkill[]>([])
const [input, setInput] = useState('')
const [attachments, setAttachments] = useState<File[]>([])
const [isDragging, setIsDragging] = useState(false)
const [isComposing, setIsComposing] = useState(false)
const [showSkillSelector, setShowSkillSelector] = useState(false)
const [selectedSkillIndex, setSelectedSkillIndex] = useState(0)
const { currentConversationId, conversations, stopStreaming, currentAgentId } = useChatStore()
const fetchSkills = useCallback(async () => {
if (!currentAgentId) {
setSkills([])
return
}
try {
const installedSkills = await skillService.listInstalledSkills(currentAgentId)
const mappedSkills: AgentSkill[] = installedSkills.map(skill => ({
name: skill.skill_name,
description:
typeof skill.metadata?.description === 'string' ? skill.metadata.description : '',
filePath: skill.relative_path || '',
source: skill.source_type || skill.skill_source || '',
}))
const uniqueSkills = Array.from(
new Map(mappedSkills.map(skill => [skill.name, skill])).values()
)
setSkills(uniqueSkills)
} catch (error) {
console.error('Failed to fetch skills:', error)
setSkills([])
}
}, [currentAgentId])
const fileInputRef = useRef<HTMLInputElement>(null)
const textareaRef = useRef<HTMLTextAreaElement>(null)
const skillListRef = useRef<HTMLDivElement>(null)
const currentConversation = conversations.find(conv => conv.id === currentConversationId)
const isStreaming = currentConversation?.isStreaming ?? false
const slashCommandQuery = useMemo(() => {
const match = input.match(/(?:^|\s)\/([a-zA-Z0-9-_]*)$/)
if (!match) {
return null
}
return match[1]
}, [input])
const filteredSkills = useMemo(() => {
if (slashCommandQuery === null) {
return []
}
const keyword = slashCommandQuery.trim().toLowerCase()
if (!keyword) {
return skills
}
return skills.filter(skill => skill.name.toLowerCase().startsWith(keyword))
}, [skills, slashCommandQuery])
useEffect(() => {
if (slashCommandQuery !== null) {
if (slashCommandQuery.length === 0) {
void fetchSkills()
}
setShowSkillSelector(true)
setSelectedSkillIndex(0)
} else {
setShowSkillSelector(false)
}
}, [fetchSkills, slashCommandQuery])
useEffect(() => {
if (skillListRef.current) {
const item = skillListRef.current.querySelector(`[data-index="${selectedSkillIndex}"]`)
item?.scrollIntoView({ block: 'nearest' })
}
}, [selectedSkillIndex])
const handleSelectSkill = useCallback((skill: AgentSkill) => {
setShowSkillSelector(false)
setInput(prev => prev.replace(/(?:^|\s)\/[a-zA-Z0-9-_]*$/, ` /${skill.name} `).trimStart())
setTimeout(() => {
textareaRef.current?.focus()
}, 50)
}, [])
const renderHighlightedContent = useCallback(
(content: string) => {
const parts = content.split(/(\/[a-zA-Z0-9-_]+)/g)
return parts.map((part, index) => {
if (part.startsWith('/') && skills.some(skill => `/${skill.name}` === part)) {
return (
<span key={index} className="bg-primary/15 rounded-xs inline px-0.5">
{part}
</span>
)
}
return <span key={index}>{part}</span>
})
},
[skills]
)
const handleSubmit = useCallback(() => {
const trimmedInput = input.trim()
if (!trimmedInput && attachments.length === 0 && presetPrompts.length === 0) return
if (isStreaming) return
if (!currentAgentId) return
let finalContent = trimmedInput
if (presetPrompts.length > 0) {
const presetContent = presetPrompts.map(p => p.prompt).join('\n\n')
finalContent = trimmedInput ? `${presetContent}\n\n${trimmedInput}` : presetContent
}
onSend(finalContent, attachments.length > 0 ? attachments : undefined)
setInput('')
setAttachments([])
onClearPresetPrompts?.()
}, [input, attachments, presetPrompts, isStreaming, onSend, onClearPresetPrompts, currentAgentId])
const handleKeyDown = (e: React.KeyboardEvent) => {
if (showSkillSelector) {
if (e.key === 'ArrowDown') {
e.preventDefault()
if (filteredSkills.length === 0) return
setSelectedSkillIndex(prev => Math.min(prev + 1, filteredSkills.length - 1))
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
if (filteredSkills.length === 0) return
setSelectedSkillIndex(prev => Math.max(prev - 1, 0))
return
}
if (e.key === 'Enter') {
e.preventDefault()
const selectedSkill = filteredSkills[selectedSkillIndex]
if (selectedSkill) {
handleSelectSkill(selectedSkill)
}
return
}
}
if (e.key === 'Backspace' && !isComposing && textareaRef.current) {
const { selectionStart, selectionEnd, value } = textareaRef.current
if (selectionStart === selectionEnd) {
const pos = selectionStart
let i = pos
while (i > 0 && value[i - 1] !== '/') {
i--
}
if (i > 0) {
const skillStr = value.slice(i - 1, pos).trimEnd()
if (skills.some(skill => `/${skill.name}` === skillStr)) {
e.preventDefault()
const newValue = value.slice(0, i - 1) + value.slice(pos)
setInput(newValue)
setTimeout(() => {
textareaRef.current?.setSelectionRange(i - 1, i - 1)
}, 0)
return
}
}
}
}
if (e.key === 'Enter' && !e.shiftKey && !isComposing && !showSkillSelector) {
e.preventDefault()
handleSubmit()
}
}
const handleCompositionStart = (e: React.CompositionEvent) => {
setIsComposing(true)
}
const handleCompositionEnd = (e: React.CompositionEvent) => {
setIsComposing(false)
}
const handleFileSelect = (files: FileList | null) => {
if (!files) return
const newFiles = Array.from(files).slice(0, 5 - attachments.length)
setAttachments(prev => [...prev, ...newFiles])
}
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(false)
handleFileSelect(e.dataTransfer.files)
}
const handleDragOver = (e: React.DragEvent) => {
e.preventDefault()
setIsDragging(true)
}
const handleDragLeave = () => {
setIsDragging(false)
}
const removeAttachment = (index: number) => {
setAttachments(prev => prev.filter((_, i) => i !== index))
}
return (
<div className="mx-auto max-w-4xl">
{/* Attachments Preview */}
{attachments.length > 0 && (
<div className="mb-3 flex flex-wrap gap-2">
{attachments.map((file, index) => (
<AttachmentPreview
key={`${file.name}-${index}`}
file={file}
onRemove={() => removeAttachment(index)}
/>
))}
</div>
)}
{/* Input Area */}
<div
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
className={cn(
'relative rounded-2xl border bg-card transition-all',
isDragging ? 'border-primary border-dashed bg-primary/5' : 'border-border',
'focus-within:border-primary/50 focus-within:ring-2 focus-within:ring-primary/20'
)}
>
{/* Agent Selector Bar */}
<AgentSelector />
{/* Skill选择器 - 绝对定位悬浮在上方,不占用高度,完全手动实现避免组件内置逻辑冲突 */}
{showSkillSelector && (
<div className="absolute bottom-full left-0 right-0 px-4 pb-2 z-50">
<div className="rounded-lg border shadow-md bg-popover text-popover-foreground">
<div className="px-2 py-1.5 text-xs font-medium text-muted-foreground">可用技能</div>
<div ref={skillListRef} className="max-h-64 overflow-y-auto p-1 scrollbar-thin">
{filteredSkills.map((skill, index) => (
<div
key={skill.name}
data-index={index}
onClick={() => handleSelectSkill(skill)}
onMouseEnter={() => setSelectedSkillIndex(index)}
className={`flex items-center py-2 px-2 rounded-sm cursor-default text-sm ${index === selectedSkillIndex ? 'bg-accent text-accent-foreground' : ''}`}
>
<span className="font-medium">/{skill.name}</span>
</div>
))}
{filteredSkills.length === 0 && (
<div className="px-2 py-2 text-xs text-muted-foreground">无匹配技能</div>
)}
</div>
</div>
</div>
)}
{isDragging && (
<div className="absolute inset-0 z-10 flex items-center justify-center rounded-2xl bg-primary/5">
<div className="flex flex-col items-center gap-2 text-primary">
<Paperclip className="h-8 w-8" />
<span className="font-medium">放开以添加文件</span>
</div>
</div>
)}
{/* 预设提示词标签 */}
{presetPrompts.length > 0 && (
<div className="flex flex-wrap gap-2 px-4 pt-3">
{presetPrompts.map(prompt => (
<div
key={prompt.id}
className="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1.5 text-sm text-primary"
>
<span className="font-medium">{prompt.title}</span>
<button
onClick={() => onRemovePresetPrompt?.(prompt.id)}
className="ml-1 rounded-full p-0.5 hover:bg-primary/20"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
)}
<div className="relative min-h-[60px]">
{/* 高亮显示层,和输入内容完全同步 */}
<div className="absolute inset-0 px-4 py-3 whitespace-pre-wrap break-words pointer-events-none z-10 text-transparent font-sans text-base leading-normal tracking-normal md:text-sm">
{renderHighlightedContent(input)}
</div>
{/* 实际输入层,透明显示 */}
<Textarea
ref={textareaRef}
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown}
onCompositionStart={handleCompositionStart}
onCompositionEnd={handleCompositionEnd}
placeholder='输入消息,按 Enter 发送,输入"/"获得更多技能'
className="min-h-[60px] max-h-[200px] resize-none border-0 bg-transparent px-4 py-3 focus-visible:ring-0 relative z-20"
disabled={isStreaming}
/>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-between border-t border-border px-3 py-2">
<div className="flex items-center gap-1">
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => fileInputRef.current?.click()}
disabled={isStreaming || attachments.length >= 5}
>
<Paperclip className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>添加附件</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isStreaming}>
<ImageIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>添加图片</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8" disabled={isStreaming}>
<Mic className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>语音输入</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">
{input.length > 0 && `${input.length} 字符`}
</span>
{isStreaming ? (
<Button variant="destructive" size="sm" className="gap-2" onClick={stopStreaming}>
<StopCircle className="h-4 w-4" />
停止生成
</Button>
) : (
<TooltipProvider delayDuration={0}>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex">
<Button
size="icon"
className="h-8 w-8"
onClick={handleSubmit}
disabled={!currentAgentId || (!input.trim() && attachments.length === 0)}
>
<Send className="h-4 w-4" />
<span className="sr-only">发送</span>
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{currentAgentId ? '发送' : '请先选择上方的智能体'}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
</div>
</div>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={e => handleFileSelect(e.target.files)}
accept="*/*"
/>
</div>
)
}
interface AttachmentPreviewProps {
file: File
onRemove: () => void
}
function AttachmentPreview({ file, onRemove }: AttachmentPreviewProps) {
const isImage = file.type.startsWith('image/')
const Icon = isImage ? ImageIcon : FileText
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
return (
<div className="group relative flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
<Icon className="h-4 w-4 text-muted-foreground" />
<div className="flex flex-col">
<span className="max-w-[120px] truncate text-sm font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">{formatSize(file.size)}</span>
</div>
<Button
variant="ghost"
size="icon"
className="absolute -right-2 -top-2 h-5 w-5 rounded-full bg-destructive text-destructive-foreground opacity-0 transition-opacity group-hover:opacity-100"
onClick={onRemove}
>
<X className="h-3 w-3" />
</Button>
</div>
)
}