重构会话页面的消息渲染,支持块级虚拟化,提升长会话性能,减少卡断
This commit is contained in:
@@ -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
482
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
Reference in New Issue
Block a user