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.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,21 +69,19 @@ 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 从而支持长按选择和自由复制!)
|
|
||||||
SelectionContainer(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState, // 绑定滚动状态
|
state = listState,
|
||||||
modifier = Modifier.fillMaxSize().padding(horizontal = 12.dp),
|
modifier = Modifier.weight(1f).fillMaxWidth().padding(horizontal = 12.dp),
|
||||||
contentPadding = PaddingValues(vertical = 12.dp),
|
contentPadding = PaddingValues(vertical = 12.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
items(messages, key = { it.id }) { msg -> // 👈 为每个消息绑定唯一 key,提升打字机重绘性能,拒绝任何卡死!
|
items(messages, key = { it.id }) { msg ->
|
||||||
val isUser = msg.role == ChatMessage.Role.USER
|
val isUser = msg.role == ChatMessage.Role.USER
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
|
@ -115,7 +99,6 @@ fun ChatArea(
|
||||||
style = MaterialTheme.typography.bodyMedium
|
style = MaterialTheme.typography.bodyMedium
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
// 👈 直接使用行业公认、最稳定坚固、真正支持标准高精度 Markdown 排版换行与代码折叠的 Markwon 渲染器!
|
|
||||||
val contentWithCursor = msg.content + if (msg.isStreaming) " █" else ""
|
val contentWithCursor = msg.content + if (msg.isStreaming) " █" else ""
|
||||||
MarkwonRenderer(
|
MarkwonRenderer(
|
||||||
markdown = contentWithCursor,
|
markdown = contentWithCursor,
|
||||||
|
|
@ -126,9 +109,7 @@ fun ChatArea(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
// 输入区
|
|
||||||
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)
|
||||||
|
|
|
||||||
|
|
@ -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 属性
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue