重构会话页面的消息渲染,支持块级虚拟化,提升长会话性能,减少卡断

This commit is contained in:
xintaofei
2026-03-24 18:52:25 +08:00
parent b7df63c5f8
commit 284e45fbdf
11 changed files with 386 additions and 223 deletions

View File

@@ -18,7 +18,7 @@
"@streamdown/code": "^1.0.2", "@streamdown/code": "^1.0.2",
"@streamdown/math": "^1.0.2", "@streamdown/math": "^1.0.2",
"@streamdown/mermaid": "^1.0.2", "@streamdown/mermaid": "^1.0.2",
"@tanstack/react-virtual": "^3.13.18", "@tanstack/react-virtual": "^3.13.23",
"@tauri-apps/api": "^2", "@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.6.0", "@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-opener": "^2",

482
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -863,11 +863,9 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let turn_model = msg.model.clone(); let turn_model = msg.model.clone();
i += 1; i += 1;
// Absorb consecutive assistant msgs AND tool-result-only user msgs // Only absorb immediately following tool-result-only user msgs
while i < messages.len() // (stop at the next assistant message to keep turns small for virtualization)
&& (matches!(messages[i].role, MessageRole::Assistant) while i < messages.len() && is_tool_result_only(&messages[i]) {
|| is_tool_result_only(&messages[i]))
{
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
i += 1; i += 1;
} }

View File

@@ -1192,9 +1192,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp; let timestamp = msg.timestamp;
i += 1; i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant) && matches!(messages[i].role, MessageRole::Tool)
|| matches!(messages[i].role, MessageRole::Tool))
{ {
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {

View File

@@ -681,9 +681,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp; let timestamp = msg.timestamp;
i += 1; i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant) && matches!(messages[i].role, MessageRole::Tool)
|| matches!(messages[i].role, MessageRole::Tool))
{ {
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {

View File

@@ -1105,9 +1105,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp; let timestamp = msg.timestamp;
i += 1; i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant) && matches!(messages[i].role, MessageRole::Tool)
|| matches!(messages[i].role, MessageRole::Tool))
{ {
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {

View File

@@ -667,9 +667,10 @@ fn group_into_turns(messages: Vec<UnifiedMessage>) -> Vec<MessageTurn> {
let timestamp = msg.timestamp; let timestamp = msg.timestamp;
i += 1; i += 1;
// Only absorb immediately following Tool messages
// (stop at the next assistant message to keep turns small for virtualization)
while i < messages.len() while i < messages.len()
&& (matches!(messages[i].role, MessageRole::Assistant) && matches!(messages[i].role, MessageRole::Tool)
|| matches!(messages[i].role, MessageRole::Tool))
{ {
blocks.extend(messages[i].content.clone()); blocks.extend(messages[i].content.clone());
if usage.is_none() { if usage.is_none() {

View File

@@ -139,7 +139,7 @@ export const Reasoning = memo(
return ( return (
<ReasoningContext.Provider value={contextValue}> <ReasoningContext.Provider value={contextValue}>
<Collapsible <Collapsible
className={cn("not-prose mb-4", className)} className={cn("not-prose", className)}
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
open={isOpen} open={isOpen}
{...props} {...props}

View File

@@ -28,7 +28,7 @@ export type ToolProps = ComponentProps<typeof Collapsible>
export const Tool = ({ className, ...props }: ToolProps) => ( export const Tool = ({ className, ...props }: ToolProps) => (
<Collapsible <Collapsible
className={cn("group mb-4 w-full rounded-md border", className)} className={cn("group w-full rounded-md border", className)}
{...props} {...props}
/> />
) )

View File

@@ -60,6 +60,8 @@ type ThreadRenderItem =
kind: "turn" kind: "turn"
group: ResolvedMessageGroup group: ResolvedMessageGroup
phase: "persisted" | "optimistic" | "streaming" phase: "persisted" | "optimistic" | "streaming"
showStats: boolean
isRoleTransition: boolean
} }
| { | {
key: string key: string
@@ -69,9 +71,11 @@ type ThreadRenderItem =
const HistoricalMessageGroup = memo(function HistoricalMessageGroup({ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
group, group,
dimmed = false, dimmed = false,
showStats = true,
}: { }: {
group: ResolvedMessageGroup group: ResolvedMessageGroup
dimmed?: boolean dimmed?: boolean
showStats?: boolean
}) { }) {
return ( return (
<div className={dimmed ? "opacity-70" : undefined}> <div className={dimmed ? "opacity-70" : undefined}>
@@ -86,7 +90,7 @@ const HistoricalMessageGroup = memo(function HistoricalMessageGroup({
<UserResourceLinks resources={group.resources} className="self-end" /> <UserResourceLinks resources={group.resources} className="self-end" />
) : null} ) : null}
</Message> </Message>
{group.role === "assistant" && ( {showStats && group.role === "assistant" && (
<TurnStats <TurnStats
usage={group.usage} usage={group.usage}
duration_ms={group.duration_ms} duration_ms={group.duration_ms}
@@ -214,9 +218,40 @@ export function MessageListView({
model: msg.model, model: msg.model,
}, },
phase, phase,
showStats: false,
isRoleTransition: false,
} }
}) })
// Compute showStats and isRoleTransition for each turn item
for (let idx = 0; idx < items.length; idx++) {
const item = items[idx]
if (item.kind !== "turn") continue
// isRoleTransition: role differs from previous turn item
if (idx > 0) {
const prev = items[idx - 1]
if (
prev.kind === "turn" &&
prev.group.role !== item.group.role
) {
item.isRoleTransition = true
}
}
// showStats: only on the last assistant turn before a non-assistant or end
if (item.group.role === "assistant") {
const next = items[idx + 1]
if (
!next ||
next.kind !== "turn" ||
next.group.role !== "assistant"
) {
item.showStats = true
}
}
}
const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null const lastPhase = timelineTurns[timelineTurns.length - 1]?.phase ?? null
if ( if (
lastPhase === "optimistic" && lastPhase === "optimistic" &&
@@ -237,21 +272,29 @@ export function MessageListView({
[historicalPlanEntries] [historicalPlanEntries]
) )
const renderThreadItem = useCallback((item: ThreadRenderItem) => { const renderThreadItem = useCallback(
switch (item.kind) { (item: ThreadRenderItem) => {
case "turn": switch (item.kind) {
return ( case "turn": {
<HistoricalMessageGroup const pt = item.isRoleTransition ? 16 : 0
group={item.group} return (
dimmed={item.phase === "optimistic"} <div style={pt > 0 ? { paddingTop: pt } : undefined}>
/> <HistoricalMessageGroup
) group={item.group}
case "typing": dimmed={item.phase === "optimistic"}
return <PendingTypingIndicator /> showStats={item.showStats}
default: />
return null </div>
} )
}, []) }
case "typing":
return <PendingTypingIndicator />
default:
return null
}
},
[]
)
const emptyState = useMemo( const emptyState = useMemo(
() => () =>
@@ -304,7 +347,7 @@ export function MessageListView({
getItemKey={(item) => item.key} getItemKey={(item) => item.key}
renderItem={renderThreadItem} renderItem={renderThreadItem}
emptyState={emptyState} emptyState={emptyState}
estimateSize={180} estimateSize={100}
overscan={10} overscan={10}
/> />
<MessageThreadScrollButton /> <MessageThreadScrollButton />

View File

@@ -1,6 +1,6 @@
"use client" "use client"
import { useCallback } from "react" import { useCallback, useEffect } from "react"
import type { ReactNode } from "react" import type { ReactNode } from "react"
import { useVirtualizer } from "@tanstack/react-virtual" import { useVirtualizer } from "@tanstack/react-virtual"
import { useStickToBottomContext } from "use-stick-to-bottom" import { useStickToBottomContext } from "use-stick-to-bottom"
@@ -17,6 +17,7 @@ interface VirtualizedMessageThreadProps<T> {
emptyState?: ReactNode emptyState?: ReactNode
estimateSize?: number estimateSize?: number
overscan?: number overscan?: number
gap?: number
className?: string className?: string
contentClassName?: string contentClassName?: string
contentProps?: Omit<MessageThreadContentProps, "children" | "className"> contentProps?: Omit<MessageThreadContentProps, "children" | "className">
@@ -29,6 +30,7 @@ export function VirtualizedMessageThread<T>({
emptyState, emptyState,
estimateSize = 160, estimateSize = 160,
overscan = 8, overscan = 8,
gap = 16,
className, className,
contentClassName, contentClassName,
contentProps, contentProps,
@@ -45,13 +47,23 @@ export function VirtualizedMessageThread<T>({
isScrollingResetDelay: 100, isScrollingResetDelay: 100,
paddingStart: 16, paddingStart: 16,
paddingEnd: 16, paddingEnd: 16,
gap: 32, gap,
getItemKey: (index) => { getItemKey: (index) => {
const item = items[index] const item = items[index]
return item ? getItemKey(item, index) : index return item ? getItemKey(item, index) : index
}, },
}) })
// Adjust scroll position when items above the viewport are measured
// differently from their estimates — prevents blank gaps when scrolling up
useEffect(() => {
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (
item,
_delta,
instance
) => item.start < (instance.scrollOffset ?? 0)
}, [virtualizer])
const renderVirtualRow = useCallback( const renderVirtualRow = useCallback(
(virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => { (virtualItem: ReturnType<typeof virtualizer.getVirtualItems>[number]) => {
const item = items[virtualItem.index] const item = items[virtualItem.index]