Download src/components/panels/agent/AgentInputBox.tsx from Leon4gr45/Velo: direct link, hf CLI and curl.
- Browser
- Download file 19.9 kB
-
https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/panels/agent/AgentInputBox.tsx
- Command line
-
hf download hf://spaces/Leon4gr45/Velo/src/components/panels/agent/AgentInputBox.tsx
-
curl -L -o AgentInputBox.tsx https://huggingface.co/spaces/Leon4gr45/Velo/resolve/main/src/components/panels/agent/AgentInputBox.tsx
19.9 kB
| import { useRef, useState, useEffect, useCallback } from 'react' | |
| import { | |
| Plus, | |
| ChevronDown, | |
| ArrowRight, | |
| Square, | |
| Image, | |
| AtSign, | |
| Workflow, | |
| } from 'lucide-react' | |
| import { useTranslation } from 'react-i18next' | |
| import { useAgentStore, type AgentMode } from '../../../stores/agent-store' | |
| import { useLLMStore } from '../../../stores/llm-store' | |
| import type { ModelProfile } from '../../../shared/ipc-channels' | |
| import { useOutsideClick } from '../../../hooks/useOutsideClick' | |
| import SlashCommandMenu from './SlashCommandMenu' | |
| import MentionMenu from './MentionMenu' | |
| import type { SlashCommand, MentionTarget } from '../../../services/agent/intent-router' | |
| /** 输入框最大高度(px),超出后框内滚动 */ | |
| const MAX_HEIGHT = 200 | |
| /** | |
| * Agent 输入框组件(参考 agent1.html 第 69-155 行) | |
| * 卡片式圆角容器,底部工具栏含模式/模型/发送 | |
| */ | |
| export default function AgentInputBox() { | |
| const { t } = useTranslation('panels') | |
| const [inputText, setInputText] = useState('') | |
| const textareaRef = useRef<HTMLTextAreaElement>(null) | |
| const { generating, sendMessage, cancelGeneration, getActiveConversation, setMode, setModelId } = useAgentStore() | |
| const models = useLLMStore(s => s.models) | |
| const defaultModelId = useLLMStore(s => s.defaultModelId) | |
| // 过滤出非仅限 embedding 专用的模型 | |
| const chatModels = models.filter(m => !(m.purposes.length === 1 && m.purposes[0] === 'embedding')) | |
| const activeConv = getActiveConversation() | |
| const currentMode = activeConv?.mode ?? 'planning' | |
| const currentModelId = activeConv?.modelId ?? defaultModelId | |
| // 找到当前模型信息 | |
| const currentModel = models.find(m => m.id === currentModelId) | |
| // 下拉菜单状态 | |
| const [showContextMenu, setShowContextMenu] = useState(false) | |
| const [showModeMenu, setShowModeMenu] = useState(false) | |
| const [showModelMenu, setShowModelMenu] = useState(false) | |
| // / 命令和 @ 提及菜单状态 | |
| const [showSlashMenu, setShowSlashMenu] = useState(false) | |
| const [slashQuery, setSlashQuery] = useState('') | |
| const [showMentionMenu, setShowMentionMenu] = useState(false) | |
| const [mentionQuery, setMentionQuery] = useState('') | |
| // 检测输入是否触发 / 或 @ 菜单 | |
| const handleInputChange = useCallback((value: string) => { | |
| setInputText(value) | |
| // 检测 / 命令 | |
| if (value.startsWith('/')) { | |
| const q = value.slice(1).split(' ')[0] ?? '' | |
| setSlashQuery(q) | |
| setShowSlashMenu(true) | |
| setShowMentionMenu(false) | |
| } else { | |
| setShowSlashMenu(false) | |
| } | |
| // 检测 @ 提及(在光标位置前面找 @) | |
| const lastAt = value.lastIndexOf('@') | |
| if (lastAt >= 0) { | |
| const afterAt = value.slice(lastAt + 1) | |
| // 如果 @ 后面没有空格,视为正在输入提及 | |
| if (!afterAt.includes(' ')) { | |
| setMentionQuery(afterAt) | |
| setShowMentionMenu(true) | |
| setShowSlashMenu(false) | |
| } else { | |
| setShowMentionMenu(false) | |
| } | |
| } else { | |
| setShowMentionMenu(false) | |
| } | |
| }, []) | |
| // 选择 / 命令 | |
| const handleSlashSelect = useCallback((cmd: SlashCommand) => { | |
| setShowSlashMenu(false) | |
| if (cmd.source === 'skill') { | |
| // Skill 命令:替换为 /skill-name 后面可以加参数 | |
| setInputText(`/${cmd.name} `) | |
| } else { | |
| // 内置命令:直接发送 | |
| setInputText('') | |
| sendMessage(`/${cmd.name}`) | |
| } | |
| textareaRef.current?.focus() | |
| }, [sendMessage]) | |
| // 选择 @ 提及 | |
| const handleMentionSelect = useCallback((target: MentionTarget) => { | |
| setShowMentionMenu(false) | |
| // 替换最后一个 @ 及其后面的文字为 @displayName | |
| const lastAt = inputText.lastIndexOf('@') | |
| if (lastAt >= 0) { | |
| const before = inputText.slice(0, lastAt) | |
| setInputText(`${before}@${target.displayName} `) | |
| } | |
| textareaRef.current?.focus() | |
| }, [inputText]) | |
| const contextRef = useRef<HTMLDivElement>(null) | |
| const modeRef = useRef<HTMLDivElement>(null) | |
| const modelRef = useRef<HTMLDivElement>(null) | |
| // 调整文本框高度的通用函数 | |
| const adjustHeight = useCallback(() => { | |
| const ta = textareaRef.current | |
| if (!ta) return | |
| // 先重置为 0px,让 scrollHeight 正确反映内容高度,避免 flex 布局拉伸导致计算出很大的初始高度 | |
| ta.style.height = '0px' | |
| const next = Math.min(Math.max(ta.scrollHeight, 36), MAX_HEIGHT) | |
| ta.style.height = next + 'px' | |
| // 超出最大高度时框内滚动,否则隐藏滚动条 | |
| ta.style.overflowY = ta.scrollHeight > MAX_HEIGHT ? 'auto' : 'hidden' | |
| }, []) | |
| // 监听尺寸变化以重新计算高度,避免刚挂载时宽度未稳定导致的 placeholder 异常换行撑起高度 | |
| useEffect(() => { | |
| const ta = textareaRef.current | |
| if (!ta) { | |
| adjustHeight() | |
| return | |
| } | |
| const ro = new ResizeObserver(() => { | |
| adjustHeight() | |
| }) | |
| ro.observe(ta) | |
| // 初始化调用一次即可 | |
| adjustHeight() | |
| return () => ro.disconnect() | |
| }, [adjustHeight]) | |
| // 内容变化时重新调整高度 | |
| useEffect(() => { | |
| adjustHeight() | |
| }, [inputText, adjustHeight]) | |
| // 点击外部关闭下拉(用 useOutsideClick 统一管理三个 ref) | |
| useOutsideClick(contextRef, () => setShowContextMenu(false), showContextMenu) | |
| useOutsideClick(modeRef, () => setShowModeMenu(false), showModeMenu) | |
| useOutsideClick(modelRef, () => setShowModelMenu(false), showModelMenu) | |
| /** 发送或停止 */ | |
| const handleSendOrStop = useCallback(async () => { | |
| if (generating) { | |
| await cancelGeneration() | |
| return | |
| } | |
| if (!inputText.trim()) return | |
| const text = inputText | |
| setInputText('') | |
| await sendMessage(text) | |
| }, [generating, inputText, sendMessage, cancelGeneration]) | |
| /** 键盘事件:Enter 发送,Shift+Enter 换行 */ | |
| const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { | |
| // / 或 @ 菜单打开时,由菜单组件处理键盘事件 | |
| if (showSlashMenu || showMentionMenu) { | |
| if (['ArrowUp', 'ArrowDown', 'Enter'].includes(e.key)) { | |
| return // 让菜单组件通过 window 事件处理 | |
| } | |
| if (e.key === 'Escape') { | |
| setShowSlashMenu(false) | |
| setShowMentionMenu(false) | |
| return | |
| } | |
| } | |
| if (e.key === 'Enter' && !e.shiftKey) { | |
| e.preventDefault() | |
| handleSendOrStop() | |
| } | |
| } | |
| const canSend = !generating && inputText.trim().length > 0 | |
| return ( | |
| <div | |
| className="relative flex flex-col gap-0 p-1.5" | |
| style={{ | |
| backgroundColor: 'var(--color-hover)', | |
| border: '1px solid var(--color-border)', | |
| borderRadius: 'var(--radius-md)', /* 4px 方正风格 */ | |
| }} | |
| > | |
| {/* / 命令菜单 */} | |
| {showSlashMenu && ( | |
| <SlashCommandMenu | |
| query={slashQuery} | |
| onSelect={handleSlashSelect} | |
| onClose={() => setShowSlashMenu(false)} | |
| /> | |
| )} | |
| {/* @ 提及菜单 */} | |
| {showMentionMenu && ( | |
| <MentionMenu | |
| query={mentionQuery} | |
| onSelect={handleMentionSelect} | |
| onClose={() => setShowMentionMenu(false)} | |
| /> | |
| )} | |
| {/* 上下文菜单(+ 按钮弹出) */} | |
| {showContextMenu && ( | |
| <div | |
| className="absolute bottom-[calc(100%+8px)] left-0 z-50 py-1 rounded-lg shadow-lg" | |
| style={{ | |
| width: 180, | |
| backgroundColor: 'var(--color-sidebar)', | |
| border: '1px solid var(--color-border)', | |
| boxShadow: '0 8px 24px rgba(0,0,0,0.25)', | |
| }} | |
| > | |
| <div className="text-[0.7rem] px-3 pb-1 pt-1" style={{ color: 'var(--color-text-muted)' }}> | |
| {t('agent.addContext')} | |
| </div> | |
| <ContextMenuItem icon={<Image size={13} />} label={t('agent.mediaFiles')} onClick={() => setShowContextMenu(false)} disabled /> | |
| <ContextMenuItem icon={<AtSign size={13} />} label={t('agent.mention')} onClick={() => { | |
| setShowContextMenu(false) | |
| // 插入 @ 字符并触发 MentionMenu | |
| setInputText(prev => prev + '@') | |
| handleInputChange(inputText + '@') | |
| textareaRef.current?.focus() | |
| }} /> | |
| <ContextMenuItem icon={<Workflow size={13} />} label={t('agent.workflowCommand')} onClick={() => { | |
| setShowContextMenu(false) | |
| // 插入 / 字符并触发 SlashCommandMenu | |
| setInputText('/') | |
| handleInputChange('/') | |
| textareaRef.current?.focus() | |
| }} /> | |
| </div> | |
| )} | |
| {/* 输入区域 */} | |
| <div className="relative w-full"> | |
| <textarea | |
| ref={textareaRef} | |
| value={inputText} | |
| onChange={e => handleInputChange(e.target.value)} | |
| onKeyDown={handleKeyDown} | |
| placeholder={t('agent.inputPlaceholder')} | |
| rows={1} | |
| className="w-full resize-none outline-none bg-transparent text-xs leading-relaxed px-2 py-2" | |
| style={{ | |
| color: 'var(--color-text)', | |
| minHeight: 36, | |
| maxHeight: MAX_HEIGHT, | |
| overflowY: 'hidden', | |
| display: 'block', | |
| }} | |
| /> | |
| {/* 占位文字颜色已通过 tailwind placeholder 设置 */} | |
| </div> | |
| {/* 底部工具栏 */} | |
| <div className="flex items-center justify-between gap-1 px-1 mt-0.5"> | |
| {/* 左侧工具按钮组 */} | |
| <div className="flex items-center gap-0.5 min-w-0 flex-1"> | |
| {/* + 添加上下文 */} | |
| <div ref={contextRef}> | |
| <ToolbarIconBtn | |
| title={t('agent.addContext')} | |
| onClick={() => { | |
| setShowModeMenu(false) | |
| setShowModelMenu(false) | |
| setShowContextMenu(v => !v) | |
| }} | |
| > | |
| <Plus size={14} /> | |
| </ToolbarIconBtn> | |
| </div> | |
| {/* 模式选择 */} | |
| <div ref={modeRef} className="relative"> | |
| <button | |
| onClick={() => { | |
| setShowContextMenu(false) | |
| setShowModelMenu(false) | |
| setShowModeMenu(v => !v) | |
| }} | |
| className="flex items-center gap-0.5 py-1 pl-1 pr-1.5 rounded-md text-xs transition-colors" | |
| style={{ | |
| color: 'var(--color-text-secondary)', | |
| opacity: 0.75, | |
| }} | |
| onMouseEnter={e => { | |
| e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| e.currentTarget.style.opacity = '1' | |
| }} | |
| onMouseLeave={e => { | |
| e.currentTarget.style.backgroundColor = 'transparent' | |
| e.currentTarget.style.opacity = '0.75' | |
| }} | |
| > | |
| <ChevronDown size={13} strokeWidth={1.5} /> | |
| <span className="select-none">{currentMode === 'planning' ? t('agent.modeDeep') : t('agent.modeFast')}</span> | |
| </button> | |
| {/* 模式选择下拉 */} | |
| {showModeMenu && ( | |
| <div | |
| className="absolute bottom-full left-0 mb-1 z-50 py-1 rounded-lg shadow-lg" | |
| style={{ | |
| width: 240, | |
| backgroundColor: 'var(--color-sidebar)', | |
| border: '1px solid var(--color-border)', | |
| boxShadow: '0 8px 24px rgba(0,0,0,0.25)', | |
| }} | |
| > | |
| <div className="text-[0.7rem] px-3 py-1" style={{ color: 'var(--color-text-muted)' }}> | |
| {t('agent.dialogMode')} | |
| </div> | |
| <ModeMenuItem | |
| mode="planning" | |
| currentMode={currentMode} | |
| label={t('agent.deepMode')} | |
| desc={t('agent.deepModeDesc')} | |
| onClick={() => { setMode('planning'); setShowModeMenu(false) }} | |
| /> | |
| <ModeMenuItem | |
| mode="fast" | |
| currentMode={currentMode} | |
| label={t('agent.fastMode')} | |
| desc={t('agent.fastModeDesc')} | |
| onClick={() => { setMode('fast'); setShowModeMenu(false) }} | |
| /> | |
| </div> | |
| )} | |
| </div> | |
| {/* 模型选择 */} | |
| <div ref={modelRef} className="relative min-w-0"> | |
| <button | |
| onClick={() => { | |
| setShowContextMenu(false) | |
| setShowModeMenu(false) | |
| setShowModelMenu(v => !v) | |
| }} | |
| className="flex items-center gap-0.5 py-1 pl-0.5 pr-1.5 rounded-md text-xs min-w-0 transition-colors" | |
| style={{ | |
| color: 'var(--color-text-secondary)', | |
| opacity: 0.75, | |
| maxWidth: 140, | |
| }} | |
| onMouseEnter={e => { | |
| e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| e.currentTarget.style.opacity = '1' | |
| }} | |
| onMouseLeave={e => { | |
| e.currentTarget.style.backgroundColor = 'transparent' | |
| e.currentTarget.style.opacity = '0.75' | |
| }} | |
| > | |
| <ChevronDown size={13} strokeWidth={1.5} className="flex-shrink-0" /> | |
| <span className="truncate select-none"> | |
| {currentModel?.name ?? (chatModels.length === 0 ? t('agent.noModelConfigured') : t('agent.selectModel'))} | |
| </span> | |
| </button> | |
| {/* 模型选择下拉 */} | |
| {showModelMenu && ( | |
| <div | |
| className="absolute bottom-full left-0 mb-1 z-50 py-1 rounded-lg shadow-lg" | |
| style={{ | |
| width: 220, | |
| backgroundColor: 'var(--color-sidebar)', | |
| border: '1px solid var(--color-border)', | |
| boxShadow: '0 8px 24px rgba(0,0,0,0.25)', | |
| maxHeight: 280, | |
| overflowY: 'auto', | |
| }} | |
| > | |
| <div className="text-[0.7rem] px-3 py-1" style={{ color: 'var(--color-text-muted)' }}> | |
| {t('agent.selectModel')} | |
| </div> | |
| {chatModels.length === 0 ? ( | |
| <div className="px-3 py-2 text-xs" style={{ color: 'var(--color-text-muted)' }}> | |
| {t('agent.configureModelFirst')} | |
| </div> | |
| ) : ( | |
| chatModels.map(model => ( | |
| <ModelMenuItem | |
| key={model.id} | |
| model={model} | |
| isActive={model.id === currentModelId} | |
| onClick={() => { | |
| setModelId(model.id) | |
| setShowModelMenu(false) | |
| }} | |
| /> | |
| )) | |
| )} | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| {/* 右侧:发送/停止 */} | |
| <div className="flex items-center gap-1 flex-shrink-0"> | |
| <button | |
| onClick={handleSendOrStop} | |
| disabled={!generating && !canSend} | |
| className="flex items-center justify-center w-6 h-6 transition-all duration-150" | |
| style={{ | |
| borderRadius: 'var(--radius-md)', | |
| backgroundColor: generating | |
| ? 'var(--color-text-secondary)' | |
| : canSend | |
| ? 'var(--color-accent)' | |
| : 'rgba(128,128,128,0.3)', | |
| color: '#ffffff', | |
| cursor: !generating && !canSend ? 'not-allowed' : 'pointer', | |
| opacity: !generating && !canSend ? 0.5 : 1, | |
| }} | |
| title={generating ? t('agent.stopGeneration') : t('agent.sendMessage')} | |
| > | |
| {generating ? ( | |
| <Square size={10} fill="currentColor" /> | |
| ) : ( | |
| <ArrowRight size={13} strokeWidth={2.5} /> | |
| )} | |
| </button> | |
| </div> | |
| </div> | |
| </div> | |
| ) | |
| } | |
| // ===== 子组件 ===== | |
| /** 工具栏图标按钮 */ | |
| function ToolbarIconBtn({ | |
| children, | |
| title, | |
| onClick, | |
| }: { | |
| children: React.ReactNode | |
| title: string | |
| onClick?: () => void | |
| }) { | |
| return ( | |
| <button | |
| title={title} | |
| onClick={onClick} | |
| className="flex items-center justify-center p-1 rounded-full transition-colors" | |
| style={{ color: 'var(--color-text-secondary)', opacity: 0.75 }} | |
| onMouseEnter={e => { | |
| e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| e.currentTarget.style.opacity = '1' | |
| }} | |
| onMouseLeave={e => { | |
| e.currentTarget.style.backgroundColor = 'transparent' | |
| e.currentTarget.style.opacity = '0.75' | |
| }} | |
| > | |
| {children} | |
| </button> | |
| ) | |
| } | |
| /** 上下文菜单项 */ | |
| function ContextMenuItem({ | |
| icon, | |
| label, | |
| onClick, | |
| disabled, | |
| }: { | |
| icon: React.ReactNode | |
| label: string | |
| onClick: () => void | |
| disabled?: boolean | |
| }) { | |
| const { t } = useTranslation('panels') | |
| return ( | |
| <button | |
| onClick={!disabled ? onClick : undefined} | |
| disabled={disabled} | |
| className="w-full flex items-center gap-2 px-3 py-1.5 text-xs transition-colors" | |
| style={{ | |
| color: disabled ? 'var(--color-text-muted)' : 'var(--color-text)', | |
| cursor: disabled ? 'not-allowed' : 'pointer', | |
| }} | |
| onMouseEnter={e => { | |
| if (!disabled) e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| }} | |
| onMouseLeave={e => { | |
| e.currentTarget.style.backgroundColor = 'transparent' | |
| }} | |
| > | |
| <span style={{ color: 'var(--color-text-secondary)' }}>{icon}</span> | |
| {label} | |
| {disabled && <span className="ml-auto text-[0.7rem] opacity-40">{t('agent.comingSoon')}</span>} | |
| </button> | |
| ) | |
| } | |
| /** 模式菜单项 */ | |
| function ModeMenuItem({ | |
| mode, | |
| currentMode, | |
| label, | |
| desc, | |
| onClick, | |
| }: { | |
| mode: AgentMode | |
| currentMode: AgentMode | |
| label: string | |
| desc: string | |
| onClick: () => void | |
| }) { | |
| const isActive = mode === currentMode | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className="w-full flex flex-col items-start gap-0.5 px-3 py-2 text-left text-xs transition-colors rounded-md mx-1" | |
| style={{ | |
| width: 'calc(100% - 8px)', | |
| backgroundColor: isActive ? 'var(--color-hover)' : 'transparent', | |
| }} | |
| onMouseEnter={e => { | |
| if (!isActive) e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| }} | |
| onMouseLeave={e => { | |
| if (!isActive) e.currentTarget.style.backgroundColor = 'transparent' | |
| }} | |
| > | |
| <div className="font-medium" style={{ color: 'var(--color-text)' }}>{label}</div> | |
| <div className="text-left leading-relaxed" style={{ color: 'var(--color-text-muted)', fontSize: "0.75rem" }}>{desc}</div> | |
| </button> | |
| ) | |
| } | |
| /** 模型菜单项 */ | |
| function ModelMenuItem({ | |
| model, | |
| isActive, | |
| onClick, | |
| }: { | |
| model: ModelProfile | |
| isActive: boolean | |
| onClick: () => void | |
| }) { | |
| return ( | |
| <button | |
| onClick={onClick} | |
| className="w-full flex items-center justify-between px-3 py-1.5 text-xs transition-colors" | |
| style={{ | |
| backgroundColor: isActive ? 'var(--color-hover)' : 'transparent', | |
| }} | |
| onMouseEnter={e => { | |
| if (!isActive) e.currentTarget.style.backgroundColor = 'var(--color-hover)' | |
| }} | |
| onMouseLeave={e => { | |
| if (!isActive) e.currentTarget.style.backgroundColor = 'transparent' | |
| }} | |
| > | |
| <span | |
| className="font-medium truncate" | |
| style={{ color: 'var(--color-text)' }} | |
| > | |
| {model.name} | |
| </span> | |
| {model.provider && ( | |
| <span | |
| className="ml-2 text-[0.7rem] px-1.5 py-0.5 rounded-full flex-shrink-0" | |
| style={{ | |
| backgroundColor: 'var(--color-border)', | |
| color: 'var(--color-text-muted)', | |
| }} | |
| > | |
| {model.provider} | |
| </span> | |
| )} | |
| </button> | |
| ) | |
| } | |