perf: 会话切换去卡顿——历史渐进渲染 + 消息树 memo + 主进程减负
This commit is contained in:
@@ -308,40 +308,17 @@ export class AgentService {
|
||||
|
||||
private emitSwitched(session: AgentSession): void {
|
||||
const state = this.buildState(session)
|
||||
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
|
||||
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
|
||||
this.emit({
|
||||
type: 'session_switched',
|
||||
state,
|
||||
messages: session.messages.map((m) => toCloneable(m)),
|
||||
activeTools: this.collectToolStates(session)
|
||||
messages: session.messages.map((m) => toCloneable(m))
|
||||
})
|
||||
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
|
||||
void this.emitStats()
|
||||
}
|
||||
|
||||
/** 从历史消息中恢复工具状态(用于切换会话后 ToolCard 的参数/结果展示) */
|
||||
private collectToolStates(session: AgentSession): Record<string, unknown> {
|
||||
const tools: Record<string, unknown> = {}
|
||||
for (const message of session.messages) {
|
||||
const m = message as { role?: string; content?: unknown; toolCallId?: unknown; toolName?: unknown; isError?: unknown }
|
||||
if (m.role !== 'assistant' || !Array.isArray(m.content)) continue
|
||||
for (const block of m.content as Array<{ type?: string; id?: string; name?: string; arguments?: unknown }>) {
|
||||
if (block.type === 'toolCall' && block.id) {
|
||||
tools[block.id] = { toolName: block.name ?? 'tool', args: toCloneable(block.arguments), status: 'done' }
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const message of session.messages) {
|
||||
const m = message as { role?: string; toolCallId?: unknown; toolName?: unknown; content?: unknown; isError?: unknown }
|
||||
if (m.role !== 'toolResult' || typeof m.toolCallId !== 'string') continue
|
||||
tools[m.toolCallId] = {
|
||||
...((tools[m.toolCallId] as Record<string, unknown>) ?? { toolName: m.toolName ?? 'tool' }),
|
||||
result: toCloneable(m),
|
||||
status: m.isError ? 'error' : 'done'
|
||||
}
|
||||
}
|
||||
return tools
|
||||
}
|
||||
|
||||
async ensureReady(): Promise<SessionState> {
|
||||
try {
|
||||
const runtime = await this.getRuntime()
|
||||
@@ -498,12 +475,12 @@ export class AgentService {
|
||||
switch (m.role) {
|
||||
case 'user':
|
||||
case 'assistant':
|
||||
byKey.get('messages')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
||||
byKey.get('messages')!.tokens += estimateMessageTokens(message)
|
||||
break
|
||||
case 'toolResult': {
|
||||
const key =
|
||||
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
|
||||
byKey.get(key)!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
||||
byKey.get(key)!.tokens += estimateMessageTokens(message)
|
||||
break
|
||||
}
|
||||
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
|
||||
@@ -511,7 +488,7 @@ export class AgentService {
|
||||
case 'custom':
|
||||
case 'compactionSummary':
|
||||
case 'branchSummary':
|
||||
byKey.get('other')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
||||
byKey.get('other')!.tokens += estimateMessageTokens(message)
|
||||
break
|
||||
default:
|
||||
break
|
||||
@@ -1154,15 +1131,35 @@ function samePath(a: string, b: string): boolean {
|
||||
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
||||
}
|
||||
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
|
||||
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
|
||||
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
|
||||
function toCloneable(value: unknown): unknown {
|
||||
if (value === null || typeof value !== 'object') return value ?? null
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value ?? null))
|
||||
return structuredClone(value)
|
||||
} catch {
|
||||
return null
|
||||
try {
|
||||
return JSON.parse(JSON.stringify(value))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 消息级 token 估算缓存:消息对象落定后不可变(pi 只追加),按引用缓存。
|
||||
* emitStats 在每条 assistant 消息后都会全量重扫,不缓存的话整个会话是 O(n²) 的重复估算。 */
|
||||
const messageTokenCache = new WeakMap<object, number>()
|
||||
|
||||
function estimateMessageTokens(message: object): number {
|
||||
let tokens = messageTokenCache.get(message)
|
||||
if (tokens === undefined) {
|
||||
tokens = estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
||||
messageTokenCache.set(message, tokens)
|
||||
}
|
||||
return tokens
|
||||
}
|
||||
|
||||
function readRole(message: unknown): string {
|
||||
if (message && typeof message === 'object' && 'role' in message) {
|
||||
return String((message as { role: unknown }).role)
|
||||
|
||||
@@ -168,6 +168,14 @@ export default function App(): JSX.Element {
|
||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
||||
}
|
||||
|
||||
// 回溯入口(稳定引用:MessageItem memo 依赖它,内联箭头每次渲染都会失效 memo)
|
||||
const handleRewind = useCallback(
|
||||
(entryId: string, summarize: boolean): void => {
|
||||
navigateTree(entryId, { summarize })
|
||||
},
|
||||
[navigateTree]
|
||||
)
|
||||
|
||||
// 标题栏会话名:优先当前会话名,其次会话列表里的名称/首条消息
|
||||
const activeMeta = state.sessionState?.sessionId
|
||||
? sessions.find((s) => s.id === state.sessionState?.sessionId)
|
||||
@@ -241,7 +249,7 @@ export default function App(): JSX.Element {
|
||||
onDismissError={clearError}
|
||||
onDismissCompactionNotice={dismissCompactionNotice}
|
||||
onAbortCompaction={abortCompaction}
|
||||
onRewind={(entryId, summarize) => navigateTree(entryId, { summarize })}
|
||||
onRewind={handleRewind}
|
||||
onPickSuggestion={handlePickSuggestion}
|
||||
onApplySelection={handleApplySelection}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { ReactNode } from 'react'
|
||||
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
|
||||
import type { ApprovalRequest, ForkPoint } from '@shared/types'
|
||||
@@ -13,6 +13,33 @@ const SUGGESTIONS = [
|
||||
'查询最近一次上架任务的执行结果'
|
||||
]
|
||||
|
||||
/** 渐进渲染参数:切会话首屏只挂载最近的 HISTORY_INITIAL 条消息,
|
||||
* 其余在浏览器空闲时按 HISTORY_BATCH 分批向前补齐——长上下文会话
|
||||
* (几百条消息、上百个 Markdown 解析)一次性挂载会让渲染线程阻塞
|
||||
* 数秒,分批后首帧开销恒定,补齐过程对用户不可感。
|
||||
* 首屏只需覆盖视口(约 3-5 条消息)加少量余量;批次足够小以把
|
||||
* 单次挂载的长任务压到不可感知(生产构建 <50ms)。 */
|
||||
const HISTORY_INITIAL = 24
|
||||
const HISTORY_BATCH = 36
|
||||
|
||||
/** 把「第一个可见消息」回退到回合边界:避免从 assistant 组的中间切开,
|
||||
* 导致首条渲染的回合缺了前半段的思考/工具调用 */
|
||||
function snapToTurnStart(messages: UiMessage[], start: number): number {
|
||||
let i = Math.min(Math.max(0, start), messages.length)
|
||||
while (i > 0 && messages[i]?.role === 'assistant') i -= 1
|
||||
return i
|
||||
}
|
||||
|
||||
/** [from, to) 区间内的用户消息数(回溯点按用户消息序对齐,被隐藏的前缀
|
||||
* 占用的序号要跳过;本地乐观插入不算) */
|
||||
function countUserMessages(messages: UiMessage[], from: number, to: number): number {
|
||||
let n = 0
|
||||
for (let i = from; i < to && i < messages.length; i++) {
|
||||
if (messages[i].role === 'user' && !messages[i].local) n += 1
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
/** 空状态:一行小标题 + 三条建议(纯文本行,点击填入输入框) */
|
||||
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||
return (
|
||||
@@ -37,7 +64,9 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
|
||||
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
||||
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序)。 */
|
||||
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
||||
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
||||
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */
|
||||
function renderTurns(
|
||||
messages: UiMessage[],
|
||||
tools: Record<string, ToolState>,
|
||||
@@ -47,11 +76,13 @@ function renderTurns(
|
||||
pinnedRef: { current: boolean },
|
||||
forkPoints: ForkPoint[],
|
||||
rewinding: boolean,
|
||||
onRewind: (entryId: string, summarize: boolean) => void
|
||||
onRewind: (entryId: string, summarize: boolean) => void,
|
||||
startUserIdx = 0,
|
||||
animate = true
|
||||
): ReactNode[] {
|
||||
const items: ReactNode[] = []
|
||||
let i = 0
|
||||
let userMessageIdx = 0
|
||||
let userMessageIdx = startUserIdx
|
||||
while (i < messages.length) {
|
||||
const message = messages[i]
|
||||
if (message.role === 'assistant') {
|
||||
@@ -89,6 +120,7 @@ function renderTurns(
|
||||
forkEntryId={forkEntryId}
|
||||
rewinding={rewinding}
|
||||
onRewind={onRewind}
|
||||
animate={animate}
|
||||
/>
|
||||
)
|
||||
i += 1
|
||||
@@ -148,6 +180,53 @@ export function ChatView({
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const pinnedRef = useRef(true)
|
||||
|
||||
// ---- 历史渐进填充 ----
|
||||
// 检测「历史被整体替换」(切换会话 / 压缩换血 / 回溯):这些路径会重建全部
|
||||
// 消息 key,而流式追加只改尾部。以首条 key 为代际标记,渲染期直接截断首屏
|
||||
// 挂载量(render-phase setState,React 会丢弃中间帧,全量列表不会被提交)。
|
||||
const historyKey = messages.length > 0 ? messages[0].key : ''
|
||||
const epochRef = useRef<string | null>(null)
|
||||
const [hidden, setHidden] = useState(0)
|
||||
if (epochRef.current !== historyKey) {
|
||||
epochRef.current = historyKey
|
||||
// 切换后回到吸底状态(旧会话里上翻过的 pinned 需要复位)
|
||||
pinnedRef.current = true
|
||||
setHidden(snapToTurnStart(messages, messages.length - HISTORY_INITIAL))
|
||||
}
|
||||
|
||||
// 空闲时分批向前补齐(超时兜底较紧:忙时也持续小步推进,不会停滞)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
useEffect(() => {
|
||||
if (hidden <= 0) return
|
||||
const id = requestIdleCallback(
|
||||
() => setHidden((h) => snapToTurnStart(messagesRef.current, h - HISTORY_BATCH)),
|
||||
{ timeout: 150 }
|
||||
)
|
||||
return () => cancelIdleCallback(id)
|
||||
}, [hidden])
|
||||
|
||||
// 补齐批次在视口上方插入内容:吸底时保持贴底;用户已上翻阅读时锚定视口
|
||||
// (滚动位置随内容增长平移,阅读位置不跳)。cleanup 在下一次更新前捕获
|
||||
// 变更前的滚动状态,作为锚点。代际切换时高度差跨内容无意义,跳过锚定。
|
||||
const fillAnchorRef = useRef<{ scrollTop: number; height: number } | null>(null)
|
||||
const anchorEpochRef = useRef<string | null>(null)
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current
|
||||
if (!el) return
|
||||
const epochChanged = anchorEpochRef.current !== historyKey
|
||||
anchorEpochRef.current = historyKey
|
||||
const anchor = fillAnchorRef.current
|
||||
if (anchor && !epochChanged) {
|
||||
if (pinnedRef.current) el.scrollTop = el.scrollHeight
|
||||
else el.scrollTop = anchor.scrollTop + (el.scrollHeight - anchor.height)
|
||||
}
|
||||
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
|
||||
return () => {
|
||||
fillAnchorRef.current = { scrollTop: el.scrollTop, height: el.scrollHeight }
|
||||
}
|
||||
}, [hidden, historyKey])
|
||||
|
||||
// 用户上滚时停止自动吸底,回到底部附近恢复
|
||||
const handleScroll = (): void => {
|
||||
const el = scrollRef.current
|
||||
@@ -276,8 +355,14 @@ export function ChatView({
|
||||
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||
{hasContent ? (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
||||
{hidden > 0 && (
|
||||
<div className="flex items-center justify-center gap-1.5 text-[12px] text-ink-3">
|
||||
<LuLoaderCircle size={12} className="animate-spin" />
|
||||
正在载入更早的对话…
|
||||
</div>
|
||||
)}
|
||||
{renderTurns(
|
||||
messages,
|
||||
messages.slice(hidden),
|
||||
tools,
|
||||
streaming,
|
||||
turnActive,
|
||||
@@ -285,7 +370,9 @@ export function ChatView({
|
||||
pinnedRef,
|
||||
forkPoints,
|
||||
navigating,
|
||||
onRewind
|
||||
onRewind,
|
||||
countUserMessages(messages, 0, hidden),
|
||||
hidden === 0
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { memo, useEffect, useLayoutEffect, useRef, useState, type ComponentProps, type ReactNode } from 'react'
|
||||
import type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||
import { animateCollapse, animateExpand } from '../lib/anim'
|
||||
import { Markdown } from './Markdown'
|
||||
@@ -123,7 +123,7 @@ function CopyAnswerButton({ text }: { text: string }): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
export function AssistantTurn({
|
||||
function AssistantTurnBase({
|
||||
messages,
|
||||
tools,
|
||||
streaming,
|
||||
@@ -140,75 +140,21 @@ export function AssistantTurn({
|
||||
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
||||
scrollPinnedRef?: { current: boolean }
|
||||
}): ReactNode {
|
||||
const processNodes: ReactNode[] = []
|
||||
// ---- 第一遍:块级描述符(只做类型判断与字符串引用收集,不拆行、不建 JSX) ----
|
||||
// 长会话渐进补齐时每个批次会挂载几十个历史回合,若在首遍就执行
|
||||
// thinkingRows 拆行 / Markdown 解析 / ToolChips 行构建,批次依旧很重。
|
||||
// 重活推迟到「过程区确实要显示」(contentMounted)时再做。
|
||||
type ProcItem =
|
||||
| { k: 'think'; text: string }
|
||||
| { k: 'text'; text: string }
|
||||
| { k: 'tool'; id?: string; name: string }
|
||||
const proc: ProcItem[] = []
|
||||
const answerNodes: ReactNode[] = []
|
||||
const labelCount = new Map<string, number>()
|
||||
let calls = 0
|
||||
let thinking = 0
|
||||
let pendingCalls: ToolCallView[] = []
|
||||
let stream: UiMessage['stream'] | null = null
|
||||
let answerText = ''
|
||||
|
||||
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
||||
const flushRows = (): void => {
|
||||
if (!pendingCalls.length) return
|
||||
const group = pendingCalls
|
||||
pendingCalls = []
|
||||
processNodes.push(
|
||||
<div key={`rows-${processNodes.length}`} className="my-1.5">
|
||||
<ToolChips calls={group} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** 消费一条已完成消息:asAnswer=false 时正文作为过程叙述留在过程区 */
|
||||
const consume = (raw: unknown, asAnswer: boolean): void => {
|
||||
for (const block of readBlocks(raw)) {
|
||||
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
|
||||
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
|
||||
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致。
|
||||
// 以前在这里压成 ToolChips 的单行摘要,每个工具轮次 message_end 时
|
||||
// 展开面板「缩」成一行、下一轮又展开新面板,看起来像过程区被反复收起。
|
||||
flushRows()
|
||||
processNodes.push(
|
||||
<div key={`think-${processNodes.length}`} className="mb-3">
|
||||
<Thinking rows={thinkingRows(thinkingText)} streaming={false} />
|
||||
</div>
|
||||
)
|
||||
thinking += 1
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
flushRows()
|
||||
if (asAnswer) answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
|
||||
const node = (
|
||||
<div key={`${asAnswer ? 'a' : 'p'}-${processNodes.length + answerNodes.length}`} className="my-3">
|
||||
<Markdown text={block.text} />
|
||||
</div>
|
||||
)
|
||||
if (asAnswer) answerNodes.push(node)
|
||||
else processNodes.push(node)
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall') {
|
||||
const name = block.name ?? 'tool'
|
||||
const tool = block.id ? tools[block.id] : undefined
|
||||
if (!tool) {
|
||||
const pending = pendingApprovals.find((a) => a.toolName === name)
|
||||
if (pending) {
|
||||
pendingCalls.push({ id: block.id, name, pendingApproval: { args: pending.args } })
|
||||
calls += 1
|
||||
continue
|
||||
}
|
||||
}
|
||||
const dup = labelCount.get(name) ?? 0
|
||||
labelCount.set(name, dup + 1)
|
||||
pendingCalls.push({ id: block.id, name, tool })
|
||||
calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 流式中的消息 = 答案雏形(最后一条);其余消息全部是过程。
|
||||
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
|
||||
const streamPos = messages.findIndex((m) => m.stream)
|
||||
@@ -219,9 +165,33 @@ export function AssistantTurn({
|
||||
stream = message.stream
|
||||
continue
|
||||
}
|
||||
consume(message.raw, settled && streamPos === -1 && idx === messages.length - 1)
|
||||
const asAnswer = settled && streamPos === -1 && idx === messages.length - 1
|
||||
for (const block of readBlocks(message.raw)) {
|
||||
const thinkingText = block.type === 'thinking' ? (block.text ?? block.thinking) : undefined
|
||||
if (block.type === 'thinking' && typeof thinkingText === 'string' && thinkingText.trim()) {
|
||||
proc.push({ k: 'think', text: thinkingText })
|
||||
thinking += 1
|
||||
continue
|
||||
}
|
||||
if (block.type === 'text' && block.text) {
|
||||
if (asAnswer) {
|
||||
answerText = answerText ? `${answerText}\n\n${block.text}` : block.text
|
||||
answerNodes.push(
|
||||
<div key={`a-${answerNodes.length}`} className="my-3">
|
||||
<Markdown text={block.text} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
proc.push({ k: 'text', text: block.text })
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (block.type === 'toolCall') {
|
||||
proc.push({ k: 'tool', id: block.id, name: block.name ?? 'tool' })
|
||||
calls += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
flushRows()
|
||||
|
||||
const streamHasThinking = stream
|
||||
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
||||
@@ -238,6 +208,11 @@ export function AssistantTurn({
|
||||
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
|
||||
// 时自动重新展开;用户手动收起的不参与自动展开。
|
||||
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
|
||||
// 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染
|
||||
// (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。
|
||||
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
|
||||
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
|
||||
const [contentMounted, setContentMounted] = useState<boolean>(() => live)
|
||||
const autoCollapsedRef = useRef(false)
|
||||
const prevLiveRef = useRef(live)
|
||||
useEffect(() => {
|
||||
@@ -254,10 +229,58 @@ export function AssistantTurn({
|
||||
}
|
||||
if (!prev && live && autoCollapsedRef.current) {
|
||||
autoCollapsedRef.current = false
|
||||
setContentMounted(true)
|
||||
setCollapsed(false)
|
||||
}
|
||||
}, [live, scrollPinnedRef])
|
||||
|
||||
// ---- 第二遍:按需物化过程区(仅在内容已挂载时执行重活) ----
|
||||
const processNodes: ReactNode[] = []
|
||||
if (contentMounted) {
|
||||
/** 连续的工具行作为一组裸行铺出(组内保留顺序),不缩进 */
|
||||
let pendingCalls: ToolCallView[] = []
|
||||
const flushRows = (): void => {
|
||||
if (!pendingCalls.length) return
|
||||
const group = pendingCalls
|
||||
pendingCalls = []
|
||||
processNodes.push(
|
||||
<div key={`rows-${processNodes.length}`} className="my-1.5">
|
||||
<ToolChips calls={group} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
for (const item of proc) {
|
||||
if (item.k === 'think') {
|
||||
// 已完成的思考以展开的 Thinking 面板留在过程区——与流式形态一致
|
||||
flushRows()
|
||||
processNodes.push(
|
||||
<div key={`think-${processNodes.length}`} className="mb-3">
|
||||
<Thinking rows={thinkingRows(item.text)} streaming={false} />
|
||||
</div>
|
||||
)
|
||||
} else if (item.k === 'text') {
|
||||
// 干活过程中模型穿插写的叙述性正文,样式与普通正文一致
|
||||
flushRows()
|
||||
processNodes.push(
|
||||
<div key={`p-${processNodes.length}`} className="my-3">
|
||||
<Markdown text={item.text} />
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
const tool = item.id ? tools[item.id] : undefined
|
||||
if (!tool) {
|
||||
const pending = pendingApprovals.find((a) => a.toolName === item.name)
|
||||
if (pending) {
|
||||
pendingCalls.push({ id: item.id, name: item.name, pendingApproval: { args: pending.args } })
|
||||
continue
|
||||
}
|
||||
}
|
||||
pendingCalls.push({ id: item.id, name: item.name, tool })
|
||||
}
|
||||
}
|
||||
flushRows()
|
||||
}
|
||||
|
||||
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
|
||||
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
|
||||
// 首帧直接落位(历史回合默认收起),不播动画。
|
||||
@@ -340,6 +363,7 @@ export function AssistantTurn({
|
||||
aria-expanded={!collapsed}
|
||||
onClick={() => {
|
||||
autoCollapsedRef.current = false
|
||||
setContentMounted(true)
|
||||
setCollapsed((v) => !v)
|
||||
}}
|
||||
className="-mx-1.5 flex h-7 w-fit items-center gap-1.5 rounded-control px-1.5 text-[12.5px] text-ink-3 transition-colors hover:bg-hover hover:text-ink-2"
|
||||
@@ -385,9 +409,10 @@ export function AssistantTurn({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
|
||||
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect)。
|
||||
未展开过的历史回合不挂载内容(懒渲染),首展时随本组件 state 更新入树 */}
|
||||
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
||||
{processNodes}
|
||||
{contentMounted ? processNodes : null}
|
||||
</div>
|
||||
|
||||
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||
@@ -468,8 +493,8 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
|
||||
}
|
||||
|
||||
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
|
||||
function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
|
||||
const ref = useEntrance<HTMLDivElement>()
|
||||
function SystemNotice({ message, animate = true }: { message: UiMessage; animate?: boolean }): ReactNode | null {
|
||||
const ref = useEntrance<HTMLDivElement>(animate)
|
||||
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
|
||||
const summary = typeof raw.summary === 'string' ? raw.summary : ''
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
@@ -501,7 +526,7 @@ function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
|
||||
)
|
||||
}
|
||||
|
||||
export function MessageItem({
|
||||
function MessageItemBase({
|
||||
message,
|
||||
tools,
|
||||
streaming,
|
||||
@@ -510,7 +535,8 @@ export function MessageItem({
|
||||
scrollPinnedRef,
|
||||
forkEntryId,
|
||||
rewinding,
|
||||
onRewind
|
||||
onRewind,
|
||||
animate = true
|
||||
}: {
|
||||
message: UiMessage
|
||||
tools: Record<string, ToolState>
|
||||
@@ -523,11 +549,13 @@ export function MessageItem({
|
||||
/** 分支回溯进行中(禁用回溯入口) */
|
||||
rewinding?: boolean
|
||||
onRewind?: (entryId: string, summarize: boolean) => void
|
||||
/** 渐进填充历史时不播入场动画 */
|
||||
animate?: boolean
|
||||
}): ReactNode | null {
|
||||
const ref = useEntrance<HTMLDivElement>()
|
||||
const ref = useEntrance<HTMLDivElement>(animate)
|
||||
|
||||
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
|
||||
return <SystemNotice message={message} />
|
||||
return <SystemNotice message={message} animate={animate} />
|
||||
}
|
||||
|
||||
if (message.role === 'user') {
|
||||
@@ -565,3 +593,69 @@ export function MessageItem({
|
||||
// 其他角色(custom / notification 等)暂不渲染
|
||||
return null
|
||||
}
|
||||
|
||||
// ---- memo ----
|
||||
// ChatView 在每次流式刷新(delta 合批约 40ms 一次)都会重跑 renderTurns,
|
||||
// 重建整棵消息列表的 React 元素。没有 memo 时所有历史回合都会跟着重渲染
|
||||
// (重跑块扫描、重建 ToolChips 行、diff Markdown vdom)——长会话下流式
|
||||
// 也会越写越卡。比较器按「本回合实际引用的东西」判断:消息对象按引用相等
|
||||
// (reducer 只替换变更的消息),tools 只比较本回合 toolCall 引用到的条目。
|
||||
|
||||
/** messages 数组按元素引用相等(renderTurns 每次 slice 出新数组,引用比较无意义) */
|
||||
function sameMessages(prev: UiMessage[], next: UiMessage[]): boolean {
|
||||
if (prev === next) return true
|
||||
if (prev.length !== next.length) return false
|
||||
for (let i = 0; i < prev.length; i++) {
|
||||
if (prev[i] !== next[i]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** tools 记录中本回合引用的条目是否有变化(未引用的变化不需要重渲染) */
|
||||
function referencedToolChanged(
|
||||
prevTools: Record<string, ToolState>,
|
||||
nextTools: Record<string, ToolState>,
|
||||
messages: UiMessage[]
|
||||
): boolean {
|
||||
for (const m of messages) {
|
||||
for (const block of readBlocks(m.raw)) {
|
||||
if (block.type === 'toolCall' && block.id && prevTools[block.id] !== nextTools[block.id]) return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
function sameApprovals(prev: ApprovalRequest[], next: ApprovalRequest[]): boolean {
|
||||
if (prev === next) return true
|
||||
if (prev.length === 0 && next.length === 0) return true
|
||||
return false
|
||||
}
|
||||
|
||||
function sameTurnProps(
|
||||
prev: ComponentProps<typeof AssistantTurnBase>,
|
||||
next: ComponentProps<typeof AssistantTurnBase>
|
||||
): boolean {
|
||||
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
|
||||
if (!sameApprovals(prev.pendingApprovals, next.pendingApprovals)) return false
|
||||
if (!sameMessages(prev.messages, next.messages)) return false
|
||||
if (prev.tools !== next.tools && referencedToolChanged(prev.tools, next.tools, next.messages)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
function sameItemProps(
|
||||
prev: ComponentProps<typeof MessageItemBase>,
|
||||
next: ComponentProps<typeof MessageItemBase>
|
||||
): boolean {
|
||||
if (prev.message !== next.message) return false
|
||||
if (prev.streaming !== next.streaming || prev.turnActive !== next.turnActive) return false
|
||||
if (prev.forkEntryId !== next.forkEntryId || prev.rewinding !== next.rewinding) return false
|
||||
if (prev.onRewind !== next.onRewind || prev.animate !== next.animate) return false
|
||||
if (!sameApprovals(prev.pendingApprovals ?? [], next.pendingApprovals ?? [])) return false
|
||||
if (prev.tools !== next.tools && next.message.role === 'assistant') {
|
||||
if (referencedToolChanged(prev.tools, next.tools, [next.message])) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const AssistantTurn = memo(AssistantTurnBase, sameTurnProps)
|
||||
export const MessageItem = memo(MessageItemBase, sameItemProps)
|
||||
|
||||
@@ -2,12 +2,16 @@ import { useEffect, useRef } from 'react'
|
||||
import type { RefObject } from 'react'
|
||||
import { animateEntrance } from '../lib/anim'
|
||||
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
|
||||
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
|
||||
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
|
||||
* 之后 animate 变化不追溯触发。 */
|
||||
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
|
||||
const ref = useRef<T | null>(null)
|
||||
const animateRef = useRef(animate)
|
||||
useEffect(() => {
|
||||
const el = ref.current
|
||||
if (el) animateEntrance(el)
|
||||
if (el && animateRef.current) animateEntrance(el)
|
||||
}, [])
|
||||
return ref
|
||||
}
|
||||
|
||||
@@ -308,7 +308,7 @@ export interface BrowserImportResult {
|
||||
export type AgentUiEvent =
|
||||
| { type: 'session_ready'; state: SessionState }
|
||||
| { type: 'session_error'; message: string }
|
||||
| { type: 'session_switched'; state: SessionState; messages: unknown[]; activeTools: Record<string, unknown> }
|
||||
| { type: 'session_switched'; state: SessionState; messages: unknown[] }
|
||||
| { type: 'state'; state: SessionState }
|
||||
| { type: 'approval_request'; request: ApprovalRequest }
|
||||
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
||||
|
||||
Reference in New Issue
Block a user