diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/DisplayPreferencesManager.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/DisplayPreferencesManager.kt index 3c6f486387..83ebba2db3 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/DisplayPreferencesManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/DisplayPreferencesManager.kt @@ -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 = + (STREAM_SCROLL_SPEED_MIN_DP..STREAM_SCROLL_SPEED_MAX_LIMITED_DP step STREAM_SCROLL_SPEED_STEP_DP) + .toList() + STREAM_SCROLL_SPEED_UNLIMITED } /** @@ -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 = + context.displayPreferencesDataStore.data.map { preferences -> + preferences[KEY_STREAM_SCROLL_MAX_SPEED_DP] ?: STREAM_SCROLL_SPEED_DEFAULT_DP + } + + /** + * 是否在非流式输出时将页面定位到消息开头 + * 默认值:true + */ + val enableNonStreamingScrollToTop: Flow = + context.displayPreferencesDataStore.data.map { preferences -> + preferences[KEY_ENABLE_NON_STREAMING_SCROLL_TO_TOP] ?: true + } + /** * 保存显示设置 */ @@ -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 } @@ -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 + } } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt index 8d0f8957bc..1f665ea2d0 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt @@ -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,8 +261,14 @@ 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() } + val streamedAiMessageTimestamps = remember(currentChatId) { mutableSetOf() } var pendingJumpToMessageTimestamp by remember(currentChatId) { mutableStateOf(null) } val lastMessage = chatHistory.lastOrNull() val pendingTargetAnchor = @@ -270,14 +276,13 @@ fun ChatArea( 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,9 +319,26 @@ 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) + } 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) @@ -325,27 +346,29 @@ fun ChatArea( } 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 && diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimit.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimit.kt new file mode 100644 index 0000000000..1e48e98768 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimit.kt @@ -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() +} + +/** + * 带有最大速度限制的平滑滚动。 + * + * [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) + ) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/screens/AIChatScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/screens/AIChatScreen.kt index 62da3a05a8..3dfd4640ce 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/screens/AIChatScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/screens/AIChatScreen.kt @@ -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 @@ -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) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/GlobalDisplaySettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/GlobalDisplaySettingsScreen.kt index 8046d7ca69..25a0082d6c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/GlobalDisplaySettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/GlobalDisplaySettingsScreen.kt @@ -73,6 +73,11 @@ fun GlobalDisplaySettingsScreen( val screenshotScalePercent by displayPreferencesManager.screenshotScalePercent.collectAsState(initial = 75) val visitWebWaitSeconds by displayPreferencesManager.visitWebWaitSeconds.collectAsState(initial = 0) val toolPkgHookTimeoutSeconds by displayPreferencesManager.toolPkgHookTimeoutSeconds.collectAsState(initial = 10) + val streamScrollMaxSpeedDp by displayPreferencesManager.streamScrollMaxSpeedDp.collectAsState( + initial = DisplayPreferencesManager.STREAM_SCROLL_SPEED_DEFAULT_DP + ) + val streamScrollSpeedOptions = DisplayPreferencesManager.STREAM_SCROLL_SPEED_OPTIONS + val enableNonStreamingScrollToTop by displayPreferencesManager.enableNonStreamingScrollToTop.collectAsState(initial = true) val virtualDisplayBitrateKbps by displayPreferencesManager.virtualDisplayBitrateKbps.collectAsState(initial = 3000) val keepScreenOn by apiPreferences.keepScreenOnFlow.collectAsState(initial = true) val convertLongPastedTextToFile by userPreferences.convertLongPastedTextToFile.collectAsState(initial = true) @@ -103,6 +108,17 @@ fun GlobalDisplaySettingsScreen( var toolPkgHookTimeoutSliderValue by remember(toolPkgHookTimeoutSeconds) { mutableFloatStateOf(toolPkgHookTimeoutSeconds.toFloat()) } + var streamScrollSpeedSliderValue by remember(streamScrollMaxSpeedDp) { + mutableFloatStateOf( + streamScrollSpeedOptions.indexOf(streamScrollMaxSpeedDp) + .let { if (it < 0) streamScrollSpeedOptions.indexOf(DisplayPreferencesManager.STREAM_SCROLL_SPEED_DEFAULT_DP) else it } + .toFloat() + ) + } + val selectedStreamScrollSpeed = + streamScrollSpeedOptions[ + streamScrollSpeedSliderValue.roundToInt().coerceIn(0, streamScrollSpeedOptions.lastIndex) + ] var qualitySliderValue by remember(screenshotQuality) { mutableFloatStateOf(screenshotQuality.toFloat()) } @@ -147,6 +163,7 @@ fun GlobalDisplaySettingsScreen( collapseModeSliderValue, visitWebWaitSliderValue, toolPkgHookTimeoutSliderValue, + streamScrollSpeedSliderValue, qualitySliderValue, scaleSliderValue ) { @@ -154,6 +171,7 @@ fun GlobalDisplaySettingsScreen( collapseModeOptions[collapseModeSliderValue.roundToInt().coerceIn(0, collapseModeOptions.lastIndex)] val localVisitWebWaitSeconds = visitWebWaitSliderValue.roundToInt().coerceIn(0, 10) val localToolPkgHookTimeoutSeconds = toolPkgHookTimeoutSliderValue.roundToInt().coerceIn(1, 60) + val localStreamScrollMaxSpeedDp = selectedStreamScrollSpeed val localScreenshotQuality = qualitySliderValue.roundToInt().coerceIn(50, 100) val localScreenshotScalePercent = scaleSliderValue.roundToInt().coerceIn(50, 100) @@ -161,6 +179,7 @@ fun GlobalDisplaySettingsScreen( localCollapseMode != toolCollapseMode || localVisitWebWaitSeconds != visitWebWaitSeconds || localToolPkgHookTimeoutSeconds != toolPkgHookTimeoutSeconds || + localStreamScrollMaxSpeedDp != streamScrollMaxSpeedDp || localScreenshotQuality != screenshotQuality || localScreenshotScalePercent != screenshotScalePercent @@ -172,6 +191,7 @@ fun GlobalDisplaySettingsScreen( toolCollapseMode = if (localCollapseMode != toolCollapseMode) localCollapseMode else null, visitWebWaitSeconds = if (localVisitWebWaitSeconds != visitWebWaitSeconds) localVisitWebWaitSeconds else null, toolPkgHookTimeoutSeconds = if (localToolPkgHookTimeoutSeconds != toolPkgHookTimeoutSeconds) localToolPkgHookTimeoutSeconds else null, + streamScrollMaxSpeedDp = if (localStreamScrollMaxSpeedDp != streamScrollMaxSpeedDp) localStreamScrollMaxSpeedDp else null, screenshotQuality = if (localScreenshotQuality != screenshotQuality) localScreenshotQuality else null, screenshotScalePercent = if (localScreenshotScalePercent != screenshotScalePercent) localScreenshotScalePercent else null ) @@ -281,7 +301,62 @@ fun GlobalDisplaySettingsScreen( } }, ) - + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 4.dp) + .clip(RoundedCornerShape(6.dp)) + .background(componentBackgroundColor) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(R.string.stream_scroll_max_speed_title), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(R.string.stream_scroll_max_speed_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically + ) { + Slider( + value = streamScrollSpeedSliderValue, + onValueChange = { streamScrollSpeedSliderValue = it.roundToInt().toFloat() }, + valueRange = 0f..streamScrollSpeedOptions.lastIndex.toFloat(), + steps = (streamScrollSpeedOptions.size - 2).coerceAtLeast(0), + modifier = Modifier.weight(1f).padding(vertical = 8.dp), + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (selectedStreamScrollSpeed == DisplayPreferencesManager.STREAM_SCROLL_SPEED_UNLIMITED) { + stringResource(R.string.stream_scroll_max_speed_unlimited) + } else { + stringResource( + R.string.stream_scroll_max_speed_value, + selectedStreamScrollSpeed + ) + }, + style = MaterialTheme.typography.bodySmall + ) + } + } + DisplayToggleItem( + title = stringResource(R.string.enable_non_streaming_scroll_to_top), + subtitle = stringResource(R.string.enable_non_streaming_scroll_to_top_desc), + checked = enableNonStreamingScrollToTop, + onCheckedChange = { + scope.launch { + displayPreferencesManager.saveDisplaySettings(enableNonStreamingScrollToTop = it) + } + }, + backgroundColor = componentBackgroundColor + ) Spacer(modifier = Modifier.height(16.dp)) // ======= 系统显示设置 ======= diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index da3ae8664a..9875f466b6 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8325,4 +8325,10 @@ Current client version %1$s is lower than the minimum required version %2$s for this resource. Please update the client before downloading. Current client version %1$s is higher than the maximum supported version %2$s for this resource. Please use a supported client version. Invalid Thinking Configuration + Streaming Scroll Speed Cap + Cap the maximum auto-scroll speed while the AI generates quickly. This is an upper bound, not a fixed speed, so slower models are unaffected. The last step removes the cap and scrolling follows the model throughput. + %1$d dp/s + No cap + Show Top for Non-streaming Output + Position the page at the top of complete single-turn AI responses instead of jumping to the bottom diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 91d19cc84e..148e80916d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -7782,4 +7782,10 @@ Ahora puede usar el modo AutoGLM en la interfaz de conversación. Análisis de tendencias Configuración de estadísticas Aceptar + Límite de velocidad de desplazamiento en flujo + Limita la velocidad máxima de desplazamiento automático durante la generación rápida de la IA. Es un límite superior, no una velocidad fija, por lo que los modelos lentos no se ven afectados. El último paso elimina el límite y el desplazamiento sigue el rendimiento del modelo. + %1$d dp/s + Sin límite + Mostrar inicio en salidas no continuas + Posiciona la página al principio de las respuestas completas de la IA en lugar de ir directamente al final diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 864f535afd..7dcd455a0f 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -7633,4 +7633,10 @@ Sekarang Anda dapat menggunakan mode AutoGLM di antarmuka percakapan. Ketuk grafik untuk melihat data detail Total Token Token puncak + Batas Kecepatan Gulir Output Streaming + Batasi kecepatan gulir otomatis maksimum saat AI menghasilkan teks dengan cepat. Ini adalah batas atas, bukan kecepatan tetap, sehingga model yang lambat tidak terpengaruh. Langkah terakhir menghapus batas dan gulir mengikuti throughput model. + %1$d dp/s + Tanpa batas + Tampilkan Awal Pesan untuk Output Non-streaming + Posisikan halaman di awal respons lengkap AI alih-alih langsung melompat ke bagian bawah diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index bef17c9d23..0bfb81646d 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -7480,4 +7480,10 @@ 차트를 탭하여 자세한 데이터 보기 누적 토큰 최대 토큰 + 스트리밍 출력 스크롤 속도 상한 + AI가 빠르게 생성할 때 자동 스크롤의 최대 속도를 제한합니다. 고정 속도가 아닌 상한이므로 느린 모델에는 영향이 없습니다. 마지막 단계에서는 상한을 해제하여 스크롤이 모델 처리량을 그대로 따릅니다. + %1$d dp/초 + 제한 없음 + 비스트리밍 출력 시 메시지 상단으로 이동 + AI 전체 응답이 한 번에 반환될 때 맨 아래로 이동하지 않고 응답 시작 부분에 페이지를 맞춥니다 diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index e471122d20..7dcb0036d4 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -7711,4 +7711,10 @@ Kini anda boleh menggunakan mod AutoGLM di antara muka perbualan. Analisis trend Tetapan statistik OK + Had Kelajuan Tatalan Output Penstriman + Hadkan kelajuan tatalan automatik maksimum semasa AI menjana teks dengan pantas. Ini ialah had atas dan bukan kelajuan tetap, jadi model perlahan tidak terjejas. Langkah terakhir membuang had dan tatalan mengikut throughput model. + %1$d dp/s + Tiada had + Papar Bahagian Atas Mesej untuk Output Bukan Penstriman + Letakkan halaman pada permulaan respons lengkap AI dan bukannya melompat terus ke bahagian bawah diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 31e3e667ec..f3f7f3d2dc 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -7471,4 +7471,10 @@ Agora é possível usar o modo AutoGLM na interface de diálogo. Toque no gráfico para ver dados detalhados Tokens acumulados Pico de tokens + Limite de velocidade de rolagem no fluxo + Limita a velocidade máxima de rolagem automática durante a geração rápida da IA. É um limite superior, não uma velocidade fixa, então modelos mais lentos não são afetados. A última etapa remove o limite e a rolagem acompanha a taxa do modelo. + %1$d dp/s + Sem limite + Mostrar topo em saída não transmitida + Posiciona a página no início de respostas completas da IA em vez de rolar diretamente para o final diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 25f57b895c..c4867b0066 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -8309,4 +8309,10 @@ Acum poate fi utilizat în interfața de dialog AutoGLM A devenit un model.Analiza tendințelor Setări statistici OK + Limita vitezei de derulare în flux + Limitează viteza maximă de derulare automată în timpul generării rapide AI. Este o limită superioară, nu o viteză fixă, așa că modelele lente nu sunt afectate. Ultima treaptă elimină limita, iar derularea urmează debitul modelului. + %1$d dp/s + Fără limită + Afișează începutul pentru răspunsurile non-streaming + Poziționează pagina la începutul răspunsului complet al AI-ului, în loc să deruleze direct la sfârșit diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 54019940f0..190960b62c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8342,4 +8342,10 @@ 额度暂时不可用 %1$d 天后重置 %1$02d:%2$02d 后重置 + 流式输出滚动速度上限 + 限制 AI 快速生成时页面的最大自动滚动速度上限;这是上限而非固定速度,模型输出较慢时不会介入。拉到最后一档为不设上限,屏幕滚动完全跟随模型吞吐量。 + %1$d dp/秒 + 不设上限 + 非流式输出定位到消息开头 + AI 单次完整回复时,页面自动定位在回复内容开头,避免直接滚动到底部 diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimitTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimitTest.kt new file mode 100644 index 0000000000..ca1d11d4a9 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollSpeedLimitTest.kt @@ -0,0 +1,64 @@ +package com.ai.assistance.operit.ui.features.chat.components + +import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatScrollSpeedLimitTest { + + @Test + fun returnsMinDurationWhenDeltaIsZeroOrNegative() { + assertEquals(100, calculateScrollDurationMs(0, 2.0f, 600f)) + assertEquals(100, calculateScrollDurationMs(-50, 2.0f, 600f)) + } + + @Test + fun scalesDurationProportionallyWithDelta() { + // density = 2.0f, 600 dp/s => 1200 px/s + // delta = 1200 px => 600 dp => 600 / 600 * 1000 = 1000 ms + assertEquals(1000, calculateScrollDurationMs(1200, 2.0f, 600f)) + // delta = 600 px => 300 dp => 300 / 600 * 1000 = 500 ms + assertEquals(500, calculateScrollDurationMs(600, 2.0f, 600f)) + } + + @Test + fun lowerSpeedCapProducesLongerDuration() { + val slow = calculateScrollDurationMs(400, 2.0f, 50f) + val fast = calculateScrollDurationMs(400, 2.0f, 1000f) + assertTrue("更低的速度上限应产生更长的动画时长", slow > fast) + } + + @Test + fun clampsDurationBetweenMinAndMax() { + assertEquals(100, calculateScrollDurationMs(10, 2.0f, 600f)) + assertEquals(1000, calculateScrollDurationMs(10000, 2.0f, 600f)) + } + + @Test + fun handlesInvalidDensityOrSpeedGracefully() { + assertEquals(100, calculateScrollDurationMs(500, 0f, 600f)) + assertEquals(100, calculateScrollDurationMs(500, 2.0f, 0f)) + } + + @Test + fun speedOptionsHave20LimitedStepsPlusUnlimited() { + val options = DisplayPreferencesManager.STREAM_SCROLL_SPEED_OPTIONS + // 50..1000 步进 50 共 20 档,加上最后一档“不设上限”共 21 档 + assertEquals(21, options.size) + assertEquals(DisplayPreferencesManager.STREAM_SCROLL_SPEED_MIN_DP, options.first()) + assertEquals( + DisplayPreferencesManager.STREAM_SCROLL_SPEED_MAX_LIMITED_DP, + options[options.lastIndex - 1] + ) + assertEquals(DisplayPreferencesManager.STREAM_SCROLL_SPEED_UNLIMITED, options.last()) + } + + @Test + fun defaultSpeedIsSelectableOption() { + assertTrue( + DisplayPreferencesManager.STREAM_SCROLL_SPEED_DEFAULT_DP + in DisplayPreferencesManager.STREAM_SCROLL_SPEED_OPTIONS + ) + } +} \ No newline at end of file