Просмотр исходного кода

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

HapeLee 4 месяцев назад
Родитель
Сommit
c318e259c7

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -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" />

+ 6 - 0
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")
     @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)
 }
 }

+ 5 - 0
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("-", "")}"
     private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
 }
 }

+ 1 - 0
app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt

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

+ 23 - 0
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(
     private suspend fun buildRequestMessages(
         newContent: String,
         newContent: String,
         history: List<AiChatMessageUi>,
         history: List<AiChatMessageUi>,

+ 2 - 0
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 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 {

+ 407 - 192
app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt

@@ -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 = {
-                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
             // 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
-                    .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
                 // Message list
                 LazyColumn(
                 LazyColumn(
                     state = listState,
                     state = listState,
-                    modifier = Modifier
-                        .fillMaxSize()
-                        .fillMaxWidth()
-                        .padding(horizontal = 8.dp),
+                    modifier = Modifier.fillMaxSize(),
                     contentPadding = PaddingValues(
                     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)
                     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,85 +371,216 @@ 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()
+                ) {
+                    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
+                            .fillMaxWidth()
+                            .height(systemBottomPadding)
+                            .background(bottomGradientColor.copy(alpha = 0.68f))
+                    )
+                }
+
+                // Bottom controls consume IME/navigation insets exactly once.
+                Box(
+                    modifier = Modifier
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth()
+                        .imePadding()
                         .navigationBarsPadding()
                         .navigationBarsPadding()
                 ) {
                 ) {
-                    // Scroll-to-bottom button
-                    if (!isAtBottom && (state.messages.isNotEmpty() || state.streamingMessage != null)) {
-                        Box(
-                            modifier = Modifier.fillMaxWidth(),
-                            contentAlignment = Alignment.Center
+                    Box(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .onSizeChanged { bottomOverlayHeightPx = it.height }
+                    ) {
+                        Column(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(top = 32.dp)
                         ) {
                         ) {
-                            SmallPlainButton(
-                                onClick = {
-                                    scope.launch {
-                                        val lastIndex = listState.layoutInfo.totalItemsCount - 1
-                                        if (lastIndex >= 0) {
-                                            listState.animateScrollToItem(lastIndex)
-                                        }
-                                        shouldStickToBottom = true
+                            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))
                                 },
                                 },
-                                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)
+                                onStop = { onIntent(AiChatIntent.StopGenerating) },
+                                onUpdateReasoningLevel = {
+                                    onIntent(AiChatIntent.UpdateReasoningLevel(it))
+                                }
                             )
                             )
                         }
                         }
-                        Spacer(modifier = Modifier.height(8.dp))
                     }
                     }
+                }
 
 
-                    state.pendingToolConfirmation?.let { confirmation ->
-                        PendingToolConfirmationCard(
-                            confirmation = confirmation,
-                            onConfirm = { onIntent(AiChatIntent.ConfirmPendingTool) },
-                            onReject = { onIntent(AiChatIntent.RejectPendingTool) }
+                // 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
+                                )
+                            )
+                        }
                     }
                     }
-                    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)) }
-                    )
                 }
                 }
             }
             }
         }
         }
     }
     }
 }
 }
 
 
-/**
- * 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
-            }
-        }
-    }
-}
-
 @Composable
 @Composable
 private fun PendingToolConfirmationCard(
 private fun PendingToolConfirmationCard(
     confirmation: AiToolConfirmationUi,
     confirmation: AiToolConfirmationUi,
@@ -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(
-            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(
         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
-        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
-                                    )
-                                ),
-                                onOpenBookInfo = onOpenBookInfo
+                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
                             )
                             )
-                        }
-                        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()) {
         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),
-        shape = RoundedCornerShape(28.dp),
+            .padding(
+                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(

+ 53 - 16
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.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)
-
-            // 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 ->
             _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

+ 11 - 5
app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt

@@ -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
-    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)
     // Show time only for reasoning (not tool-only)

+ 0 - 7
app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt

@@ -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

+ 3 - 1
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

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

+ 2 - 0
app/src/main/java/io/legado/app/ui/main/MainScreen.kt

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

+ 7 - 0
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.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,

+ 73 - 18
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.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(
+            modifier = Modifier
+                .fillMaxWidth()
+                .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(
             AppText(
-                text = language,
+                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,
                 style = LegadoTheme.typography.labelSmall,
-                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                color = LegadoTheme.colorScheme.primary,
                 modifier = Modifier
                 modifier = Modifier
                     .fillMaxWidth()
                     .fillMaxWidth()
-                    .background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
-                    .padding(horizontal = 12.dp, vertical = 4.dp),
+                    .clickable { isExpanded = !isExpanded }
+                    .padding(vertical = 4.dp)
+                    .padding(horizontal = 12.dp),
             )
             )
         }
         }
-        AppText(
-            text = code,
-            style = LegadoTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 0.85.em),
-            modifier = Modifier
-                .fillMaxWidth()
-                .padding(horizontal = 12.dp, vertical = 8.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),
-            )
         }
         }
     }
     }
 }
 }