Skip to content
Toggle navigation
P
Projects
G
Groups
S
Snippets
Help
phsl
/
new-api
This project
Loading...
Sign in
Toggle navigation
Go to a project
Project
Repository
Issues
0
Merge Requests
0
Pipelines
Wiki
Snippets
Members
Activity
Graph
Charts
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Charts
Commit
3ea13caa
authored
Jun 02, 2025
by
Apple\Apple
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
💄
feat(playground): Enhance the fade-in animation for the chat
parent
b7b5b6cf
Hide whitespace changes
Inline
Side-by-side
Showing
4 changed files
with
95 additions
and
8 deletions
+95
-8
web/src/components/common/markdown/MarkdownRenderer.js
+10
-3
web/src/components/playground/MessageContent.js
+28
-1
web/src/components/playground/ThinkingContent.js
+22
-0
web/src/utils/rehypeSplitWordsIntoSpans.js
+35
-4
No files found.
web/src/components/common/markdown/MarkdownRenderer.js
View file @
3ea13caa
...
...
@@ -315,6 +315,7 @@ function _MarkdownContent(props) {
content
,
className
,
animated
=
false
,
previousContentLength
=
0
,
}
=
props
;
const
escapedContent
=
useMemo
(()
=>
{
...
...
@@ -336,10 +337,10 @@ function _MarkdownContent(props) {
],
];
if
(
animated
)
{
base
.
push
(
rehypeSplitWordsIntoSpans
);
base
.
push
(
[
rehypeSplitWordsIntoSpans
,
{
previousContentLength
}]
);
}
return
base
;
},
[
animated
]);
},
[
animated
,
previousContentLength
]);
return
(
<
ReactMarkdown
...
...
@@ -463,6 +464,7 @@ export function MarkdownRenderer(props) {
className
,
style
,
animated
=
false
,
previousContentLength
=
0
,
...
otherProps
}
=
props
;
...
...
@@ -498,7 +500,12 @@ export function MarkdownRenderer(props) {
正在渲染
...
<
/div
>
)
:
(
<
MarkdownContent
content
=
{
content
}
className
=
{
className
}
animated
=
{
animated
}
/
>
<
MarkdownContent
content
=
{
content
}
className
=
{
className
}
animated
=
{
animated
}
previousContentLength
=
{
previousContentLength
}
/
>
)}
<
/div
>
);
...
...
web/src/components/playground/MessageContent.js
View file @
3ea13caa
import
React
from
'react'
;
import
React
,
{
useRef
,
useEffect
}
from
'react'
;
import
{
Typography
,
TextArea
,
...
...
@@ -25,6 +25,8 @@ const MessageContent = ({
onEditValueChange
})
=>
{
const
{
t
}
=
useTranslation
();
const
previousContentLengthRef
=
useRef
(
0
);
const
lastContentRef
=
useRef
(
''
);
if
(
message
.
status
===
'error'
)
{
let
errorText
;
...
...
@@ -128,6 +130,14 @@ const MessageContent = ({
const
finalExtractedThinkingContent
=
currentExtractedThinkingContent
;
const
finalDisplayableFinalContent
=
currentDisplayableFinalContent
;
// 流式状态结束时重置
useEffect
(()
=>
{
if
(
!
isThinkingStatus
)
{
previousContentLengthRef
.
current
=
0
;
lastContentRef
.
current
=
''
;
}
},
[
isThinkingStatus
]);
if
(
message
.
role
===
'assistant'
&&
isThinkingStatus
&&
!
finalExtractedThinkingContent
&&
...
...
@@ -243,6 +253,7 @@ const MessageContent = ({
content
=
{
textContent
.
text
}
className
=
{
message
.
role
===
'user'
?
'user-message'
:
''
}
animated
=
{
false
}
previousContentLength
=
{
0
}
/
>
<
/div
>
)}
...
...
@@ -253,12 +264,27 @@ const MessageContent = ({
if
(
typeof
message
.
content
===
'string'
)
{
if
(
message
.
role
===
'assistant'
)
{
if
(
finalDisplayableFinalContent
&&
finalDisplayableFinalContent
.
trim
()
!==
''
)
{
// 获取上一次的内容长度
let
prevLength
=
0
;
if
(
isThinkingStatus
&&
lastContentRef
.
current
)
{
// 只有当前内容包含上一次内容时,才使用上一次的长度
if
(
finalDisplayableFinalContent
.
startsWith
(
lastContentRef
.
current
))
{
prevLength
=
lastContentRef
.
current
.
length
;
}
}
// 更新最后内容的引用
if
(
isThinkingStatus
)
{
lastContentRef
.
current
=
finalDisplayableFinalContent
;
}
return
(
<
div
className
=
"prose prose-xs sm:prose-sm prose-gray max-w-none overflow-x-auto text-xs sm:text-sm"
>
<
MarkdownRenderer
content
=
{
finalDisplayableFinalContent
}
className
=
""
animated
=
{
isThinkingStatus
}
previousContentLength
=
{
prevLength
}
/
>
<
/div
>
);
...
...
@@ -270,6 +296,7 @@ const MessageContent = ({
content
=
{
message
.
content
}
className
=
{
message
.
role
===
'user'
?
'user-message'
:
''
}
animated
=
{
false
}
previousContentLength
=
{
0
}
/
>
<
/div
>
);
...
...
web/src/components/playground/ThinkingContent.js
View file @
3ea13caa
...
...
@@ -13,6 +13,7 @@ const ThinkingContent = ({
})
=>
{
const
{
t
}
=
useTranslation
();
const
scrollRef
=
useRef
(
null
);
const
lastContentRef
=
useRef
(
''
);
if
(
!
finalExtractedThinkingContent
)
return
null
;
...
...
@@ -25,6 +26,26 @@ const ThinkingContent = ({
}
},
[
finalExtractedThinkingContent
,
message
.
isReasoningExpanded
]);
// 流式状态结束时重置
useEffect
(()
=>
{
if
(
!
isThinkingStatus
)
{
lastContentRef
.
current
=
''
;
}
},
[
isThinkingStatus
]);
// 获取上一次的内容长度
let
prevLength
=
0
;
if
(
isThinkingStatus
&&
lastContentRef
.
current
)
{
if
(
finalExtractedThinkingContent
.
startsWith
(
lastContentRef
.
current
))
{
prevLength
=
lastContentRef
.
current
.
length
;
}
}
// 更新最后内容的引用
if
(
isThinkingStatus
)
{
lastContentRef
.
current
=
finalExtractedThinkingContent
;
}
return
(
<
div
className
=
"rounded-xl sm:rounded-2xl mb-2 sm:mb-4 overflow-hidden shadow-sm backdrop-blur-sm"
>
<
div
...
...
@@ -93,6 +114,7 @@ const ThinkingContent = ({
content
=
{
finalExtractedThinkingContent
}
className
=
""
animated
=
{
isThinkingStatus
}
previousContentLength
=
{
prevLength
}
/
>
<
/div
>
<
/div
>
...
...
web/src/utils/rehypeSplitWordsIntoSpans.js
View file @
3ea13caa
...
...
@@ -4,8 +4,12 @@ import { visit } from 'unist-util-visit';
* rehype 插件:将段落等文本节点拆分为逐词 <span>,并添加淡入动画 class。
* 仅在流式渲染阶段使用,避免已渲染文字重复动画。
*/
export
function
rehypeSplitWordsIntoSpans
()
{
export
function
rehypeSplitWordsIntoSpans
(
options
=
{})
{
const
{
previousContentLength
=
0
}
=
options
;
return
(
tree
)
=>
{
let
currentCharCount
=
0
;
// 当前已处理的字符数
visit
(
tree
,
'element'
,
(
node
)
=>
{
if
(
[
'p'
,
'h1'
,
'h2'
,
'h3'
,
'h4'
,
'h5'
,
'h6'
,
'li'
,
'strong'
].
includes
(
node
.
tagName
)
&&
...
...
@@ -18,22 +22,49 @@ export function rehypeSplitWordsIntoSpans() {
// 使用 Intl.Segmenter 精准拆分中英文及标点
const
segmenter
=
new
Intl
.
Segmenter
(
'zh'
,
{
granularity
:
'word'
});
const
segments
=
segmenter
.
segment
(
child
.
value
);
Array
.
from
(
segments
)
.
map
((
seg
)
=>
seg
.
segment
)
.
filter
(
Boolean
)
.
forEach
((
word
)
=>
{
const
wordStartPos
=
currentCharCount
;
const
wordEndPos
=
currentCharCount
+
word
.
length
;
// 判断这个词是否是新增的(在 previousContentLength 之后)
const
isNewContent
=
wordStartPos
>=
previousContentLength
;
newChildren
.
push
({
type
:
'element'
,
tagName
:
'span'
,
properties
:
{
className
:
[
'animate-fade-in'
],
className
:
isNewContent
?
[
'animate-fade-in'
]
:
[
],
},
children
:
[{
type
:
'text'
,
value
:
word
}],
});
currentCharCount
=
wordEndPos
;
});
}
catch
(
_
)
{
// Fallback:如果浏览器不支持 Segmenter,直接输出原文本
newChildren
.
push
(
child
);
// Fallback:如果浏览器不支持 Segmenter
const
textStartPos
=
currentCharCount
;
const
isNewContent
=
textStartPos
>=
previousContentLength
;
if
(
isNewContent
)
{
// 新内容,添加动画
newChildren
.
push
({
type
:
'element'
,
tagName
:
'span'
,
properties
:
{
className
:
[
'animate-fade-in'
],
},
children
:
[{
type
:
'text'
,
value
:
child
.
value
}],
});
}
else
{
// 旧内容,不添加动画
newChildren
.
push
(
child
);
}
currentCharCount
+=
child
.
value
.
length
;
}
}
else
{
newChildren
.
push
(
child
);
...
...
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment