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

This commit is contained in:
HapeLee committed 2026-06-28 01:46:36 +08:00
1 parent 6d8660ee5b
commit c318e259c7
14 files changed
+605 -251

No files matched your search

+1 -1
View File
@@ -127,7 +127,7 @@
android:name=".ui.main.MainActivity" android:name=".ui.main.MainActivity"
android:alwaysRetainTaskState="true" android:alwaysRetainTaskState="true"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="true"
android:windowSoftInputMode="adjustPan" android:windowSoftInputMode="adjustResize"
android:exported="true"> android:exported="true">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
@@ -76,4 +76,10 @@ interface AiChatDao {
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId") @Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
suspend fun touchConversation(conversationId: String, updatedAt: Long) 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("-", "")}" 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 getBranchCounts(conversationId: String): Map<String, Int>
suspend fun updateConversationTitle(conversationId: String, title: String) suspend fun updateConversationTitle(conversationId: String, title: String)
suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: 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( private suspend fun buildRequestMessages(
newContent: String, newContent: String,
history: List<AiChatMessageUi>, history: List<AiChatMessageUi>,
@@ -71,6 +71,8 @@ sealed interface AiChatIntent {
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
data class RegenerateMessage(val messageId: String) : AiChatIntent data class RegenerateMessage(val messageId: String) : AiChatIntent
data class SwitchBranch(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 { sealed interface AiChatEffect {
@@ -1,14 +1,15 @@
package io.legado.app.ui.ai.chat package io.legado.app.ui.ai.chat
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloat import androidx.compose.animation.core.animateFloat
import androidx.compose.animation.core.infiniteRepeatable import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.scrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column 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.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
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.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
@@ -72,7 +75,12 @@ import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip 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.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource 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.image.cover.CoilBookCover
import io.legado.app.ui.widget.components.text.AppText 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.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.Flow
import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.androidx.compose.koinViewModel import org.koin.androidx.compose.koinViewModel
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@@ -133,13 +138,55 @@ fun AiChatScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onOpenBookInfo: (AiChatBookResultUi) -> Unit onOpenBookInfo: (AiChatBookResultUi) -> Unit
) { ) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val drawerState = rememberDrawerState(DrawerValue.Closed) val drawerState = rememberDrawerState(DrawerValue.Closed)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
var draft by rememberSaveable { mutableStateOf("") } var draft by rememberSaveable { mutableStateOf("") }
val listState = rememberLazyListState() 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) { LaunchedEffect(Unit) {
effects.collectLatest { effect -> effects.collectLatest { effect ->
@@ -166,37 +213,10 @@ fun AiChatScreen(
} }
) { ) {
AppScaffold( AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = { alwaysDrawBehindBars = true,
GlassMediumFlexibleTopAppBar( contentWindowInsets = WindowInsets(0)
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)
// Track whether user is near the bottom // Track whether user is near the bottom
val isNearBottom by remember { val isNearBottom by remember {
derivedStateOf { derivedStateOf {
@@ -216,6 +236,28 @@ fun AiChatScreen(
// Stick-to-bottom: tracks whether user has scrolled away // Stick-to-bottom: tracks whether user has scrolled away
var shouldStickToBottom by remember { mutableStateOf(true) } 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 // Update stick-to-bottom when user scrolls
LaunchedEffect(listState) { LaunchedEffect(listState) {
snapshotFlow { listState.isScrollInProgress to isNearBottom } snapshotFlow { listState.isScrollInProgress to isNearBottom }
@@ -250,20 +292,28 @@ fun AiChatScreen(
} }
Box( Box(
modifier = Modifier modifier = Modifier.fillMaxSize()
.fillMaxSize()
.padding(paddingValues)
) { ) {
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 // Message list
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier modifier = Modifier.fillMaxSize(),
.fillMaxSize()
.fillMaxWidth()
.padding(horizontal = 8.dp),
contentPadding = PaddingValues( contentPadding = PaddingValues(
top = 8.dp, start = 8.dp,
bottom = inputBarReservedHeight top = topContentPadding + 8.dp,
end = 8.dp,
bottom = bottomContentPadding + 8.dp
), ),
verticalArrangement = Arrangement.spacedBy(12.dp) verticalArrangement = Arrangement.spacedBy(12.dp)
) { ) {
@@ -273,14 +323,6 @@ fun AiChatScreen(
} }
} }
items(state.messages, key = { it.id }) { message -> 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( ChatMessageItem(
message = message, message = message,
isStreaming = false, isStreaming = false,
@@ -313,14 +355,6 @@ fun AiChatScreen(
// Streaming message // Streaming message
val streaming = state.streamingMessage val streaming = state.streamingMessage
if (streaming != null) { 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") { item(key = "streaming") {
ChatMessageItem( ChatMessageItem(
message = streaming, 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( Column(
modifier = Modifier modifier = Modifier
.align(Alignment.BottomCenter) .align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.navigationBarsPadding()
) { ) {
// Scroll-to-bottom button Spacer(
if (!isAtBottom && (state.messages.isNotEmpty() || state.streamingMessage != null)) { modifier = Modifier
Box( .fillMaxWidth()
modifier = Modifier.fillMaxWidth(), .height(bottomContentPadding - systemBottomPadding)
contentAlignment = Alignment.Center .background(
) { Brush.verticalGradient(
SmallPlainButton( colorStops = arrayOf(
onClick = { 0f to bottomGradientColor.copy(alpha = 0f),
scope.launch { 0.58f to bottomGradientColor.copy(alpha = 0f),
val lastIndex = listState.layoutInfo.totalItemsCount - 1 0.82f to bottomGradientColor.copy(alpha = 0.24f),
if (lastIndex >= 0) { 1f to bottomGradientColor.copy(alpha = 0.68f)
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)) Spacer(
} modifier = Modifier
.fillMaxWidth()
state.pendingToolConfirmation?.let { confirmation -> .height(systemBottomPadding)
PendingToolConfirmationCard( .background(bottomGradientColor.copy(alpha = 0.68f))
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)) }
) )
} }
}
}
}
}
/** // Bottom controls consume IME/navigation insets exactly once.
* Tracks keyboard (IME) height changes and scrolls the LazyList to compensate, Box(
* so the list stays in place when the keyboard appears/disappears. modifier = Modifier
*/ .align(Alignment.BottomCenter)
@Composable .fillMaxWidth()
private fun ImeLazyListAutoScroller(lazyListState: LazyListState) { .imePadding()
val ime = WindowInsets.ime .navigationBarsPadding()
val density = LocalDensity.current ) {
var imeHeight by remember { mutableIntStateOf(0) } Box(
LaunchedEffect(Unit) { modifier = Modifier
snapshotFlow { ime.getBottom(density) }.collect { keyboardHeight -> .fillMaxWidth()
if (keyboardHeight > 0) { .onSizeChanged { bottomOverlayHeightPx = it.height }
lazyListState.scrollBy((keyboardHeight - imeHeight).toFloat()) ) {
imeHeight = keyboardHeight 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, onNewChat: () -> Unit,
onSelectConversation: (String) -> 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) { val filteredConversations = remember(conversations, searchQuery) {
if (searchQuery.isBlank()) { if (searchQuery.isBlank()) {
conversations conversations
@@ -482,6 +649,12 @@ private fun RecentChatsDrawer(
} }
} }
LaunchedEffect(showSearch) {
if (showSearch) {
searchFocusRequester.requestFocus()
}
}
ModalDrawerSheet( ModalDrawerSheet(
modifier = Modifier.width(280.dp), modifier = Modifier.width(280.dp),
drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow drawerContainerColor = LegadoTheme.colorScheme.surfaceContainerLow
@@ -496,24 +669,41 @@ private fun RecentChatsDrawer(
text = stringResource(R.string.ai_recent_chats), text = stringResource(R.string.ai_recent_chats),
style = LegadoTheme.typography.titleMedium, style = LegadoTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, 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, onClick = onNewChat,
icon = Icons.Default.Add, icon = Icons.Default.Add,
contentDescription = stringResource(R.string.ai_new_chat) contentDescription = stringResource(R.string.ai_new_chat)
) )
} }
AppTextField( AnimatedVisibility(visible = showSearch) {
value = searchQuery, AppTextField(
onValueChange = { searchQuery = it }, value = searchQuery,
modifier = Modifier onValueChange = { searchQuery = it },
.fillMaxWidth() modifier = Modifier
.padding(horizontal = 12.dp, vertical = 4.dp), .fillMaxWidth()
backgroundColor = LegadoTheme.colorScheme.surface, .padding(horizontal = 12.dp, vertical = 4.dp)
label = stringResource(R.string.search), .focusRequester(searchFocusRequester),
singleLine = true backgroundColor = LegadoTheme.colorScheme.surface,
) label = stringResource(R.string.search),
singleLine = true
)
}
LazyColumn( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 12.dp, vertical = 4.dp),
@@ -647,6 +837,13 @@ private fun ChatMessageContent(
val groupedParts = remember(message.parts, message.thinkingDuration) { val groupedParts = remember(message.parts, message.thinkingDuration) {
message.parts.groupMessageParts(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) { Column(modifier = modifier) {
AppText( AppText(
@@ -660,64 +857,57 @@ private fun ChatMessageContent(
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Render parts in order: thinking blocks first, then content blocks // Presentation order is stable regardless of persistence order:
groupedParts.forEach { block -> // reasoning, tool calls, then the assistant response.
when (block) { if (reasoningSteps.isNotEmpty()) {
is AiMessagePartBlock.ThinkingBlock -> { AiThinkingCard(
if (block.steps.isNotEmpty()) { steps = reasoningSteps,
AiThinkingCard( isStreaming = isStreaming,
steps = block.steps, durationSeconds = message.thinkingDuration,
isStreaming = isStreaming, modifier = Modifier.padding(bottom = 8.dp)
durationSeconds = block.durationSeconds, )
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 -> { is AiMessagePart.BookResult -> {
when (val part = block.part) { BookResultsList(
is AiMessagePart.Text -> { books = listOf(
MessageTextContent( AiChatBookResultUi(
text = part.text, bookUrl = part.bookUrl,
isUser = isUser, name = part.name,
isStreaming = isStreaming, author = part.author,
origin = part.origin,
coverPath = part.coverPath,
latestChapterTitle = part.latestChapterTitle,
currentChapterTitle = part.currentChapterTitle,
intro = part.intro
) )
} ),
is AiMessagePart.BookResult -> { onOpenBookInfo = onOpenBookInfo
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 */ }
}
} }
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()) { 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>() val displayReasoning = message.parts.filterIsInstance<AiMessagePart.Reasoning>()
.joinToString("\n\n") { it.text }.trim() .joinToString("\n\n") { it.text }.trim()
.takeIf { it.isNotBlank() } ?: message.reasoning .takeIf { it.isNotBlank() } ?: message.reasoning
@@ -729,7 +919,6 @@ private fun ChatMessageContent(
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
} }
// Legacy tool trace fallback
val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>() val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
.joinToString("\n\n") { tool -> .joinToString("\n\n") { tool ->
buildString { buildString {
@@ -745,7 +934,16 @@ private fun ChatMessageContent(
content = displayToolTrace 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()) { if (message.bookResults.isNotEmpty()) {
BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo) BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
} }
@@ -996,6 +1194,18 @@ private fun ChatInputBar(
AiReasoningLevel.XHIGH -> "Max" AiReasoningLevel.XHIGH -> "Max"
} }
val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF 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 // Thinking mode bottom sheet
AppModalBottomSheet( AppModalBottomSheet(
@@ -1071,17 +1281,22 @@ private fun ChatInputBar(
Surface( Surface(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp), .padding(
shape = RoundedCornerShape(28.dp), start = horizontalPadding,
end = horizontalPadding,
bottom = bottomPadding
),
shape = RoundedCornerShape(32.dp),
color = LegadoTheme.colorScheme.surfaceContainerHigh, color = LegadoTheme.colorScheme.surfaceContainerHigh,
tonalElevation = 2.dp tonalElevation = 2.dp,
shadowElevation = 8.dp
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 6.dp, vertical = 6.dp), .padding(all = 8.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp) horizontalArrangement = Arrangement.spacedBy(4.dp)
) { ) {
// Thinking mode button // Thinking mode button
MediumTonalButton( 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.AiMessagePartJson
import io.legado.app.domain.model.AiMessageRole import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiReasoningLevel 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.AiToolApprovalState
import io.legado.app.domain.model.reasoningContent import io.legado.app.domain.model.reasoningContent
import io.legado.app.domain.model.textContent 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.PendingToolRun
import io.legado.app.domain.usecase.ToolTraceBuilder import io.legado.app.domain.usecase.ToolTraceBuilder
import io.legado.app.utils.GSON import io.legado.app.utils.GSON
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -66,6 +68,8 @@ class AiChatViewModel(
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level) is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId) is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
is AiChatIntent.SwitchBranch -> switchBranch(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 -> aiChatGateway.observeConversations().collect { conversations ->
val selectedId = currentConversationId.value val selectedId = currentConversationId.value
_uiState.update { current -> _uiState.update { current ->
val previousConversations = current.conversations.associateBy { it.id }
current.copy( current.copy(
conversations = conversations.map { conversations = conversations.map {
val previous = previousConversations[it.id]
AiChatConversationUi( AiChatConversationUi(
id = it.id, id = it.id,
title = it.title, title = it.title,
updatedAt = it.updatedAt, updatedAt = it.updatedAt,
isSelected = it.id == selectedId isSelected = it.id == selectedId,
providerName = previous?.providerName.orEmpty(),
modelName = previous?.modelName.orEmpty()
) )
}.toImmutableList() }.toImmutableList()
) )
@@ -152,18 +160,10 @@ class AiChatViewModel(
currentConversationId.value = id currentConversationId.value = id
viewModelScope.launch { viewModelScope.launch {
val conversation = aiChatGateway.getConversation(id) val conversation = aiChatGateway.getConversation(id)
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
// Load provider/model info for the conversation ?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
var providerName = "" val providerName = preset?.model?.provider?.name.orEmpty()
var modelName = "" val modelName = preset?.model?.displayName.orEmpty()
conversation?.modelProfileId?.let { profileId ->
val modelProfile = aiProfileGateway.getModel(profileId)
if (modelProfile != null) {
modelName = modelProfile.displayName
val provider = aiProfileGateway.getProvider(modelProfile.providerId)
providerName = provider?.name ?: ""
}
}
_uiState.update { current -> _uiState.update { current ->
current.copy( 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) { private fun regenerateMessage(messageId: String) {
val message = lastMessages.find { it.id == messageId } val message = lastMessages.find { it.id == messageId }
?: return ?: return
@@ -390,9 +426,6 @@ class AiChatViewModel(
) )
conversationIdForMsg = userMessage.conversationId conversationIdForMsg = userMessage.conversationId
parentMessageId = userMessage.id parentMessageId = userMessage.id
if (historySnapshot.none { it.role == AiMessageRole.USER }) {
aiChatGateway.updateConversationTitle(conversationId, content.take(24))
}
val state = _uiState.value val state = _uiState.value
val request = generationUseCase.buildRequest( val request = generationUseCase.buildRequest(
userContent = content, userContent = content,
@@ -448,6 +481,10 @@ class AiChatViewModel(
thinkingDuration = duration thinkingDuration = duration
) )
} }
// Generate AI title for first conversation
if (conversationIdForMsg != null && historySnapshot.none { it.role == AiMessageRole.ASSISTANT }) {
generateConversationTitle(conversationIdForMsg, content, assistantContent)
}
} }
_uiState.update { _uiState.update {
if (waitingForToolConfirmation) it if (waitingForToolConfirmation) it
@@ -40,7 +40,8 @@ import kotlinx.coroutines.delay
/** /**
* Unified thinking/tool card. * 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 * - Auto-collapses when streaming ends
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow * - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
*/ */
@@ -50,6 +51,7 @@ fun AiThinkingCard(
isStreaming: Boolean, isStreaming: Boolean,
durationSeconds: Int = 0, durationSeconds: Int = 0,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
autoExpandWhileStreaming: Boolean = true,
) { ) {
val hasTools = steps.any { it is AiThinkingStep.ToolStep } val hasTools = steps.any { it is AiThinkingStep.ToolStep }
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep } val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
@@ -74,10 +76,14 @@ fun AiThinkingCard(
} }
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
// Expand while streaming, collapse when done // Tool cards disable this so only an explicit user click can expand them.
var expanded by remember { mutableStateOf(isStreaming) } var expanded by remember {
LaunchedEffect(isStreaming) { mutableStateOf(isStreaming && autoExpandWhileStreaming)
expanded = isStreaming }
LaunchedEffect(isStreaming, autoExpandWhileStreaming) {
if (autoExpandWhileStreaming) {
expanded = isStreaming
}
} }
// Show time only for reasoning (not tool-only) // Show time only for reasoning (not tool-only)
@@ -34,7 +34,6 @@ fun AiConfigRouteScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit, onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit, onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit, onNavigateToTranslation: () -> Unit,
viewModel: AiConfigViewModel = koinViewModel() viewModel: AiConfigViewModel = koinViewModel()
) { ) {
@@ -45,7 +44,6 @@ fun AiConfigRouteScreen(
onBackClick = onBackClick, onBackClick = onBackClick,
onNavigateToProviderEdit = onNavigateToProviderEdit, onNavigateToProviderEdit = onNavigateToProviderEdit,
onNavigateToModelEdit = onNavigateToModelEdit, onNavigateToModelEdit = onNavigateToModelEdit,
onNavigateToChat = onNavigateToChat,
onNavigateToTranslation = onNavigateToTranslation onNavigateToTranslation = onNavigateToTranslation
) )
} }
@@ -59,7 +57,6 @@ fun AiConfigScreen(
onBackClick: () -> Unit, onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit, onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit, onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit onNavigateToTranslation: () -> Unit
) { ) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior() val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -128,10 +125,6 @@ fun AiConfigScreen(
item { item {
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) { SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
ClickableSettingItem(
title = stringResource(R.string.ai_chat),
onClick = onNavigateToChat
)
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.translation_config), title = stringResource(R.string.translation_config),
onClick = onNavigateToTranslation onClick = onNavigateToTranslation
@@ -95,6 +95,9 @@ fun MainActivity.mainEntryProvider(
onOpenSettings = { onOpenSettings = {
onNavigateToRoute(MainRouteSettings) onNavigateToRoute(MainRouteSettings)
}, },
onNavigateToChat = {
onNavigateToRoute(MainRouteAiChat)
},
onNavigateToSearch = { key -> onNavigateToSearch = { key ->
onNavigateToRoute( onNavigateToRoute(
MainRouteSearch( MainRouteSearch(
@@ -227,7 +230,6 @@ fun MainActivity.mainEntryProvider(
) )
) )
}, },
onNavigateToChat = { backStack.add(MainRouteAiChat) },
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) } onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
) )
} }
@@ -105,6 +105,7 @@ fun MainScreen(
viewModel: MainViewModel = koinViewModel(), viewModel: MainViewModel = koinViewModel(),
useRail: Boolean, useRail: Boolean,
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToSearch: (String?) -> Unit, onNavigateToSearch: (String?) -> Unit,
onNavigateToRemoteImport: () -> Unit, onNavigateToRemoteImport: () -> Unit,
onNavigateToLocalImport: () -> Unit, onNavigateToLocalImport: () -> Unit,
@@ -424,6 +425,7 @@ fun MainScreen(
) )
MainDestination.My -> MyScreen( MainDestination.My -> MyScreen(
onOpenSettings = onOpenSettings, onOpenSettings = onOpenSettings,
onNavigateToChat = onNavigateToChat,
onNavigate = { event -> onNavigate = { event ->
when (event) { when (event) {
PrefClickEvent.OpenBookCacheManage -> onNavigateToBookCacheManage() 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.LibraryBooks
import androidx.compose.material.icons.automirrored.filled.Rule import androidx.compose.material.icons.automirrored.filled.Rule
import androidx.compose.material.icons.filled.Bookmark 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.ContentCopy
import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.FindReplace import androidx.compose.material.icons.filled.FindReplace
@@ -67,6 +68,7 @@ import org.koin.androidx.compose.koinViewModel
fun MyScreen( fun MyScreen(
viewModel: MyViewModel = koinViewModel(), viewModel: MyViewModel = koinViewModel(),
onOpenSettings: () -> Unit, onOpenSettings: () -> Unit,
onNavigateToChat: () -> Unit,
onNavigate: (PrefClickEvent) -> Unit onNavigate: (PrefClickEvent) -> Unit
) { ) {
@@ -170,6 +172,11 @@ fun MyScreen(
SplicedColumnGroup( SplicedColumnGroup(
title = stringResource(R.string.other) title = stringResource(R.string.other)
) { ) {
ClickableSettingItem(
title = stringResource(R.string.ai_chat),
imageVector = Icons.Default.AutoAwesome,
onClick = onNavigateToChat
)
ClickableSettingItem( ClickableSettingItem(
title = stringResource(R.string.setting), title = stringResource(R.string.setting),
imageVector = Icons.Default.Settings, imageVector = Icons.Default.Settings,
@@ -5,16 +5,22 @@ import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ProvideTextStyle import androidx.compose.material3.ProvideTextStyle
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.geometry.Size
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle 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.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.core.net.toUri import androidx.core.net.toUri
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme import io.legado.app.ui.theme.LegadoTheme
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
@@ -52,6 +60,7 @@ import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.flow.mapLatest
import splitties.systemservices.clipboardManager
import org.intellij.markdown.IElementType import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes import org.intellij.markdown.MarkdownTokenTypes
@@ -541,6 +550,13 @@ private fun MarkdownCodeBlock(
language: String?, language: String?,
modifier: Modifier = Modifier, 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( Column(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
@@ -552,24 +568,63 @@ private fun MarkdownCodeBlock(
RoundedCornerShape(8.dp) RoundedCornerShape(8.dp)
) )
) { ) {
if (!language.isNullOrBlank()) { Row(
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),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .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
rows.forEach { row -> rows.forEachIndexed { index, row ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(
if (index % 2 == 0) LegadoTheme.colorScheme.surface
else LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.15f)
)
.padding(horizontal = 8.dp, vertical = 4.dp), .padding(horizontal = 8.dp, vertical = 4.dp),
) { ) {
row.forEachIndexed { index, cell -> row.forEach { cell ->
AppText( AppText(
text = cell, text = cell,
style = LegadoTheme.typography.bodySmall, style = LegadoTheme.typography.bodySmall,
@@ -634,10 +693,6 @@ private fun MarkdownTable(
) )
} }
} }
HorizontalDivider(
thickness = 0.25.dp,
color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
} }
} }