perf: GSAP 统一交互动画 + 砍掉流式热路径的逐帧开销
This commit is contained in:
@@ -118,9 +118,13 @@ export function ChatView({
|
||||
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (el && pinnedRef.current) {
|
||||
if (!el || !pinnedRef.current) return
|
||||
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
|
||||
// 且读 scrollHeight 的强制布局每帧至多一次
|
||||
const raf = requestAnimationFrame(() => {
|
||||
el.scrollTop = el.scrollHeight
|
||||
}
|
||||
})
|
||||
return () => cancelAnimationFrame(raf)
|
||||
}, [messages, tools, streaming])
|
||||
|
||||
const hasContent = messages.length > 0
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useState, type ReactNode } from 'react'
|
||||
import ReactMarkdown, { type Components } from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import {
|
||||
@@ -261,7 +261,7 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
||||
return <Component blockMatch={blockMatch} />
|
||||
}
|
||||
|
||||
export function Markdown({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||
function MarkdownBase({ text, streaming = false }: { text: string; streaming?: boolean }): ReactNode {
|
||||
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||
const { blockMatches } = useLLMOutput({
|
||||
@@ -279,3 +279,7 @@ export function Markdown({ text, streaming = false }: { text: string; streaming?
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
/** memo:流式 delta 会让整份消息列表重渲染,历史消息的 text/streaming 不变,
|
||||
* 直接跳过 react-markdown 的整棵 vdom diff —— 这是流式卡顿的大头。 */
|
||||
export const Markdown = memo(MarkdownBase)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { animateCollapse, animateExpand } from '../lib/anim'
|
||||
import { Markdown } from './Markdown'
|
||||
import { Loading } from './Loading'
|
||||
import { StreamingText } from './StreamingText'
|
||||
@@ -169,6 +170,30 @@ export function AssistantTurn({
|
||||
prevTurnActive.current = turnActive
|
||||
}, [turnActive])
|
||||
|
||||
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
|
||||
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
|
||||
// 首帧直接落位(历史回合默认收起),不播动画。
|
||||
const processRef = useRef<HTMLDivElement | null>(null)
|
||||
const prevCollapsedRef = useRef<boolean | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const el = processRef.current
|
||||
if (!el) return
|
||||
const effective = collapsed && hasProcess
|
||||
const prev = prevCollapsedRef.current
|
||||
prevCollapsedRef.current = effective
|
||||
if (prev === null) {
|
||||
if (effective) {
|
||||
el.style.height = '0px'
|
||||
el.style.opacity = '0'
|
||||
el.style.pointerEvents = 'none'
|
||||
}
|
||||
return
|
||||
}
|
||||
if (prev === effective) return
|
||||
if (effective) animateCollapse(el)
|
||||
else animateExpand(el)
|
||||
}, [collapsed, hasProcess])
|
||||
|
||||
const workedFor = useWorkingSeconds(turnActive)
|
||||
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||
|
||||
@@ -239,22 +264,10 @@ export function AssistantTurn({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */}
|
||||
<div
|
||||
aria-hidden={collapsed && hasProcess}
|
||||
className={`grid transition-[grid-template-rows] duration-500 ease-in-out ${
|
||||
collapsed && hasProcess ? 'pointer-events-none' : ''
|
||||
}`}
|
||||
style={{ gridTemplateRows: collapsed && hasProcess ? '0fr' : '1fr' }}
|
||||
>
|
||||
<div
|
||||
className={`min-h-0 overflow-hidden transition-opacity duration-300 ${
|
||||
collapsed && hasProcess ? 'opacity-0' : 'opacity-100'
|
||||
}`}
|
||||
>
|
||||
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
|
||||
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
||||
{processNodes}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||
{answerNodes}
|
||||
|
||||
@@ -30,13 +30,7 @@ export function Thinking({
|
||||
}, [rows, expanded])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex w-full max-w-95 flex-col"
|
||||
style={{
|
||||
minHeight: expanded ? Math.min(60 + rows.length * 28, 220) : undefined,
|
||||
transition: 'min-height 400ms ease'
|
||||
}}
|
||||
>
|
||||
<div className="flex w-full max-w-95 flex-col" style={{ minHeight: expanded ? 60 : undefined }}>
|
||||
{/* 表头:状态词 + 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -1,26 +1,13 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { animateEntrance } from '../lib/anim'
|
||||
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (!el) return
|
||||
let tween: { kill(): void } | null = null
|
||||
let cancelled = false
|
||||
void import('gsap').then((gsapModule) => {
|
||||
if (cancelled) return
|
||||
tween = gsapModule.gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
|
||||
)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
tween?.kill()
|
||||
}
|
||||
if (el) animateEntrance(el)
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/** 共享动画模块(GSAP 懒加载单例)。
|
||||
* 原则:transform/opacity 小动画交给 CSS 合成器;GSAP 负责需要中断衔接、
|
||||
* 按内容测量的补间(消息入场、回合过程区折叠/展开)。
|
||||
* 全部遵循系统「减弱动态效果」(prefers-reduced-motion)。 */
|
||||
|
||||
type Gsap = (typeof import('gsap'))['gsap']
|
||||
|
||||
let gsapPromise: Promise<Gsap> | null = null
|
||||
|
||||
function loadGsap(): Promise<Gsap> {
|
||||
gsapPromise ??= import('gsap').then((m) => {
|
||||
m.gsap.defaults({ overwrite: 'auto' })
|
||||
return m.gsap
|
||||
})
|
||||
return gsapPromise
|
||||
}
|
||||
|
||||
function reducedMotion(): boolean {
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
}
|
||||
|
||||
/** 消息入场:上移淡入,仅挂载时一次 */
|
||||
export function animateEntrance(el: HTMLElement): void {
|
||||
if (reducedMotion()) return
|
||||
void loadGsap().then((gsap) => {
|
||||
gsap.fromTo(
|
||||
el,
|
||||
{ opacity: 0, y: 10 },
|
||||
{ opacity: 1, y: 0, duration: 0.4, ease: 'power2.out', clearProps: 'transform' }
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 收起:高度 → 0、透明度 → 0。先同步落起始帧再异步补间,中间不闪帧;
|
||||
* 从当前高度续接(可中断重入,快速反复点击也顺滑)。 */
|
||||
export function animateCollapse(el: HTMLElement): void {
|
||||
el.style.pointerEvents = ''
|
||||
if (reducedMotion()) {
|
||||
el.style.height = '0px'
|
||||
el.style.opacity = '0'
|
||||
el.style.pointerEvents = 'none'
|
||||
return
|
||||
}
|
||||
const from = el.offsetHeight
|
||||
el.style.height = `${from}px`
|
||||
void loadGsap().then((gsap) => {
|
||||
gsap.killTweensOf(el)
|
||||
gsap.to(el, {
|
||||
height: 0,
|
||||
opacity: 0,
|
||||
duration: 0.5,
|
||||
ease: 'power2.inOut',
|
||||
onComplete: () => {
|
||||
el.style.pointerEvents = 'none'
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** 展开:高度 0 → 内容实际高度,完成后复位 auto,后续内容增长不被裁剪 */
|
||||
export function animateExpand(el: HTMLElement): void {
|
||||
el.style.height = 'auto'
|
||||
el.style.pointerEvents = ''
|
||||
const to = el.offsetHeight
|
||||
if (reducedMotion()) {
|
||||
el.style.height = ''
|
||||
el.style.opacity = ''
|
||||
return
|
||||
}
|
||||
el.style.height = '0px'
|
||||
void loadGsap().then((gsap) => {
|
||||
gsap.killTweensOf(el)
|
||||
gsap.to(el, {
|
||||
height: to,
|
||||
opacity: 1,
|
||||
duration: 0.5,
|
||||
ease: 'power2.inOut',
|
||||
onComplete: () => {
|
||||
el.style.height = ''
|
||||
el.style.opacity = ''
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user