import { ArrowUpOutlined, BookOutlined, CloseOutlined, DownOutlined, PaperClipOutlined } from '@ant-design/icons'; import { Button, Image as AntImage, Input, Select, Tag, Tooltip, Upload, Popover } from 'antd'; import type { TextAreaRef } from 'antd/es/input/TextArea'; import type { ChatAttachment } from '../../../api'; import type { Agent } from '../../../api/agents'; import { HistoryIcon, NewChatIcon } from '../../../components/icons'; import { useState, useRef, useEffect } from 'react'; export default function ChatInput(props: { input: string; setInput: (v: string) => void; sending: boolean; attachments: ChatAttachment[]; setAttachments: (updater: (prev: ChatAttachment[]) => ChatAttachment[]) => void; imageUrls: string[]; setImageUrls: (updater: (prev: string[]) => string[]) => void; onSend: () => void; onStop: () => void; onAttach: (files: File[]) => void; onOpenTpl: () => void; modelOptions: Array<{ value: string; label: string }>; activeModelValue: string; onChangeModel: (modelId: string) => void; onOpenHistory: () => void; onNewSession: () => void; agentList: Agent[]; onInsertMention: (agentName: string) => void; showActions?: boolean; }) { const { input, setInput, sending, attachments, setAttachments, imageUrls, setImageUrls, onSend, onStop, onAttach, onOpenTpl, modelOptions, activeModelValue, onChangeModel, onOpenHistory, onNewSession, agentList, onInsertMention, showActions = true } = props; const [showMentionPopover, setShowMentionPopover] = useState(false); const [mentionQuery, setMentionQuery] = useState(''); const [mentionPos, setMentionPos] = useState<{ top: number; left: number } | null>(null); const inputRef = useRef(null); const getCaretPos = (textarea: HTMLTextAreaElement, caretIndex: number) => { const rect = textarea.getBoundingClientRect(); const cs = window.getComputedStyle(textarea); const div = document.createElement('div'); div.style.position = 'absolute'; div.style.visibility = 'hidden'; div.style.top = '0'; div.style.left = '-9999px'; div.style.whiteSpace = 'pre-wrap'; div.style.wordWrap = 'break-word'; div.style.overflow = 'hidden'; div.style.boxSizing = cs.boxSizing; div.style.width = rect.width + 'px'; div.style.fontFamily = cs.fontFamily; div.style.fontSize = cs.fontSize; div.style.fontWeight = cs.fontWeight; div.style.fontStyle = cs.fontStyle; div.style.letterSpacing = cs.letterSpacing; div.style.textTransform = cs.textTransform; div.style.lineHeight = cs.lineHeight; div.style.padding = cs.padding; div.style.border = cs.border; div.style.tabSize = (cs as any).tabSize || '8'; const before = textarea.value.slice(0, caretIndex).replace(/ /g, '\u00a0'); div.textContent = before; const span = document.createElement('span'); span.textContent = '\u200b'; div.appendChild(span); document.body.appendChild(div); const divRect = div.getBoundingClientRect(); const spanRect = span.getBoundingClientRect(); document.body.removeChild(div); const lineHeight = Number.parseFloat(cs.lineHeight) || Number.parseFloat(cs.fontSize) * 1.2; return { top: rect.top + (spanRect.top - divRect.top) - textarea.scrollTop, left: rect.left + (spanRect.left - divRect.left) - textarea.scrollLeft, lineHeight }; }; // 检测 @ 触发提及选择 const handleInputChange = (e: React.ChangeEvent) => { const value = e.target.value; setInput(value); const cursorPos = e.target.selectionStart ?? value.length; const textBeforeCursor = value.slice(0, cursorPos); const atIndex = textBeforeCursor.lastIndexOf('@'); console.log('[@mention] input change:', { value, cursorPos, textBeforeCursor, atIndex, agentListLength: agentList.length, agentList: agentList.map(a => ({ id: a.id, name: a.name })) }); if (atIndex !== -1 && (atIndex === 0 || /\s$/.test(textBeforeCursor.slice(0, atIndex)))) { const query = textBeforeCursor.slice(atIndex + 1); console.log('[@mention] matched @ at position:', { atIndex, query }); if (!query.includes(' ')) { setMentionQuery(query); // 计算位置 - Ant Design Input.TextArea 需要从 resizableTextArea 获取实际 DOM requestAnimationFrame(() => { const textarea = inputRef.current?.resizableTextArea?.textArea; if (!textarea) { console.log('[@mention] cannot get textarea DOM from inputRef:', inputRef.current); return; } const caret = getCaretPos(textarea, cursorPos); const top = Math.min(window.innerHeight - 8, caret.top + caret.lineHeight + 8); const left = Math.min(window.innerWidth - 160, Math.max(8, caret.left)); setMentionPos({ top, left }); console.log('[@mention] show popover:', { top, left, query }); setShowMentionPopover(true); }); return; } } setShowMentionPopover(false); }; const filteredAgents = agentList.filter(a => a.name.toLowerCase().includes(mentionQuery.toLowerCase()) ); console.log('[@mention] filtered:', { mentionQuery, count: filteredAgents.length, agents: filteredAgents.map(a => a.name) }); const handleSelectAgent = (agent: Agent) => { const textarea = inputRef.current?.resizableTextArea?.textArea; if (!textarea) return; const value = input; const cursorPos = textarea.selectionStart ?? value.length; const textBefore = value.slice(0, cursorPos); const atIndex = textBefore.lastIndexOf('@'); if (atIndex === -1) { setShowMentionPopover(false); return; } const newValue = value.slice(0, atIndex) + `@${agent.name} ` + value.slice(cursorPos); setInput(newValue); setShowMentionPopover(false); requestAnimationFrame(() => { textarea.focus(); const newCursor = atIndex + agent.name.length + 2; textarea.setSelectionRange(newCursor, newCursor); }); }; return (
{attachments.map((a, i) => ( setAttachments((arr) => arr.filter((_, j) => j !== i))}> 📎 {a.name} ))} {imageUrls.map((u, i) => (
))}
{showActions &&
}
{ if (e.key !== 'Enter') return; if ((e as any).isComposing) return; if (e.metaKey || e.ctrlKey) { e.preventDefault(); const el = e.currentTarget; const start = el.selectionStart ?? input.length; const end = el.selectionEnd ?? input.length; const next = input.slice(0, start) + '\n' + input.slice(end); setInput(next); requestAnimationFrame(() => { el.selectionStart = el.selectionEnd = start + 1; }); return; } if (!e.shiftKey && !e.altKey) { e.preventDefault(); onSend(); } }} className="chat-input-textarea" disabled={sending} /> {showMentionPopover && mentionPos && (
{filteredAgents.length === 0 ? (
未找到匹配的智能体
) : ( filteredAgents.map(agent => (
handleSelectAgent(agent)} style={{ padding: '6px 10px', cursor: 'pointer', borderBottom: '1px solid var(--color-border)' }} onMouseEnter={e => e.currentTarget.style.background = 'var(--color-fill-hover)'} onMouseLeave={e => e.currentTarget.style.background = 'transparent'} >
{agent.name}
{agent.description && (
{agent.description.slice(0, 30)} {agent.description.length > 30 ? '...' : ''}
)}
)) )}
)}
{/*