diff --git a/src/renderer/src/components/message/MessageBlockThink.vue b/src/renderer/src/components/message/MessageBlockThink.vue index d93943bd0..e398f2ffb 100644 --- a/src/renderer/src/components/message/MessageBlockThink.vue +++ b/src/renderer/src/components/message/MessageBlockThink.vue @@ -22,6 +22,10 @@ const props = defineProps<{ reasoning_end_time: number } }>() + +const emit = defineEmits<{ + (e: 'toggle-collapse', isCollapsed: boolean): void +}>() const { t } = useI18n() const configPresenter = usePresenter('configPresenter') @@ -97,8 +101,9 @@ const headerText = computed(() => { watch( () => collapse.value, - () => { - configPresenter.setSetting('think_collapse', collapse.value) + (newValue) => { + configPresenter.setSetting('think_collapse', newValue) + emit('toggle-collapse', !newValue) } ) diff --git a/src/renderer/src/components/message/MessageItemAssistant.vue b/src/renderer/src/components/message/MessageItemAssistant.vue index b02bd8403..04310749b 100644 --- a/src/renderer/src/components/message/MessageItemAssistant.vue +++ b/src/renderer/src/components/message/MessageItemAssistant.vue @@ -35,6 +35,7 @@ v-else-if="block.type === 'reasoning_content' && block.content" :block="block" :usage="message.usage" + @toggle-collapse="handleCollapseToggle" /> { + emit('variantChanged', props.message.id) +} + const handleAction = (action: HandleActionType) => { if (action === 'retry') { chatStore.retryMessage(currentMessage.value.id) diff --git a/src/renderer/src/components/message/MessageList.vue b/src/renderer/src/components/message/MessageList.vue index 2e5a22449..83866bf77 100644 --- a/src/renderer/src/components/message/MessageList.vue +++ b/src/renderer/src/components/message/MessageList.vue @@ -16,7 +16,11 @@ @@ -129,6 +133,10 @@ const shouldAutoFollow = ref(true) const traceMessageId = ref(null) let highlightRefreshTimer: number | null = null +const previousHeights = new Map() +const messageResizeObservers = new Map() +const pendingHeightUpdate = ref(false) + // === Composable Integrations === // Scroll management const scroll = useMessageScroll({ @@ -184,6 +192,77 @@ const MAX_REASONABLE_HEIGHT = 2000 const BUFFER_ZONE_MULTIPLIER = 2 const EXTREME_POSITION_THRESHOLD = 100000 +const HEIGHT_CHANGE_THRESHOLD = 10 +const SCROLL_CHECK_DELAY = 150 + +const trackMessageHeightChange = (messageId: string, newHeight: number) => { + const previousHeight = previousHeights.get(messageId) ?? 0 + const heightDiff = Math.abs(newHeight - previousHeight) + + if (heightDiff > HEIGHT_CHANGE_THRESHOLD && heightDiff < MAX_REASONABLE_HEIGHT) { + if (!pendingHeightUpdate.value) { + pendingHeightUpdate.value = true + nextTick(() => { + scrollerUpdate() + setTimeout(() => { + pendingHeightUpdate.value = false + }, SCROLL_CHECK_DELAY) + }) + } + } + + previousHeights.set(messageId, newHeight) +} + +const scrollerUpdate = () => { + const scroller = dynamicScrollerRef.value + scroller?.updateVisibleItems?.(true) +} + +const setupMessageResizeObserver = () => { + const container = messagesContainer.value + if (!container) return + + const observerCallback = (entries: ResizeObserverEntry[]) => { + for (const entry of entries) { + const messageId = entry.target.getAttribute('data-message-id') + if (!messageId) continue + + const newHeight = entry.contentRect.height + trackMessageHeightChange(messageId, newHeight) + } + } + + const debouncedObserverCallback = useDebounceFn(observerCallback, 100) + + const visibleMessages = container.querySelectorAll('[data-message-id]') + + visibleMessages.forEach((element) => { + const el = element as HTMLElement + const messageId = el.getAttribute('data-message-id') + if (!messageId) return + + const previousObserver = messageResizeObservers.get(messageId) + if (previousObserver) { + previousObserver.disconnect() + } + + const observer = new ResizeObserver(debouncedObserverCallback) + observer.observe(el) + messageResizeObservers.set(messageId, observer) + + previousHeights.set(messageId, el.getBoundingClientRect().height) + }) +} + +const cleanupMessageResizeObservers = () => { + messageResizeObservers.forEach((observer) => { + observer.disconnect() + }) + messageResizeObservers.clear() + previousHeights.clear() +} + // === Helper Functions === const getTextLength = (value?: string) => value?.length ?? 0 @@ -222,6 +301,30 @@ const getVariantSizeKey = (item: MessageListItem) => { return chatStore.selectedVariantsMap[message.id] ?? '' } +const getRenderingStateKey = (item: MessageListItem) => { + const message = item.message + if (!message) return `rendering:placeholder:${item.id}` + + const loadingStates: string[] = [] + + if (message.role === 'assistant') { + const blocks = (message as AssistantMessage).content + if (Array.isArray(blocks)) { + blocks.forEach((block) => { + if (block.status === 'loading') { + loadingStates.push(`${block.type}`) + } + }) + } + } + + if (loadingStates.length > 0) { + return `rendering:loading:${loadingStates.sort().join(',')}` + } + + return '' +} + // === Event Handlers === const handleCopyImage = async ( messageId: string, @@ -562,6 +665,9 @@ const refreshVirtualScroller = async (messageId?: string) => { } scroller?.updateVisibleItems?.(true) + + await new Promise((resolve) => requestAnimationFrame(resolve)) + scroller?.updateVisibleItems?.(true) } const wrapScrollToMessage = async (messageId: string) => { @@ -583,6 +689,7 @@ const handleVirtualUpdate = ( void chatStore.prefetchMessagesForRange(safeStart, safeEnd) recordVisibleDomInfo() handleVirtualScrollUpdate() + setupMessageResizeObserver() } watch( @@ -595,13 +702,14 @@ watch( onMounted(() => { bindScrollContainer() - // Initialize scroll and visibility + scrollToBottom(true) nextTick(() => { visible.value = true setupScrollObserver() updateScrollInfo() recordVisibleDomInfo() + setupMessageResizeObserver() }) useResizeObserver(messagesContainer, () => { @@ -634,10 +742,15 @@ onMounted(() => { watch( () => { const lastMessage = props.items[props.items.length - 1] - return lastMessage ? getMessageSizeKey(lastMessage) : '' + return lastMessage + ? `${getMessageSizeKey(lastMessage)}:${getRenderingStateKey(lastMessage)}` + : '' }, () => { scrollToBottom() + nextTick(() => { + scrollerUpdate() + }) }, { flush: 'post' } ) @@ -650,6 +763,7 @@ onBeforeUnmount(() => { if (highlightRefreshTimer) clearTimeout(highlightRefreshTimer) highlightRefreshTimer = null + cleanupMessageResizeObservers() }) // === Expose === diff --git a/src/renderer/src/composables/message/useMessageScroll.ts b/src/renderer/src/composables/message/useMessageScroll.ts index bd3cc65b9..b0a6c7441 100644 --- a/src/renderer/src/composables/message/useMessageScroll.ts +++ b/src/renderer/src/composables/message/useMessageScroll.ts @@ -5,8 +5,8 @@ import type { DynamicScroller } from 'vue-virtual-scroller' // === Constants === const MESSAGE_HIGHLIGHT_CLASS = 'message-highlight' -const MAX_SCROLL_RETRIES = 8 -const SCROLL_RETRY_DELAY = 50 +const MAX_SCROLL_RETRIES = 12 +const SCROLL_RETRY_DELAY = 80 const HIGHLIGHT_DURATION = 2000 const PLACEHOLDER_POSITION_THRESHOLD = 5000