perf: 会话切换去卡顿——历史渐进渲染 + 消息树 memo + 主进程减负
This commit is contained in:
@@ -308,40 +308,17 @@ export class AgentService {
|
|||||||
|
|
||||||
private emitSwitched(session: AgentSession): void {
|
private emitSwitched(session: AgentSession): void {
|
||||||
const state = this.buildState(session)
|
const state = this.buildState(session)
|
||||||
|
// 工具状态不再随事件重发:渲染层用 historyToTools 从 messages 自行重建,
|
||||||
|
// 重复克隆一遍全量工具参数/结果是长会话切换卡顿的大头(历史被序列化两次)
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'session_switched',
|
type: 'session_switched',
|
||||||
state,
|
state,
|
||||||
messages: session.messages.map((m) => toCloneable(m)),
|
messages: session.messages.map((m) => toCloneable(m))
|
||||||
activeTools: this.collectToolStates(session)
|
|
||||||
})
|
})
|
||||||
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
|
// 切换/回溯后立刻带上一次统计(上下文容量、缓存命中率),Usage Card 无需等第一回合
|
||||||
void this.emitStats()
|
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> {
|
async ensureReady(): Promise<SessionState> {
|
||||||
try {
|
try {
|
||||||
const runtime = await this.getRuntime()
|
const runtime = await this.getRuntime()
|
||||||
@@ -498,12 +475,12 @@ export class AgentService {
|
|||||||
switch (m.role) {
|
switch (m.role) {
|
||||||
case 'user':
|
case 'user':
|
||||||
case 'assistant':
|
case 'assistant':
|
||||||
byKey.get('messages')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
byKey.get('messages')!.tokens += estimateMessageTokens(message)
|
||||||
break
|
break
|
||||||
case 'toolResult': {
|
case 'toolResult': {
|
||||||
const key =
|
const key =
|
||||||
typeof m.toolName === 'string' && CUSTOM_TOOL_NAMES.has(m.toolName) ? 'customTools' : 'builtinTools'
|
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
|
break
|
||||||
}
|
}
|
||||||
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
|
// bash 执行、扩展注入、压缩/分支摘要等注入上下文的消息
|
||||||
@@ -511,7 +488,7 @@ export class AgentService {
|
|||||||
case 'custom':
|
case 'custom':
|
||||||
case 'compactionSummary':
|
case 'compactionSummary':
|
||||||
case 'branchSummary':
|
case 'branchSummary':
|
||||||
byKey.get('other')!.tokens += estimateTokens(message as Parameters<typeof estimateTokens>[0])
|
byKey.get('other')!.tokens += estimateMessageTokens(message)
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
break
|
break
|
||||||
@@ -1154,13 +1131,33 @@ function samePath(a: string, b: string): boolean {
|
|||||||
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
return a.replace(/\\/g, '/').toLowerCase() === b.replace(/\\/g, '/').toLowerCase()
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 事件消息对象转为可结构化克隆的普通对象(剥离 undefined/函数/循环引用) */
|
/** 事件消息对象转为可结构化克隆的普通对象(剥离函数/循环引用等不可克隆值)。
|
||||||
|
* structuredClone 远快于 JSON 往返——切换会话时整段历史都要过一遍这里,
|
||||||
|
* 长上下文下 JSON 序列化+反序列化是主进程最重的同步开销。 */
|
||||||
function toCloneable(value: unknown): unknown {
|
function toCloneable(value: unknown): unknown {
|
||||||
|
if (value === null || typeof value !== 'object') return value ?? null
|
||||||
try {
|
try {
|
||||||
return JSON.parse(JSON.stringify(value ?? null))
|
return structuredClone(value)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
return JSON.parse(JSON.stringify(value))
|
||||||
} catch {
|
} catch {
|
||||||
return null
|
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 {
|
function readRole(message: unknown): string {
|
||||||
|
|||||||
@@ -168,6 +168,14 @@ export default function App(): JSX.Element {
|
|||||||
send(`${instruction}:\n\n> ${selection.split('\n').join('\n> ')}`)
|
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
|
const activeMeta = state.sessionState?.sessionId
|
||||||
? sessions.find((s) => s.id === state.sessionState?.sessionId)
|
? sessions.find((s) => s.id === state.sessionState?.sessionId)
|
||||||
@@ -241,7 +249,7 @@ export default function App(): JSX.Element {
|
|||||||
onDismissError={clearError}
|
onDismissError={clearError}
|
||||||
onDismissCompactionNotice={dismissCompactionNotice}
|
onDismissCompactionNotice={dismissCompactionNotice}
|
||||||
onAbortCompaction={abortCompaction}
|
onAbortCompaction={abortCompaction}
|
||||||
onRewind={(entryId, summarize) => navigateTree(entryId, { summarize })}
|
onRewind={handleRewind}
|
||||||
onPickSuggestion={handlePickSuggestion}
|
onPickSuggestion={handlePickSuggestion}
|
||||||
onApplySelection={handleApplySelection}
|
onApplySelection={handleApplySelection}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
|
import { LuFoldVertical, LuLoaderCircle, LuRotateCw, LuTriangleAlert, LuX } from 'react-icons/lu'
|
||||||
import type { ApprovalRequest, ForkPoint } from '@shared/types'
|
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 {
|
function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
||||||
return (
|
return (
|
||||||
@@ -37,7 +64,9 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }): ReactNode {
|
|||||||
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
/** 连续 assistant 消息(pi 每个工具轮次一条)合并为一个回合渲染,
|
||||||
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
|
* 整轮思考 + 工具调用共享一张 beautifului ToolChips 卡。
|
||||||
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
* pinnedRef 向下透传:回合结束时据此判断用户是否位于底部,位于底部才即时折叠。
|
||||||
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序)。 */
|
* forkPoints 与时间线中的用户消息按出现顺序一一对齐(pi getUserMessagesForForking 同序),
|
||||||
|
* startUserIdx 为被渐进填充隐藏的前缀中已占用的用户消息序号。
|
||||||
|
* animate=false(正在补齐历史)时挂载的消息不播入场动画。 */
|
||||||
function renderTurns(
|
function renderTurns(
|
||||||
messages: UiMessage[],
|
messages: UiMessage[],
|
||||||
tools: Record<string, ToolState>,
|
tools: Record<string, ToolState>,
|
||||||
@@ -47,11 +76,13 @@ function renderTurns(
|
|||||||
pinnedRef: { current: boolean },
|
pinnedRef: { current: boolean },
|
||||||
forkPoints: ForkPoint[],
|
forkPoints: ForkPoint[],
|
||||||
rewinding: boolean,
|
rewinding: boolean,
|
||||||
onRewind: (entryId: string, summarize: boolean) => void
|
onRewind: (entryId: string, summarize: boolean) => void,
|
||||||
|
startUserIdx = 0,
|
||||||
|
animate = true
|
||||||
): ReactNode[] {
|
): ReactNode[] {
|
||||||
const items: ReactNode[] = []
|
const items: ReactNode[] = []
|
||||||
let i = 0
|
let i = 0
|
||||||
let userMessageIdx = 0
|
let userMessageIdx = startUserIdx
|
||||||
while (i < messages.length) {
|
while (i < messages.length) {
|
||||||
const message = messages[i]
|
const message = messages[i]
|
||||||
if (message.role === 'assistant') {
|
if (message.role === 'assistant') {
|
||||||
@@ -89,6 +120,7 @@ function renderTurns(
|
|||||||
forkEntryId={forkEntryId}
|
forkEntryId={forkEntryId}
|
||||||
rewinding={rewinding}
|
rewinding={rewinding}
|
||||||
onRewind={onRewind}
|
onRewind={onRewind}
|
||||||
|
animate={animate}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
i += 1
|
i += 1
|
||||||
@@ -148,6 +180,53 @@ export function ChatView({
|
|||||||
const scrollRef = useRef<HTMLDivElement>(null)
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
const pinnedRef = useRef(true)
|
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 handleScroll = (): void => {
|
||||||
const el = scrollRef.current
|
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">
|
<div ref={scrollRef} onScroll={handleScroll} className="min-h-0 flex-1 overflow-y-auto">
|
||||||
{hasContent ? (
|
{hasContent ? (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6 px-6 py-8">
|
<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(
|
{renderTurns(
|
||||||
messages,
|
messages.slice(hidden),
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
turnActive,
|
turnActive,
|
||||||
@@ -285,7 +370,9 @@ export function ChatView({
|
|||||||
pinnedRef,
|
pinnedRef,
|
||||||
forkPoints,
|
forkPoints,
|
||||||
navigating,
|
navigating,
|
||||||
onRewind
|
onRewind,
|
||||||
|
countUserMessages(messages, 0, hidden),
|
||||||
|
hidden === 0
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 type { ApprovalRequest, ToolState, UiMessage } from '../hooks/useAgent'
|
||||||
import { animateCollapse, animateExpand } from '../lib/anim'
|
import { animateCollapse, animateExpand } from '../lib/anim'
|
||||||
import { Markdown } from './Markdown'
|
import { Markdown } from './Markdown'
|
||||||
@@ -123,7 +123,7 @@ function CopyAnswerButton({ text }: { text: string }): ReactNode {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AssistantTurn({
|
function AssistantTurnBase({
|
||||||
messages,
|
messages,
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
@@ -140,75 +140,21 @@ export function AssistantTurn({
|
|||||||
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
* 时执行即时折叠——用户上翻阅读时折叠会猛拽滚动位置/滚动条,保持展开 */
|
||||||
scrollPinnedRef?: { current: boolean }
|
scrollPinnedRef?: { current: boolean }
|
||||||
}): ReactNode {
|
}): 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 answerNodes: ReactNode[] = []
|
||||||
const labelCount = new Map<string, number>()
|
|
||||||
let calls = 0
|
let calls = 0
|
||||||
let thinking = 0
|
let thinking = 0
|
||||||
let pendingCalls: ToolCallView[] = []
|
|
||||||
let stream: UiMessage['stream'] | null = null
|
let stream: UiMessage['stream'] | null = null
|
||||||
let answerText = ''
|
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)且没有流式占位时,最后一条已完成消息的正文才是答案。
|
// 只有回合结束(settled)且没有流式占位时,最后一条已完成消息的正文才是答案。
|
||||||
const streamPos = messages.findIndex((m) => m.stream)
|
const streamPos = messages.findIndex((m) => m.stream)
|
||||||
@@ -219,9 +165,33 @@ export function AssistantTurn({
|
|||||||
stream = message.stream
|
stream = message.stream
|
||||||
continue
|
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
|
const streamHasThinking = stream
|
||||||
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
? stream.segments.some((s) => s.kind === 'thinking' && s.text.trim().length > 0)
|
||||||
@@ -238,6 +208,11 @@ export function AssistantTurn({
|
|||||||
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
|
// 上翻阅读时折叠会猛拽滚动位置,保持展开。因抖动被自动收起的回合在 live 恢复
|
||||||
// 时自动重新展开;用户手动收起的不参与自动展开。
|
// 时自动重新展开;用户手动收起的不参与自动展开。
|
||||||
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
|
const [collapsed, setCollapsed] = useState<boolean>(() => !live && hasProcess)
|
||||||
|
// 过程内容懒挂载:历史回合默认收起,其思考/工具内容在用户首次展开前不渲染
|
||||||
|
// (折叠只是视觉收起,子树照常挂载的话长会话切换仍要全量解析)。
|
||||||
|
// live 回合从一开始就可见,挂载内容;一旦挂载过就保持(再次折叠走 GSAP
|
||||||
|
// 高度动画需要内容在场,且 Thinking 面板内部滚动状态不重置)。
|
||||||
|
const [contentMounted, setContentMounted] = useState<boolean>(() => live)
|
||||||
const autoCollapsedRef = useRef(false)
|
const autoCollapsedRef = useRef(false)
|
||||||
const prevLiveRef = useRef(live)
|
const prevLiveRef = useRef(live)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -254,10 +229,58 @@ export function AssistantTurn({
|
|||||||
}
|
}
|
||||||
if (!prev && live && autoCollapsedRef.current) {
|
if (!prev && live && autoCollapsedRef.current) {
|
||||||
autoCollapsedRef.current = false
|
autoCollapsedRef.current = false
|
||||||
|
setContentMounted(true)
|
||||||
setCollapsed(false)
|
setCollapsed(false)
|
||||||
}
|
}
|
||||||
}, [live, scrollPinnedRef])
|
}, [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 高度 + 透明度补间(可中断续接)。内联高度完全由
|
// 过程区折叠/展开:GSAP 高度 + 透明度补间(可中断续接)。内联高度完全由
|
||||||
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
|
// 动画层持有,React 不渲染 style,避免重渲染时清掉补间中的值。
|
||||||
// 首帧直接落位(历史回合默认收起),不播动画。
|
// 首帧直接落位(历史回合默认收起),不播动画。
|
||||||
@@ -340,6 +363,7 @@ export function AssistantTurn({
|
|||||||
aria-expanded={!collapsed}
|
aria-expanded={!collapsed}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
autoCollapsedRef.current = false
|
autoCollapsedRef.current = false
|
||||||
|
setContentMounted(true)
|
||||||
setCollapsed((v) => !v)
|
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"
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect) */}
|
{/* 过程区:GSAP 高度 + 透明度补间(见上方 useLayoutEffect)。
|
||||||
|
未展开过的历史回合不挂载内容(懒渲染),首展时随本组件 state 更新入树 */}
|
||||||
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
<div ref={processRef} aria-hidden={collapsed && hasProcess} className="overflow-hidden">
|
||||||
{processNodes}
|
{contentMounted ? processNodes : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
{/* 答案区:最终正文 / 流式中的答案雏形,始终可见 */}
|
||||||
@@ -468,8 +493,8 @@ function RewindMenu({ entryId, onRewind }: { entryId: string; onRewind?: (entryI
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
|
/** 压缩 / 分支摘要通知行(pi 会话时间线中的 compactionSummary / branchSummary) */
|
||||||
function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
|
function SystemNotice({ message, animate = true }: { message: UiMessage; animate?: boolean }): ReactNode | null {
|
||||||
const ref = useEntrance<HTMLDivElement>()
|
const ref = useEntrance<HTMLDivElement>(animate)
|
||||||
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
|
const raw = (message.raw ?? {}) as { summary?: unknown; tokensBefore?: unknown }
|
||||||
const summary = typeof raw.summary === 'string' ? raw.summary : ''
|
const summary = typeof raw.summary === 'string' ? raw.summary : ''
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
@@ -501,7 +526,7 @@ function SystemNotice({ message }: { message: UiMessage }): ReactNode | null {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MessageItem({
|
function MessageItemBase({
|
||||||
message,
|
message,
|
||||||
tools,
|
tools,
|
||||||
streaming,
|
streaming,
|
||||||
@@ -510,7 +535,8 @@ export function MessageItem({
|
|||||||
scrollPinnedRef,
|
scrollPinnedRef,
|
||||||
forkEntryId,
|
forkEntryId,
|
||||||
rewinding,
|
rewinding,
|
||||||
onRewind
|
onRewind,
|
||||||
|
animate = true
|
||||||
}: {
|
}: {
|
||||||
message: UiMessage
|
message: UiMessage
|
||||||
tools: Record<string, ToolState>
|
tools: Record<string, ToolState>
|
||||||
@@ -523,11 +549,13 @@ export function MessageItem({
|
|||||||
/** 分支回溯进行中(禁用回溯入口) */
|
/** 分支回溯进行中(禁用回溯入口) */
|
||||||
rewinding?: boolean
|
rewinding?: boolean
|
||||||
onRewind?: (entryId: string, summarize: boolean) => void
|
onRewind?: (entryId: string, summarize: boolean) => void
|
||||||
|
/** 渐进填充历史时不播入场动画 */
|
||||||
|
animate?: boolean
|
||||||
}): ReactNode | null {
|
}): ReactNode | null {
|
||||||
const ref = useEntrance<HTMLDivElement>()
|
const ref = useEntrance<HTMLDivElement>(animate)
|
||||||
|
|
||||||
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
|
if (message.role === 'compactionSummary' || message.role === 'branchSummary') {
|
||||||
return <SystemNotice message={message} />
|
return <SystemNotice message={message} animate={animate} />
|
||||||
}
|
}
|
||||||
|
|
||||||
if (message.role === 'user') {
|
if (message.role === 'user') {
|
||||||
@@ -565,3 +593,69 @@ export function MessageItem({
|
|||||||
// 其他角色(custom / notification 等)暂不渲染
|
// 其他角色(custom / notification 等)暂不渲染
|
||||||
return null
|
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 type { RefObject } from 'react'
|
||||||
import { animateEntrance } from '../lib/anim'
|
import { animateEntrance } from '../lib/anim'
|
||||||
|
|
||||||
/** 流式消息的入场动画(GSAP),仅挂载时执行一次 */
|
/** 流式消息的入场动画(GSAP),仅挂载时执行一次。
|
||||||
export function useEntrance<T extends HTMLElement>(): RefObject<T | null> {
|
* animate=false 供历史消息渐进填充时跳过动画——切会话时批量挂载的旧消息
|
||||||
|
* 不需要逐条淡入,几十个并发 tween 会造成可感知的卡顿。取挂载时刻的值,
|
||||||
|
* 之后 animate 变化不追溯触发。 */
|
||||||
|
export function useEntrance<T extends HTMLElement>(animate = true): RefObject<T | null> {
|
||||||
const ref = useRef<T | null>(null)
|
const ref = useRef<T | null>(null)
|
||||||
|
const animateRef = useRef(animate)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = ref.current
|
const el = ref.current
|
||||||
if (el) animateEntrance(el)
|
if (el && animateRef.current) animateEntrance(el)
|
||||||
}, [])
|
}, [])
|
||||||
return ref
|
return ref
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,7 +308,7 @@ export interface BrowserImportResult {
|
|||||||
export type AgentUiEvent =
|
export type AgentUiEvent =
|
||||||
| { type: 'session_ready'; state: SessionState }
|
| { type: 'session_ready'; state: SessionState }
|
||||||
| { type: 'session_error'; message: string }
|
| { 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: 'state'; state: SessionState }
|
||||||
| { type: 'approval_request'; request: ApprovalRequest }
|
| { type: 'approval_request'; request: ApprovalRequest }
|
||||||
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
| { type: 'approval_settled'; id: string; approved: boolean; timedOut: boolean }
|
||||||
|
|||||||
Reference in New Issue
Block a user