This commit is contained in:
parent
74788fd22e
commit
bd5bd63f42
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue