diff --git a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx index ea30a6c69c..11210053c4 100644 --- a/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx +++ b/apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx @@ -11,7 +11,7 @@ * into Markdown image nodes before HTML filtering. */ -import { createElement, memo, useCallback, useEffect, useLayoutEffect, useRef, useState, useMemo, isValidElement, type HTMLAttributes, type ReactNode } from 'react'; +import { createElement, memo, useCallback, useEffect, useRef, useState, useMemo, isValidElement, type HTMLAttributes, type ReactNode } from 'react'; import ReactMarkdown, { defaultUrlTransform } from 'react-markdown'; import remarkGfm from 'remark-gfm'; import remarkCjkFriendly from 'remark-cjk-friendly'; @@ -33,14 +33,12 @@ import remarkSessionLinks from './remarkSessionLinks'; import { rehypeMathBlockMarker } from './rehypeMathBlockMarker'; import { FENCED_CODE_PROP, rehypeFencedCodeMarker } from './rehypeFencedCodeMarker'; import { - commitWordFadeCandidate, - createWordFadeCandidate, getOrCreateWordFadeState, releaseWordFadeState, - rehypeStreamWordFade, } from './rehypeStreamWordFade'; -import { StreamFadeSpan } from './StreamFadeSpan'; import { repairStreamingMarkdown } from './repairStreamingMarkdown'; +import { StreamingMarkdownChunk } from './StreamingMarkdownChunk'; +import { splitStreamingMarkdownChunks } from './streamingMarkdownChunks'; import { useReducedMotion } from '@/hooks/useReducedMotion'; import { useStreamFadeEnabled } from '@/hooks/useStreamFadePreference'; import { CopyAsImageBlock, mathBlockToLatex, tableToTsv } from './CopyAsImageBlock'; @@ -1641,13 +1639,9 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ // Static callers (TextLightbox) leave isStreaming undefined → false, // so the throttle is fully bypassed — same behavior as before. const throttledContent = useStreamingThrottle(content, isStreaming); - // 流式逐词淡入(rehypeStreamWordFade,§14.4 第五个 sanctioned motion class): - // 仅 isStreaming + 非 reduced-motion 时把插件挂到 rehype 链尾。committed state - // 按消息身份跨 parse / remount 保留;本次 render 只写 candidate,layout effect - // 确认 DOM 已提交后才落状态,避免被放弃的并发 render 提前推进 key / 时间线。 - // 根节点监听 animationend 把播完的段落袋(settled),下一次 parse 还原纯文本。 - // isStreaming 翻 false 时整段回落到模块级常量 REHYPE_PLUGINS —— 终版渲染无 - // 任何 span 包装,插件、state 与监听一起被回收,静态路径零开销。 + // 流式逐词淡入(DESIGN.md §14.4):消息级 state 跨 parse / remount 保留, + // 各稳定 Markdown 分片只维护自己的内容匹配状态,但共享同一条连续时间线。 + // isStreaming 翻 false 时整段回落到普通 Markdown,终版没有流式 span 包装。 // 用户开关(Settings → 个性化 → 流式动效,默认开)与 reduced-motion 取 AND: // 系统级减弱动效永远优先,开关只在 motion 允许的前提下再做个人选择。 const reducedMotion = useReducedMotion(); @@ -1677,21 +1671,13 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ () => normalizeMathDelimiters(repairedContent, { preserveLineCount: emitSourceLines }), [repairedContent, emitSourceLines], ); - const wordFade = useMemo(() => { - if (!wordFadeState) return null; - const candidate = createWordFadeCandidate(wordFadeState); - return { - candidate, - plugins: [...REHYPE_PLUGINS, [rehypeStreamWordFade, candidate]] as PluggableList, - }; - // animationend 不触发 React state;即使正文没变,其它 render 也要克隆最新 settled。 - }, [wordFadeState, renderedContent, wordFadeState?.settled.size]); - useLayoutEffect(() => { - if (wordFadeState && wordFade) { - commitWordFadeCandidate(wordFadeState, wordFade.candidate); - } - }, [wordFadeState, wordFade]); - const rehypePlugins = wordFade?.plugins ?? REHYPE_PLUGINS; + const streamingChunks = useMemo( + () => + isStreaming && !emitSourceLines + ? splitStreamingMarkdownChunks(renderedContent) + : [{ start: 0, content: renderedContent }], + [emitSourceLines, isStreaming, renderedContent], + ); const [lightboxSrc, setLightboxSrc] = useState(null); // 远程入方向:远程会话里 markdown 的图片/音频 URL 指向远端机器,按来源改写到 // cindy-remote-media://(device 经 OSS 中转、ssh 经 file-service 落盘缓存)。本地 @@ -1718,16 +1704,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ } | null>(null); // model-local chip/link click → in-app 3D preview (ModelLightbox, local mode). const [modelLightboxPath, setModelLightboxPath] = useState(null); - const streamFadeComponents = useMemo( - () => - wordFadeState - ? { - span: (props) => , - } - : {}, - [wordFadeState], - ); - // workingDir and localFileRefs are stable within a session lifecycle — they // only change on session switch or when the message list gains a new user // attachment. So in steady-state streaming, the components object is still @@ -1735,7 +1711,6 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ const components = useMemo( () => ({ ...baseComponents, - ...streamFadeComponents, // doc-mode anchor: emitSourceLines=true 时把 baseComponents 里的 block // renderer 整体替换成带 data-source-line 注入的版本。chat 调用方不传 prop // → 默认 false → 整段是 falsy 短路, components 对象与之前完全一致。 @@ -1890,21 +1865,41 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({ currentSessionTitle, allowPrivilegedLinks, remoteMediaOrigin, - streamFadeComponents, ], ); return (
- - {renderedContent} - + {isStreaming ? ( + streamingChunks.map((chunk) => ( + + )) + ) : ( + + {renderedContent} + + )} {lightboxSrc && ( setLightboxSrc(null)} /> )} diff --git a/apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx b/apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx index 95dedd7545..d2e160661d 100644 --- a/apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx +++ b/apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx @@ -2,15 +2,40 @@ import type { AnimationEvent as ReactAnimationEvent, ComponentPropsWithoutRef, } from 'react'; +import { useCallback } from 'react'; import type { Element } from 'hast'; -import { markWordFadeSettled, type WordFadeState } from './rehypeStreamWordFade'; +import { + isWordFadeSettled, + markWordFadeSettled, + scheduleWordFadeSegment, + type WordFadeState, +} from './rehypeStreamWordFade'; interface StreamFadeSpanProps extends ComponentPropsWithoutRef<'span'> { node?: Element; wordFadeState: WordFadeState | null; } +interface StreamFadeListItemProps extends ComponentPropsWithoutRef<'li'> { + node?: Element; + wordFadeState: WordFadeState | null; +} + +function readWordFadeKey(node?: Element): string | undefined { + const rawWordFadeKey = node?.properties?.dataWfKey; + return typeof rawWordFadeKey === 'string' ? rawWordFadeKey : undefined; +} + +function removeStreamWordClass(className?: string): string | undefined { + if (!className) return className; + const next = className + .split(/\s+/) + .filter((name) => name && name !== 'stream-word') + .join(' '); + return next || undefined; +} + /** * ReactMarkdown 给自定义 renderer 的外层 key 是位置型 span-N。这里再用逻辑 key * 标识真实 DOM span:位置被复用给别的段时会 remount,旧动画不能污染新段。 @@ -22,8 +47,18 @@ export function StreamFadeSpan({ wordFadeState, ...props }: StreamFadeSpanProps) { - const rawWordFadeKey = node?.properties?.dataWfKey; - const wordFadeKey = typeof rawWordFadeKey === 'string' ? rawWordFadeKey : undefined; + const wordFadeKey = readWordFadeKey(node); + const settled = Boolean( + wordFadeState && wordFadeKey && isWordFadeSettled(wordFadeState, wordFadeKey), + ); + const attachSpan = useCallback( + (element: HTMLSpanElement | null) => { + if (element && wordFadeState && wordFadeKey && !settled) { + scheduleWordFadeSegment(wordFadeState, wordFadeKey, element); + } + }, + [settled, wordFadeKey, wordFadeState], + ); const handleAnimationEnd = (event: ReactAnimationEvent) => { onAnimationEnd?.(event); if ( @@ -35,12 +70,15 @@ export function StreamFadeSpan({ return; } markWordFadeSettled(wordFadeState, wordFadeKey); + event.currentTarget.classList.remove('stream-word'); }; return ( @@ -48,3 +86,51 @@ export function StreamFadeSpan({ ); } + +/** 列表圆点与 li 内首段共用 key 和时间线;ref 在 paint 前校正 ::marker delay。 */ +export function StreamFadeListItem({ + children, + node, + onAnimationEnd, + wordFadeState, + ...props +}: StreamFadeListItemProps) { + const wordFadeKey = readWordFadeKey(node); + const settled = Boolean( + wordFadeState && wordFadeKey && isWordFadeSettled(wordFadeState, wordFadeKey), + ); + const attachListItem = useCallback( + (element: HTMLLIElement | null) => { + if (element && wordFadeState && wordFadeKey && !settled) { + scheduleWordFadeSegment(wordFadeState, wordFadeKey, element); + } + }, + [settled, wordFadeKey, wordFadeState], + ); + const handleAnimationEnd = (event: ReactAnimationEvent) => { + onAnimationEnd?.(event); + if ( + !wordFadeState || + !wordFadeKey || + event.target !== event.currentTarget || + event.animationName !== 'stream-marker-in' + ) { + return; + } + markWordFadeSettled(wordFadeState, wordFadeKey); + event.currentTarget.removeAttribute('data-stream-marker'); + }; + const markerProps = props as typeof props & { 'data-stream-marker'?: boolean }; + + return ( +
  • + {children} +
  • + ); +} diff --git a/apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx b/apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx new file mode 100644 index 0000000000..fa5604d6f0 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx @@ -0,0 +1,104 @@ +import { memo, useLayoutEffect, useMemo } from 'react'; +import ReactMarkdown, { type Components, type UrlTransform } from 'react-markdown'; +import type { Element } from 'hast'; +import type { PluggableList } from 'unified'; + +import { + commitWordFadeCandidate, + createWordFadeCandidate, + createWholeDocumentWordFadeCandidate, + getOrCreateWordFadeSourceState, + rehypeStreamWordFade, + retainOnlyWordFadeSourceState, + type WordFadeState, +} from './rehypeStreamWordFade'; +import { StreamFadeListItem, StreamFadeSpan } from './StreamFadeSpan'; + +interface StreamingMarkdownChunkProps { + sourceKey: string; + content: string; + remarkPlugins: PluggableList; + rehypePlugins: PluggableList; + components: Components; + urlTransform?: UrlTransform; + wordFadeState: WordFadeState | null; + emitSourceLines: boolean; + wholeDocument?: boolean; +} + +function sourceLineAttr(node?: Element): { 'data-source-line'?: number } { + const line = node?.position?.start.line; + return typeof line === 'number' ? { 'data-source-line': line } : {}; +} + +/** + * 单个流式 Markdown 分片。React.memo 让内容不再变化的前缀保留解析结果和 DOM, + * 只有最后一个增长分片会随 token 到达重新进入 Markdown 处理链。 + */ +export const StreamingMarkdownChunk = memo(function StreamingMarkdownChunk({ + sourceKey, + content, + remarkPlugins, + rehypePlugins, + components, + urlTransform, + wordFadeState, + emitSourceLines, + wholeDocument = false, +}: StreamingMarkdownChunkProps) { + const sourceWordFadeState = useMemo( + () => + wordFadeState ? getOrCreateWordFadeSourceState(wordFadeState, sourceKey) : null, + [sourceKey, wordFadeState], + ); + const wordFade = useMemo(() => { + if (!sourceWordFadeState) return null; + const candidate = + wholeDocument && wordFadeState + ? createWholeDocumentWordFadeCandidate(wordFadeState, sourceWordFadeState) + : createWordFadeCandidate(sourceWordFadeState); + return { + candidate, + plugins: [...rehypePlugins, [rehypeStreamWordFade, candidate]] as PluggableList, + }; + }, [content, rehypePlugins, sourceWordFadeState, wholeDocument, wordFadeState]); + + useLayoutEffect(() => { + if (sourceWordFadeState && wordFade) { + commitWordFadeCandidate(sourceWordFadeState, wordFade.candidate); + if (wholeDocument && wordFadeState) { + retainOnlyWordFadeSourceState(wordFadeState, sourceKey); + } + } + }, [sourceKey, sourceWordFadeState, wholeDocument, wordFade, wordFadeState]); + + const chunkComponents = useMemo(() => { + if (!sourceWordFadeState) return components; + return { + ...components, + span: (props) => ( + + ), + li: ({ node, ...props }) => ( + + ), + }; + }, [components, emitSourceLines, sourceWordFadeState]); + + return ( + + {content} + + ); +}); diff --git a/apps/desktop/src/renderer/components/chat/__tests__/StreamFadeSpan.test.tsx b/apps/desktop/src/renderer/components/chat/__tests__/StreamFadeSpan.test.tsx index c5cff2ed9a..cd46c76dc4 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/StreamFadeSpan.test.tsx +++ b/apps/desktop/src/renderer/components/chat/__tests__/StreamFadeSpan.test.tsx @@ -6,7 +6,7 @@ import ReactMarkdown, { type Components } from 'react-markdown'; import type { PluggableList } from 'unified'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import { StreamFadeSpan } from '../StreamFadeSpan'; +import { StreamFadeListItem, StreamFadeSpan } from '../StreamFadeSpan'; import { commitWordFadeCandidate, createWordFadeCandidate, @@ -39,6 +39,7 @@ function StreamingMarkdownHarness({ const components = useMemo( () => ({ span: (props) => , + li: (props) => , }), [state], ); @@ -86,8 +87,8 @@ describe('StreamFadeSpan', () => { expect(forwardedAnimationEnd).toHaveBeenCalledOnce(); expect(forwardedAnimationEnd.mock.calls[0][0].animationName).toBe('stream-word-in'); expect(forwardedTargetMatched).toBe(true); - expect(state.settled.has('wf-original')).toBe(true); - expect(state.settled.has('wf-reused')).toBe(false); + expect(state.timeline.settled.has('wf-original')).toBe(true); + expect(state.timeline.settled.has('wf-reused')).toBe(false); }); it('忽略子节点冒泡和其它动画名', () => { @@ -111,35 +112,94 @@ describe('StreamFadeSpan', () => { fireAnimationEnd(span.querySelector('code')!); fireAnimationEnd(span, 'spinner-rotate'); - expect(state.settled.size).toBe(0); + expect(state.timeline.settled.size).toBe(0); }); - it('settled 前缀拆除导致位置 key 前移时重建真实 span,旧节点不能结算新段', () => { + it('settled 后只摘动画 class,后续增长不替换既有 DOM 节点', () => { const state = createWordFadeState(); - state.nowFn = () => 0; + state.timeline.nowFn = () => 0; const view = render(); const firstFrame = Array.from( - view.container.querySelectorAll('.stream-word'), + view.container.querySelectorAll('[data-wf-key]'), ); - const oldFirst = firstFrame[0]; - const oldSecond = firstFrame[1]; - const secondKey = oldSecond.dataset.wfKey!; + const firstKeys = firstFrame.map((span) => span.dataset.wfKey); - fireAnimationEnd(oldFirst); + fireAnimationEnd(firstFrame[0]); + expect(firstFrame[0].isConnected).toBe(true); + expect(firstFrame[0].classList.contains('stream-word')).toBe(false); view.rerender(); const secondFrame = Array.from( - view.container.querySelectorAll('.stream-word'), + view.container.querySelectorAll('[data-wf-key]'), + ); + expect(secondFrame.map((span) => span.textContent)).toEqual([ + 'one ', + 'two ', + 'three ', + 'four', + ]); + expect(secondFrame.slice(0, 3).map((span) => span.dataset.wfKey)).toEqual(firstKeys); + expect(secondFrame[0]).toBe(firstFrame[0]); + expect(secondFrame[1]).toBe(firstFrame[1]); + expect(secondFrame[2]).toBe(firstFrame[2]); + }); + + it('DOM commit 接入 16ms / 96ms 连续时间线', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const content = Array.from({ length: 10 }, (_, index) => `w${index}`).join(' '); + const { container } = render(); + const delays = Array.from( + container.querySelectorAll('[data-wf-key]'), + (span) => span.style.getPropertyValue('--wf-delay'), + ); + expect(delays).toEqual([ + '0ms', + '16ms', + '32ms', + '48ms', + '64ms', + '80ms', + '96ms', + '100ms', + '104ms', + '108ms', + ]); + }); + + it('千级突发段把透明等待限制在 160ms 内,同时保留前段节奏', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const content = Array.from({ length: 1_000 }, (_, index) => `w${index}`).join(' '); + const { container } = render(); + const delays = Array.from( + container.querySelectorAll('[data-wf-key]'), + (span) => Number.parseInt(span.style.getPropertyValue('--wf-delay'), 10), ); - expect(secondFrame.map((span) => span.textContent)).toEqual(['two ', 'three ', 'four']); - expect(secondFrame[0].dataset.wfKey).toBe(secondKey); - expect(secondFrame[0]).not.toBe(oldFirst); - expect(oldSecond.isConnected).toBe(false); - fireAnimationEnd(oldSecond); - expect(state.settled.has(secondKey)).toBe(false); + expect(delays).toHaveLength(1_000); + expect(delays.slice(0, 10)).toEqual([0, 16, 32, 48, 64, 80, 96, 100, 104, 108]); + expect(Math.max(...delays)).toBe(160); + expect(delays.at(-1)).toBe(160); + }); - fireAnimationEnd(secondFrame[0]); - expect(state.settled.has(secondKey)).toBe(true); + it('列表圆点与首段共用 delay,完成后保留 li 节点', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const { container } = render( + , + ); + const li = container.querySelector('[data-stream-marker]')!; + const firstWord = li.querySelector('[data-wf-key]')!; + const key = firstWord.dataset.wfKey!; + + expect(li.dataset.wfKey).toBe(key); + expect(li.style.getPropertyValue('--wf-delay')).toBe( + firstWord.style.getPropertyValue('--wf-delay'), + ); + fireAnimationEnd(li, 'stream-marker-in'); + expect(li.isConnected).toBe(true); + expect(li.hasAttribute('data-stream-marker')).toBe(false); + expect(state.timeline.settled.has(key)).toBe(true); }); }); diff --git a/apps/desktop/src/renderer/components/chat/__tests__/rehypeStreamWordFade.test.ts b/apps/desktop/src/renderer/components/chat/__tests__/rehypeStreamWordFade.test.ts index b9e2f139eb..2e33a55d30 100644 --- a/apps/desktop/src/renderer/components/chat/__tests__/rehypeStreamWordFade.test.ts +++ b/apps/desktop/src/renderer/components/chat/__tests__/rehypeStreamWordFade.test.ts @@ -1,7 +1,7 @@ /** * rehypeStreamWordFade.test.ts * --------------------------------------------------------------------------- - * 流式分段淡入插件的行为测试:切词、零 stagger、稳定 key(不重播)、结构 + * 流式分段淡入插件的行为测试:切词、连续时间线、稳定 key(不重播)、结构 * 变化下的 key 稳定性、render candidate 提交、inline-code 原子淡入、 * pre/KaTeX 跳过,以及 CSS 侧动画本体的静态回归。 */ @@ -19,6 +19,7 @@ import { getOrCreateWordFadeState, releaseWordFadeState, rehypeStreamWordFade, + scheduleWordFadeSegment, splitWords, type WordFadeState, } from '../rehypeStreamWordFade'; @@ -44,7 +45,7 @@ function root(...children: Root['children']): Root { } function run(tree: Root, state: WordFadeState, nowMs = 0): Root { - state.nowFn = () => nowMs; + state.timeline.nowFn = () => nowMs; const transformer = (rehypeStreamWordFade as (s: WordFadeState) => (t: Root) => void)(state); transformer(tree); return tree; @@ -80,6 +81,20 @@ function collectWords(node: Root | Element): { text: string; delay: number; key: return out; } +function schedule(state: WordFadeState, key: string): number { + let writtenDelay = ''; + const element = { + style: { + setProperty(name: string, value: string) { + if (name === '--wf-delay') writtenDelay = value; + }, + }, + } as unknown as HTMLElement; + const delay = scheduleWordFadeSegment(state, key, element); + expect(writtenDelay).toBe(`${delay}ms`); + return delay; +} + describe('splitWords', () => { it('英文按空格切词,空白并入前一词', () => { expect(splitWords('hello world foo')).toEqual(['hello ', 'world ', 'foo']); @@ -104,6 +119,7 @@ describe('rehypeStreamWordFade', () => { const tree1 = root(el('p', [textNode('one two')])); run(tree1, firstMount, 0); const firstKeys = collectWords(tree1).map((word) => word.key); + for (const key of firstKeys) schedule(firstMount, key); const remount = getOrCreateWordFadeState(cacheKey); const tree2 = root(el('p', [textNode('one two three')])); @@ -111,9 +127,9 @@ describe('rehypeStreamWordFade', () => { const words = collectWords(tree2); expect(remount).toBe(firstMount); - expect(firstKeys.every((key) => remount.settled.has(key))).toBe(true); + expect(firstKeys.every((key) => remount.timeline.settled.has(key))).toBe(true); expect(words.map((word) => word.text)).toEqual(['three']); - expect(words[0].delay).toBe(0); + expect(schedule(remount, words[0].key)).toBe(0); }); it('消息进入终态后释放 remount 状态', () => { @@ -123,20 +139,29 @@ describe('rehypeStreamWordFade', () => { expect(getOrCreateWordFadeState(cacheKey)).not.toBe(active); }); - it('普通聊天零 stagger:同 tick 新词全部从 0ms 开始淡入', () => { + it('首帧估值按 16ms 连续错峰,DOM commit 时间线与之对齐', () => { const state = createWordFadeState(); const tree = root(el('p', [textNode('one two three')])); run(tree, state); const words = collectWords(tree); expect(words.map((w) => w.text)).toEqual(['one ', 'two ', 'three']); - expect(words.map((w) => w.delay)).toEqual([0, 0, 0]); + expect(words.map((w) => w.delay)).toEqual([0, 16, 32]); + expect(words.map((word) => schedule(state, word.key))).toEqual([0, 16, 32]); }); - it('同一 state 重跑(流式 re-parse)已见词拿回同一 key,发剩余(负)delay 续播', () => { + it('积压到 96ms 后把新增段步长压缩为 4ms', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const delays = Array.from({ length: 10 }, (_, index) => schedule(state, `wf-${index}`)); + expect(delays).toEqual([0, 16, 32, 48, 64, 80, 96, 100, 104, 108]); + }); + + it('同一 state 重跑时已见词恢复原进度,新词续接消息时间线', () => { const state = createWordFadeState(); const tree1 = root(el('p', [textNode('one two')])); run(tree1, state); const keys1 = collectWords(tree1).map((w) => w.key); + expect(keys1.map((key) => schedule(state, key))).toEqual([0, 16]); // 下一个 tick(100ms 后):全文重建 + 新词到达。 const tree2 = root(el('p', [textNode('one two three four')])); @@ -147,30 +172,21 @@ describe('rehypeStreamWordFade', () => { expect(words[0].key).toBe(keys1[0]); expect(words[1].key).toBe(keys1[1]); expect(words[0].delay).toBe(-100); - expect(words[1].delay).toBe(-100); - // 所有新词都从本 tick 的 0 起播,不互相排队。 - expect(words[2].delay).toBe(0); - expect(words[3].delay).toBe(0); - }); - - it('跨 tick 没有历史积压:后到的新词仍立即开始', () => { - const state = createWordFadeState(); - run(root(el('p', [textNode('a b c d')])), state); - const tree2 = root(el('p', [textNode('a b c d e f')])); - run(tree2, state, 50); - const words = collectWords(tree2); - expect(words.slice(0, 4).map((word) => word.delay)).toEqual([-50, -50, -50, -50]); - expect(words.slice(4).map((word) => word.delay)).toEqual([0, 0]); + expect(words[1].delay).toBe(-84); + // 真实 ref 在 paint 前把新词接到当前时刻,不继承已经消化完的历史积压。 + expect(words.slice(2).map((word) => schedule(state, word.key))).toEqual([0, 16]); }); - it('卡顿后突发的大段文字也整批立即开始,不产生新的可见积压', () => { + it('空闲后突发的新段从当前时刻重新起排', () => { const state = createWordFadeState(); - run(root(el('p', [textNode('a b')])), state); + const tree1 = root(el('p', [textNode('a b')])); + run(tree1, state); + for (const word of collectWords(tree1)) schedule(state, word.key); const tree2 = root(el('p', [textNode('a b x y z')])); run(tree2, state, 5000); - const words = collectWords(tree2); - expect(words.map((w) => w.text)).toEqual(['x ', 'y ', 'z']); - expect(words.map((w) => w.delay)).toEqual([0, 0, 0]); + const newWords = collectWords(tree2); + expect(newWords.map((w) => w.text)).toEqual(['x ', 'y ', 'z']); + expect(newWords.map((word) => schedule(state, word.key))).toEqual([0, 16, 32]); }); it('chunk 边界半个词长成整词:前缀延续复用同一 key', () => { @@ -224,14 +240,16 @@ describe('rehypeStreamWordFade', () => { expect(words2[0].key).toBe(keys1[0]); }); - it('超大 tick 也不把尾部排到未来', () => { + it('超大 tick 在阈值后压缩步长,并把透明等待收敛到 160ms', () => { const state = createWordFadeState(); const many = Array.from({ length: 500 }, (_, i) => `w${i}`).join(' '); const tree = root(el('p', [textNode(many)])); run(tree, state); const delays = collectWords(tree).map((w) => w.delay); expect(delays).toHaveLength(500); - expect(new Set(delays)).toEqual(new Set([0])); + expect(delays.slice(0, 10)).toEqual([0, 16, 32, 48, 64, 80, 96, 100, 104, 108]); + expect(Math.max(...delays)).toBe(160); + expect(delays.at(-1)).toBe(160); }); it('被放弃的 render candidate 不污染 committed key 状态', () => { @@ -241,7 +259,7 @@ describe('rehypeStreamWordFade', () => { expect(committed.nextId).toBe(0); expect(committed.previous).toEqual([]); - expect(committed.startAtByKey.size).toBe(0); + expect(committed.timeline.startAtByKey.size).toBe(0); const mounted = createWordFadeCandidate(committed); const tree = root(el('p', [textNode('mounted render')])); @@ -256,15 +274,15 @@ describe('rehypeStreamWordFade', () => { run(tree, candidate, 0); const keys = collectWords(tree).map((word) => word.key); - committed.settled.add('older-frame'); + committed.timeline.settled.add('older-frame'); commitWordFadeCandidate(committed, candidate); expect(committed.previous.map((segment) => segment.key)).toEqual(keys); - expect(committed.startAtByKey.size).toBe(2); - expect(committed.settled.has('older-frame')).toBe(true); + expect(committed.timeline.startAtByKey.size).toBe(0); + expect(committed.timeline.settled.has('older-frame')).toBe(true); }); - it('截图回归:前文与后到 inline code 同时开始淡入,code 不再抢先显示', () => { + it('inline code 与前文进入同一条连续时间线,不会抢先显示', () => { const state = createWordFadeState(); const code = el('code', [textNode('meta.Disabled')]); const pre = el('pre', [el('code', [textNode('const a = 1')])]); @@ -275,7 +293,9 @@ describe('rehypeStreamWordFade', () => { const codeSegment = paragraph.children.at(-1) as Element; expect(segments.at(-1)?.text).toBe('meta.Disabled'); - expect(segments.every((segment) => segment.delay === 0)).toBe(true); + expect(segments.map((segment) => schedule(state, segment.key))).toEqual( + segments.map((_, index) => (index <= 6 ? index * 16 : 96 + (index - 6) * 4)), + ); expect(codeSegment.tagName).toBe('span'); expect(codeSegment.properties?.className).toContain('stream-word'); expect(codeSegment.children).toEqual([code]); @@ -289,6 +309,7 @@ describe('rehypeStreamWordFade', () => { const tree1 = root(el('p', [textNode('same '), el('code', [textNode('same')])])); run(tree1, state, 0); const first = collectWords(tree1); + for (const segment of first) schedule(state, segment.key); const tree2 = root( el('p', [textNode('same '), el('code', [textNode('same')]), textNode(' tail')]), @@ -299,7 +320,7 @@ describe('rehypeStreamWordFade', () => { expect(first[0].key).not.toBe(first[1].key); expect(second[0].key).toBe(first[0].key); expect(second[1].key).toBe(first[1].key); - expect(second[1].delay).toBe(-100); + expect(second[1].delay).toBe(-84); }); it('表格格内文字照常分段淡入,结构(table/tr/td)不打任何动画标', () => { @@ -352,7 +373,7 @@ describe('rehypeStreamWordFade', () => { expect(li2.properties?.dataWfKey).toBe(words[0].key); expect(String(li2.properties?.style)).toContain(`--wf-delay:${words[0].delay}ms`); // tick 3:第一个词 settled 后圆点不再打标(remount 无从重播)。 - state.settled.add(words[0].key); + state.timeline.settled.add(words[0].key); const li3 = el('li', [textNode('four')]); run(root(el('p', [textNode('one two three')]), el('ul', [li3])), state, 0); expect(li3.properties?.dataStreamMarker).toBeUndefined(); @@ -375,59 +396,67 @@ describe('rehypeStreamWordFade', () => { expect(words.map((w) => w.text)).toEqual(['a', 'b']); }); - it('全 settled 的文本槽位不改树(原生文本节点,零 span)—— 流式长文档性能核心', () => { + it('全 settled 的文本槽位保留 span 身份,只摘动画 class', () => { const state = createWordFadeState(); const tree1 = root(el('p', [textNode('one two three')]), el('p', [textNode('tail')])); run(tree1, state); const words1 = collectWords(tree1); // 第一段全部播完落袋;第二段仍在播。 - state.settled.add(words1[0].key); - state.settled.add(words1[1].key); - state.settled.add(words1[2].key); + state.timeline.settled.add(words1[0].key); + state.timeline.settled.add(words1[1].key); + state.timeline.settled.add(words1[2].key); const tree2 = root(el('p', [textNode('one two three')]), el('p', [textNode('tail more')])); run(tree2, state, 100); - // 全 settled 槽位:文本节点原样保留,不包任何 span。 + // settled 节点仍在原位,避免后续兄弟因包装拆除而换身份。 const p1 = tree2.children[0] as Element; - expect(p1.children).toEqual([textNode('one two three')]); + expect(p1.children).toHaveLength(3); + expect( + p1.children.map((child) => + child.type === 'element' ? child.properties?.className : undefined, + ), + ).toEqual([undefined, undefined, undefined]); + expect(nodeText(p1)).toBe('one two three'); // 在播槽位照常:tail 保住原 key(续播),more 拿新 key。 const words2 = collectWords(tree2); expect(words2.map((w) => w.text)).toEqual(['tail ', 'more']); expect(words2[0].key).toBe(words1[3].key); }); - it('部分 settled 的槽位把 settled 前缀还原为纯文本,仅活动尾部保留 span', () => { + it('部分 settled 的槽位保留全部 span,只有活动段携带动画 class', () => { const state = createWordFadeState(); const tree1 = root(el('p', [textNode('one two three')])); run(tree1, state); const words1 = collectWords(tree1); - // 只有 0 号播完:槽位未全 settled,但 settled 前缀不再保留 span。 - state.settled.add(words1[0].key); + // 只有 0 号播完。 + state.timeline.settled.add(words1[0].key); const tree2 = root(el('p', [textNode('one two three four')])); - run(tree2, state, 500); + run(tree2, state, 100); const words2 = collectWords(tree2); - expect(words2.map((w) => w.text)).toEqual(['four']); + expect(words2.map((w) => w.text)).toEqual(['two ', 'three ', 'four']); const p = tree2.children[0] as Element; - expect(p.children[0]).toEqual(textNode('one two three ')); - // 即使 remount 丢了 animationend,超过 150ms 的旧词也会按开播时刻自动落袋。 - expect(words1.every((word) => state.settled.has(word.key))).toBe(true); - expect(words2[0].delay).toBe(0); + expect(p.children).toHaveLength(4); + const settledSpan = p.children[0] as Element; + expect(settledSpan.properties?.dataWfKey).toBe(words1[0].key); + expect(settledSpan.properties?.className).toBeUndefined(); }); - it('长 settled 前缀只保留一个原生文本节点', () => { + it('长 settled 前缀保留稳定 span,增长尾部继续取得新 key', () => { const state = createWordFadeState(); const prefix = Array.from({ length: 200 }, (_, i) => `w${i}`).join(' '); const tree1 = root(el('p', [textNode(`${prefix} tail`)])); run(tree1, state); const words1 = collectWords(tree1); - for (const word of words1.slice(0, 200)) state.settled.add(word.key); + for (const word of words1.slice(0, 200)) state.timeline.settled.add(word.key); const tree2 = root(el('p', [textNode(`${prefix} tail next`)])); - run(tree2, state, 500); + run(tree2, state, 100); const p = tree2.children[0] as Element; - expect(p.children[0]).toEqual(textNode(`${prefix} tail `)); - expect(collectWords(tree2).map((w) => w.text)).toEqual(['next']); + expect(p.children).toHaveLength(202); + expect((p.children[0] as Element).properties?.className).toBeUndefined(); + expect((p.children[199] as Element).properties?.className).toBeUndefined(); + expect(collectWords(tree2).map((w) => w.text)).toEqual(['tail ', 'next']); }); }); @@ -440,8 +469,10 @@ describe('globals.css 的 stream-word 动画本体', () => { it('引用 --motion-fast token + both 填充(delay 未到时保持透明)', () => { const rule = /\.stream-word\s*\{[\s\S]*?\}/.exec(css)?.[0] ?? ''; expect(rule).toContain('var(--motion-fast)'); + expect(rule).toContain('var(--motion-ease-out)'); expect(rule).toContain('var(--wf-delay'); expect(rule).toContain('both'); + expect(css).not.toContain('--motion-ease-stream-word'); }); it('关键帧只动 opacity(compositor-only,一次性非 infinite)', () => { @@ -455,6 +486,7 @@ describe('globals.css 的 stream-word 动画本体', () => { const rule = /\[data-stream-marker\]::marker\s*\{[\s\S]*?\}/.exec(css)?.[0] ?? ''; expect(rule).toContain('stream-marker-in'); expect(rule).toContain('var(--motion-fast)'); + expect(rule).toContain('var(--motion-ease-out)'); expect(rule).toContain('var(--wf-delay'); expect(rule).toContain('both'); // ::marker 只支持 color/font 系属性,关键帧必须动 color 而不是 opacity。 diff --git a/apps/desktop/src/renderer/components/chat/__tests__/streamingMarkdownChunks.test.tsx b/apps/desktop/src/renderer/components/chat/__tests__/streamingMarkdownChunks.test.tsx new file mode 100644 index 0000000000..31ff2f9f1a --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/__tests__/streamingMarkdownChunks.test.tsx @@ -0,0 +1,245 @@ +// @vitest-environment jsdom + +import { cleanup, render } from '@testing-library/react'; +import type { Element, Root } from 'hast'; +import type { Plugin, PluggableList } from 'unified'; +import { afterEach, describe, expect, it } from 'vitest'; + +import { StreamingMarkdownChunk } from '../StreamingMarkdownChunk'; +import { createWordFadeState } from '../rehypeStreamWordFade'; +import { splitStreamingMarkdownChunks } from '../streamingMarkdownChunks'; + +afterEach(cleanup); + +describe('splitStreamingMarkdownChunks', () => { + it('只在已经出现下一段的顶层空行后确认稳定前缀', () => { + expect(splitStreamingMarkdownChunks('alpha\n\n')).toEqual([ + { start: 0, content: 'alpha\n\n' }, + ]); + expect(splitStreamingMarkdownChunks('alpha\n\nbeta')).toEqual([ + { start: 0, content: 'alpha\n\n' }, + { start: 7, content: 'beta' }, + ]); + }); + + it('代码围栏和 directive 内的空行不产生边界', () => { + const markdown = [ + 'intro', + '', + '````ts', + 'const fence = "```";', + '', + 'more', + '````', + '', + 'after', + '', + ':::note', + 'inside', + '', + 'still inside', + ':::', + '', + 'tail', + ].join('\n'); + const chunks = splitStreamingMarkdownChunks(markdown); + + expect(chunks.map((chunk) => chunk.content)).toEqual([ + 'intro\n\n', + '````ts\nconst fence = "```";\n\nmore\n````\n\n', + 'after\n\n', + ':::note\ninside\n\nstill inside\n:::\n\n', + 'tail', + ]); + }); + + it('数学块内部空行不产生边界', () => { + const markdown = 'before\n\n$$\na + b\n\nc + d\n$$\n\nafter'; + expect(splitStreamingMarkdownChunks(markdown).map((chunk) => chunk.content)).toEqual([ + 'before\n\n', + '$$\na + b\n\nc + d\n$$\n\n', + 'after', + ]); + }); + + it('列表、引用和缩进续行保持在同一分片', () => { + const markdown = 'before\n\n- one\n\n- two\n\n> quote\n> next\n\nafter'; + const chunks = splitStreamingMarkdownChunks(markdown); + + expect(chunks).toEqual([ + { start: 0, content: 'before\n\n- one\n\n- two\n\n> quote\n> next\n\n' }, + { start: 38, content: 'after' }, + ]); + }); + + it('括号式有序列表续项保持在同一分片', () => { + const markdown = 'before\n\n1) one\n\n2) two\n\nafter'; + + expect(splitStreamingMarkdownChunks(markdown)).toEqual([ + { start: 0, content: 'before\n\n1) one\n\n2) two\n\n' }, + { start: 24, content: 'after' }, + ]); + }); + + it('单行或多行引用式链接定义出现时保留整篇上下文', () => { + for (const markdown of [ + 'See [guide].\n\nMore text.\n\n[guide]: https://example.com', + 'See [guide].\n\nMore text.\n\n[guide]:\n https://example.com', + 'See [foo\\]bar].\n\nMore text.\n\n[foo\\]bar]: https://example.com', + ]) { + expect(splitStreamingMarkdownChunks(markdown)).toEqual([ + { start: 0, content: markdown }, + ]); + } + }); + + it('多个标题或块级 HTML 出现时保留整篇解析上下文', () => { + for (const headings of ['# Same\n\nbody\n\n# same', '# foo\n\nbody\n\n# foo!']) { + expect(splitStreamingMarkdownChunks(headings)).toEqual([ + { start: 0, content: headings }, + ]); + } + + for (const htmlBlock of [ + '
    \nsummary\n\nbody\n
    \n\nafter', + '\n\nafter', + '\n\nafter', + '\n\nafter', + '\n\nafter', + ]) { + expect(splitStreamingMarkdownChunks(htmlBlock)).toEqual([ + { start: 0, content: htmlBlock }, + ]); + } + }); +}); + +function treeText(node: Root | Element): string { + return node.children + .map((child) => { + if (child.type === 'text') return child.value; + if (child.type === 'element') return treeText(child); + return ''; + }) + .join(''); +} + +describe('StreamingMarkdownChunk', () => { + it('增长尾部重解析时保留稳定前缀的解析结果与 DOM', () => { + const parsed: string[] = []; + const countParses: Plugin<[], Root> = () => (tree) => { + parsed.push(treeText(tree)); + }; + const remarkPlugins: PluggableList = []; + const rehypePlugins: PluggableList = [countParses]; + const components = {}; + + function Harness({ markdown }: { markdown: string }) { + return ( +
    + {splitStreamingMarkdownChunks(markdown).map((chunk) => ( + + ))} +
    + ); + } + + const view = render(); + const stableParagraph = view.container.querySelectorAll('p')[0]; + expect(parsed).toEqual(['stable prefix', 'live']); + + view.rerender(); + expect(parsed).toEqual(['stable prefix', 'live', 'live tail']); + expect(view.container.querySelectorAll('p')[0]).toBe(stableParagraph); + }); + + it('多个分片共享一条淡入时间线', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const remarkPlugins: PluggableList = []; + const rehypePlugins: PluggableList = []; + const markdown = 'one two\n\nthree four'; + const { container } = render( +
    + {splitStreamingMarkdownChunks(markdown).map((chunk) => ( + + ))} +
    , + ); + + expect( + Array.from(container.querySelectorAll('[data-wf-key]'), (element) => + element.style.getPropertyValue('--wf-delay'), + ), + ).toEqual(['0ms', '16ms', '32ms', '48ms']); + }); + + it('全局上下文晚到并回退整篇解析时不重播已完成分片', () => { + const state = createWordFadeState(); + state.timeline.nowFn = () => 0; + const remarkPlugins: PluggableList = []; + const rehypePlugins: PluggableList = []; + + function Harness({ markdown }: { markdown: string }) { + const chunks = splitStreamingMarkdownChunks(markdown); + return ( +
    + {chunks.map((chunk) => ( + + ))} +
    + ); + } + + const initial = '# First\n\nstable prefix\n\nlive tail'; + const view = render(); + const firstFrame = Array.from( + view.container.querySelectorAll('[data-wf-key]'), + ); + const completedKeys = firstFrame.map((span) => span.dataset.wfKey!); + expect(state.sourceStateByKey.size).toBeGreaterThan(1); + for (const key of completedKeys) state.timeline.settled.add(key); + + view.rerender(); + + const secondFrame = Array.from( + view.container.querySelectorAll('[data-wf-key]'), + ); + const byKey = new Map(secondFrame.map((span) => [span.dataset.wfKey!, span])); + expect(completedKeys.every((key) => byKey.has(key))).toBe(true); + expect(completedKeys.every((key) => !byKey.get(key)!.classList.contains('stream-word'))).toBe( + true, + ); + expect(secondFrame.filter((span) => span.classList.contains('stream-word'))).toHaveLength(1); + expect(secondFrame.at(-1)?.textContent).toBe('Second'); + expect(state.sourceStateByKey.size).toBe(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts b/apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts index f06f03f621..22c841ba93 100644 --- a/apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts +++ b/apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts @@ -1,66 +1,11 @@ /** - * rehypeStreamWordFade — 流式正文分段淡入(DESIGN.md §14.4 第五个 sanctioned - * motion class,2026-08-07)。 + * 流式正文分段淡入(DESIGN.md §14.4)。文本词段、行内 code 原子和列表圆点 + * 共用消息级连续时间线;块级结构即时出现。稳定 key 记录绝对开播时刻,结构 + * 调整导致节点重挂载时用负 delay 恢复进度,不会从头重播。 * - * 形态:流式输出时,每个新词与 inline-code 原子以 150ms opacity 淡入「浮现」。 - * 这**不是**被红线禁止的逐字打字机 —— 段整体已渲染就位,只有透明度渐变; - * 设计裁决(2026-08-07)按「浮现 ≠ 打字」放行。 - * - * 架构:CSS 管形态、JS 只管时序: - * - 本插件仅在 isStreaming 且非 reduced-motion 时挂进 rehype 链尾,把文本词段 - * 与 inline-code 原子包 ``;动画本体是 - * globals.css 的 stream-word-in(--motion-fast 淡入,`both` 填充,delay 前隐藏)。 - * - **不重播 —— 内容匹配的稳定 key**:delay 与 settled 都按段的稳定 key 记账, - * 不按文档序号。每次 parse 把本次段列表与上一次(state.previous)做匹配: - * 同位置内容相等或前缀延续(chunk 边界半个词长成整词)→ 复用旧 key;错位则 - * 按内容从后往前找未被占用的旧 key(结构变化整体平移的词都能找回自己);都 - * 没有才发新 key。markdown 结构变化(列表标记吃掉 "2. "、加粗闭合劈开文本 - * 节点、Segmenter 对 chunk 尾部的切分变化)只会让**词序号**漂移,key 不漂 —— - * 漂移序号曾让已稳定的整片前文被当新词重淡(2026-08-08 实测)。 - * - **settled 落袋 + settled 前缀还原纯文本**:span 带 data-wf-key,作为 HAST - * 到 React renderer 的逻辑身份通道;StreamFadeSpan 用该 key 控制真实 DOM - * remount,并由每个 span 的 animationend 闭包把自身 key 放进 state.settled。 - * settled 词从槽位中还原为合并后的原生文本, - * settled inline code 则恢复原始 code 节点, - * 只保留仍在播放的尾部 span——流式长文档的元素数因此回落到与无动效渲染 - * 同阶,react-markdown 每 tick 的重建 + diff 不随已播完的前文线性涨 - * (曾因全文逐词包 span,几千元素把主线程打满,流式中点击切换 session - * 无响应,2026-08-09 实测)。部分 settled 的槽位仍整槽包 span(settled 词 - * 的负 delay 已超动画时长,both 填充直接呈现终态,不重播)。抽掉 span 引 - * 发的兄弟位置 key 平移由 remount 免疫兜底(见下),不再需要空壳保位。 - * - **普通聊天零 stagger**(Codex Desktop 普通聊天同款,2026-08-21):同一 - * parse tick 新到的词段都从 0ms 开始 150ms opacity 淡入,不再跨词排队。 - * 旧实现把专用展示场景的 24ms stagger 推广到普通聊天,又让行内 code - * 完全跳过动画,导致后到的 code chip 已清晰显示、前文仍在排队的视觉反序。 - * - **remount 免疫 —— 存开播时刻、每 tick 发剩余 delay**(2026-08-08,与 - * Codex 的根本差异所要求的补偿):Codex 自研渲染器用 segmentKey 当 React - * key,span 永不 remount;我们骑在 react-markdown 上,React key 是位置 - * 序号,流式中正在生长的区域(表格尾行、成形中的列表)每 tick 都可能 - * remount。若给 span 发固定 delay,remount 会让 CSS 动画带原始 delay 从头 - * 重等,下一 tick 又 remount —— 词永远透明(2026-08-08 表格实测)。故 - * state 仍记**绝对开播时刻**,每个 tick 重新发出「开播时刻 - now」: - * 已过时刻发**负 delay**,CSS 负 animation-delay 让动画从中途续播, - * remount 后帧级跳回正确进度,观感无缝。 - * - 流式结束(isStreaming 翻 false)由 MarkdownRenderer 切回无插件的常量链, - * 终版渲染没有任何 span 包装,按消息缓存的 state 同步释放。 - * - * 原子淡入:inline code / 路径 chip 保持内部结构不拆,外层只包一个 stream-word, - * 与同 tick 正文一起从 0ms 淡入。跳过:pre 代码块与 KaTeX 子树(公式内部是 - * 几十个定位 span,逐词包装会拆坏排版)。 - * - * 淡入对象模型(Codex Desktop 架构 + inline-code 顺序修正):**只有文本词段、 - * 行内代码原子和列表圆点淡入,块级结构永远即时出现**。表格边框、引用 rail、分隔线不做 - * 块级整体淡入 —— 曾试过 tr/li 整块排队,大积压窗口下必然出现"空骨架先 - * 画好、文字憋一坨"(2026-08-08 两轮实测翻车)。表格格内文字与正文一样逐词 - * 淡入(Codex FIa 对 table 的处理同款)。列表圆点(::marker)单独处理:li 打 - * data-stream-marker,delay/key **借用 li 内第一个段**(listItemDecorationByToken - * 同构)—— 圆点与正文同帧浮现,不额外占段位,空 li 长出文字也不重播。 - * 标记用 data 属性而不是 className:MarkdownRenderer 的自定义 renderer 是 - * `className={cn(...)} {...props}` 写法,hast 塞 className 会经 spread 把样式 - * 类整个覆盖掉;data-* 从 spread 直通,互不相扰。 - * - * 切词用 Intl.Segmenter(granularity: 'word'):CJK 无空格也能按词切,避免整句 - * 中文一次性淡入退化成"逐句蹦"。空白并入前一词,不为纯空白生成 span。 + * settled 段在流式期间保留原 span 身份,只摘动画 class;稳定 Markdown 前缀 + * 由外层分片组件保留解析结果和 DOM。消息结束后切回普通 Markdown,一次性移除 + * 所有流式包装。pre 代码块与 KaTeX 子树不进入逐词处理。 */ import type { Plugin } from 'unified'; @@ -79,35 +24,71 @@ interface PreviousSegment { key: string; } +export interface WordFadeTimeline { + /** key → 绝对开播时刻(performance.now() 基准)。 */ + startAtByKey: Map; + /** 已完成淡入的 key。流式期间只摘动画 class,节点留到终态统一回收。 */ + settled: Set; + /** 下一段最早可开播的绝对时刻;同一条消息的所有 Markdown 分片共享。 */ + nextSegmentStartAtMs: number; + /** 时钟注入口,仅测试使用。 */ + nowFn?: () => number; +} + export interface WordFadeState { /** 稳定 key 发号器。 */ nextId: number; /** 上一次 parse 的淡入段列表(文档序),类型 + 内容匹配复用 key 的依据。 */ previous: PreviousSegment[]; - /** - * key → 绝对开播时刻(performance.now() 基准)。只排一次;每个 tick 由 - * 「开播时刻 - now」重新发出剩余 delay(可为负,负值 = CSS 从中途续播)。 - * 存绝对时刻而不是固定 delay,是 remount 免疫的关键(见头注释)。 - */ - startAtByKey: Map; - /** 已播完淡入的 key(animationend 落袋)。后续 parse 摘掉动画类。 */ - settled: Set; - /** 时钟注入口,仅测试用;生产恒为 performance.now。 */ - nowFn?: () => number; + /** 分片 key 前缀,保证同一条消息的并行 Markdown 分片不会撞 key。 */ + keyPrefix: string; + /** 同一条消息的连续时间线。 */ + timeline: WordFadeTimeline; + /** 稳定前缀分片与增长尾部分别维护匹配状态,但共享 timeline。 */ + sourceStateByKey: Map; } /** 与 globals.css 的 --motion-fast 保持一致,用于 animationend 丢失时的到期兜底。 */ const FADE_DURATION_MS = 150; +/** 普通流式正文的连续节奏:正常 16ms,积压到 96ms 后压缩为 4ms。 */ +export const WORD_FADE_SEGMENT_DELAY_MS = 16; +export const WORD_FADE_MAX_DELAY_MS = 96; +/** 高速突发时最多让文字等待这么久;超过窗口的段同帧淡入,避免透明内容长期占位。 */ +export const WORD_FADE_MAX_VISIBLE_DELAY_MS = 160; +const WORD_FADE_BACKLOG_STEP_MS = WORD_FADE_SEGMENT_DELAY_MS * 0.25; + +function createWordFadeTimeline(): WordFadeTimeline { + return { + startAtByKey: new Map(), + settled: new Set(), + nextSegmentStartAtMs: 0, + }; +} -export function createWordFadeState(): WordFadeState { +export function createWordFadeState( + keyPrefix = '', + timeline = createWordFadeTimeline(), +): WordFadeState { return { nextId: 0, previous: [], - startAtByKey: new Map(), - settled: new Set(), + keyPrefix, + timeline, + sourceStateByKey: new Map(), }; } +export function getOrCreateWordFadeSourceState( + owner: WordFadeState, + sourceKey: string, +): WordFadeState { + const cached = owner.sourceStateByKey.get(sourceKey); + if (cached) return cached; + const state = createWordFadeState(`s${sourceKey}-`, owner.timeline); + owner.sourceStateByKey.set(sourceKey, state); + return state; +} + /** * React render 只允许改 candidate。真正挂载后由 MarkdownRenderer 的 * useLayoutEffect 提交,被并发渲染放弃的 parse 不会偷跑稳定 key / 时间线。 @@ -116,18 +97,49 @@ export function createWordFadeCandidate(committed: WordFadeState): WordFadeState return { nextId: committed.nextId, previous: committed.previous, - startAtByKey: new Map(committed.startAtByKey), - settled: new Set(committed.settled), - nowFn: committed.nowFn, + keyPrefix: committed.keyPrefix, + timeline: { + startAtByKey: new Map(committed.timeline.startAtByKey), + settled: new Set(committed.timeline.settled), + nextSegmentStartAtMs: committed.timeline.nextSegmentStartAtMs, + nowFn: committed.timeline.nowFn, + }, + sourceStateByKey: new Map(), }; } +/** + * 全局 Markdown 上下文晚到时,外层会从多个稳定分片回退成 start=0 的整篇解析。 + * 这里按原文起点合并各分片的匹配历史,让整篇 candidate 继续认出已经播放过的段; + * 只读 committed state,不在 React render 阶段发布状态。 + */ +export function createWholeDocumentWordFadeCandidate( + owner: WordFadeState, + committed: WordFadeState, +): WordFadeState { + const candidate = createWordFadeCandidate(committed); + const previous = [...owner.sourceStateByKey.entries()] + .sort(([left], [right]) => Number(left) - Number(right)) + .flatMap(([, sourceState]) => sourceState.previous); + if (previous.length > 0) candidate.previous = previous; + return candidate; +} + +/** 整篇 candidate 落袋后移除旧分片匹配状态;共享 timeline 仍由保留项持有。 */ +export function retainOnlyWordFadeSourceState( + owner: WordFadeState, + sourceKey: string, +): void { + for (const key of owner.sourceStateByKey.keys()) { + if (key !== sourceKey) owner.sourceStateByKey.delete(key); + } +} + export function commitWordFadeCandidate(committed: WordFadeState, candidate: WordFadeState): void { committed.nextId = candidate.nextId; committed.previous = candidate.previous; - committed.startAtByKey = new Map(candidate.startAtByKey); // render 到 layout-effect 之间可能正好收到上一帧的 animationend;只合并,不能覆盖。 - committed.settled = new Set([...committed.settled, ...candidate.settled]); + for (const key of candidate.timeline.settled) committed.timeline.settled.add(key); } /** @@ -169,7 +181,42 @@ export function _resetWordFadeStateCacheForTests(): void { /** animationend 闭包的落袋入口;身份来自 render 时捕获的 key,不读取可变 DOM dataset。 */ export function markWordFadeSettled(state: WordFadeState, key: string): void { - state.settled.add(key); + state.timeline.settled.add(key); +} + +export function isWordFadeSettled(state: WordFadeState, key: string): boolean { + return state.timeline.settled.has(key); +} + +function nextWordFadeStep(delayMs: number): number { + return delayMs < WORD_FADE_MAX_DELAY_MS + ? WORD_FADE_SEGMENT_DELAY_MS + : Math.max(1, WORD_FADE_BACKLOG_STEP_MS); +} + +/** + * DOM commit 阶段把段接入消息级连续时间线。新段正常每 16ms 开播;积压达到 + * 96ms 后步长压到 4ms,避免长批次排出明显等待。已见段只恢复原绝对时刻, + * remount 不重排、不重播。 + */ +export function scheduleWordFadeSegment( + state: WordFadeState, + key: string, + element: HTMLElement, +): number { + const { timeline } = state; + const nowMs = (timeline.nowFn ?? (() => performance.now()))(); + let startAt = timeline.startAtByKey.get(key); + if (startAt === undefined) { + const queuedStartAt = Math.max(timeline.nextSegmentStartAtMs, nowMs); + startAt = Math.min(queuedStartAt, nowMs + WORD_FADE_MAX_VISIBLE_DELAY_MS); + const delayMs = Math.max(Math.round(startAt - nowMs), 0); + timeline.startAtByKey.set(key, startAt); + timeline.nextSegmentStartAtMs = startAt + nextWordFadeStep(delayMs); + } + const remainingDelayMs = Math.round(startAt - nowMs); + element.style.setProperty('--wf-delay', `${remainingDelayMs}ms`); + return remainingDelayMs; } /** 整棵子树跳过(不进入):块级代码、非正文节点与公式内部结构。 */ @@ -188,9 +235,8 @@ const segmenter = : null; /** - * 切词结果 LRU 缓存(Codex Desktop 同款优化,容量同 500):流式每 tick 全文 - * 重解析,绝大多数文本节点与上个 tick 完全相同,而 Intl.Segmenter 对 CJK 分词 - * 相对昂贵 —— 无缓存时长文档每 tick 全量重切,成本随文档长度线性涨。Map 的 + * 切词结果 LRU 缓存(容量 500):增长尾部重解析时仍会反复遇到相同文本节点, + * 而 Intl.Segmenter 对 CJK 分词相对昂贵。Map 的 * 插入序即访问序近似(命中即删再插,超限逐出最老),对"稳定前文 + 生长尾部" * 的访问模式命中率接近 100%。 */ @@ -254,7 +300,7 @@ interface InlineCodeSlot { type FadeSlot = TextSlot | InlineCodeSlot; -/** 列表项圆点条目:圆点借用 li 内第一个段的 key/delay(Codex 同款)。 */ +/** 列表项圆点条目:圆点借用 li 内第一个段的 key/delay。 */ interface MarkerEntry { node: Element; /** li 内第一个段在全文档段列表中的下标;li 收集完仍相等 = 暂无内容。 */ @@ -281,8 +327,7 @@ function elementText(node: Element): string { /** * 按文档序收集文本、行内 code 槽位与列表项条目。普通文本(含表格格内)切词; * inline code 整体占一个原子段。其它结构元素不占段位 —— li 圆点记下首段的 - * 下标,之后与该段共享 key 和 delay(Codex Desktop listItemDecorationByToken - * 同构),圆点与文字永远同帧浮现,不会"圆点亮了文字干等"。 + * 下标,之后与该段共享 key 和 delay,圆点与文字永远同帧浮现。 */ function collect(node: Root | Element, out: Collected): void { const children = node.children; @@ -327,7 +372,7 @@ function collect(node: Root | Element, out: Collected): void { } /** - * 内容匹配分配稳定 key(Codex Desktop 同源思路): + * 内容匹配分配稳定 key: * 1. 同位置且内容相等 / 前缀延续(旧词是新词前缀,chunk 边界补全)→ 复用; * 2. 错位则按内容从后往前找尚未被占用的旧 key(整体平移的词各自找回); * 3. 都没有 → 发新 key。 @@ -367,26 +412,38 @@ function assignKeys(segments: FadeSegment[], state: WordFadeState): string[] { unmatched.delete(idx); return state.previous[idx].key; } - return `wf-${state.nextId++}`; + return `wf-${state.keyPrefix}${state.nextId++}`; }); state.previous = keys.map((key, i) => ({ ...segments[i], key })); return keys; } /** - * 取(必要时分配)某个 key 的开播时刻,返回**本 tick 视角的剩余 delay**。 - * 普通聊天不 stagger:同一 parse tick 的所有新 key 都以 nowMs 为开播时刻。 - * 已见 key 不重排,但每个 tick 都按「开播时刻 - now」重新发剩余 delay:开播 - * 时刻已过去则为负值,CSS 负 animation-delay 从中途续播 —— react-markdown - * 位置 key 引发的 remount 只会让动画跳回正确进度,不会从头重等(remount 免疫)。 + * ref 在 DOM commit 阶段写入真实消息级时间线;这里的值只负责首轮 React 属性。 + * 已排段按绝对时刻恢复进度,新段用当前分片内的静态估值避免首帧闪烁,ref 会在 + * paint 前把它校正为跨分片连续的 16ms / 96ms 时间线。 */ -function ensureDelay(key: string, state: WordFadeState, nowMs: number): number { - let startAt = state.startAtByKey.get(key); - if (startAt === undefined) { - startAt = nowMs; - state.startAtByKey.set(key, startAt); - } - return Math.round(startAt - nowMs); +function estimateWordFadeDelay(segmentIndex: number): number { + const rawDelay = segmentIndex * WORD_FADE_SEGMENT_DELAY_MS; + if (rawDelay <= WORD_FADE_MAX_DELAY_MS) return rawDelay; + return Math.min( + WORD_FADE_MAX_VISIBLE_DELAY_MS, + WORD_FADE_MAX_DELAY_MS + + (segmentIndex - Math.floor(WORD_FADE_MAX_DELAY_MS / WORD_FADE_SEGMENT_DELAY_MS)) * + WORD_FADE_BACKLOG_STEP_MS, + ); +} + +function renderDelay( + key: string, + state: WordFadeState, + nowMs: number, + segmentIndex: number, +): number { + const startAt = state.timeline.startAtByKey.get(key); + return startAt === undefined + ? estimateWordFadeDelay(segmentIndex) + : Math.round(startAt - nowMs); } function makeFadeNode( @@ -394,41 +451,21 @@ function makeFadeNode( key: string, state: WordFadeState, nowMs: number, + segmentIndex: number, ): ElementContent { + const settled = state.timeline.settled.has(key); return { type: 'element', tagName: 'span', properties: { - className: ['stream-word'], - style: `--wf-delay:${ensureDelay(key, state, nowMs)}ms`, + ...(settled ? {} : { className: ['stream-word'] }), + style: `--wf-delay:${renderDelay(key, state, nowMs, segmentIndex)}ms`, dataWfKey: key, }, children, }; } -/** - * 一个槽位的词是否已全部尘埃落定(animationend 落袋进 settled)。 - * 全 settled 的槽位**完全不改树**:原文本节点原样保留 —— 这是流式长文档的 - * 性能核心。settled 词若逐个包空 span,元素数随全文词数线性涨(几千 span), - * react-markdown 每 tick 重建 + React diff 全量元素,主线程被 parse tick 打满, - * 点击/切换 session 全部排不上队(2026-08-09 实测:流式中无法切换会话)。 - * 还原成纯文本后,React 元素数回落到与无动效渲染同阶;只有 150ms 动画窗口 - * 内仍在播的段才有 span。安全性依赖 remount 免疫:文本节点数量变化会让后续 - * 兄弟位置 key 平移、在播 span remount,但绝对开播时刻 + 负 delay 续播保证 - * remount 后动画进度不变(而非从头重播)。 - */ -function isSlotFullySettled(slot: FadeSlot, keys: string[], state: WordFadeState): boolean { - if (slot.kind === 'inline-code') return state.settled.has(keys[slot.segmentStart]); - let segmentIndex = slot.segmentStart; - for (const w of slot.words) { - if (!w.trim()) continue; - if (!state.settled.has(keys[segmentIndex])) return false; - segmentIndex++; - } - return true; -} - function appendPlainText(nodes: ElementContent[], value: string): void { if (!value) return; const last = nodes[nodes.length - 1]; @@ -444,7 +481,7 @@ function makeSlotNodes( ): ElementContent[] { if (slot.kind === 'inline-code') { const key = keys[slot.segmentStart]; - return [makeFadeNode([slot.node], key, state, nowMs)]; + return [makeFadeNode([slot.node], key, state, nowMs, slot.segmentStart)]; } const nodes: ElementContent[] = []; let segmentIndex = slot.segmentStart; @@ -454,14 +491,21 @@ function makeSlotNodes( continue; } const key = keys[segmentIndex++]; - if (state.settled.has(key)) appendPlainText(nodes, word); - else nodes.push(makeFadeNode([{ type: 'text', value: word }], key, state, nowMs)); + nodes.push( + makeFadeNode( + [{ type: 'text', value: word }], + key, + state, + nowMs, + segmentIndex - 1, + ), + ); } return nodes; } /** - * 给 li 挂圆点淡入(Codex Desktop fadeListDecoration 同构)。动画打在 + * 给 li 挂圆点淡入。动画打在 * `::marker` 上(CSS 见 globals.css),delay/key 借用 li 内第一个段 —— * 圆点与正文同帧浮现。li 内暂无内容(结构刚长出)→ 直接 0ms 淡入; * 内容到达后按首段 key 正常淡入,圆点已见 key 不重播。 @@ -475,8 +519,8 @@ function applyMarkerFade( ): void { const hasSegment = entry.segmentEnd > entry.segmentStart; const key = hasSegment ? keys[entry.segmentStart] : undefined; - if (key && state.settled.has(key)) return; - const delay = key ? ensureDelay(key, state, nowMs) : 0; + if (key && state.timeline.settled.has(key)) return; + const delay = key ? renderDelay(key, state, nowMs, entry.segmentStart) : 0; entry.node.properties = { ...entry.node.properties, dataStreamMarker: true, @@ -494,25 +538,20 @@ export const rehypeStreamWordFade: Plugin<[WordFadeState], Root> = (state) => { if (segments.length === 0 && markers.length === 0) return; // pass 2:类型 + 内容匹配分配稳定 key(已见段命中旧 key 拿回 delay/settled)。 const keys = assignKeys(segments, state); - // pass 3:回填 span。同 tick 新段全部 0ms 开始,旧段按绝对开播时刻续播。 + // pass 3:回填 span。ref 会在 DOM commit 阶段接入共享连续时间线。 // 槽位从后往前 splice,前面槽位的 index 不受影响。 - const nowMs = (state.nowFn ?? (() => performance.now()))(); + const nowMs = (state.timeline.nowFn ?? (() => performance.now()))(); // 结构变化会让活动 span remount,旧节点因此可能收不到 animationend。超过动画 // 时长的已开播段直接落袋,避免它永久保留活动包装。 for (const key of keys) { - const startAt = state.startAtByKey.get(key); + const startAt = state.timeline.startAtByKey.get(key); if (startAt !== undefined && nowMs - startAt >= FADE_DURATION_MS) { - state.settled.add(key); + state.timeline.settled.add(key); } } - const nodesBySlot = slots.map((slot) => { - // 全 settled 槽位不改树(性能核心,见 isSlotFullySettled 注释)。 - if (isSlotFullySettled(slot, keys, state)) return null; - return makeSlotNodes(slot, keys, state, nowMs); - }); + const nodesBySlot = slots.map((slot) => makeSlotNodes(slot, keys, state, nowMs)); for (let s = slots.length - 1; s >= 0; s--) { const nodes = nodesBySlot[s]; - if (nodes === null) continue; const slot = slots[s]; slot.parent.children.splice(slot.index, 1, ...nodes); } diff --git a/apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts b/apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts new file mode 100644 index 0000000000..f2ec6a0305 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/streamingMarkdownChunks.ts @@ -0,0 +1,168 @@ +export interface StreamingMarkdownChunk { + /** 原文中的稳定起点,可直接作为 React key 与淡入状态分片 key。 */ + start: number; + content: string; +} + +function isTopLevelContinuation(line: string): boolean { + return ( + /^[\t ]/.test(line) || + /^[-+*][\t ]+/.test(line) || + /^\d{1,9}[.)][\t ]+/.test(line) || + line.startsWith('>') + ); +} + +interface FenceState { + marker: '`' | '~'; + length: number; +} + +function readFenceRun(line: string): { marker: '`' | '~'; length: number; rest: string } | null { + const match = /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line); + if (!match) return null; + return { + marker: match[1][0] as '`' | '~', + length: match[1].length, + rest: match[2], + }; +} + +function isDirectiveOpen(trimmedLine: string): boolean { + return /^:::[a-zA-Z]/.test(trimmedLine); +} + +function isDirectiveClose(trimmedLine: string): boolean { + return /^:::[\t ]*$/.test(trimmedLine); +} + +function hasGlobalMarkdownDefinitions(markdown: string): boolean { + // 引用式链接定义会反向影响前面的 block;一旦出现就保留整篇解析,避免稳定前缀 + // 被拆开后失去定义上下文。围栏里的误命中只会少做一次优化,不影响正确性。 + return /^ {0,3}\[(?:\\[^\r\n]|[^\]\\\r\n])+\]:/m.test(markdown); +} + +function hasMultipleHeadings(markdown: string): boolean { + const lines = markdown.split(/\r?\n/); + let headingCount = 0; + let fence: FenceState | null = null; + + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const fenceRun = readFenceRun(line); + if (fenceRun) { + if (!fence) fence = { marker: fenceRun.marker, length: fenceRun.length }; + else if ( + fenceRun.marker === fence.marker && + fenceRun.length >= fence.length && + fenceRun.rest.trim().length === 0 + ) { + fence = null; + } + continue; + } + if (fence) continue; + + const isAtx = /^ {0,3}#{1,6}(?:[\t ]+|$)/.test(line); + const isSetext = + line.trim().length > 0 && + index + 1 < lines.length && + /^ {0,3}(?:=+|-+)[\t ]*$/.test(lines[index + 1]); + if ((isAtx || isSetext) && ++headingCount > 1) return true; + } + return false; +} + +function hasPotentialHtmlBlock(markdown: string): boolean { + return /^ {0,3}<(?:!--|\?|!\[CDATA\[|![A-Za-z]|\/?[A-Za-z][A-Za-z0-9-]*(?:[\t />]|$))/m.test( + markdown, + ); +} + +function needsWholeDocumentContext(markdown: string): boolean { + return ( + hasGlobalMarkdownDefinitions(markdown) || + hasMultipleHeadings(markdown) || + hasPotentialHtmlBlock(markdown) + ); +} + +/** + * 把流式 Markdown 切成已经封口的顶层前缀块与仍在增长的尾块。 + * + * 分界只落在代码围栏 / directive 外的空行,并且下一条非空行必须是新的顶层块; + * 缩进、列表和引用的续行不会被拆开。这样已封口块可由 React.memo 永久复用, + * 后续 token 只会让最后一个块重新走 Markdown 解析。 + */ +export function splitStreamingMarkdownChunks(markdown: string): StreamingMarkdownChunk[] { + if ( + (!markdown.includes('\n\n') && !markdown.includes('\r\n\r\n')) || + needsWholeDocumentContext(markdown) + ) { + return [{ start: 0, content: markdown }]; + } + + const boundaries: number[] = []; + let pendingBoundary: number | null = null; + let fence: FenceState | null = null; + let mathBlock = false; + let directiveDepth = 0; + let lineStart = 0; + + while (lineStart <= markdown.length) { + const newlineIndex = markdown.indexOf('\n', lineStart); + const lineEnd = newlineIndex === -1 ? markdown.length : newlineIndex; + const nextLineStart = newlineIndex === -1 ? markdown.length : newlineIndex + 1; + const line = markdown.slice(lineStart, lineEnd).replace(/\r$/, ''); + const trimmedLine = line.trimStart(); + + if (pendingBoundary !== null && trimmedLine.length > 0) { + if (!isTopLevelContinuation(line)) boundaries.push(pendingBoundary); + pendingBoundary = null; + } + + const fenceRun = readFenceRun(line); + if (fenceRun) { + if (!fence) { + fence = { marker: fenceRun.marker, length: fenceRun.length }; + } else if ( + fenceRun.marker === fence.marker && + fenceRun.length >= fence.length && + fenceRun.rest.trim().length === 0 + ) { + fence = null; + } + } else if (!fence && directiveDepth === 0 && /^ {0,3}\$\$[\t ]*$/.test(line)) { + mathBlock = !mathBlock; + } else if (!fence && !mathBlock && isDirectiveOpen(trimmedLine)) { + directiveDepth += 1; + } else if (!fence && !mathBlock && directiveDepth > 0 && isDirectiveClose(trimmedLine)) { + directiveDepth -= 1; + } + + if ( + !fence && + !mathBlock && + directiveDepth === 0 && + trimmedLine.length === 0 && + newlineIndex !== -1 + ) { + pendingBoundary = nextLineStart; + } + + if (newlineIndex === -1) break; + lineStart = nextLineStart; + } + + if (boundaries.length === 0) return [{ start: 0, content: markdown }]; + + const chunks: StreamingMarkdownChunk[] = []; + let start = 0; + for (const boundary of boundaries) { + if (boundary <= start || boundary > markdown.length) continue; + chunks.push({ start, content: markdown.slice(start, boundary) }); + start = boundary; + } + chunks.push({ start, content: markdown.slice(start) }); + return chunks; +} diff --git a/apps/desktop/src/renderer/styles/globals.css b/apps/desktop/src/renderer/styles/globals.css index 3b4120bcc7..071995ebfb 100644 --- a/apps/desktop/src/renderer/styles/globals.css +++ b/apps/desktop/src/renderer/styles/globals.css @@ -1149,26 +1149,25 @@ body.resizing-pane [data-ghost-webview] { } /* 流式正文分段淡入(§14.4 第五个 sanctioned motion class,2026-08-07): - 每个新词与 inline-code 原子以 --motion-fast 从透明「浮现」。普通聊天不做 - 跨词 stagger,新段的 --wf-delay 为 0ms;已见段 remount 时可用负 delay 恢复 - 原动画进度。不是逐字打字机 —— 段整体已就位,只有 opacity 渐变; + 每个新词与 inline-code 原子以 --motion-fast 从透明「浮现」。词段沿消息级 + 连续时间线错峰进入;已见段 remount 时可用负 delay 恢复原动画进度。 + 不是逐字打字机 —— 段整体已就位,只有 opacity 渐变; pure-opacity 一次性动画,compositor-only。`both` 保证负 delay / remount 续播。 reduced-motion 双保险:JS 侧短路(插件不挂载,span 不进 DOM)+ 下方 reduce 块的 animation: none(摘掉动画连同 both 填充,span 落回自然 opacity 1, 不会钉在透明帧 —— 与「forwards/both 入场动画只能时长归零」那条坑的区别在于 这里 0% 帧之外元素本身没有 opacity 声明)。 */ .stream-word { - animation: stream-word-in var(--motion-fast) ease-out var(--wf-delay, 0ms) both; + animation: stream-word-in var(--motion-fast) var(--motion-ease-out) var(--wf-delay, 0ms) both; } -/* 流式列表圆点淡入(同一 motion class 家族,2026-08-08,Codex Desktop - fadeListDecoration 同构):动画打在 ::marker 上,圆点/序号从 transparent +/* 流式列表圆点淡入(同一 motion class 家族,2026-08-08):动画打在 ::marker 上,圆点/序号从 transparent 浮现,delay 由 rehypeStreamWordFade 写入(与 li 内第一个词共享 timeline 位置,圆点与文字同帧出现)。::marker 只支持 color/font 等少数属性,故用 color 关键帧而不是 opacity。data 属性仅流式挂载;终版渲染无此属性。 */ [data-stream-marker]::marker { - animation: stream-marker-in var(--motion-fast) ease-out var(--wf-delay, 0ms) both; + animation: stream-marker-in var(--motion-fast) var(--motion-ease-out) var(--wf-delay, 0ms) both; } -/* 只定义 0% 帧(Codex 同款):终点回落元素自然颜色,不用 inherit 终帧。 */ +/* 只定义 0% 帧:终点回落元素自然颜色,不用 inherit 终帧。 */ @keyframes stream-marker-in { from { color: transparent; diff --git a/docs/design-rules/DESIGN.md b/docs/design-rules/DESIGN.md index 51950fe352..db2fbd86bd 100644 --- a/docs/design-rules/DESIGN.md +++ b/docs/design-rules/DESIGN.md @@ -684,9 +684,9 @@ _Reference-implementation paths in this table are relative to `apps/desktop/src/ - **Definition**: the seal's signature running animation (two gapped arcs, outer 83/17 + inner 39/61, spinning as a whole at 2.4s linear on an HTML wrapper) is a beloved product signature and is preserved verbatim. What was missing was an ending: when the turn stops, the gaps used to freeze in place and read as "stuck mid-load". The choreography gives the animation a curtain call instead of replacing it: **while still spinning, both arcs close their gaps into full circles** (600ms one-shot `stroke-dasharray` transition — permitted as a one-shot transient per the red lines above); when the turn actually issued a `ghost_call` (fulfilled), a success-colored halo ring then dilates outward once (`summon-seal-halo`, 0.9s scale 1→1.65 + fade) while a ✓ badge pops at the avatar's corner (reusing the Done semantic's sanctioned overshoot curve, same as `status-done-pop`); the spin is removed only after the circles are closed — a spinning full circle is visually static, so the removal is seamless. Semantics stay self-consistent: gap = in progress, full circle = turn finished; ✓/halo appear **only on a fulfilled call** — a cancelled/never-issued call closes neutrally with no success badge (the ring must not fake success). - **Parameter red lines**: close 600ms (`--motion-ease-out`; outside the duration tiers — sanctioned here, do not reuse elsewhere), halo 0.9s one-shot, tick pop 0.3s. Historic messages mount directly on the closed static frame with zero animation; the choreography plays only for a mount that actually witnessed `running` flip false. Halo/badge fill comes from the status-dot done-green exemption (`--card-status-done`); the ✓ glyph uses `--completion-badge-fg` (dark ink both modes, 5.29:1 on the done green — white would be 2.88:1, below the WCAG 1.4.11 non-text 3:1 floor). All keyframes are in the `prefers-reduced-motion` whitelist — reduced motion lands straight on the terminal frame. - **Scope boundary**: this seal only. The closing-ring language must not leak into other spinners or progress indicators. Implementation: `apps/desktop/src/renderer/components/chat/GhostSummonCard.tsx` + `.summon-seal-*` in `globals.css`. -- **Stream-word fade(流式正文分段淡入)** — the fifth sanctioned motion class (approved 2026-08-07, ordinary-chat timing aligned 2026-08-21), **only** for streaming assistant prose in the chat message flow: +- **Stream-word fade(流式正文分段淡入)** — the fifth sanctioned motion class (approved 2026-08-07, ordinary-chat timing aligned 2026-08-25), **only** for streaming assistant prose in the chat message flow: - **Definition**: during streaming, each newly-arrived text **word** and each inline-code atom fades in over `--motion-fast` (150ms, opacity only) instead of popping in — content appears to *surface*, not *type*. This is explicitly **not** the forbidden per-character typewriter: each segment is fully rendered and positioned from its first frame; only opacity ramps. CJK is segmented per word via `Intl.Segmenter`; an inline-code chip keeps its internal structure and fades as one indivisible segment. - - **Architecture red lines**: CSS owns the form, JS owns only the timing — `rehypeStreamWordFade` wraps words and inline-code atoms in `span.stream-word` and writes `--wf-delay`. Ordinary chat has **no cross-segment stagger**: every segment newly received in one render starts at `0ms`, matching Codex Desktop's normal-chat default and preventing later semantic atoms from overtaking queued prose. Segments are keyed by type + stable content matching (same-position matches, text-prefix continuation, and bounded backward matching), not by document index. Render computes a candidate state; only a committed DOM render may publish it from a layout effect. Each segment keeps an absolute start time / remaining delay across re-parse and remount, so already-seen content never replays. The animation itself is a pure-opacity one-shot keyframe in `globals.css` (compositor-only). The final (non-streaming) render carries **zero** wrapper spans — the plugin only mounts while `isStreaming`. + - **Architecture red lines**: CSS owns the form, JS owns only the timing — `rehypeStreamWordFade` wraps words and inline-code atoms in `span.stream-word` and writes `--wf-delay`. Ordinary chat uses one **message-level continuous timeline** across render batches and Markdown chunks: new segments normally start `16ms` apart; once queued transparent wait reaches `96ms`, the step compresses to `4ms`; no segment may remain transparent for more than `160ms`. This keeps a burst from flashing in all at once without allowing a long batch to leave a visible transparent hole. Inline-code atoms and list markers borrow the corresponding prose segment's key and delay, so semantic atoms cannot overtake their text. Segments are keyed by type + stable content matching (same-position matches, text-prefix continuation, and bounded backward matching), not by document index. Render computes a candidate state; only a committed DOM render may publish it from a layout effect. Each segment keeps an absolute start time / remaining delay across re-parse and remount, so already-seen content never replays. The animation itself is a pure-opacity one-shot keyframe in `globals.css` (compositor-only). The final (non-streaming) render carries **zero** wrapper spans — the plugin only mounts while `isStreaming`. - **Degradation**: `prefers-reduced-motion` short-circuits in JS (plugin not mounted, no spans in DOM) *and* the CSS reduce block strips the animation — double coverage, and the CSS side is safe because `.stream-word` has no own opacity declaration to get stuck on. - **Scope boundary**: streaming chat prose only. Inline `code` participates as one atomic segment; fenced `pre` code blocks and KaTeX subtrees are skipped. Must not be applied to static content, titles, toasts, or any non-streamed text. Implementation: `apps/desktop/src/renderer/components/chat/rehypeStreamWordFade.ts` + `.stream-word` in `globals.css`; tests: `rehypeStreamWordFade.test.ts`. - **Session-loading wordmark sheen(会话加载字标扫光)** — the sixth sanctioned motion class (approved 2026-08-10), **only** for Desktop `BrandLoadingMark` while `MessageStream` defers its message tree during a session switch: diff --git a/docs/design-rules/assets/stream-word-fade/pr-3402-burst-contact-sheet.png b/docs/design-rules/assets/stream-word-fade/pr-3402-burst-contact-sheet.png new file mode 100644 index 0000000000..0abad9175a Binary files /dev/null and b/docs/design-rules/assets/stream-word-fade/pr-3402-burst-contact-sheet.png differ