Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,39 @@ class DisplayPreferencesManager private constructor(private val context: Context

// 工具折叠设置(多个只读工具 / 多个任意工具 / 全部工具)
private val KEY_TOOL_COLLAPSE_MODE = stringPreferencesKey("tool_collapse_mode")
// 滚动行为相关设置的 Key
private val KEY_STREAM_SCROLL_MAX_SPEED_DP =
intPreferencesKey("stream_scroll_max_speed_dp")
private val KEY_ENABLE_NON_STREAMING_SCROLL_TO_TOP =
booleanPreferencesKey("enable_non_streaming_scroll_to_top")

/** 流式输出滚动速度上限的起步档(dp/s) */
const val STREAM_SCROLL_SPEED_MIN_DP = 50

/** 流式输出滚动速度上限的档位步进(dp/s) */
const val STREAM_SCROLL_SPEED_STEP_DP = 50

/**
* 流式输出滚动速度上限的最高有效限速档(dp/s)。
* 即倒数第二档;再往上一档为 [STREAM_SCROLL_SPEED_UNLIMITED]。
*/
const val STREAM_SCROLL_SPEED_MAX_LIMITED_DP = 1000

/**
* 最后一档:不设上限。
* 此时滚动速度完全跟随模型吞吐量,模型输出多快屏幕就滚多快。
*/
const val STREAM_SCROLL_SPEED_UNLIMITED = Int.MAX_VALUE

/** 流式输出滚动速度上限的默认值(dp/s),默认即启用限速 */
const val STREAM_SCROLL_SPEED_DEFAULT_DP = 600

/**
* 全部可选档位:50..1000(步进 50,共 20 档)+ 不限速(第 21 档)。
*/
val STREAM_SCROLL_SPEED_OPTIONS: List<Int> =
(STREAM_SCROLL_SPEED_MIN_DP..STREAM_SCROLL_SPEED_MAX_LIMITED_DP step STREAM_SCROLL_SPEED_STEP_DP)
.toList() + STREAM_SCROLL_SPEED_UNLIMITED
}

/**
Expand Down Expand Up @@ -204,6 +237,25 @@ class DisplayPreferencesManager private constructor(private val context: Context
ToolCollapseMode.fromValue(preferences[KEY_TOOL_COLLAPSE_MODE])
}

/**
* 流式输出时页面自动滚动的速度上限(dp/s)。
* 取 [STREAM_SCROLL_SPEED_UNLIMITED] 时表示最后一档“不设上限”,滚动速度跟随模型吞吐量。
* 默认值:[STREAM_SCROLL_SPEED_DEFAULT_DP]
*/
val streamScrollMaxSpeedDp: Flow<Int> =
context.displayPreferencesDataStore.data.map { preferences ->
preferences[KEY_STREAM_SCROLL_MAX_SPEED_DP] ?: STREAM_SCROLL_SPEED_DEFAULT_DP
}

/**
* 是否在非流式输出时将页面定位到消息开头
* 默认值:true
*/
val enableNonStreamingScrollToTop: Flow<Boolean> =
context.displayPreferencesDataStore.data.map { preferences ->
preferences[KEY_ENABLE_NON_STREAMING_SCROLL_TO_TOP] ?: true
}

/**
* 保存显示设置
*/
Expand All @@ -226,7 +278,9 @@ class DisplayPreferencesManager private constructor(private val context: Context
visitWebWaitSeconds: Int? = null,
toolPkgHookTimeoutSeconds: Int? = null,
virtualDisplayBitrateKbps: Int? = null,
toolCollapseMode: ToolCollapseMode? = null
toolCollapseMode: ToolCollapseMode? = null,
streamScrollMaxSpeedDp: Int? = null,
enableNonStreamingScrollToTop: Boolean? = null
) {
context.displayPreferencesDataStore.edit { preferences ->
showFpsCounter?.let { preferences[KEY_SHOW_FPS_COUNTER] = it }
Expand Down Expand Up @@ -264,6 +318,17 @@ class DisplayPreferencesManager private constructor(private val context: Context
}
virtualDisplayBitrateKbps?.let { preferences[KEY_VIRTUAL_DISPLAY_BITRATE_KBPS] = it }
toolCollapseMode?.let { preferences[KEY_TOOL_COLLAPSE_MODE] = it.value }
streamScrollMaxSpeedDp?.let {
preferences[KEY_STREAM_SCROLL_MAX_SPEED_DP] =
if (it == STREAM_SCROLL_SPEED_UNLIMITED) {
STREAM_SCROLL_SPEED_UNLIMITED
} else {
it.coerceIn(STREAM_SCROLL_SPEED_MIN_DP, STREAM_SCROLL_SPEED_MAX_LIMITED_DP)
}
}
enableNonStreamingScrollToTop?.let {
preferences[KEY_ENABLE_NON_STREAMING_SCROLL_TO_TOP] = it
}
}
}

Expand Down
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
Expand Down Expand Up @@ -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,
Expand All @@ -297,7 +302,6 @@ fun ChatArea(
pendingJumpToMessageTimestamp = lastMessage?.timestamp
}
}

LaunchedEffect(
pendingJumpToMessageTimestamp,
messagesCount,
Expand All @@ -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

Copy link
Copy Markdown
Collaborator

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 使用共享流当成网络流式输出的证据。


if (isNonStreamingAi && enableNonStreamingScrollToTop) {
val targetOffset =
targetAnchor.absoluteTopPx.roundToInt().coerceIn(0, scrollState.maxValue)
scrollState.animateScrollTo(targetOffset)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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,
Expand All @@ -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 &&
Expand Down
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()

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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)
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,10 @@ val actualViewModel: ChatViewModel = viewModel ?: viewModel { ChatViewModel(cont
val chatHeaderOverlayMode = themeSnapshot.chatHeaderOverlayMode
val showInputProcessingStatus = themeSnapshot.showInputProcessingStatus
val enableEnterToSend by displayPreferencesManager.enableEnterToSend.collectAsState(initial = false)
val streamScrollMaxSpeedDp by displayPreferencesManager.streamScrollMaxSpeedDp.collectAsState(
initial = DisplayPreferencesManager.STREAM_SCROLL_SPEED_DEFAULT_DP
)
val enableNonStreamingScrollToTop by displayPreferencesManager.enableNonStreamingScrollToTop.collectAsState(initial = true)
val showChatFloatingDotsAnimation = themeSnapshot.showChatFloatingDotsAnimation
val hasBackgroundImageFromPrefs = useBackgroundImage && backgroundImageUri != null
val effectiveHasBackgroundImage = hasBackgroundImage || hasBackgroundImageFromPrefs
Expand Down Expand Up @@ -676,8 +680,19 @@ val actualViewModel: ChatViewModel = viewModel ?: viewModel { ChatViewModel(cont
!latestIsLoadingDisplayWindow
) {
try {
val lastMsg = latestChatHistory.lastOrNull()
val isNonStreamingAi = lastMsg?.sender == "ai" && lastMsg.contentStream == null
if (isNonStreamingAi && enableNonStreamingScrollToTop) {
return@collect
}
if (latestChatHistory.isNotEmpty()) {
scrollState.animateScrollTo(scrollState.maxValue)
val isStreaming = lastMsg?.sender == "ai" && lastMsg.contentStream != null
scrollState.animateScrollToWithSpeedLimit(
targetValue = scrollState.maxValue,
density = density,
maxSpeedDpPerSecond =
if (isStreaming) streamScrollMaxSpeedDp else UNLIMITED_SCROLL_SPEED
)
}
} catch (e: Exception) {
// AppLogger.e("AIChatScreen", "自动滚动失败", e)
Expand Down
Loading