feat: 思考预览改为 ZCode 式——折叠显最新一行,展开限高滚动吸底
This commit is contained in:
@@ -29,6 +29,16 @@ function readBlocks(raw: unknown): ContentBlock[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/** 折叠态紧凑行用的最新思考行(取最后一个非空行,超长截断) */
|
||||
function lastThinkingLine(text: string): string {
|
||||
const lines = text.split('\n')
|
||||
for (let i = lines.length - 1; i >= 0; i--) {
|
||||
const line = lines[i].trim()
|
||||
if (line) return line.length > 120 ? `${line.slice(0, 120)}…` : line
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 工作计时:working=true 起表,结束后冻结读数 */
|
||||
function useWorkingSeconds(working: boolean): string {
|
||||
const startRef = useRef<number | null>(null)
|
||||
@@ -197,22 +207,20 @@ export function AssistantTurn({
|
||||
const workedFor = useWorkingSeconds(turnActive)
|
||||
const headerLabel = workedFor || `${calls} 次工具调用${thinking ? `,${thinking} 条消息` : ''}`
|
||||
|
||||
// 流式分段:思考段属过程(折叠时隐藏),正文段属答案;均按真实时序渲染
|
||||
const showStreamThinking = !collapsed || !hasProcess
|
||||
// 流式分段:思考段属过程(过程区折叠时隐藏,由头部下方的紧凑行代为展示
|
||||
// 最新一行),正文段属答案;均按真实时序渲染。思考块保持挂载(仅 display:none),
|
||||
// 折叠/展开往返不重置内部计时的滚动状态。
|
||||
const streamNodes: ReactNode[] = []
|
||||
let visibleThinkingRows = 0
|
||||
if (stream) {
|
||||
const segments = stream.segments
|
||||
segments.forEach((seg, i) => {
|
||||
if (seg.kind === 'thinking') {
|
||||
if (!showStreamThinking) return
|
||||
const rows = thinkingRows(seg.text)
|
||||
if (rows.length === 0) return
|
||||
visibleThinkingRows += rows.length
|
||||
// 该思考段之后已有正文段在流:思考已完成,表头从「思考中…」切回「思考过程」
|
||||
// 该思考段之后已有正文段在流:思考已完成,表头从「正在思考」切回
|
||||
const followedByText = segments.some((s, k) => k > i && s.kind === 'text' && s.text.length > 0)
|
||||
streamNodes.push(
|
||||
<div key={`seg-${i}`} className="mb-3">
|
||||
<div key={`seg-${i}`} className={collapsed && hasProcess ? 'hidden' : 'mb-3'}>
|
||||
<Thinking rows={rows} streaming={streaming && !followedByText} />
|
||||
</div>
|
||||
)
|
||||
@@ -227,10 +235,10 @@ export function AssistantTurn({
|
||||
}
|
||||
})
|
||||
}
|
||||
// 折叠时思考不可见 → 头部提示「思考中…」(仅当前正在流思考段时)
|
||||
// 过程区折叠时的「正在思考」紧凑行:展示最新一行思考(ZCode 式),随流式刷新
|
||||
const lastSeg = stream?.segments[stream.segments.length - 1]
|
||||
const hiddenThinking =
|
||||
collapsed && hasProcess && Boolean(stream && lastSeg?.kind === 'thinking' && lastSeg.text.trim())
|
||||
const compactThinking =
|
||||
collapsed && hasProcess && stream && lastSeg?.kind === 'thinking' ? lastThinkingLine(lastSeg.text) : null
|
||||
|
||||
return (
|
||||
<div className="min-w-0 max-w-full">
|
||||
@@ -256,14 +264,33 @@ export function AssistantTurn({
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
<span className="tabular-nums">
|
||||
{headerLabel}
|
||||
{hiddenThinking ? ',思考中…' : ''}
|
||||
</span>
|
||||
<span className="tabular-nums">{headerLabel}</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 折叠时的实时思考预览:仅展示最新一行,随流式滚动前进 */}
|
||||
{compactThinking !== null && (
|
||||
<div className="flex min-w-0 items-center gap-2 py-1.5">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="var(--ink-3)" className="shrink-0">
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
</svg>
|
||||
<span
|
||||
className="bg-clip-text whitespace-nowrap text-[12.5px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer-text 1.4s linear infinite'
|
||||
}}
|
||||
>
|
||||
正在思考
|
||||
</span>
|
||||
{compactThinking && (
|
||||
<span className="min-w-0 truncate text-[12.5px] text-ink-3">· {compactThinking}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
|
||||
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
||||
{processNodes}
|
||||
@@ -272,7 +299,7 @@ export function AssistantTurn({
|
||||
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||
{answerNodes}
|
||||
{streamNodes.length > 0 && <div>{streamNodes}</div>}
|
||||
{stream && streamNodes.length === 0 && visibleThinkingRows === 0 && showStreamThinking && (
|
||||
{stream && streamNodes.length === 0 && !(collapsed && hasProcess) && (
|
||||
<Loading label="思考中" variant="Dots" />
|
||||
)}
|
||||
{/* 回合进行中但没有流式占位:工具执行或自动压缩的静默间隙,给一个存活指示 */}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { useLayoutEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
/** 可展开的思考轨迹(beautifului.dev Thinking · Reasoning 形态)。
|
||||
* 流式期间自动展开并 shimmer「思考中…」;结束后收起为「思考过程」,
|
||||
/** 可展开的思考轨迹(ZCode 式实时预览)。
|
||||
* 流式中:展开区为限高滚动预览——新内容在尾部并自动吸底,用户上滚即取消
|
||||
* 吸底(滚回底部恢复),表头显示「正在思考 · 持续了 N 秒」;收起时表头
|
||||
* 改为「正在思考 · 最新一行」持续刷新。结束后收起为「思考过程 · 持续了 N 秒」,
|
||||
* 点击表头可手动展开/收起。rows 由真实思考文本逐行喂入。 */
|
||||
export function Thinking({
|
||||
rows,
|
||||
streaming,
|
||||
activeLabel = '思考中…',
|
||||
activeLabel = '正在思考',
|
||||
doneLabel = '思考过程'
|
||||
}: {
|
||||
rows: { primary: string }[]
|
||||
@@ -18,25 +20,49 @@ export function Thinking({
|
||||
const [manualExpanded, setManualExpanded] = useState<boolean | null>(null)
|
||||
const expanded = manualExpanded ?? streaming
|
||||
|
||||
// 已思考时长:流式期间每秒递增;分段间歇(streaming 抖动)暂停但不清零
|
||||
const [secs, setSecs] = useState(0)
|
||||
useEffect(() => {
|
||||
if (!streaming) return
|
||||
const t = setInterval(() => setSecs((s) => s + 1), 1000)
|
||||
return () => clearInterval(t)
|
||||
}, [streaming])
|
||||
|
||||
// 滚动吸底:新内容到达且当前贴着底部时跟随;用户上滚即取消,滚回底部恢复。
|
||||
// 收起时重置为吸底,重新展开直接落在最新内容。
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pinnedRef = useRef(true)
|
||||
const handleScroll = (): void => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
pinnedRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!expanded) {
|
||||
pinnedRef.current = true
|
||||
return
|
||||
}
|
||||
const el = scrollRef.current
|
||||
if (el && pinnedRef.current) el.scrollTop = el.scrollHeight
|
||||
}, [rows, expanded])
|
||||
|
||||
// 流式轨迹的行必须「随到随显」:fade-up 的 both+delay 会让新行在延迟期间
|
||||
// 透明但占高,流式期间底部因此持续留出一大片空白。交错入场只保留给
|
||||
// 非流式一次性渲染的静态轨迹(挂载时决定,之后不变更,避免整段重播动画)。
|
||||
const streamedMountRef = useRef(streaming)
|
||||
|
||||
const traceRef = useRef<HTMLDivElement>(null)
|
||||
const [lineHeight, setLineHeight] = useState(0)
|
||||
useLayoutEffect(() => {
|
||||
if (traceRef.current) setLineHeight(traceRef.current.offsetHeight)
|
||||
}, [rows, expanded])
|
||||
const statusWord = streaming ? activeLabel : expanded ? '思考' : doneLabel
|
||||
const latest = rows.length > 0 ? rows[rows.length - 1].primary : ''
|
||||
const suffix = expanded ? (secs > 0 ? `持续了 ${secs} 秒` : '') : streaming ? latest : ''
|
||||
|
||||
return (
|
||||
<div className="flex w-full max-w-95 flex-col" style={{ minHeight: expanded ? 60 : undefined }}>
|
||||
{/* 表头:状态词 + 旋转箭头 */}
|
||||
<div className="flex w-full max-w-95 flex-col">
|
||||
{/* 表头:状态词 + 后缀(时长 / 最新一行)+ 旋转箭头 */}
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setManualExpanded((current) => !(current ?? streaming))}
|
||||
className="-mx-1.5 flex w-fit items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||
className="-mx-1.5 flex w-fit min-w-0 max-w-full items-center gap-2 rounded-control px-1.5 py-1 transition-colors hover:bg-hover-2"
|
||||
>
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill={streaming ? 'var(--ink-2)' : 'var(--ink-3)'}>
|
||||
<path d="M12 2l2.4 7.2L22 12l-7.6 2.8L12 22l-2.4-7.2L2 12l7.6-2.8z" />
|
||||
@@ -44,21 +70,28 @@ export function Thinking({
|
||||
<span role="status" className="contents">
|
||||
{streaming ? (
|
||||
<span
|
||||
className="bg-clip-text text-[13px] font-medium whitespace-nowrap text-transparent"
|
||||
className="bg-clip-text whitespace-nowrap text-[13px] font-medium text-transparent"
|
||||
style={{
|
||||
backgroundImage: 'linear-gradient(90deg, var(--ink-3) 35%, var(--ink) 50%, var(--ink-3) 65%)',
|
||||
backgroundSize: '200% 100%',
|
||||
animation: 'shimmer-text 1.4s linear infinite'
|
||||
}}
|
||||
>
|
||||
{activeLabel}
|
||||
{statusWord}
|
||||
</span>
|
||||
) : (
|
||||
<span
|
||||
className="text-[13px] font-medium whitespace-nowrap text-ink-2"
|
||||
className="whitespace-nowrap text-[13px] font-medium text-ink-2"
|
||||
style={{ animation: 'fade-in 350ms ease-out both' }}
|
||||
>
|
||||
{doneLabel}
|
||||
{statusWord}
|
||||
</span>
|
||||
)}
|
||||
{suffix && (
|
||||
<span
|
||||
className={`min-w-0 text-[12.5px] text-ink-3 ${expanded ? 'whitespace-nowrap tabular-nums' : 'truncate'}`}
|
||||
>
|
||||
· {suffix}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@@ -71,14 +104,14 @@ export function Thinking({
|
||||
strokeWidth="2.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="transition-transform"
|
||||
className="shrink-0 transition-transform"
|
||||
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0)' }}
|
||||
>
|
||||
<path d="M6 9l6 6 6-6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* 展开轨迹:左侧 hairline + 逐行 fade-up */}
|
||||
{/* 展开轨迹:限高滚动预览(最新在尾部),左侧 hairline 随内容延伸 */}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows,opacity]"
|
||||
style={{
|
||||
@@ -87,14 +120,10 @@ export function Thinking({
|
||||
transitionTimingFunction: 'ease'
|
||||
}}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<div className="relative mt-1 ml-[5px] pl-4">
|
||||
<span
|
||||
aria-hidden
|
||||
className="absolute left-[3px] w-px bg-line"
|
||||
style={{ top: -8, height: lineHeight ? lineHeight - 2 : 0, transition: 'height 400ms ease' }}
|
||||
/>
|
||||
<div ref={traceRef} className="flex flex-col gap-1 py-1">
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="mt-1 ml-[5px] max-h-[220px] overflow-y-auto">
|
||||
<div className="relative flex flex-col gap-1 py-1 pl-4">
|
||||
<span aria-hidden className="absolute bottom-0 left-[3px] top-0 w-px bg-line" />
|
||||
{rows.map((row, i) => (
|
||||
<div
|
||||
key={i}
|
||||
@@ -105,7 +134,9 @@ export function Thinking({
|
||||
: `fade-up 400ms ease ${Math.min(i, 8) * 120}ms both`
|
||||
}}
|
||||
>
|
||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">{row.primary}</span>
|
||||
<span className="min-w-0 text-[12.5px] leading-relaxed break-words text-ink-2">
|
||||
{row.primary}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user