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