From c318e259c7e0ad746bcbc25f44404b9acf0238ad Mon Sep 17 00:00:00 2001 From: HapeLee <63206378+HapeLee@users.noreply.github.com> Date: Sun, 28 Jun 2026 01:46:36 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20AI=20=E5=AF=B9?= =?UTF-8?q?=E8=AF=9D=E7=95=8C=E9=9D=A2=E4=B8=8E=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/src/main/AndroidManifest.xml | 2 +- .../java/io/legado/app/data/dao/AiChatDao.kt | 6 + .../app/data/repository/AiChatRepository.kt | 5 + .../app/domain/gateway/AiChatGateway.kt | 1 + .../domain/usecase/AiChatGenerationUseCase.kt | 23 + .../legado/app/ui/ai/chat/AiChatContract.kt | 2 + .../io/legado/app/ui/ai/chat/AiChatScreen.kt | 613 ++++++++++++------ .../legado/app/ui/ai/chat/AiChatViewModel.kt | 69 +- .../legado/app/ui/ai/chat/AiThinkingCard.kt | 16 +- .../legado/app/ui/config/ai/AiConfigScreen.kt | 7 - .../io/legado/app/ui/main/MainNavGraph.kt | 4 +- .../java/io/legado/app/ui/main/MainScreen.kt | 2 + .../java/io/legado/app/ui/main/my/MyScreen.kt | 7 + .../widget/components/text/MarkdownBlock.kt | 99 ++- 14 files changed, 605 insertions(+), 251 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dce6f9b6c..26e965165 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -127,7 +127,7 @@ android:name=".ui.main.MainActivity" android:alwaysRetainTaskState="true" android:enableOnBackInvokedCallback="true" - android:windowSoftInputMode="adjustPan" + android:windowSoftInputMode="adjustResize" android:exported="true"> diff --git a/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt b/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt index 9dad31478..cd36e9608 100644 --- a/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt +++ b/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt @@ -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) } diff --git a/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt index 4fbfe0325..ca719fae7 100644 --- a/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt +++ b/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt @@ -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("-", "")}" } diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt index d451e3fd3..188690b62 100644 --- a/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt +++ b/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt @@ -30,4 +30,5 @@ interface AiChatGateway { suspend fun getBranchCounts(conversationId: String): Map suspend fun updateConversationTitle(conversationId: String, title: String) suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String) + suspend fun deleteConversation(conversationId: String) } diff --git a/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt index e85dff552..d83481892 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt +++ b/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt index da90a6fc8..1a5d1b7a6 100644 --- a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt +++ b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt @@ -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 { diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt index ec39bd447..772044084 100644 --- a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt +++ b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt @@ -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(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() + .flatMap { block -> block.steps.filterIsInstance() } + val toolSteps = groupedParts + .filterIsInstance() + .flatMap { block -> block.steps.filterIsInstance() } + val contentBlocks = groupedParts.filterIsInstance() 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() - .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() .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() .joinToString("\n\n") { tool -> buildString { @@ -745,7 +934,16 @@ private fun ChatMessageContent( content = displayToolTrace ) } - // Legacy book results fallback + val displayContent = message.parts.filterIsInstance() + .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( diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt index 05b645037..6cbdeddc4 100644 --- a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt +++ b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt index 396e2ab11..b627dd8a5 100644 --- a/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt +++ b/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt @@ -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) diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt index bc3f5a80a..2665bb12b 100644 --- a/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt +++ b/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt @@ -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 diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index feb919bcd..a42af5868 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt @@ -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) } ) } diff --git a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt index 6d7c260d4..67b1ad03d 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/MainScreen.kt @@ -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() diff --git a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt index b2c749c02..11ca67a40 100644 --- a/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt +++ b/app/src/main/java/io/legado/app/ui/main/my/MyScreen.kt @@ -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, diff --git a/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt b/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt index c897c41ae..c9f4897f4 100644 --- a/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt +++ b/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt @@ -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), - ) } } }