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.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
)
}
}

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 属性
// 👈 添加行缓冲确保完整的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;
}
}
}