perf: GSAP 统一交互动画 + 砍掉流式热路径的逐帧开销
This commit is contained in:
@@ -118,9 +118,13 @@ export function ChatView({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = scrollRef.current
|
const el = scrollRef.current
|
||||||
if (el && pinnedRef.current) {
|
if (!el || !pinnedRef.current) return
|
||||||
|
// rAF 合帧:流式 delta 一帧内多次触发时只吸底一次,
|
||||||
|
// 且读 scrollHeight 的强制布局每帧至多一次
|
||||||
|
const raf = requestAnimationFrame(() => {
|
||||||
el.scrollTop = el.scrollHeight
|
el.scrollTop = el.scrollHeight
|
||||||
}
|
})
|
||||||
|
return () => cancelAnimationFrame(raf)
|
||||||
}, [messages, tools, streaming])
|
}, [messages, tools, streaming])
|
||||||
|
|
||||||
const hasContent = messages.length > 0
|
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 ReactMarkdown, { type Components } from 'react-markdown'
|
||||||
import remarkGfm from 'remark-gfm'
|
import remarkGfm from 'remark-gfm'
|
||||||
import {
|
import {
|
||||||
@@ -261,7 +261,7 @@ function BlockView({ blockMatch }: { blockMatch: BlockMatch }): ReactNode {
|
|||||||
return <Component blockMatch={blockMatch} />
|
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),
|
// 流式开启时 llm-ui 逐块渐进解析(未闭合围栏 → partial code block,未完段落 → partial markdown),
|
||||||
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
// 已完成的块会被 lookBack 冻结不再重渲染;流式结束后整体收敛为最终形态
|
||||||
const { blockMatches } = useLLMOutput({
|
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 type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||||
|
import { animateCollapse, animateExpand } from '../lib/anim'
|
||||||
import { Markdown } from './Markdown'
|
import { Markdown } from './Markdown'
|
||||||
import { Loading } from './Loading'
|
import { Loading } from './Loading'
|
||||||
import { StreamingText } from './StreamingText'
|
import { StreamingText } from './StreamingText'
|
||||||
@@ -169,6 +170,30 @@ export function AssistantTurn({
|
|||||||
prevTurnActive.current = turnActive
|
prevTurnActive.current = turnActive
|
||||||
}, [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 workedFor = useWorkingSeconds(turnActive)
|
||||||
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||||
|
|
||||||
@@ -239,21 +264,9 @@ export function AssistantTurn({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 过程区:grid-rows 0fr/1fr 高度动画 + 透明度渐隐,收起/展开更从容 */}
|
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
|
||||||
<div
|
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
||||||
aria-hidden={collapsed && hasProcess}
|
{processNodes}
|
||||||
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'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{processNodes}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||||
|
|||||||
@@ -30,13 +30,7 @@ export function Thinking({
|
|||||||
}, [rows, expanded])
|
}, [rows, expanded])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex w-full max-w-95 flex-col" style={{ minHeight: expanded ? 60 : undefined }}>
|
||||||
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'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* 表头:状态词 + 旋转箭头 */}
|
{/* 表头:状态词 + 旋转箭头 */}
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -1,26 +1,13 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useRef } from 'react'
|
||||||
import type { RefObject } from 'react'
|
import type { RefObject } from 'react'
|
||||||
|
import { animateEntrance } from '../lib/anim'
|
||||||
|
|
||||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||||
const ref = useRef<T | null>(null)
|
const ref = useRef<T | null>(null)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (!el) return
|
if (el) animateEntrance(el)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}, [])
|
}, [])
|
||||||
return ref
|
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