把“章节正文 + 图片”从 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:
Sully
2026-03-13 11:12:10 +08:00
committed by GitHub
parent 1cb804fa37
commit 2eb066dbec
19 changed files with 1280 additions and 624 deletions

View File

@@ -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()
}
}
}
}