This commit is contained in:
844143714@qq,com 2026-05-20 20:29:26 +08:00
parent 74788fd22e
commit bd5bd63f42
3 changed files with 76 additions and 64 deletions

View File

@ -7,7 +7,6 @@ import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Menu import androidx.compose.material.icons.filled.Menu
@ -29,42 +28,29 @@ fun ChatArea(
onOpenDrawer: () -> Unit, onOpenDrawer: () -> Unit,
onClose: () -> Unit, onClose: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onDrag: (Float, Float) -> Unit = { _, _ -> }, // 新增拖动回调,传递给顶层卡片进行位移 onDrag: (Float, Float) -> Unit = { _, _ -> },
onCancelStreaming: () -> Unit = {} // 👈 新增手动中断回调 onCancelStreaming: () -> Unit = {}
) { ) {
var inputText by remember { mutableStateOf("") } var inputText by remember { mutableStateOf("") }
// 👈 声明滚动状态,默认对齐最下端布局!
val listState = rememberLazyListState() val listState = rememberLazyListState()
// 👈 完美破局第二点:动态计算位移,确保无论何时都完美对齐最底端,而非最上端对齐!
// 1. 切换会话、新开会话或发送新提问时,平滑滚动到最后一项的偏移量。
// 为了防止出现“只露出头部”的缺陷,我们直接使用 layoutInfo 高度差来实现精密的物理底部对齐!
LaunchedEffect(messages.size) { LaunchedEffect(messages.size) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
val lastIndex = messages.size - 1 listState.scrollToItem(messages.size - 1, Int.MAX_VALUE)
// 首次滚动
listState.animateScrollToItem(lastIndex)
// 双重微调:确保高度变化后继续贴底
kotlinx.coroutines.delay(100)
listState.animateScrollToItem(lastIndex)
} }
} }
// 2. 核心大招:当 AI 处于 streaming 状态一字字输出时,打字机实时精准底部跟进!
// 我们不仅监听文字总长度,还设置极小延迟后高精度跟随,绝不出现卡在半空的尴尬。
val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } } val isAnyStreaming = remember(messages) { messages.any { it.isStreaming } }
if (isAnyStreaming) { if (isAnyStreaming) {
val totalLength = messages.map { it.content.length }.sum() val totalLength = messages.map { it.content.length }.sum()
LaunchedEffect(totalLength) { LaunchedEffect(totalLength) {
if (messages.isNotEmpty()) { if (messages.isNotEmpty()) {
listState.scrollToItem(messages.size - 1) listState.scrollToItem(messages.size - 1, Int.MAX_VALUE)
} }
} }
} }
Column(modifier = Modifier.fillMaxSize().background(Color.White)) { Column(modifier = Modifier.fillMaxSize().background(Color.White)) {
// 顶部标题栏,通过手势侦测支持整卡拖拽!
TopAppBar( TopAppBar(
title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) }, title = { Text("AI 助手", style = MaterialTheme.typography.titleMedium) },
navigationIcon = { navigationIcon = {
@ -83,52 +69,47 @@ fun ChatArea(
onDragCancel = { }, onDragCancel = { },
onDrag = { change, dragAmount -> onDrag = { change, dragAmount ->
change.consume() change.consume()
onDrag(dragAmount.x, dragAmount.y) // 拦截拖拽手势并触发回调位移 onDrag(dragAmount.x, dragAmount.y)
} }
) )
} }
) )
// 消息列表(外层包裹 SelectionContainer 从而支持长按选择和自由复制!) LazyColumn(
SelectionContainer(modifier = Modifier.weight(1f).fillMaxWidth()) { state = listState,
LazyColumn( modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
state = listState, // 绑定滚动状态 contentPadding = PaddingValues(vertical = 12.dp),
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)
contentPadding = PaddingValues(vertical = 12.dp), ) {
verticalArrangement = Arrangement.spacedBy(8.dp) items(messages, key = { it.id }) { msg ->
) { val isUser = msg.role == ChatMessage.Role.USER
items(messages, key = { it.id }) { msg -> // 👈 为每个消息绑定唯一 key提升打字机重绘性能拒绝任何卡死 Box(
val isUser = msg.role == ChatMessage.Role.USER modifier = Modifier.fillMaxWidth(),
Box( contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
modifier = Modifier.fillMaxWidth(), ) {
contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart Surface(
shape = RoundedCornerShape(12.dp),
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB),
contentColor = if (isUser) Color.White else Color.Black
) { ) {
Surface( if (isUser) {
shape = RoundedCornerShape(12.dp), Text(
color = if (isUser) Color(0xFF10B981) else Color(0xFFE5E7EB), text = msg.content,
contentColor = if (isUser) Color.White else Color.Black modifier = Modifier.padding(12.dp),
) { style = MaterialTheme.typography.bodyMedium
if (isUser) { )
Text( } else {
text = msg.content, val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
modifier = Modifier.padding(12.dp), MarkwonRenderer(
style = MaterialTheme.typography.bodyMedium markdown = contentWithCursor,
) modifier = Modifier.padding(12.dp)
} else { )
// 👈 直接使用行业公认、最稳定坚固、真正支持标准高精度 Markdown 排版换行与代码折叠的 Markwon 渲染器!
val contentWithCursor = msg.content + if (msg.isStreaming) "" else ""
MarkwonRenderer(
markdown = contentWithCursor,
modifier = Modifier.padding(12.dp)
)
}
} }
} }
} }
} }
} }
// 输入区
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -143,10 +124,9 @@ fun ChatArea(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
placeholder = { Text("输入你想问的问题...") }, placeholder = { Text("输入你想问的问题...") },
shape = RoundedCornerShape(20.dp), shape = RoundedCornerShape(20.dp),
enabled = !isAnyStreaming // streaming时禁用输入 enabled = !isAnyStreaming
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
// 👈 合并发送/停止按钮streaming时显示停止否则显示发送
IconButton( IconButton(
onClick = { onClick = {
if (isAnyStreaming) { if (isAnyStreaming) {
@ -171,18 +151,14 @@ fun ChatArea(
} }
} }
/**
* 基于 Markwon 引擎的 Markdown 渲染器
* 支持标题加粗斜体删除线链接代码块表格列表换行
*/
@Composable @Composable
fun MarkwonRenderer(markdown: String, modifier: Modifier) { fun MarkwonRenderer(markdown: String, modifier: Modifier) {
androidx.compose.ui.viewinterop.AndroidView( androidx.compose.ui.viewinterop.AndroidView(
factory = { ctx -> factory = { ctx ->
val textView = android.widget.TextView(ctx).apply { val textView = SafeSelectableTextView(ctx).apply {
setTextColor(android.graphics.Color.parseColor("#1F2937")) setTextColor(android.graphics.Color.parseColor("#1F2937"))
textSize = 14f textSize = 14f
setTextIsSelectable(true) setTextIsSelectable(true) // 👈 启用选择复制功能
movementMethod = android.text.method.LinkMovementMethod.getInstance() movementMethod = android.text.method.LinkMovementMethod.getInstance()
setLineSpacing(0f, 1.15f) setLineSpacing(0f, 1.15f)
setPadding(0, 0, 0, 0) setPadding(0, 0, 0, 0)
@ -214,4 +190,4 @@ fun MarkwonRenderer(markdown: String, modifier: Modifier) {
}, },
modifier = modifier modifier = modifier
) )
} }

View File

@ -0,0 +1,25 @@
package com.stand.standapp.ui.chat.components
import android.content.Context
import android.util.AttributeSet
import android.widget.TextView
/**
* 安全的可选择文本 TextView捕获 setTextIsSelectable 导致的 IndexOutOfBoundsException 崩溃
* 问题根源当文本更新时用户正在拖动选择手柄resetDragAcceleratorState 尝试设置无效范围 (-1, -1)
*/
class SafeSelectableTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : TextView(context, attrs, defStyleAttr) {
override fun onTouchEvent(event: android.view.MotionEvent): Boolean {
return try {
super.onTouchEvent(event)
} catch (e: IndexOutOfBoundsException) {
// 吞掉异常,避免崩溃
true
}
}
}

View File

@ -60,18 +60,29 @@ class AiChatRepository {
} }
// 3. 逐行读取后端流式推送字符,手动解析 data 属性 // 3. 逐行读取后端流式推送字符,手动解析 data 属性
// 👈 添加行缓冲确保完整的markdown行被发送
BufferedReader(InputStreamReader(body.byteStream(), Charsets.UTF_8)).use { reader -> BufferedReader(InputStreamReader(body.byteStream(), Charsets.UTF_8)).use { reader ->
var line: String? var line: String?
var lastcontent = "";
while (reader.readLine().also { line = it } != null) { while (reader.readLine().also { line = it } != null) {
val curLine = line ?: continue val curLine = line ?: continue
if (curLine.startsWith("data:")) { if (curLine.startsWith("data:")) {
val content = curLine.substring(5) val content = curLine.substring(5)
// 👈 空data行表示换行非空行发送内容 Timber.d("内容111111111"+content)
if (content.isBlank()) { if (content.contains("|") && lastcontent.contains("|")){
trySend("\n") trySend("\n")
} else { Timber.d("1发送11111111111换行")
trySend(content) trySend(content)
Timber.d("2发送11111111111"+content)
}else if (content.length==0) {
// 空data行表示换行
trySend("\n")
Timber.d("3发送11111111111换行")
}else {
trySend(content)
Timber.d("4发送11111111111"+content)
} }
lastcontent = content;
} }
} }
} }