把“章节正文 + 图片”从 chapters 单表/JSON 结构,重构为“章节 chapter + 段落 section + 图片 memoir_images 独立表”的新数据模型,同时联动修改接口、PDF 导出、异步任务、迁移脚本、测试,以及修复 Android 端聊天列表显示问题。 (#9)
* refactor: 表结构重构,新增段落section和图片image新表 * fix: fix android app import error * refactor: 重构文件名 * fix: 优化提示词 * fix: 消息气泡显示位置异常问题 --------- Co-authored-by: yangshilin <2157598560@qq.com>
This commit is contained in:
@@ -13,7 +13,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.key
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
@@ -91,54 +90,30 @@ fun MessageList(
|
||||
count
|
||||
}
|
||||
|
||||
// 当前对话是否已加载过消息(用于首次显示时直接定位到底部,避免从顶部再滑到底部)
|
||||
var hasReceivedMessages by remember(conversationId) { mutableStateOf(false) }
|
||||
LaunchedEffect(conversationId, messages) {
|
||||
if (messages.isNotEmpty()) hasReceivedMessages = true
|
||||
val listState = key(conversationId) {
|
||||
rememberLazyListState()
|
||||
}
|
||||
|
||||
// 用 key 在「首次有消息」时重建列表状态,使 initialFirstVisibleItemIndex 生效,打开对话即显示底部
|
||||
val initialIndex = if (hasReceivedMessages && estimatedItemCount > 0) (estimatedItemCount - 1).coerceAtLeast(0) else 0
|
||||
val listState = key(conversationId, hasReceivedMessages) {
|
||||
rememberLazyListState(initialFirstVisibleItemIndex = initialIndex, initialFirstVisibleItemScrollOffset = 0)
|
||||
}
|
||||
|
||||
// 自动滚动到底部 - 当消息变化或流式内容更新时滚动
|
||||
// reverseLayout 下 index 0 在视口底部,scrollToItem(0) 即显示最新内容
|
||||
LaunchedEffect(messages.size, messages.lastOrNull()?.id, isStreaming, streamingText, isTyping) {
|
||||
// 短暂延迟确保内容已渲染
|
||||
if (estimatedItemCount <= 0) return@LaunchedEffect
|
||||
delay(100)
|
||||
|
||||
// 滚动到最后一项
|
||||
if (estimatedItemCount > 0) {
|
||||
try {
|
||||
listState.animateScrollToItem(estimatedItemCount - 1)
|
||||
} catch (e: Exception) {
|
||||
// 如果索引超出范围,尝试滚动到实际的最后一项
|
||||
val actualCount = listState.layoutInfo.totalItemsCount
|
||||
if (actualCount > 0) {
|
||||
listState.animateScrollToItem(actualCount - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
listState.animateScrollToItem(0)
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
reverseLayout = true,
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onSizeChanged { size ->
|
||||
val h = size.height
|
||||
// 键盘弹出导致列表高度变矮时,滚到最底部,让最后一条气泡紧贴输入框上方
|
||||
if (lastHeightPx > 0 && h < lastHeightPx) {
|
||||
scope.launch {
|
||||
delay(80)
|
||||
val count = listState.layoutInfo.totalItemsCount
|
||||
if (count > 0) {
|
||||
val viewportHeight = listState.layoutInfo.viewportSize.height
|
||||
// 一次平滑滚到底:用 scrollOffset 让最后一项贴底,避免两段滚动和中间停顿
|
||||
val scrollOffset = (viewportHeight - 120).coerceAtLeast(0)
|
||||
listState.animateScrollToItem(count - 1, scrollOffset)
|
||||
}
|
||||
listState.animateScrollToItem(0)
|
||||
}
|
||||
}
|
||||
lastHeightPx = h
|
||||
@@ -146,16 +121,30 @@ fun MessageList(
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
// 时间分隔线
|
||||
// reverseLayout: 先添加的显示在底部。顺序:typing(底) -> streaming -> messages(新->旧)
|
||||
if (isTyping || (isStreaming && streamingText.isEmpty())) {
|
||||
item { TypingIndicator() }
|
||||
}
|
||||
if (isStreaming) {
|
||||
val streamingParts = streamingText.split("[SPLIT]").map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (streamingParts.size > 1) {
|
||||
streamingParts.dropLast(1).forEachIndexed { partIndex, part ->
|
||||
item(key = "streaming_complete_$partIndex") { AIMessageBubble(text = part) }
|
||||
}
|
||||
item(key = "streaming_message") { StreamingAIMessageBubble(text = streamingParts.last()) }
|
||||
} else {
|
||||
item(key = "streaming_message") { StreamingAIMessageBubble(text = streamingText) }
|
||||
}
|
||||
}
|
||||
// 历史消息:从新到旧,最新在底部
|
||||
var lastDate: Long? = null
|
||||
|
||||
messages.forEachIndexed { index, message ->
|
||||
messages.asReversed().forEachIndexed { index, message ->
|
||||
val currentDate = message.timestamp
|
||||
val shouldShowDivider = lastDate == null ||
|
||||
(currentDate - lastDate!!) > 300000 // 5分钟间隔
|
||||
// 反向迭代:lastDate 是更新的消息,间隔>5分钟显示分隔线
|
||||
val shouldShowDivider = index > 0 && lastDate != null && (lastDate!! - currentDate) > 300000
|
||||
|
||||
if (shouldShowDivider && index > 0) {
|
||||
item {
|
||||
if (shouldShowDivider) {
|
||||
item(key = "divider_${message.id}") {
|
||||
TimeDivider(timestamp = currentDate)
|
||||
}
|
||||
}
|
||||
@@ -228,40 +217,6 @@ fun MessageList(
|
||||
|
||||
lastDate = currentDate
|
||||
}
|
||||
|
||||
// 流式消息显示 - 使用专门的流式消息气泡组件
|
||||
// 在 [SPLIT] 处分割流式消息
|
||||
if (isStreaming) {
|
||||
val streamingParts = streamingText.split("[SPLIT]")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
if (streamingParts.size > 1) {
|
||||
// 已完成的部分显示为普通气泡
|
||||
streamingParts.dropLast(1).forEachIndexed { partIndex, part ->
|
||||
item(key = "streaming_complete_$partIndex") {
|
||||
AIMessageBubble(text = part)
|
||||
}
|
||||
}
|
||||
// 最后一部分显示为流式气泡(可能还在输入)
|
||||
item(key = "streaming_message") {
|
||||
StreamingAIMessageBubble(text = streamingParts.last())
|
||||
}
|
||||
} else {
|
||||
item(key = "streaming_message") {
|
||||
StreamingAIMessageBubble(text = streamingText)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 正在输入指示器 - 显示加载动画
|
||||
// 1. 如果正在流式接收但还没有内容,显示加载动画
|
||||
// 2. 如果设置了isTyping且不在流式状态,显示加载动画
|
||||
if (isTyping || (isStreaming && streamingText.isEmpty())) {
|
||||
item {
|
||||
TypingIndicator()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user