新增: UX-19消息列表虚拟滚动(自研IntersectionObserver+sentinel占位+流式末条保活)
This commit is contained in:
@@ -363,9 +363,15 @@
|
|||||||
|
|
||||||
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
<!-- 消息列表(F-15 阶段2:按 renderItems 扁平渲染 — 折叠段 emit 'sep' 项,
|
||||||
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
正常消息 + 展开的折叠段内消息 emit 'msg' 项。连续 archived_segment/compressed
|
||||||
合并一条分隔条,点击展开/收起看原文) -->
|
合并一条分隔条,点击展开/收起看原文)
|
||||||
|
UX-2025-19: 每项外层包 sentinel div(虚拟滚动观测点 + 高度占位),
|
||||||
|
不可见窗口外的 msg 内容卸载、sentinel 留存占位高度(scrollHeight 不塌)。 -->
|
||||||
<template v-for="item in renderItems" :key="item.key">
|
<template v-for="item in renderItems" :key="item.key">
|
||||||
<!-- 折叠分隔条(归档段 / 压缩段) -->
|
<div
|
||||||
|
class="ai-msg-slot"
|
||||||
|
:ref="(el) => registerMsgSentinel(item.key, el as Element)"
|
||||||
|
>
|
||||||
|
<!-- 折叠分隔条(归档段 / 压缩段)— 轻量,始终渲染不过虚拟化 -->
|
||||||
<div
|
<div
|
||||||
v-if="item.kind === 'sep'"
|
v-if="item.kind === 'sep'"
|
||||||
class="ai-msg-segment"
|
class="ai-msg-segment"
|
||||||
@@ -389,9 +395,10 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 消息(正常段 + 展开的折叠段内消息) -->
|
<!-- 消息(正常段 + 展开的折叠段内消息)— 不可见时卸载(sentinel 占位高度),
|
||||||
|
流式末条经 pinnedKeys 保活(shouldRenderMsg 恒 true) -->
|
||||||
<div
|
<div
|
||||||
v-else
|
v-else-if="shouldRenderMsg(item.key)"
|
||||||
class="ai-msg"
|
class="ai-msg"
|
||||||
:class="'ai-msg--' + item.msg.role"
|
:class="'ai-msg--' + item.msg.role"
|
||||||
:data-msg-id="item.msg.id"
|
:data-msg-id="item.msg.id"
|
||||||
@@ -543,6 +550,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div><!-- /.ai-msg-slot(sentinel,UX-2025-19 虚拟滚动观测点) -->
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -732,6 +740,7 @@ import { useRouter } from 'vue-router'
|
|||||||
import { listen } from '@tauri-apps/api/event'
|
import { listen } from '@tauri-apps/api/event'
|
||||||
import { useAiStore } from '../stores/ai'
|
import { useAiStore } from '../stores/ai'
|
||||||
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
|
import { pendingMaxRounds } from '../composables/ai/useAiEvents'
|
||||||
|
import { useAiVirtualScroll } from '../composables/ai/useAiVirtualScroll'
|
||||||
import { aiApi } from '../api'
|
import { aiApi } from '../api'
|
||||||
import ConfirmDialog from './ConfirmDialog.vue'
|
import ConfirmDialog from './ConfirmDialog.vue'
|
||||||
import { useConfirm } from '../composables/useConfirm'
|
import { useConfirm } from '../composables/useConfirm'
|
||||||
@@ -944,6 +953,13 @@ const inputEl = ref<HTMLTextAreaElement>()
|
|||||||
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
|
// UX-09: 编辑末条 user 消息态。editingMsgId 有值时 handleSend 走 editMessage 而非 sendMessage。
|
||||||
const editingMsgId = ref<string | null>(null)
|
const editingMsgId = ref<string | null>(null)
|
||||||
const messagesContainer = ref<HTMLDivElement>()
|
const messagesContainer = ref<HTMLDivElement>()
|
||||||
|
// UX-2025-19: 虚拟滚动(可见窗口懒渲染 + 流式末条保活)。渲染层裁剪,不触碰既有滚动/收起/流式逻辑。
|
||||||
|
const {
|
||||||
|
registerSentinel: registerMsgSentinel,
|
||||||
|
setupOnMount: setupVirtualScroll,
|
||||||
|
shouldRender: shouldRenderMsg,
|
||||||
|
setPinned: setPinnedMsgs,
|
||||||
|
} = useAiVirtualScroll({ root: messagesContainer, rootMarginPx: 600 })
|
||||||
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
// 工具卡片列表(子组件 ToolCardList 自治折叠态,父级仅经 expose 调 collapseInactive 收起旧卡)
|
||||||
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
const toolCardListRef = ref<InstanceType<typeof ToolCardList>>()
|
||||||
|
|
||||||
@@ -1906,6 +1922,8 @@ watch(
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
|
// 全局快捷键(UX-2025-07):window 级 listener,卸载时移除(对齐 _unlistenToolSlow 生命周期)
|
||||||
window.addEventListener('keydown', onGlobalKeydown)
|
window.addEventListener('keydown', onGlobalKeydown)
|
||||||
|
// UX-2025-19: 滚动容器 ref 就绪,补建 IntersectionObserver(模板 sentinel 已在挂载时逐项 observe)
|
||||||
|
setupVirtualScroll()
|
||||||
// UX-18: 点击外部关闭导出格式菜单
|
// UX-18: 点击外部关闭导出格式菜单
|
||||||
document.addEventListener('click', onExportOutsideClick)
|
document.addEventListener('click', onExportOutsideClick)
|
||||||
const t0 = (window as any).__APP_T0 ?? 0
|
const t0 = (window as any).__APP_T0 ?? 0
|
||||||
@@ -2339,6 +2357,21 @@ const hasActiveMessages = computed(() =>
|
|||||||
store.state.messages.some(m => !isFoldedStatus(m)),
|
store.state.messages.some(m => !isFoldedStatus(m)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// UX-2025-19: 流式末条保活 — streaming 时把末条 AI 消息的 renderItem key 钉进虚拟滚动
|
||||||
|
// pinned 集合,使其无论是否在可见窗口都渲染内容,保证 ARC-08 块级 memo + streamingBlocks
|
||||||
|
// v-for + 选区保存/恢复不因虚拟化卸载丢帧。非 streaming 时清空(回归正常虚拟化裁剪)。
|
||||||
|
const lastStreamingRenderKey = computed<string | null>(() => {
|
||||||
|
if (!store.state.streaming) return null
|
||||||
|
const msgs = store.state.messages
|
||||||
|
const last = msgs[msgs.length - 1]
|
||||||
|
if (!last || last.role !== 'assistant') return null
|
||||||
|
// 末条 streaming AI 消息必为 normal 段(renderItems 里 key='m-'+id)
|
||||||
|
return 'm-' + last.id
|
||||||
|
})
|
||||||
|
watch(lastStreamingRenderKey, (key) => {
|
||||||
|
setPinnedMsgs(key ? new Set([key]) : new Set())
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
|
* 清空当前对话上下文(二次确认 → aiApi.clearContext)。
|
||||||
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
|
* 历史消息后端标 archived_segment 归档保留,DB 不删;新对话不受影响。
|
||||||
@@ -2903,6 +2936,9 @@ body.ai-sidebar-resizing * {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
/* UX-2025-19: 虚拟滚动 sentinel 容器 — 取代 msg/sep 成为 .ai-messages 的直接 flex 子
|
||||||
|
(gap:14px 由它承担,内部消息间距零变化)。占位时 minHeight 锁住上次实测高度防塌。 */
|
||||||
|
.ai-msg-slot { display: flex; flex-direction: column; }
|
||||||
|
|
||||||
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */
|
/* ── 回到底部按钮(浮动,绝对定位于 .ai-chat-area) ── */
|
||||||
.ai-back-to-bottom {
|
.ai-back-to-bottom {
|
||||||
|
|||||||
162
src/composables/ai/useAiVirtualScroll.ts
Normal file
162
src/composables/ai/useAiVirtualScroll.ts
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
/**
|
||||||
|
* useAiVirtualScroll — AiChat 消息列表虚拟滚动(IntersectionObserver 可见窗口懒渲染)。
|
||||||
|
*
|
||||||
|
* 设计取舍(UX-2025-19):选自研 IntersectionObserver 而非 vue-virtual-scroller。
|
||||||
|
* - 零新依赖(项目 package.json 无 vue-virtual-scroller)。
|
||||||
|
* - vue-virtual-scroller DynamicScroller 会接管滚动容器 + 重排子节点,
|
||||||
|
* 破坏 .ai-messages 现有 flex/gap 布局、onMessagesScroll 边沿收起(wasNearBottom)、
|
||||||
|
* isNearBottom/scrollToBottom 计算等"渲染层"以外的既有逻辑。
|
||||||
|
* - 自研方案只做"渲染层裁剪":可见窗口外的 item 内容卸载、保留占位高度,
|
||||||
|
* scrollHeight/scrollTop 计算与既有滚动逻辑零感知。
|
||||||
|
*
|
||||||
|
* 工作机制:
|
||||||
|
* 1. 每个列表项包一个 sentinel div(始终挂载,只承担高度占位 + IO 观测)。
|
||||||
|
* 2. IntersectionObserver root=滚动容器,rootMargin 上下各预加载一段(默认 600px),
|
||||||
|
* 保证滚动时内容提前就位,不出现空白闪烁。
|
||||||
|
* 3. sentinel 进入预加载区→key 进 renderedKeys→内容渲染;离开→移除→内容卸载,
|
||||||
|
* 但 sentinel 自身保留(其已测得的高度经 inline style 占位,scrollHeight 不塌)。
|
||||||
|
* 4. 高度测量:ResizeObserver 观测 sentinel(内容挂载时其高度=内容高度),
|
||||||
|
* 卸载前把最后一次实测高度写进 inline style,卸载后 sentinel 仍占该高度。
|
||||||
|
* 5. 流式末条保活:pinnedKeys 中的 key 永远视为"可见",不虚拟化(见 AiChat.vue 调用点
|
||||||
|
* 把 streaming 末条 AI 消息 key 传入),保证流式渲染(ARC-08 块级 memo +
|
||||||
|
* streamingBlocks v-for + 选区保存/恢复)不因虚拟化丢帧。
|
||||||
|
*
|
||||||
|
* 不处理(留观察,见诚实拆波):
|
||||||
|
* - ToolCard 嵌套内部的虚拟化(单条消息内多工具卡,当前不裁剪,首屏内条目少)。
|
||||||
|
* - 编辑光标在 user 消息上的保持(UX-09 走 input 框回填,与列表虚拟化无耦合)。
|
||||||
|
*/
|
||||||
|
import { ref, onBeforeUnmount, type Ref } from 'vue'
|
||||||
|
|
||||||
|
export interface AiVirtualScrollOptions {
|
||||||
|
/** 滚动容器(IntersectionObserver root) */
|
||||||
|
root: Ref<HTMLElement | null | undefined>
|
||||||
|
/** 上下预加载像素数(可见区外提前渲染的高度,防滚出空白)。 */
|
||||||
|
rootMarginPx?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAiVirtualScroll(options: AiVirtualScrollOptions) {
|
||||||
|
const rootMargin = options.rootMarginPx ?? 600
|
||||||
|
// 当前"应当渲染内容"的 key 集合(IO 判定可见 + pinned)。模板读这个判断渲染与否。
|
||||||
|
const renderedKeys = ref<Set<string>>(new Set())
|
||||||
|
// 永远渲染的 key(流式末条等),由调用方维护传入。
|
||||||
|
const pinnedKeys = ref<Set<string>>(new Set())
|
||||||
|
|
||||||
|
// 每个观测项的元数据:DOM 元素 + 最近实测高度(卸载后占位用)。
|
||||||
|
const itemMeta = new Map<string, { el: HTMLElement; height: number }>()
|
||||||
|
let io: IntersectionObserver | null = null
|
||||||
|
let ro: ResizeObserver | null = null
|
||||||
|
|
||||||
|
function ensureObserver(): void {
|
||||||
|
if (io) return
|
||||||
|
const rootEl = options.root.value
|
||||||
|
if (!rootEl) return // root 未就绪;setupOnMount(root ref 就绪后)会再调一次建观察器
|
||||||
|
io = new IntersectionObserver(
|
||||||
|
(entries) => {
|
||||||
|
const next = new Set(renderedKeys.value)
|
||||||
|
let changed = false
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||||
|
if (!key) continue
|
||||||
|
const visible = e.isIntersecting
|
||||||
|
const has = next.has(key)
|
||||||
|
if (visible && !has) {
|
||||||
|
// 重新可见:清掉卸载时锁的占位 minHeight,让内容自然撑高(RO 再回填真实高度)
|
||||||
|
const meta = itemMeta.get(key)
|
||||||
|
if (meta && meta.el.isConnected) meta.el.style.minHeight = ''
|
||||||
|
next.add(key); changed = true
|
||||||
|
}
|
||||||
|
else if (!visible && has && !pinnedKeys.value.has(key)) {
|
||||||
|
// 卸载前:把实测高度锁到 sentinel 的 minHeight,防内容移除后 sentinel 塌成 0
|
||||||
|
// → scrollHeight 不变 → isNearBottom/scrollToBottom/回到底部计算零感知。
|
||||||
|
// 内容重新挂载时 RO 会把真实高度回填,registerSentinel 复挂时清 minHeight 由真实高度接管。
|
||||||
|
const meta = itemMeta.get(key)
|
||||||
|
if (meta && meta.height > 0 && meta.el.isConnected) {
|
||||||
|
meta.el.style.minHeight = `${meta.height}px`
|
||||||
|
}
|
||||||
|
next.delete(key); changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (changed) renderedKeys.value = next
|
||||||
|
},
|
||||||
|
{ root: rootEl, rootMargin: `${rootMargin}px 0px ${rootMargin}px 0px`, threshold: 0 },
|
||||||
|
)
|
||||||
|
ro = new ResizeObserver((entries) => {
|
||||||
|
for (const e of entries) {
|
||||||
|
const key = (e.target as HTMLElement).dataset.vscrollKey
|
||||||
|
if (!key) continue
|
||||||
|
const meta = itemMeta.get(key)
|
||||||
|
if (meta) meta.height = e.contentRect.height
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模板 sentinel 注册回调(每个 v-for 项的 ref 调用一次,挂载时 el 非空、卸载时为 null)。
|
||||||
|
* 调用方在 v-for item 上用 `:ref="(el) => registerSentinel(key, el as HTMLElement)"`。
|
||||||
|
*/
|
||||||
|
function registerSentinel(key: string, el: Element | null | undefined): void {
|
||||||
|
const prev = itemMeta.get(key)
|
||||||
|
if (el instanceof HTMLElement) {
|
||||||
|
el.dataset.vscrollKey = key
|
||||||
|
// 复用上次实测高度作为初始 inline 占位(再次挂载时先占位,内容挂载后 RO 再校准)
|
||||||
|
const h = prev?.height ?? 0
|
||||||
|
if (h > 0) el.style.minHeight = `${h}px`
|
||||||
|
itemMeta.set(key, { el, height: h })
|
||||||
|
ensureObserver()
|
||||||
|
io?.observe(el)
|
||||||
|
ro?.observe(el)
|
||||||
|
// 新挂载/初次进入的项默认视为应渲染(IO 回调会按真实相交态修正)
|
||||||
|
if (!renderedKeys.value.has(key)) {
|
||||||
|
const next = new Set(renderedKeys.value)
|
||||||
|
next.add(key)
|
||||||
|
renderedKeys.value = next
|
||||||
|
}
|
||||||
|
} else if (prev) {
|
||||||
|
// 卸载:停止观测,保留 height 元数据供下次复用(itemMeta 不删)
|
||||||
|
io?.unobserve(prev.el)
|
||||||
|
ro?.unobserve(prev.el)
|
||||||
|
// el 已被 Vue 移除,记录保留 height 即可
|
||||||
|
itemMeta.set(key, { el: prev.el, height: prev.height })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 滚动容器 ref 就绪后调用(通常 onMounted),补建观察器并预渲染首批可见项。 */
|
||||||
|
function setupOnMount(): void {
|
||||||
|
ensureObserver()
|
||||||
|
// 不主动全量 observe——registerSentinel 在模板挂载时已逐项 observe。
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否应渲染该项内容(IO 可见 或 pinned)。 */
|
||||||
|
function shouldRender(key: string): boolean {
|
||||||
|
return renderedKeys.value.has(key) || pinnedKeys.value.has(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 更新 pinned 集合(调用方 watch 流式末条后传入新集合)。 */
|
||||||
|
function setPinned(keys: Set<string>): void {
|
||||||
|
pinnedKeys.value = keys
|
||||||
|
// pinned 的 key 强制进渲染集(IO 不可见也渲染)
|
||||||
|
if (keys.size > 0) {
|
||||||
|
const next = new Set(renderedKeys.value)
|
||||||
|
let changed = false
|
||||||
|
for (const k of keys) if (!next.has(k)) { next.add(k); changed = true }
|
||||||
|
if (changed) renderedKeys.value = next
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
io?.disconnect()
|
||||||
|
ro?.disconnect()
|
||||||
|
io = null
|
||||||
|
ro = null
|
||||||
|
itemMeta.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
renderedKeys,
|
||||||
|
pinnedKeys,
|
||||||
|
registerSentinel,
|
||||||
|
setupOnMount,
|
||||||
|
shouldRender,
|
||||||
|
setPinned,
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user