Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 43 additions & 48 deletions apps/desktop/src/renderer/components/chat/MarkdownRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<string | null>(null);
// 远程入方向:远程会话里 markdown 的图片/音频 URL 指向远端机器,按来源改写到
// cindy-remote-media://(device 经 OSS 中转、ssh 经 file-service 落盘缓存)。本地
Expand All @@ -1718,24 +1704,13 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
} | null>(null);
// model-local chip/link click → in-app 3D preview (ModelLightbox, local mode).
const [modelLightboxPath, setModelLightboxPath] = useState<string | null>(null);
const streamFadeComponents = useMemo<Components>(
() =>
wordFadeState
? {
span: (props) => <StreamFadeSpan {...props} wordFadeState={wordFadeState} />,
}
: {},
[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
// recreated rarely.
const components = useMemo<Components>(
() => ({
...baseComponents,
...streamFadeComponents,
// doc-mode anchor: emitSourceLines=true 时把 baseComponents 里的 block
// renderer 整体替换成带 data-source-line 注入的版本。chat 调用方不传 prop
// → 默认 false → 整段是 falsy 短路, components 对象与之前完全一致。
Expand Down Expand Up @@ -1890,21 +1865,41 @@ export const MarkdownRenderer = memo(function MarkdownRenderer({
currentSessionTitle,
allowPrivilegedLinks,
remoteMediaOrigin,
streamFadeComponents,
],
);

return (
<div className="msg-markdown select-text">
<ReactMarkdown
remarkPlugins={allowPrivilegedLinks ? REMARK_PLUGINS_PRIVILEGED : REMARK_PLUGINS}
rehypePlugins={rehypePlugins}
components={components}
urlTransform={allowPrivilegedLinks ? trustedUrlTransform : previewSafeUrlTransform}
skipHtml
>
{renderedContent}
</ReactMarkdown>
{isStreaming ? (
streamingChunks.map((chunk) => (
<StreamingMarkdownChunk
key={chunk.start}
sourceKey={String(chunk.start)}
content={chunk.content}
remarkPlugins={
allowPrivilegedLinks ? REMARK_PLUGINS_PRIVILEGED : REMARK_PLUGINS
}
rehypePlugins={REHYPE_PLUGINS}
components={components}
urlTransform={
allowPrivilegedLinks ? trustedUrlTransform : previewSafeUrlTransform
}
wordFadeState={wordFadeState}
emitSourceLines={emitSourceLines}
wholeDocument={streamingChunks.length === 1}
/>
))
) : (
<ReactMarkdown
remarkPlugins={allowPrivilegedLinks ? REMARK_PLUGINS_PRIVILEGED : REMARK_PLUGINS}
rehypePlugins={REHYPE_PLUGINS}
components={components}
urlTransform={allowPrivilegedLinks ? trustedUrlTransform : previewSafeUrlTransform}
skipHtml
>
{renderedContent}
</ReactMarkdown>
)}
{lightboxSrc && (
<ImageLightbox src={lightboxSrc} onClose={() => setLightboxSrc(null)} />
)}
Expand Down
92 changes: 89 additions & 3 deletions apps/desktop/src/renderer/components/chat/StreamFadeSpan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,旧动画不能污染新段。
Expand All @@ -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<HTMLSpanElement>) => {
onAnimationEnd?.(event);
if (
Expand All @@ -35,16 +70,67 @@ export function StreamFadeSpan({
return;
}
markWordFadeSettled(wordFadeState, wordFadeKey);
event.currentTarget.classList.remove('stream-word');
};

return (
<span
key={wordFadeKey}
ref={attachSpan}
{...props}
className={settled ? removeStreamWordClass(props.className) : props.className}
data-wf-key={wordFadeKey}
onAnimationEnd={handleAnimationEnd}
>
{children}
</span>
);
}

/** 列表圆点与 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<HTMLLIElement>) => {
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 (
<li
ref={attachListItem}
{...markerProps}
data-stream-marker={settled ? undefined : markerProps['data-stream-marker']}
data-wf-key={wordFadeKey}
onAnimationEnd={handleAnimationEnd}
>
{children}
</li>
);
}
104 changes: 104 additions & 0 deletions apps/desktop/src/renderer/components/chat/StreamingMarkdownChunk.tsx
Original file line number Diff line number Diff line change
@@ -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<Components>(() => {
if (!sourceWordFadeState) return components;
return {
...components,
span: (props) => (
<StreamFadeSpan {...props} wordFadeState={sourceWordFadeState} />
),
li: ({ node, ...props }) => (
<StreamFadeListItem
{...props}
{...(emitSourceLines ? sourceLineAttr(node) : {})}
node={node}
wordFadeState={sourceWordFadeState}
/>
),
};
}, [components, emitSourceLines, sourceWordFadeState]);

return (
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={wordFade?.plugins ?? rehypePlugins}
components={chunkComponents}
urlTransform={urlTransform}
skipHtml
>
{content}
</ReactMarkdown>
);
});
Loading