// 画布 Agent 输入区:场景胶囊、输入框(附件、@ 引用、/ 技能)、发送/插话/停止按钮与图片预览。 // // 运行中输入框保持可用(产品约定,勿收紧):发送走插话,停止由 running 驱动,两者互不排斥。 import type { Skill, SkillPreset } from "@/services/api/skills"; import { ArrowLeft, ArrowUp, AtSign, Bookmark, Clapperboard, ImagePlus, Layers3, LoaderCircle, Palette, Shapes, Share2, ShoppingBag, Sparkles, Square, X } from "lucide-react"; import { canvasThemes } from "@/lib/canvas-theme"; import { type ClipboardEvent as ReactClipboardEvent, type KeyboardEvent as ReactKeyboardEvent, type ReactNode, type PointerEvent as ReactPointerEvent, useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { CanvasResourceReference } from "@/lib/canvas/canvas-resource-references"; import { buildSkillMentionReferences } from "@/services/skill-runtime"; import { motion, useReducedMotion } from "motion/react"; import { WorkingGlow } from "@/components/ai/working-indicator"; import { CanvasResourceMentionTextarea } from "./canvas-resource-mention-textarea"; import { Tooltip } from "@/components/ui/base/tooltip"; import { Button } from "antd"; import { agentAttachmentReferences, type CloudAgentChatAttachment } from "./canvas-cloud-agent-attachments"; export const MIN_AGENT_PROMPT_HEIGHT = 60; export const MAX_AGENT_PROMPT_HEIGHT = 240; export function clampAgentPromptHeight(height: number) { return Math.min(MAX_AGENT_PROMPT_HEIGHT, Math.max(MIN_AGENT_PROMPT_HEIGHT, Math.ceil(height))); } /** * 场景起步胶囊:把「我大概想做 X」一步翻译成一组技能。 * 两组来源,都不限剧典技能: * 1) 配方组——只读消费 GET /skills/presets(随二进制内置的手工策展配方) * 2) 常用组——用户已装(其中可含已收藏)及自建的技能,按常用度排序(含官方种子库与自定义) * 选择只作用于本会话;缺失的技能会持久安装到当前用户的技能库。 * 挂上之后具体用哪张卡由 Agent 在任务里检索判断,胶囊只负责"把对的技能送到手边"。 */ export type AgentSceneBucket = { key: string; label: string; presets: SkillPreset[]; skills: Skill[]; }; /** 场景分类:与 presets.json 的 scene 字段、技能的 tag 字段共用同一套 key。 */ export const AGENT_SCENE_DEFS: Array<{ key: string; label: string; icon: typeof Sparkles }> = [ { key: "frequent", label: "我的常用", icon: Bookmark }, { key: "drama", label: "短剧故事", icon: Clapperboard }, { key: "ecommerce", label: "广告电商", icon: ShoppingBag }, { key: "creative", label: "视觉创意", icon: Palette }, { key: "social", label: "传播社媒", icon: Share2 }, { key: "others", label: "其他", icon: Shapes }, ]; export function AgentSceneCapsules({ buckets, installedIds, theme, disabled = false, onPick, onPickSkill, }: { buckets: AgentSceneBucket[]; installedIds: Set; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; disabled?: boolean; onPick: (preset: SkillPreset) => void; onPickSkill: (skill: Skill) => void; }) { // 始终只占一排:默认显示场景分类,点某个场景后在同一排内就地切换内容。 const [activeKey, setActiveKey] = useState(null); const capsuleClass = "agent-scene-capsule shrink-0"; const stop = { onMouseDown: (event: { stopPropagation(): void }) => event.stopPropagation(), onPointerDown: (event: { stopPropagation(): void }) => event.stopPropagation(), }; const visible = buckets.filter((bucket) => bucket.presets.length + bucket.skills.length > 0); const active = activeKey ? visible.find((bucket) => bucket.key === activeKey) || null : null; if (!visible.length) return null; return (
{active ? ( <> {active.presets.map((preset) => { const missing = preset.skillIds.filter((id) => !installedIds.has(id)).length; return ( ); })} {active.skills.map((skill) => { const owned = skill.isOwner ? "自建" : skill.isLike ? "已收藏" : installedIds.has(skill.skillId) ? "已装" : "待装"; return ( ); })} ) : ( visible.map((bucket) => { const count = bucket.presets.length + bucket.skills.length; const Icon = AGENT_SCENE_DEFS.find((definition) => definition.key === bucket.key)?.icon || Shapes; return ( ); }) )}

仅本会话生效 · 缺失技能将加入技能库 · Agent 按任务调用

); } export function AgentChatComposer({ prompt, attachments = [], disabled, sending, running, placeholder, theme, onPromptChange, onSubmit, onAddFiles, onRemoveAttachment, left, submitAccessory, footer, onStop, stopping, references = [], slashSkills, includeAssetLibrary, }: { prompt: string; attachments?: CloudAgentChatAttachment[]; disabled?: boolean; sending?: boolean; running?: boolean; placeholder: string; theme: (typeof canvasThemes)[keyof typeof canvasThemes]; onPromptChange: (value: string) => void; onSubmit: () => void; onStop?: () => void | Promise; stopping?: boolean; onAddFiles?: (files: FileList | File[] | null) => void | Promise; onRemoveAttachment?: (id: string) => void; left?: ReactNode; /** 发送按钮左侧的附属控件,例如上下文用量环。 */ submitAccessory?: ReactNode; /** 输入区底部的附加内容(例如连接器条)。 */ footer?: ReactNode; /** 供「@」插入的画布节点/素材/技能引用候选(可选,默认空,缺省时退化为普通输入框) */ references?: CanvasResourceReference[]; /** 供「/」弹出的技能候选(可选) */ slashSkills?: Skill[]; /** 是否在「@」候选里包含素材库资源 */ includeAssetLibrary?: boolean; }) { const fileInputRef = useRef(null); const [slash, setSlash] = useState<{ start: number; query: string } | null>(null); const [slashIndex, setSlashIndex] = useState(0); const [previewAttachment, setPreviewAttachment] = useState(null); const [promptHeight, setPromptHeight] = useState(MIN_AGENT_PROMPT_HEIGHT); const promptResizeRef = useRef<{ startY: number; startHeight: number } | null>(null); const manualPromptHeightRef = useRef(null); const availableSlashSkills = slashSkills ?? []; const slashCandidates = useMemo(() => { const query = slash?.query.trim().toLocaleLowerCase() || ""; if (!query) return availableSlashSkills; return availableSlashSkills.filter((skill) => `${skill.skillName} ${skill.description || ""}`.toLocaleLowerCase().includes(query)); }, [availableSlashSkills, slash?.query]); const attachmentReferences = useMemo(() => agentAttachmentReferences(attachments), [attachments]); const skillReferences = useMemo(() => buildSkillMentionReferences(availableSlashSkills), [availableSlashSkills]); const composerReferences = useMemo(() => [...references, ...skillReferences, ...attachmentReferences].filter((reference, index, all) => all.findIndex((item) => item.id === reference.id) === index), [attachmentReferences, references, skillReferences]); const canStop = Boolean(running && onStop); const canSubmit = !disabled && !sending && Boolean(prompt.trim() || attachments.length); const reducedMotion = useReducedMotion(); const activeSlashIndex = Math.min(Math.max(slashIndex, 0), Math.max(slashCandidates.length - 1, 0)); const handlePromptContentSizeChange = useCallback((naturalHeight: number) => { const nextHeight = clampAgentPromptHeight(naturalHeight); // 用户开始拖动后,面板高度由用户掌控;超出部分交给内部滚动区, // 避免输入新内容时又把手动缩小的面板强行撑开。 setPromptHeight((currentHeight) => (manualPromptHeightRef.current === null ? nextHeight : currentHeight)); }, []); useEffect(() => { if (prompt.trim()) return; manualPromptHeightRef.current = null; setPromptHeight(MIN_AGENT_PROMPT_HEIGHT); }, [prompt]); const startPromptResize = (event: ReactPointerEvent) => { if (disabled) return; event.preventDefault(); manualPromptHeightRef.current = promptHeight; promptResizeRef.current = { startY: event.clientY, startHeight: promptHeight }; event.currentTarget.setPointerCapture(event.pointerId); }; const resizePrompt = (event: ReactPointerEvent) => { const resize = promptResizeRef.current; if (!resize) return; setPromptHeight(clampAgentPromptHeight(resize.startHeight + resize.startY - event.clientY)); }; const finishPromptResize = (event?: ReactPointerEvent) => { promptResizeRef.current = null; if (event?.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId); }; const resizePromptByKeyboard = (event: ReactKeyboardEvent) => { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") return; event.preventDefault(); manualPromptHeightRef.current = promptHeight; setPromptHeight(clampAgentPromptHeight(promptHeight + (event.key === "ArrowUp" ? 20 : -20))); }; // 在输入值末尾检测「/ 或 、+ 关键词」打开技能候选:中文输入法下 "/" 会打成 "、",两者等价,且都必须紧跟行首或空白,避免中文顿号误触发。选中后写入稳定 token,编辑器再把它渲染为技能 chip。 const handlePromptChange = (value: string) => { onPromptChange(value); const match = /(^|\s)[/、]([^\s/、]*)$/.exec(value); if (match && availableSlashSkills.length) { const next = { start: match.index + match[1].length, query: match[2] }; setSlash((current) => (current && current.start === next.start && current.query === next.query ? current : next)); setSlashIndex(0); } else if (slash) { setSlash(null); } }; const applySlashSkill = (skill: Skill) => { const token = `@[skill:${skill.skillId}] `; // 触发符 "/" 与 "、" 都是单字符,替换长度固定为 1。 const next = slash ? `${prompt.slice(0, slash.start)}${token}${prompt.slice(slash.start + 1 + slash.query.length)}` : prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token; setSlash(null); setSlashIndex(0); onPromptChange(next); }; // slash 菜单的键盘控制在 capture 阶段拦截(contentEditable/textarea 内部先消费 Enter,外层冒泡拿不到) const handleSlashKeyCapture = (event: ReactKeyboardEvent) => { if (!slash || !slashCandidates.length) return; if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "ArrowDown") { event.preventDefault(); event.stopPropagation(); setSlashIndex((index) => Math.min(index + 1, slashCandidates.length - 1)); } else if (event.key === "ArrowUp") { event.preventDefault(); event.stopPropagation(); setSlashIndex((index) => Math.max(index - 1, 0)); } else if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); event.stopPropagation(); applySlashSkill(slashCandidates[activeSlashIndex]); } else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setSlash(null); } }; // 保留粘贴图片成附件(contentEditable 模式内部会把粘贴转纯文本,capture 阶段先拦截图片) const handlePasteCapture = (event: ReactClipboardEvent) => { if (!onAddFiles) return; const images = Array.from(event.clipboardData.files).filter((file) => file.type.startsWith("image/")); if (!images.length) return; event.preventDefault(); event.stopPropagation(); void onAddFiles(images); }; const insertAttachmentMention = (item: CloudAgentChatAttachment) => { const token = `@[attachment:${item.id}] `; if (prompt.includes(`@[attachment:${item.id}]`)) return; onPromptChange(prompt ? `${prompt.replace(/\s+$/u, "")} ${token}` : token); }; return (
event.stopPropagation()}> {/* 有底部附加内容时,外壳托住输入卡片与底部条,形成一张整卡。 */}
{/* 整卡模式下发送光环包住整张卡,避免画在输入区与连接器之间形成一条线。 */} {footer && sending && !reducedMotion ? : null}
{!footer && sending && !reducedMotion ? : null} {attachments.length ? (
{attachments.map((item, index) => (
{onRemoveAttachment ? ( ) : null}
))}
) : null}
{ if (canSubmit) onSubmit(); }} onContentSizeChange={handlePromptContentSizeChange} className="w-full resize-none border-0 bg-transparent px-1 py-1 text-sm leading-5 outline-none placeholder:opacity-45" containerClassName="min-h-[60px] h-full" style={{ color: theme.node.text }} placeholder={placeholder} aria-label="Agent 输入" />
{slash && slashCandidates.length ? (
event.preventDefault()} > {slashCandidates.map((skill, index) => ( ))}
) : null}
{onAddFiles ? ( <> { void onAddFiles(event.target.files); event.target.value = ""; }} />
{submitAccessory} {canStop ? ( void onStop?.()} whileHover={!reducedMotion && !stopping ? { scale: 1.06, y: -1 } : undefined} whileTap={!reducedMotion && !stopping ? { scale: 0.9, y: 1 } : undefined} animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1 }} transition={{ type: "spring", stiffness: 420, damping: 24 }} className="grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" style={{ background: theme.accent.danger, color: theme.accent.onPrimary }} > {stopping ? : } ) : null} onSubmit()} whileHover={canSubmit && !reducedMotion ? { scale: 1.06, y: -1 } : undefined} whileTap={canSubmit && !reducedMotion ? { scale: 0.9, y: 1 } : undefined} animate={stopping && !reducedMotion ? { scale: [1, 0.94, 1] } : { scale: 1, rotate: 0 }} transition={sending && !reducedMotion ? { duration: 0.42, ease: "easeOut" } : { type: "spring", stiffness: 420, damping: 24 }} data-icon-only className="agent-composer-send grid size-7 shrink-0 place-items-center rounded-full p-0 outline-none transition-[background-color,box-shadow,color,transform] duration-200 focus-visible:ring-2 focus-visible:ring-current/35 disabled:cursor-not-allowed" style={{ background: canSubmit || sending ? theme.accent.primary : theme.spatial.surface, color: canSubmit || sending ? theme.accent.onPrimary : theme.node.muted, }} > {sending ? : }
{footer}
{previewAttachment ? setPreviewAttachment(null)} /> : null}
); } export function AgentImagePreview({ attachment, onClose }: { attachment: CloudAgentChatAttachment; onClose: () => void }) { return (
{attachment.name} event.stopPropagation()} />
); }