-
-
Notifications
You must be signed in to change notification settings - Fork 629
feat(chat): add scroll speed limit for streaming output and top positioning for non-streaming output (#1116) #1120
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,5 @@ | ||
| package com.ai.assistance.operit.ui.features.chat.components | ||
|
|
||
| import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager | ||
| import android.widget.Toast | ||
| import androidx.compose.animation.core.RepeatMode | ||
| import androidx.compose.animation.core.StartOffset | ||
|
|
@@ -261,23 +261,28 @@ fun ChatArea( | |
| val showMessageTokenStats = themeSnapshot.showMessageTokenStats | ||
| val showMessageTimingStats = themeSnapshot.showMessageTimingStats | ||
| val showMessageTimestamp = themeSnapshot.showMessageTimestamp | ||
| val displayPreferencesManager = remember { DisplayPreferencesManager.getInstance(context) } | ||
| val streamScrollMaxSpeedDp by displayPreferencesManager.streamScrollMaxSpeedDp.collectAsState( | ||
| initial = DisplayPreferencesManager.STREAM_SCROLL_SPEED_DEFAULT_DP | ||
| ) | ||
| val enableNonStreamingScrollToTop by displayPreferencesManager.enableNonStreamingScrollToTop.collectAsState(initial = true) | ||
| var viewportHeightPx by remember { mutableStateOf(0) } | ||
| val messageAnchors = remember(currentChatId) { mutableStateMapOf<Long, ChatScrollMessageAnchor>() } | ||
| val streamedAiMessageTimestamps = remember(currentChatId) { mutableSetOf<Long>() } | ||
| var pendingJumpToMessageTimestamp by remember(currentChatId) { mutableStateOf<Long?>(null) } | ||
| val lastMessage = chatHistory.lastOrNull() | ||
| val pendingTargetAnchor = | ||
| pendingJumpToMessageTimestamp?.let { targetTimestamp -> messageAnchors[targetTimestamp] } | ||
| var hasLastAiMessageStartedStreaming by remember(lastMessage?.timestamp) { | ||
| mutableStateOf(lastMessage?.run { sender == "ai" && content.isNotBlank() } == true) | ||
| } | ||
|
|
||
| val messagesCount = chatHistory.size | ||
| LaunchedEffect(currentChatId, chatHistory.isEmpty()) { | ||
| if (chatHistory.isEmpty()) { | ||
| pendingJumpToMessageTimestamp = null | ||
| streamedAiMessageTimestamps.clear() | ||
| } | ||
| } | ||
|
|
||
| val lastMessageContentLength = lastMessage?.content?.length | ||
| LaunchedEffect( | ||
| autoScrollToBottom, | ||
|
|
@@ -297,7 +302,6 @@ fun ChatArea( | |
| pendingJumpToMessageTimestamp = lastMessage?.timestamp | ||
| } | ||
| } | ||
|
|
||
| LaunchedEffect( | ||
| pendingJumpToMessageTimestamp, | ||
| messagesCount, | ||
|
|
@@ -315,37 +319,56 @@ fun ChatArea( | |
| val targetAnchor = pendingTargetAnchor ?: return@LaunchedEffect | ||
| val isActualLatestMessage = targetIndex == messagesCount - 1 && !hasNewerDisplayHistory | ||
| onAutoScrollToBottomChange?.invoke(isActualLatestMessage) | ||
|
|
||
| if (targetIndex == messagesCount - 1) { | ||
| scrollState.animateScrollTo(scrollState.maxValue) | ||
| val targetMessage = chatHistory.getOrNull(targetIndex) | ||
| val isTargetAi = targetMessage?.sender == "ai" | ||
| val isNonStreamingAi = isTargetAi && | ||
| targetMessage?.contentStream == null && | ||
| targetMessage?.timestamp !in streamedAiMessageTimestamps | ||
|
|
||
| if (isNonStreamingAi && enableNonStreamingScrollToTop) { | ||
| val targetOffset = | ||
| targetAnchor.absoluteTopPx.roundToInt().coerceIn(0, scrollState.maxValue) | ||
| scrollState.animateScrollTo(targetOffset) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 保留用户显式“滚动到底部”的导航行为 默认启用该选项后,重开以长 AI 回答结尾的历史并向上拖动,再点击底部按钮:ChatScrollNavigator 先启动到底动画并设置 autoScroll=true;后者触发 ChatArea 上方 effect,将最新消息设为 pending。历史消息没有 contentStream,且本次进入的 streamed 集合为空,于是这里又启动到消息开头的动画,打断或覆盖用户的到底请求。请把新非流式回复的自动顶部定位与用户显式到底导航区分开。 |
||
| } else { | ||
| val isStreaming = isTargetAi && (targetMessage?.contentStream != null || targetMessage?.timestamp in streamedAiMessageTimestamps) | ||
| scrollState.animateScrollToWithSpeedLimit( | ||
| targetValue = scrollState.maxValue, | ||
| density = density, | ||
| maxSpeedDpPerSecond = | ||
| if (isStreaming) streamScrollMaxSpeedDp else UNLIMITED_SCROLL_SPEED | ||
| ) | ||
| } | ||
| } else { | ||
| val targetOffset = | ||
| targetAnchor.absoluteTopPx.roundToInt().coerceIn(0, scrollState.maxValue) | ||
| scrollState.animateScrollTo(targetOffset) | ||
| } | ||
| pendingJumpToMessageTimestamp = null | ||
| } | ||
|
|
||
| LaunchedEffect(lastMessage?.timestamp, lastMessage?.contentStream) { | ||
| val lastAiMessageHasStaticContent = | ||
| lastMessage?.let { it.sender == "ai" && it.content.isNotBlank() } == true | ||
| hasLastAiMessageStartedStreaming = lastAiMessageHasStaticContent | ||
|
|
||
| val shouldAwaitFirstChunk = | ||
| lastMessage?.let { | ||
| it.sender == "ai" && it.content.isBlank() && it.contentStream != null | ||
| } == true | ||
| val stream = lastMessage?.contentStream | ||
|
|
||
| if (lastMessage?.sender == "ai" && stream != null) { | ||
| lastMessage.timestamp.let { streamedAiMessageTimestamps.add(it) } | ||
| } | ||
|
|
||
| if (!lastAiMessageHasStaticContent && shouldAwaitFirstChunk && stream != null) { | ||
| stream.collect { chunk -> | ||
| if (!hasLastAiMessageStartedStreaming && chunk.isNotEmpty()) { | ||
| hasLastAiMessageStartedStreaming = true | ||
| lastMessage?.timestamp?.let { streamedAiMessageTimestamps.add(it) } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| LaunchedEffect( | ||
| messagesCount, | ||
| chatHistory.firstOrNull()?.timestamp, | ||
|
|
@@ -356,8 +379,8 @@ fun ChatArea( | |
| .toList() | ||
| .filterNot { it in visibleTimestamps } | ||
| .forEach(messageAnchors::remove) | ||
| streamedAiMessageTimestamps.retainAll(visibleTimestamps) | ||
| } | ||
|
|
||
| val isLatestMessageVisible = messagesCount > 0 && !hasNewerDisplayHistory | ||
| val showLoadingIndicator = | ||
| isLatestMessageVisible && | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package com.ai.assistance.operit.ui.features.chat.components | ||
|
|
||
| import androidx.compose.animation.core.LinearEasing | ||
| import androidx.compose.animation.core.tween | ||
| import androidx.compose.foundation.ScrollState | ||
| import androidx.compose.ui.unit.Density | ||
| import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager | ||
|
|
||
| /** | ||
| * 表示“不限速”的速度值:滚动速度完全跟随模型吞吐量。 | ||
| */ | ||
| const val UNLIMITED_SCROLL_SPEED = DisplayPreferencesManager.STREAM_SCROLL_SPEED_UNLIMITED | ||
|
|
||
| /** | ||
| * 根据位移像素与屏幕密度,计算限速滚动所需的动画持续时间(毫秒)。 | ||
| * 当内容产生大量下移时,拉长动画时间以将滚动速度限制在 [maxSpeedDpPerSecond] 之下。 | ||
| */ | ||
| internal fun calculateScrollDurationMs( | ||
| deltaPx: Int, | ||
| densityDpiRatio: Float, | ||
| maxSpeedDpPerSecond: Float, | ||
| minDurationMs: Long = 100L, | ||
| maxDurationMs: Long = 1000L | ||
| ): Int { | ||
| if (deltaPx <= 0 || densityDpiRatio <= 0f || maxSpeedDpPerSecond <= 0f) { | ||
| return minDurationMs.toInt() | ||
| } | ||
| val deltaDp = deltaPx / densityDpiRatio | ||
| val calculatedMs = ((deltaDp / maxSpeedDpPerSecond) * 1000f).toLong() | ||
| return calculatedMs.coerceIn(minDurationMs, maxDurationMs).toInt() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [P2] 不要用 1 秒时长上限突破用户设置的滚动速度 当流式输出积压 500 dp、用户选择 50 dp/s 时,计算需要 10 秒,但这里截成 1000 ms,后面的 LinearEasing 动画仍滚完全部距离,实际达到 500 dp/s。ChatArea 和 AIChatScreen 的追赶滚动都会走此函数,因此大块输出时恰好无法保持设置的上限。请保留满足限速所需的时长,或限制每段滚动位移。 |
||
| } | ||
|
|
||
| /** | ||
| * 带有最大速度限制的平滑滚动。 | ||
| * | ||
| * [maxSpeedDpPerSecond] 是速度上限而非固定速度:模型输出较慢时不会介入, | ||
| * 只有在单次位移会导致滚动超速时才拉长动画时长削峰。 | ||
| * 传入 [UNLIMITED_SCROLL_SPEED] 时不做任何限制,滚动速度完全跟随模型吞吐量。 | ||
| */ | ||
| suspend fun ScrollState.animateScrollToWithSpeedLimit( | ||
| targetValue: Int, | ||
| density: Density, | ||
| maxSpeedDpPerSecond: Int | ||
| ) { | ||
| val clampedTarget = targetValue.coerceIn(0, maxValue) | ||
| val delta = clampedTarget - value | ||
| if (maxSpeedDpPerSecond == UNLIMITED_SCROLL_SPEED || delta <= 0) { | ||
| animateScrollTo(clampedTarget) | ||
| return | ||
| } | ||
|
|
||
| val duration = calculateScrollDurationMs( | ||
| deltaPx = delta, | ||
| densityDpiRatio = density.density, | ||
| maxSpeedDpPerSecond = maxSpeedDpPerSecond.toFloat() | ||
| ) | ||
| animateScrollTo( | ||
| clampedTarget, | ||
| animationSpec = tween(durationMillis = duration, easing = LinearEasing) | ||
| ) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[P2] 根据实际输出模式识别非流式回复
普通聊天关闭 Waifu、开启“禁用流式输出”时,AIMessageManager 虽将 stream=false 传入请求,仍无条件调用 shareRevisable;MessageProcessingDelegate 随后把带 contentStream 的占位 AI 消息加入界面。下面的 effect 因此会将 timestamp 记入 streamedAiMessageTimestamps,完成后即使清掉 contentStream,该记录仍在,这里也不能进入顶部定位分支。请传递实际请求的流式模式,避免把 UI 使用共享流当成网络流式输出的证据。