feat: 完善 AI 对话界面与交互

This commit is contained in:
HapeLee
2026-06-28 01:46:36 +08:00
parent 6d8660ee5b
commit c318e259c7
14 changed files with 605 additions and 251 deletions
+1 -1
View File
@@ -127,7 +127,7 @@
android:name=".ui.main.MainActivity"
android:alwaysRetainTaskState="true"
android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustPan"
android:windowSoftInputMode="adjustResize"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
@@ -76,4 +76,10 @@ interface AiChatDao {
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
suspend fun touchConversation(conversationId: String, updatedAt: Long)
@Query("DELETE FROM ai_chat_conversations WHERE id = :conversationId")
suspend fun deleteConversation(conversationId: String)
@Query("DELETE FROM ai_chat_messages WHERE conversationId = :conversationId")
suspend fun deleteMessagesByConversation(conversationId: String)
}
@@ -126,5 +126,10 @@ class AiChatRepository(
)
}
override suspend fun deleteConversation(conversationId: String) = withContext(Dispatchers.IO) {
aiChatDao.deleteMessagesByConversation(conversationId)
aiChatDao.deleteConversation(conversationId)
}
private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
}
@@ -30,4 +30,5 @@ interface AiChatGateway {
suspend fun getBranchCounts(conversationId: String): Map<String, Int>
suspend fun updateConversationTitle(conversationId: String, title: String)
suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String)
suspend fun deleteConversation(conversationId: String)
}
@@ -116,6 +116,29 @@ class AiChatGenerationUseCase(
}
}
suspend fun generateTitle(
userContent: String,
assistantContent: String,
reasoningLevel: AiReasoningLevel
): String {
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
?: error("No AI model configured")
val prompt = """
请根据以下对话内容,生成一个简短的中文标题(不超过20个字)。
只输出标题,不要任何解释或前缀。
用户:${userContent.take(500)}
助手:${assistantContent.take(500)}
""".trimIndent()
val request = AiGenerateRequest(
model = preset.model,
messages = listOf(AiMessage(AiMessageRole.USER, prompt)),
params = preset.params.copy(reasoningLevel = reasoningLevel)
)
val result = aiTextGateway.generate(request)
return result.getOrNull()?.text?.trim()?.take(30) ?: userContent.take(20)
}
private suspend fun buildRequestMessages(
newContent: String,
history: List<AiChatMessageUi>,
@@ -71,6 +71,8 @@ sealed interface AiChatIntent {
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
data class RegenerateMessage(val messageId: String) : AiChatIntent
data class SwitchBranch(val messageId: String) : AiChatIntent
data class DeleteConversation(val id: String) : AiChatIntent
data class RenameConversation(val id: String, val title: String) : AiChatIntent
}
sealed interface AiChatEffect {
@@ -1,14 +1,15 @@
package io.legado.app.ui.ai.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -23,15 +24,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
@@ -72,7 +75,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource
@@ -96,12 +104,9 @@ import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.text.MarkdownBlock
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarActionButton
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel
import java.text.SimpleDateFormat
@@ -133,13 +138,55 @@ fun AiChatScreen(
onBackClick: () -> Unit,
onOpenBookInfo: (AiChatBookResultUi) -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current
var draft by rememberSaveable { mutableStateOf("") }
val listState = rememberLazyListState()
val density = LocalDensity.current
var topOverlayHeightPx by remember { mutableIntStateOf(0) }
var bottomOverlayHeightPx by remember { mutableIntStateOf(0) }
var initiallyPositionedConversationId by remember { mutableStateOf<String?>(null) }
val currentConversation = state.conversations.firstOrNull {
it.id == state.currentConversationId
} ?: state.conversations.firstOrNull { it.isSelected }
val conversationTitle = currentConversation?.title?.takeIf { it.isNotBlank() }
?: stringResource(R.string.ai_chat)
val modelName = currentConversation?.modelName?.takeIf { it.isNotBlank() }
?: stringResource(R.string.ai_select_model)
val assistantLabel = when {
currentConversation == null -> ""
currentConversation.providerName.isNotBlank() &&
currentConversation.modelName.isNotBlank() -> {
"${currentConversation.providerName} ${currentConversation.modelName}"
}
currentConversation.modelName.isNotBlank() -> currentConversation.modelName
else -> ""
}
val generationGradientProgress = if (state.isSending) {
val transition = androidx.compose.animation.core.rememberInfiniteTransition(
label = "AiChatGeneratingGradient"
)
val progress by transition.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 1800, easing = LinearEasing),
repeatMode = RepeatMode.Reverse
),
label = "AiChatGeneratingGradientProgress"
)
progress
} else {
0f
}
val bottomGradientColor = lerp(
LegadoTheme.colorScheme.surface,
LegadoTheme.colorScheme.secondaryContainer,
generationGradientProgress
)
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
@@ -166,37 +213,10 @@ fun AiChatScreen(
}
) {
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.ai_chat),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
},
actions = {
TopBarActionButton(
onClick = { scope.launch { drawerState.open() } },
imageVector = Icons.Default.Menu,
contentDescription = stringResource(R.string.ai_recent_chats)
)
TopBarActionButton(
onClick = { onIntent(AiChatIntent.NewConversation) },
imageVector = Icons.Default.Add,
contentDescription = stringResource(R.string.ai_new_chat)
)
}
)
}
) { paddingValues ->
val inputBarReservedHeight = 96.dp + WindowInsets.navigationBars
.asPaddingValues()
.calculateBottomPadding()
// Keyboard-aware auto-scroll
ImeLazyListAutoScroller(lazyListState = listState)
alwaysDrawBehindBars = true,
contentWindowInsets = WindowInsets(0)
) {
// Track whether user is near the bottom
val isNearBottom by remember {
derivedStateOf {
@@ -216,6 +236,28 @@ fun AiChatScreen(
// Stick-to-bottom: tracks whether user has scrolled away
var shouldStickToBottom by remember { mutableStateOf(true) }
// Position immediately at the bottom once this conversation is laid out.
LaunchedEffect(
state.currentConversationId,
state.messages.size,
state.streamingMessage?.id
) {
val conversationId = state.currentConversationId ?: currentConversation?.id
if (conversationId == null ||
initiallyPositionedConversationId == conversationId ||
(state.messages.isEmpty() && state.streamingMessage == null)
) {
return@LaunchedEffect
}
val bottomAnchorIndex = state.messages.size +
if (state.streamingMessage != null) 1 else 0
snapshotFlow { listState.layoutInfo.totalItemsCount }
.first { it > bottomAnchorIndex }
listState.scrollToItem(bottomAnchorIndex)
initiallyPositionedConversationId = conversationId
shouldStickToBottom = true
}
// Update stick-to-bottom when user scrolls
LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress to isNearBottom }
@@ -250,20 +292,28 @@ fun AiChatScreen(
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(paddingValues)
modifier = Modifier.fillMaxSize()
) {
val systemBottomPadding = maxOf(
WindowInsets.ime.asPaddingValues().calculateBottomPadding(),
WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
)
val topContentPadding = with(density) {
topOverlayHeightPx.toDp()
}
val bottomContentPadding = with(density) {
bottomOverlayHeightPx.toDp()
} + systemBottomPadding
// Message list
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.fillMaxWidth()
.padding(horizontal = 8.dp),
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(
top = 8.dp,
bottom = inputBarReservedHeight
start = 8.dp,
top = topContentPadding + 8.dp,
end = 8.dp,
bottom = bottomContentPadding + 8.dp
),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
@@ -273,14 +323,6 @@ fun AiChatScreen(
}
}
items(state.messages, key = { it.id }) { message ->
val currentConversation = state.conversations.find { it.isSelected }
val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
"${currentConversation.providerName} ${currentConversation.modelName}"
} else if (currentConversation?.modelName?.isNotBlank() == true) {
currentConversation.modelName
} else {
""
}
ChatMessageItem(
message = message,
isStreaming = false,
@@ -313,14 +355,6 @@ fun AiChatScreen(
// Streaming message
val streaming = state.streamingMessage
if (streaming != null) {
val currentConversation = state.conversations.find { it.isSelected }
val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
"${currentConversation.providerName} ${currentConversation.modelName}"
} else if (currentConversation?.modelName?.isNotBlank() == true) {
currentConversation.modelName
} else {
""
}
item(key = "streaming") {
ChatMessageItem(
message = streaming,
@@ -337,80 +371,211 @@ fun AiChatScreen(
}
}
// Bottom: tool confirmation + floating input
// Draw behind both the floating input and the system navigation/IME area.
Column(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.navigationBarsPadding()
) {
// Scroll-to-bottom button
if (!isAtBottom && (state.messages.isNotEmpty() || state.streamingMessage != null)) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
SmallPlainButton(
onClick = {
scope.launch {
val lastIndex = listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) {
listState.animateScrollToItem(lastIndex)
}
shouldStickToBottom = true
}
},
icon = Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(R.string.ai_scroll_to_bottom),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(LegadoTheme.colorScheme.surfaceVariant)
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(bottomContentPadding - systemBottomPadding)
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to bottomGradientColor.copy(alpha = 0f),
0.58f to bottomGradientColor.copy(alpha = 0f),
0.82f to bottomGradientColor.copy(alpha = 0.24f),
1f to bottomGradientColor.copy(alpha = 0.68f)
)
)
)
}
Spacer(modifier = Modifier.height(8.dp))
}
state.pendingToolConfirmation?.let { confirmation ->
PendingToolConfirmationCard(
confirmation = confirmation,
onConfirm = { onIntent(AiChatIntent.ConfirmPendingTool) },
onReject = { onIntent(AiChatIntent.RejectPendingTool) }
)
}
ChatInputBar(
value = draft,
isSending = state.isSending,
reasoningLevel = state.reasoningLevel,
onValueChange = { draft = it },
onSend = {
val text = draft
draft = ""
onIntent(AiChatIntent.SendMessage(text))
},
onStop = { onIntent(AiChatIntent.StopGenerating) },
onUpdateReasoningLevel = { onIntent(AiChatIntent.UpdateReasoningLevel(it)) }
)
Spacer(
modifier = Modifier
.fillMaxWidth()
.height(systemBottomPadding)
.background(bottomGradientColor.copy(alpha = 0.68f))
)
}
}
}
}
}
/**
* Tracks keyboard (IME) height changes and scrolls the LazyList to compensate,
* so the list stays in place when the keyboard appears/disappears.
*/
@Composable
private fun ImeLazyListAutoScroller(lazyListState: LazyListState) {
val ime = WindowInsets.ime
val density = LocalDensity.current
var imeHeight by remember { mutableIntStateOf(0) }
LaunchedEffect(Unit) {
snapshotFlow { ime.getBottom(density) }.collect { keyboardHeight ->
if (keyboardHeight > 0) {
lazyListState.scrollBy((keyboardHeight - imeHeight).toFloat())
imeHeight = keyboardHeight
// Bottom controls consume IME/navigation insets exactly once.
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.imePadding()
.navigationBarsPadding()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { bottomOverlayHeightPx = it.height }
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(top = 32.dp)
) {
if (!isAtBottom &&
(state.messages.isNotEmpty() || state.streamingMessage != null)
) {
Box(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
) {
SmallPlainButton(
onClick = {
scope.launch {
val lastIndex =
listState.layoutInfo.totalItemsCount - 1
if (lastIndex >= 0) {
listState.animateScrollToItem(lastIndex)
}
shouldStickToBottom = true
}
},
icon = Icons.Default.KeyboardArrowDown,
contentDescription = stringResource(
R.string.ai_scroll_to_bottom
),
modifier = Modifier
.size(36.dp)
.clip(RoundedCornerShape(18.dp))
.background(LegadoTheme.colorScheme.surfaceVariant)
)
}
Spacer(modifier = Modifier.height(8.dp))
}
state.pendingToolConfirmation?.let { confirmation ->
PendingToolConfirmationCard(
confirmation = confirmation,
onConfirm = {
onIntent(AiChatIntent.ConfirmPendingTool)
},
onReject = {
onIntent(AiChatIntent.RejectPendingTool)
}
)
}
ChatInputBar(
value = draft,
isSending = state.isSending,
reasoningLevel = state.reasoningLevel,
onValueChange = { draft = it },
onSend = {
val text = draft
draft = ""
onIntent(AiChatIntent.SendMessage(text))
},
onStop = { onIntent(AiChatIntent.StopGenerating) },
onUpdateReasoningLevel = {
onIntent(AiChatIntent.UpdateReasoningLevel(it))
}
)
}
}
}
// Top controls draw above the list while keeping actions below the status bar.
Box(
modifier = Modifier
.align(Alignment.TopCenter)
.fillMaxWidth()
.onSizeChanged { topOverlayHeightPx = it.height }
) {
Spacer(
modifier = Modifier
.matchParentSize()
.background(
Brush.verticalGradient(
colorStops = arrayOf(
0f to LegadoTheme.colorScheme.surface.copy(alpha = 0.66f),
0.38f to LegadoTheme.colorScheme.surface.copy(alpha = 0.46f),
0.72f to LegadoTheme.colorScheme.surface.copy(alpha = 0.12f),
1f to LegadoTheme.colorScheme.surface.copy(alpha = 0f)
)
)
)
)
Row(
modifier = Modifier
.fillMaxWidth()
.statusBarsPadding()
.padding(start = 8.dp, top = 8.dp, end = 8.dp, bottom = 24.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
MediumTonalButton(
onClick = onBackClick,
icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
contentDescription = stringResource(R.string.back),
modifier = Modifier.shadow(
elevation = 2.dp,
shape = CircleShape,
clip = false
)
)
Box(
modifier = Modifier.weight(1f),
contentAlignment = Alignment.CenterStart
) {
Surface(
modifier = Modifier.height(40.dp),
shape = RoundedCornerShape(50),
color = LegadoTheme.colorScheme.surfaceContainerLow,
tonalElevation = 2.dp,
shadowElevation = 2.dp
) {
Column(
modifier = Modifier
.height(40.dp)
.padding(horizontal = 12.dp),
horizontalAlignment = Alignment.Start,
verticalArrangement = Arrangement.Center
) {
AppText(
text = conversationTitle,
style = LegadoTheme.typography.labelMediumEmphasized,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
AppText(
text = modelName,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
MediumTonalButton(
onClick = { scope.launch { drawerState.open() } },
icon = Icons.Default.Menu,
contentDescription = stringResource(R.string.ai_recent_chats),
modifier = Modifier.shadow(
elevation = 2.dp,
shape = CircleShape,
clip = false
)
)
MediumTonalButton(
onClick = { onIntent(AiChatIntent.NewConversation) },
icon = Icons.Default.Add,
contentDescription = stringResource(R.string.ai_new_chat),
modifier = Modifier.shadow(
elevation = 2.dp,
shape = CircleShape,
clip = false
)
)
}
}
}
}
}
}
@@ -471,7 +636,9 @@ private fun RecentChatsDrawer(
onNewChat: () -> Unit,
onSelectConversation: (String) -> Unit
) {
var searchQuery by remember { mutableStateOf("") }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showSearch by rememberSaveable { mutableStateOf(false) }
val searchFocusRequester = remember { FocusRequester() }
val filteredConversations = remember(conversations, searchQuery) {
if (searchQuery.isBlank()) {
conversations
@@ -482,6 +649,12 @@ private fun RecentChatsDrawer(
}
}
LaunchedEffect(showSearch) {
if (showSearch) {
searchFocusRequester.requestFocus()
}
}
ModalDrawerSheet(
modifier = Modifier.width(280.dp),
drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -496,24 +669,41 @@ private fun RecentChatsDrawer(
text = stringResource(R.string.ai_recent_chats),
style = LegadoTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f)
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
SmallPlainButton(
MediumTonalButton(
onClick = {
showSearch = !showSearch
if (!showSearch) {
searchQuery = ""
}
},
icon = Icons.Default.Search,
selected = showSearch,
contentDescription = stringResource(R.string.search)
)
Spacer(modifier = Modifier.width(8.dp))
MediumTonalButton(
onClick = onNewChat,
icon = Icons.Default.Add,
contentDescription = stringResource(R.string.ai_new_chat)
)
}
AppTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.search),
singleLine = true
)
AnimatedVisibility(visible = showSearch) {
AppTextField(
value = searchQuery,
onValueChange = { searchQuery = it },
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp)
.focusRequester(searchFocusRequester),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.search),
singleLine = true
)
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
@@ -647,6 +837,13 @@ private fun ChatMessageContent(
val groupedParts = remember(message.parts, message.thinkingDuration) {
message.parts.groupMessageParts(message.thinkingDuration)
}
val reasoningSteps = groupedParts
.filterIsInstance<AiMessagePartBlock.ThinkingBlock>()
.flatMap { block -> block.steps.filterIsInstance<AiThinkingStep.ReasoningStep>() }
val toolSteps = groupedParts
.filterIsInstance<AiMessagePartBlock.ThinkingBlock>()
.flatMap { block -> block.steps.filterIsInstance<AiThinkingStep.ToolStep>() }
val contentBlocks = groupedParts.filterIsInstance<AiMessagePartBlock.ContentBlock>()
Column(modifier = modifier) {
AppText(
@@ -660,64 +857,57 @@ private fun ChatMessageContent(
)
Spacer(modifier = Modifier.height(8.dp))
// Render parts in order: thinking blocks first, then content blocks
groupedParts.forEach { block ->
when (block) {
is AiMessagePartBlock.ThinkingBlock -> {
if (block.steps.isNotEmpty()) {
AiThinkingCard(
steps = block.steps,
isStreaming = isStreaming,
durationSeconds = block.durationSeconds,
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Presentation order is stable regardless of persistence order:
// reasoning, tool calls, then the assistant response.
if (reasoningSteps.isNotEmpty()) {
AiThinkingCard(
steps = reasoningSteps,
isStreaming = isStreaming,
durationSeconds = message.thinkingDuration,
modifier = Modifier.padding(bottom = 8.dp)
)
}
if (toolSteps.isNotEmpty()) {
AiThinkingCard(
steps = toolSteps,
isStreaming = isStreaming,
durationSeconds = message.thinkingDuration,
autoExpandWhileStreaming = false,
modifier = Modifier.padding(bottom = 8.dp)
)
}
contentBlocks.forEach { block ->
when (val part = block.part) {
is AiMessagePart.Text -> {
MessageTextContent(
text = part.text,
isUser = isUser,
isStreaming = isStreaming,
)
}
is AiMessagePartBlock.ContentBlock -> {
when (val part = block.part) {
is AiMessagePart.Text -> {
MessageTextContent(
text = part.text,
isUser = isUser,
isStreaming = isStreaming,
is AiMessagePart.BookResult -> {
BookResultsList(
books = listOf(
AiChatBookResultUi(
bookUrl = part.bookUrl,
name = part.name,
author = part.author,
origin = part.origin,
coverPath = part.coverPath,
latestChapterTitle = part.latestChapterTitle,
currentChapterTitle = part.currentChapterTitle,
intro = part.intro
)
}
is AiMessagePart.BookResult -> {
BookResultsList(
books = listOf(
AiChatBookResultUi(
bookUrl = part.bookUrl,
name = part.name,
author = part.author,
origin = part.origin,
coverPath = part.coverPath,
latestChapterTitle = part.latestChapterTitle,
currentChapterTitle = part.currentChapterTitle,
intro = part.intro
)
),
onOpenBookInfo = onOpenBookInfo
)
}
else -> { /* skip */ }
}
),
onOpenBookInfo = onOpenBookInfo
)
}
else -> { /* skip */ }
}
}
// Legacy fallback: if no grouped parts but there's content, show it directly
// Legacy fallback keeps the same reasoning -> tools -> content order.
if (groupedParts.isEmpty()) {
val displayContent = message.parts.filterIsInstance<AiMessagePart.Text>()
.joinToString("\n\n") { it.text }.trim()
.ifBlank { message.content }
if (displayContent.isNotBlank()) {
MessageTextContent(
text = displayContent,
isUser = isUser,
isStreaming = isStreaming,
)
}
// Legacy reasoning fallback
val displayReasoning = message.parts.filterIsInstance<AiMessagePart.Reasoning>()
.joinToString("\n\n") { it.text }.trim()
.takeIf { it.isNotBlank() } ?: message.reasoning
@@ -729,7 +919,6 @@ private fun ChatMessageContent(
modifier = Modifier.padding(bottom = 8.dp)
)
}
// Legacy tool trace fallback
val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
.joinToString("\n\n") { tool ->
buildString {
@@ -745,7 +934,16 @@ private fun ChatMessageContent(
content = displayToolTrace
)
}
// Legacy book results fallback
val displayContent = message.parts.filterIsInstance<AiMessagePart.Text>()
.joinToString("\n\n") { it.text }.trim()
.ifBlank { message.content }
if (displayContent.isNotBlank()) {
MessageTextContent(
text = displayContent,
isUser = isUser,
isStreaming = isStreaming,
)
}
if (message.bookResults.isNotEmpty()) {
BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
}
@@ -996,6 +1194,18 @@ private fun ChatInputBar(
AiReasoningLevel.XHIGH -> "Max"
}
val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF
val isKeyboardVisible =
WindowInsets.ime.asPaddingValues().calculateBottomPadding() > 0.dp
val horizontalPadding by animateDpAsState(
targetValue = if (isKeyboardVisible) 16.dp else 46.dp,
animationSpec = tween(durationMillis = 250),
label = "AiChatInputHorizontalPadding"
)
val bottomPadding by animateDpAsState(
targetValue = if (isKeyboardVisible) 16.dp else 32.dp,
animationSpec = tween(durationMillis = 250),
label = "AiChatInputBottomPadding"
)
// Thinking mode bottom sheet
AppModalBottomSheet(
@@ -1071,17 +1281,22 @@ private fun ChatInputBar(
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp),
shape = RoundedCornerShape(28.dp),
.padding(
start = horizontalPadding,
end = horizontalPadding,
bottom = bottomPadding
),
shape = RoundedCornerShape(32.dp),
color = LegadoTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp
tonalElevation = 2.dp,
shadowElevation = 8.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 6.dp),
.padding(all = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
// Thinking mode button
MediumTonalButton(
@@ -9,6 +9,7 @@ import io.legado.app.domain.model.AiMessagePart
import io.legado.app.domain.model.AiMessagePartJson
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiTaskType
import io.legado.app.domain.model.AiToolApprovalState
import io.legado.app.domain.model.reasoningContent
import io.legado.app.domain.model.textContent
@@ -17,6 +18,7 @@ import io.legado.app.domain.usecase.AiChatGenerationUseCase
import io.legado.app.domain.usecase.PendingToolRun
import io.legado.app.domain.usecase.ToolTraceBuilder
import io.legado.app.utils.GSON
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -66,6 +68,8 @@ class AiChatViewModel(
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
is AiChatIntent.SwitchBranch -> switchBranch(intent.messageId)
is AiChatIntent.DeleteConversation -> deleteConversation(intent.id)
is AiChatIntent.RenameConversation -> renameConversation(intent.id, intent.title)
}
}
@@ -76,13 +80,17 @@ class AiChatViewModel(
aiChatGateway.observeConversations().collect { conversations ->
val selectedId = currentConversationId.value
_uiState.update { current ->
val previousConversations = current.conversations.associateBy { it.id }
current.copy(
conversations = conversations.map {
val previous = previousConversations[it.id]
AiChatConversationUi(
id = it.id,
title = it.title,
updatedAt = it.updatedAt,
isSelected = it.id == selectedId
isSelected = it.id == selectedId,
providerName = previous?.providerName.orEmpty(),
modelName = previous?.modelName.orEmpty()
)
}.toImmutableList()
)
@@ -152,18 +160,10 @@ class AiChatViewModel(
currentConversationId.value = id
viewModelScope.launch {
val conversation = aiChatGateway.getConversation(id)
// Load provider/model info for the conversation
var providerName = ""
var modelName = ""
conversation?.modelProfileId?.let { profileId ->
val modelProfile = aiProfileGateway.getModel(profileId)
if (modelProfile != null) {
modelName = modelProfile.displayName
val provider = aiProfileGateway.getProvider(modelProfile.providerId)
providerName = provider?.name ?: ""
}
}
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
val providerName = preset?.model?.provider?.name.orEmpty()
val modelName = preset?.model?.displayName.orEmpty()
_uiState.update { current ->
current.copy(
@@ -268,6 +268,42 @@ class AiChatViewModel(
}
}
private fun deleteConversation(conversationId: String) {
viewModelScope.launch {
aiChatGateway.deleteConversation(conversationId)
if (currentConversationId.value == conversationId) {
currentConversationId.value = null
_uiState.update { it.copy(messages = persistentListOf(), currentConversationId = null) }
createConversation()
}
}
}
private fun renameConversation(conversationId: String, title: String) {
viewModelScope.launch {
aiChatGateway.updateConversationTitle(conversationId, title)
}
}
private fun generateConversationTitle(conversationId: String, userContent: String, assistantContent: String) {
viewModelScope.launch {
try {
val state = _uiState.value
val shortTitle = generationUseCase.generateTitle(
userContent = userContent,
assistantContent = assistantContent,
reasoningLevel = state.reasoningLevel
)
if (shortTitle.isNotBlank()) {
aiChatGateway.updateConversationTitle(conversationId, shortTitle)
}
} catch (_: Exception) {
// Fallback to truncated user message
aiChatGateway.updateConversationTitle(conversationId, userContent.take(24))
}
}
}
private fun regenerateMessage(messageId: String) {
val message = lastMessages.find { it.id == messageId }
?: return
@@ -390,9 +426,6 @@ class AiChatViewModel(
)
conversationIdForMsg = userMessage.conversationId
parentMessageId = userMessage.id
if (historySnapshot.none { it.role == AiMessageRole.USER }) {
aiChatGateway.updateConversationTitle(conversationId, content.take(24))
}
val state = _uiState.value
val request = generationUseCase.buildRequest(
userContent = content,
@@ -448,6 +481,10 @@ class AiChatViewModel(
thinkingDuration = duration
)
}
// Generate AI title for first conversation
if (conversationIdForMsg != null && historySnapshot.none { it.role == AiMessageRole.ASSISTANT }) {
generateConversationTitle(conversationIdForMsg, content, assistantContent)
}
}
_uiState.update {
if (waitingForToolConfirmation) it
@@ -40,7 +40,8 @@ import kotlinx.coroutines.delay
/**
* Unified thinking/tool card.
*
* - Expands only while actively thinking or calling tools
* - Reasoning can expand while streaming
* - Tool calls can opt out and expand only after a user click
* - Auto-collapses when streaming ends
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
*/
@@ -50,6 +51,7 @@ fun AiThinkingCard(
isStreaming: Boolean,
durationSeconds: Int = 0,
modifier: Modifier = Modifier,
autoExpandWhileStreaming: Boolean = true,
) {
val hasTools = steps.any { it is AiThinkingStep.ToolStep }
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
@@ -74,10 +76,14 @@ fun AiThinkingCard(
}
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
// Expand while streaming, collapse when done
var expanded by remember { mutableStateOf(isStreaming) }
LaunchedEffect(isStreaming) {
expanded = isStreaming
// Tool cards disable this so only an explicit user click can expand them.
var expanded by remember {
mutableStateOf(isStreaming && autoExpandWhileStreaming)
}
LaunchedEffect(isStreaming, autoExpandWhileStreaming) {
if (autoExpandWhileStreaming) {
expanded = isStreaming
}
}
// Show time only for reasoning (not tool-only)
@@ -34,7 +34,6 @@ fun AiConfigRouteScreen(
onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit,
viewModel: AiConfigViewModel = koinViewModel()
) {
@@ -45,7 +44,6 @@ fun AiConfigRouteScreen(
onBackClick = onBackClick,
onNavigateToProviderEdit = onNavigateToProviderEdit,
onNavigateToModelEdit = onNavigateToModelEdit,
onNavigateToChat = onNavigateToChat,
onNavigateToTranslation = onNavigateToTranslation
)
}
@@ -59,7 +57,6 @@ fun AiConfigScreen(
onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -128,10 +125,6 @@ fun AiConfigScreen(
item {
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
ClickableSettingItem(
title = stringResource(R.string.ai_chat),
onClick = onNavigateToChat
)
ClickableSettingItem(
title = stringResource(R.string.translation_config),
onClick = onNavigateToTranslation
@@ -95,6 +95,9 @@ fun MainActivity.mainEntryProvider(
onOpenSettings = {
onNavigateToRoute(MainRouteSettings)
},
onNavigateToChat = {
onNavigateToRoute(MainRouteAiChat)
},
onNavigateToSearch = { key ->
onNavigateToRoute(
MainRouteSearch(
@@ -227,7 +230,6 @@ fun MainActivity.mainEntryProvider(
)
)
},
onNavigateToChat = { backStack.add(MainRouteAiChat) },
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
)
}
@@ -105,6 +105,7 @@ fun MainScreen(
viewModel: MainViewModel = koinViewModel(),
useRail: Boolean,
onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit,
@@ -424,6 +425,7 @@ fun MainScreen(
)
MainDestination.My -> MyScreen(
onOpenSettings = onOpenSettings,
onNavigateToChat = onNavigateToChat,
onNavigate = { event ->
when (event) {
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage()
@@ -23,6 +23,7 @@ import androidx.compose.material.icons.automirrored.filled.HelpOutline
import androidx.compose.material.icons.automirrored.filled.LibraryBooks
import androidx.compose.material.icons.automirrored.filled.Rule
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FindReplace
@@ -67,6 +68,7 @@ import org.koin.androidx.compose.koinViewModel
fun MyScreen(
viewModel: MyViewModel = koinViewModel(),
onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit
) {
@@ -170,6 +172,11 @@ fun MyScreen(
SplicedColumnGroup(
title = stringResource(R.string.other)
) {
ClickableSettingItem(
title = stringResource(R.string.ai_chat),
imageVector = Icons.Default.AutoAwesome,
onClick = onNavigateToChat
)
ClickableSettingItem(
title = stringResource(R.string.setting),
imageVector = Icons.Default.Settings,
@@ -5,16 +5,22 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -30,6 +36,7 @@ import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
@@ -45,6 +52,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -52,6 +60,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import splitties.systemservices.clipboardManager
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
@@ -541,6 +550,13 @@ private fun MarkdownCodeBlock(
language: String?,
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
val collapsedLines = 10
val codeLines = remember(code) { code.lines() }
var isExpanded by remember(code) { mutableStateOf(codeLines.size <= collapsedLines) }
val canCollapse = codeLines.size > collapsedLines
Column(
modifier = modifier
.fillMaxWidth()
@@ -552,24 +568,63 @@ private fun MarkdownCodeBlock(
RoundedCornerShape(8.dp)
)
) {
if (!language.isNullOrBlank()) {
AppText(
text = language,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
AppText(
text = code,
style = LegadoTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 0.85.em),
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically,
) {
if (!language.isNullOrBlank()) {
Text(
text = language,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.weight(1f))
Icon(
painter = painterResource(R.drawable.ic_copy),
contentDescription = null,
modifier = Modifier
.clip(RoundedCornerShape(4.dp))
.clickable {
val clip = android.content.ClipData.newPlainText("code", code)
clipboardManager.setPrimaryClip(clip)
}
.padding(4.dp)
.size(16.dp),
tint = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
}
Row(
modifier = Modifier.then(
if (true) Modifier else Modifier.horizontalScroll(scrollState)
)
) {
val displayCode = if (isExpanded) code else codeLines.take(collapsedLines).joinToString("\n")
AppText(
text = displayCode,
style = LegadoTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace,
),
modifier = Modifier
.weight(1f)
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
if (canCollapse) {
Text(
text = if (isExpanded) "▲ 收起" else "▼ 展开 (${codeLines.size} 行)",
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.primary,
modifier = Modifier
.fillMaxWidth()
.clickable { isExpanded = !isExpanded }
.padding(vertical = 4.dp)
.padding(horizontal = 12.dp),
)
}
}
}
@@ -618,13 +673,17 @@ private fun MarkdownTable(
}
}
// Rows
rows.forEach { row ->
rows.forEachIndexed { index, row ->
Row(
modifier = Modifier
.fillMaxWidth()
.background(
if (index % 2 == 0) LegadoTheme.colorScheme.surface
else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)
)
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
row.forEachIndexed { index, cell ->
row.forEach { cell ->
AppText(
text = cell,
style = LegadoTheme.typography.bodySmall,
@@ -634,10 +693,6 @@ private fun MarkdownTable(
)
}
}
HorizontalDivider(
thickness = 0.25.dp,
color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}