Explorar o código

fix: 修改对话框滚动

jiaxing.liao hai 1 semana
pai
achega
3e4418fea3

+ 3 - 17
apps/web/src/components/Chat/AiUiCard.vue

@@ -33,7 +33,7 @@ interface BpmToolsWindow extends Window {
 const win = window as BpmToolsWindow
 
 const props = defineProps<{
-	children: any
+	data: any
 	attrs: Record<string, any>
 }>()
 
@@ -42,28 +42,14 @@ const emit = defineEmits<{
 }>()
 
 const html = computed(() => {
-	const text = extractTextFromVNodes(props.children).trim()
+	const text = props.data.trim()
 	const { attrs } = props
 	const keys = Object.keys(attrs)
 	const str = `<ai-ui-card ${keys.map((k) => `${k}="${attrs[k] || ''}"`).join(' ')}>${text || ''}</ai-ui-card>`
-	console.log('card-dom:', str)
+	console.log(str)
 	return str
 })
 
-/**
- * 获取children的文本
- * @param vnodes
- */
-function extractTextFromVNodes(vnodes: any): string {
-	if (!vnodes) return ''
-	if (typeof vnodes === 'string') return vnodes
-	let text = ''
-	if (typeof vnodes === 'function') {
-		text = vnodes()?.[0]
-	}
-	return text
-}
-
 const iframeUrl = computed(() => {
 	return win?.BpmTools?.$$make_ai_card_item_iframe?.(html.value) || ''
 })

+ 30 - 7
apps/web/src/components/Chat/MessageList.vue

@@ -264,11 +264,12 @@ let scrollFrameId: number | null = null
 let scrollResizeObserver: ResizeObserver | null = null
 let scrollMutationObserver: MutationObserver | null = null
 let scrollStabilityTimerId: number | null = null
+let iframeLoadController: AbortController | null = null
 let boundScrollContainer: HTMLElement | null = null
 let isProgrammaticScroll = false
 const scrollTimeoutIds = new Set<number>()
 const AUTO_SCROLL_BOTTOM_THRESHOLD = 120
-const SCROLL_STABILITY_MS = 3000
+const SCROLL_STABILITY_MS = 10000
 
 const bubbleListItems = computed(() =>
 	props.messages.map((item) => ({
@@ -292,6 +293,9 @@ const clearScheduledScroll = () => {
 	}
 	scrollTimeoutIds.forEach((id) => window.clearTimeout(id))
 	scrollTimeoutIds.clear()
+
+	iframeLoadController?.abort()
+	iframeLoadController = null
 }
 
 const unbindScrollContainer = () => {
@@ -369,8 +373,8 @@ const keepBottomDuringLayout = () => {
 		}
 	})
 	scrollResizeObserver.observe(scrollContainer)
-	Array.from(scrollContainer.children).forEach((child) => {
-		scrollResizeObserver?.observe(child)
+	scrollContainer.querySelectorAll('.el-bubble').forEach((el) => {
+		scrollResizeObserver?.observe(el)
 	})
 
 	// MutationObserver: 观察整个子树的 DOM 变化(Markdown 渲染、图片加载、虚拟滚动新增项等)
@@ -381,17 +385,35 @@ const keepBottomDuringLayout = () => {
 			resetStabilityTimer()
 		}
 		// 动态观察新增的子元素(虚拟滚动可能动态添加)
-		Array.from(scrollContainer.children).forEach((child) => {
-			scrollResizeObserver?.observe(child)
+		scrollContainer.querySelectorAll('.el-bubble').forEach((el) => {
+			scrollResizeObserver?.observe(el)
 		})
 	})
 	scrollMutationObserver.observe(scrollContainer, {
+		attributes: true,
+		attributeFilter: ['style'],
 		characterData: true,
 		childList: true,
 		subtree: true
 	})
 
 	// 内容稳定后自动断开(无 DOM 变化 3s 后停止观察,避免长期性能开销)
+
+	// iframe 加载完成后重置稳定性计时器,让观察器继续等待高度测量完成
+	iframeLoadController?.abort()
+	iframeLoadController = new AbortController()
+	scrollContainer.addEventListener(
+		'load',
+		(e) => {
+			if ((e.target as HTMLElement)?.tagName === 'IFRAME' && autoScrollEnabled.value) {
+				// 不立刻滚动(iframe 高度还在测量中),但重置计时器保持观察器活跃
+				resetStabilityTimer()
+			}
+		},
+		{ capture: true, signal: iframeLoadController.signal }
+	)
+
+	// 启动稳定性计时器,内容无变化后自动断开观察器释放性能
 	resetStabilityTimer()
 }
 
@@ -423,7 +445,7 @@ const scrollToBottom = async () => {
 		}
 	})
 
-	for (const delay of [60, 180, 360]) {
+	for (const delay of [60, 180, 360, 1000, 3000, 6000]) {
 		const timeoutId = window.setTimeout(() => {
 			scrollTimeoutIds.delete(timeoutId)
 			if (autoScrollEnabled.value) {
@@ -600,6 +622,7 @@ const handleAddToKb = (message: BubbleMessage) => {
 	.item-list :deep(.el-bubble-list) {
 		height: 100%;
 		padding-right: 4px;
+		scroll-behavior: auto !important;
 	}
 
 	.item-list :deep(.el-bubble + .el-bubble) {
@@ -664,7 +687,7 @@ const handleAddToKb = (message: BubbleMessage) => {
 	}
 
 	.msg-content-text :deep(p) {
-		margin: 0 0 10px;
+		margin: 10px 0;
 		white-space: pre-wrap;
 	}
 

+ 19 - 7
apps/web/src/components/Chat/SMarkdown.vue

@@ -8,8 +8,12 @@
 			</span>
 		</template>
 		<!-- UI信息卡片 -->
-		<template #ai-ui-card="{ children, ...attrs }">
-			<AiUiCard :attrs="attrs" :children="children" @submit="(val) => emit('card-submit', val)" />
+		<template #ai-ui-card="{ children, key, ...attrs }">
+			<AiUiCard
+				:attrs="attrs"
+				:data="aiUiChildStrMap?.[key]"
+				@submit="(val) => emit('card-submit', val)"
+			/>
 		</template>
 		<!-- 图片预览 -->
 		<template #img="{ ...props }">
@@ -26,7 +30,7 @@
 </template>
 
 <script setup lang="tsx">
-import { computed } from 'vue'
+import { computed, ref } from 'vue'
 import { XMarkdown } from 'vue-element-plus-x'
 import { Document } from '@element-plus/icons-vue'
 import AiUiCard from './AiUiCard.vue'
@@ -41,6 +45,8 @@ const props = defineProps<MarkdownProps>()
 const emit = defineEmits<{
 	(e: 'card-submit', payload: string): void
 }>()
+//
+const aiUiChildStrMap = ref<Record<string, string>>()
 
 const KB_SELF_CLOSING_TAG_RE = /<kb\b([^>]*)\/>/gi
 
@@ -50,6 +56,16 @@ const normalizeMarkdownContent = (content?: string) => {
 	// kb 自闭合标签补全
 	text = text.replace(KB_SELF_CLOSING_TAG_RE, '<kb$1></kb>')
 
+	// 提取 <ai-ui-card>xxx</ai-ui-card> 中间的 inner text 并剥离
+	const AI_UI_CARD_RE = /<ai-ui-card([^>]*)>([\s\S]*?)<\/ai-ui-card>/gi
+	const newMap: Record<string, string> = {}
+	let cardIdx = 0
+	text = text.replace(AI_UI_CARD_RE, (_match, attrs, inner) => {
+		newMap[`ai-ui-card-${cardIdx}`] = inner
+		cardIdx++
+		return `<ai-ui-card${attrs}></ai-ui-card>`
+	})
+	aiUiChildStrMap.value = newMap
 	// ai-ui-card: 流式输出时,不完整的标签先隐藏,待闭合标签完整后才渲染
 	text = stripIncompleteAiCard(text)
 
@@ -83,10 +99,6 @@ const markdownCustomAttrs = {
 		'data-doc': attrs.doc || '',
 		'data-chunk-id': attrs.chunk_id || ''
 	})
-	// 'ai-ui-card': (_node: any, attrs: Record<string, any>) => {
-	// 	console.log('ai-ui-card', attrs)
-	// 	return {}
-	// }
 }
 </script>
 

+ 1 - 1
apps/web/src/views/chat/AGENTS.md

@@ -218,7 +218,7 @@ MessageList 实现了智能自动滚动:
 3. **用户手动上滚**:禁用自动滚动,不再强制追底
 4. **ResizeObserver**:布局变化时保持追底(限时 1200ms)
 5. **多次延迟滚动**:`[60ms, 180ms, 360ms]` 三次延迟确保异步渲染完成
-6. **加载历史消息时**:需滚动到最底部
+6. **加载历史消息时**:直接在会话底部,无需滚动
 7. **会话切换时**:滚动到会话最底部
 8. **发起新的对话时**:滚动到会话最底部