一键重新生成与多版本对比滑动交互设计
在大模型落地于内容生成、代码重构与文案润色的前端场景中,“重新生成”绝不是一个简单的覆盖替换按钮。业务一线经常遭遇两个极端:要么直接抹掉上一轮生成,导致用户遗失了某个闪光片段;要么粗暴地在下方追加卡片,造成信息流无限膨胀,眼花缭乱。如何在单一视口内承载同一上下文的多个生成版本,并提供如拉开卷轴般丝滑的对比交互,是交互架构师必须攻克的细节。
版本树的拓扑与状态切片
大模型的多版本并非线性数组,而是一个轻量级的版本分支树。每一次点击“重新生成”,本质上是在父节点下挂载新的兄弟节点。前端状态管理器必须精确记录激活路径,同时保留各分支的流式生成元数据与滚动锚点。
export interface GenerationNode { id: string; parentId: string | null; version: number; content: string; status: 'streaming' | 'complete' | 'failed'; metrics?: { latency: number; tokenCount: number; }; createdAt: number; } export interface ConversationTurn { turnId: string; prompt: string; versions: GenerationNode[]; activeVersionId: string; }在状态树中,我们维护activeVersionId作为默认展示态。当用户触发对比模式时,状态派生出compareVersionId,将单一渲染管道扩展为双轨对比通道。
幕布滑动式(Split-Curtain)交互机制
传统的左右双栏排版在移动端或窄屏容器中会极度压缩排版宽度,造成长文本折行紊乱。幕布滑动对比(Curtain Comparison)采用双层绝对定位叠加渲染,上层通过clip-path: inset()动态裁切,配合中间的物理手柄,让用户通过拖拽直观探查两版之间的微妙差异。
import React, { useState, useRef, useCallback, useEffect } from 'react'; interface DiffSliderProps { leftContent: string; rightContent: string; leftVersionLabel: string; rightVersionLabel: string; } export const DiffSlider: React.FC<DiffSliderProps> = ({ leftContent, rightContent, leftVersionLabel, rightVersionLabel, }) => { const [sliderPos, setSliderPos] = useState<number>(50); // 百分比 const [isDragging, setIsDragging] = useState<boolean>(false); const containerRef = useRef<HTMLDivElement>(null); const leftScrollRef = useRef<HTMLDivElement>(null); const rightScrollRef = useRef<HTMLDivElement>(null); const isSyncingScroll = useRef<boolean>(false); // 双栏滚动联动锁 const handleScroll = useCallback((source: 'left' | 'right') => { if (isSyncingScroll.current) return; isSyncingScroll.current = true; const srcEl = source === 'left' ? leftScrollRef.current : rightScrollRef.current; const tgtEl = source === 'left' ? rightScrollRef.current : leftScrollRef.current; if (srcEl && tgtEl) { const scrollPercentage = srcEl.scrollTop / (srcEl.scrollHeight - srcEl.clientHeight); tgtEl.scrollTop = scrollPercentage * (tgtEl.scrollHeight - tgtEl.clientHeight); } requestAnimationFrame(() => { isSyncingScroll.current = false; }); }, []); const handlePointerMove = useCallback( (e: PointerEvent) => { if (!isDragging || !containerRef.current) return; const rect = containerRef.current.getBoundingClientRect(); const x = Math.max(0, Math.min(e.clientX - rect.left, rect.width)); const percentage = (x / rect.width) * 100; setSliderPos(percentage); }, [isDragging] ); const handlePointerUp = useCallback(() => { setIsDragging(false); }, []); useEffect(() => { if (isDragging) { window.addEventListener('pointermove', handlePointerMove); window.addEventListener('pointerup', handlePointerUp); } return () => { window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointerup', handlePointerUp); }; }, [isDragging, handlePointerMove, handlePointerUp]); return ( <div ref={containerRef} className="relative w-full h-[520px] overflow-hidden rounded-xl border border-slate-700 bg-slate-900 select-none" > {/* 底层:版本 B (右侧/最新版) */} <div ref={rightScrollRef} onScroll={() => handleScroll('right')} className="absolute inset-0 p-6 overflow-y-auto font-mono text-sm leading-relaxed text-slate-200 bg-slate-950" > <div className="inline-block px-2.5 py-1 mb-4 text-xs font-semibold text-emerald-400 bg-emerald-950/60 rounded border border-emerald-800/50"> {rightVersionLabel} </div> <div className="whitespace-pre-wrap">{rightContent}</div> </div> {/* 顶层:版本 A (左侧/历史版) 配合 clip-path */} <div ref={leftScrollRef} onScroll={() => handleScroll('left')} style={{ clipPath: `inset(0 ${100 - sliderPos}% 0 0)` }} className="absolute inset-0 p-6 overflow-y-auto font-mono text-sm leading-relaxed text-slate-200 bg-slate-900 z-10" > <div className="inline-block px-2.5 py-1 mb-4 text-xs font-semibold text-indigo-400 bg-indigo-950/60 rounded border border-indigo-800/50"> {leftVersionLabel} </div> <div className="whitespace-pre-wrap">{leftContent}</div> </div> {/* 中间拖拽手柄分割线 */} <div style={{ left: `${sliderPos}%` }} onPointerDown={() => setIsDragging(true)} className="absolute top-0 bottom-0 w-1 bg-indigo-500/80 cursor-ew-resize z-20 transition-shadow hover:shadow-[0_0_12px_rgba(99,102,241,0.8)]" > <div className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-7 h-7 rounded-full bg-indigo-600 border-2 border-white flex items-center justify-center shadow-lg"> <svg className="w-4 h-4 text-white" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5"> <path d="M8 7l-5 5 5 5M16 7l5 5-5 5" /> </svg> </div> </div> </div> ); };文本粒度的 Diff 映射与滚动防抖
当两版文本长度悬殊时,简单的像素等比滚动会导致视口内对齐错位。在专业级工程实现中,需借助轻量分词与 Myers 算法生成对齐标记点(Anchor Anchoring):
- 在组件初始化时,使用 Web Worker 将两版内容解析为 Token 序列,计算出新增、删除与保留段落的行索引映射表。
- 双栏滚动联动不再直接同步
scrollTop,而是计算当前视口顶部的 Token 序号,通过二分查找锁定目标栏对应的 Token 偏移,再平滑插值到物理像素。 - 对流式生成中的版本,由于内容实时增长,渲染层采用动态挂起的防抖策略:在打字机吐字阶段禁用重度 Diff 计算,仅维持底部的滚动跟随,待响应结束进入
complete态后统一计算行级映射。
交互细节中的动效克制
多版本切换时的生硬切屏是破坏沉浸感的主因。我们采用弹性过渡曲线为手柄赋予微量惯性阻尼。在点击版本号胶囊(如1/3、2/3)时,手柄并非瞬移,而是以形如cubic-bezier(0.22, 1, 0.36, 1)的山水回锋姿态滑行至对侧,将视觉过渡的主动权自然还给读者。这种兼具工程严密与视觉呼吸感的交互,才是生产级 AI 工具应有的质感。