Przeglądaj źródła

feat: 优化一些AI功能

HapeLee 3 miesięcy temu
rodzic
commit
caafbfddeb

+ 17 - 2
app/src/main/java/io/legado/app/domain/usecase/AiTextFactoryUseCase.kt

@@ -8,6 +8,7 @@ import io.legado.app.domain.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiPromptTemplate
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.AiToolContext
@@ -38,6 +39,7 @@ class AiTextFactoryUseCase(
         val maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
         val skipCache: Boolean = false,
         val artifactContentHash: String? = null,
+        val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     )
 
     sealed interface StreamEvent {
@@ -131,6 +133,7 @@ class AiTextFactoryUseCase(
                         referenceText = request.referenceText,
                     ),
                     toolContext = toolContext,
+                    reasoningLevel = request.reasoningLevel,
                 )
             }
             val output = if (partialOutputs.size == 1) {
@@ -141,6 +144,7 @@ class AiTextFactoryUseCase(
                     systemPrompt = systemPrompt,
                     userContent = buildMergeUserInput(partialOutputs),
                     toolContext = toolContext,
+                    reasoningLevel = request.reasoningLevel,
                 )
             }
             val now = System.currentTimeMillis()
@@ -216,6 +220,7 @@ class AiTextFactoryUseCase(
                 outputBuilder = outputBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
+                reasoningLevel = request.reasoningLevel,
             )
         } else {
             val partialOutputs = chunks.map { chunk ->
@@ -228,6 +233,7 @@ class AiTextFactoryUseCase(
                         referenceText = request.referenceText,
                     ),
                     toolContext = toolContext,
+                    reasoningLevel = request.reasoningLevel,
                 )
             }
             val merged = generate(
@@ -235,6 +241,7 @@ class AiTextFactoryUseCase(
                 systemPrompt = systemPrompt,
                 userContent = buildMergeUserInput(partialOutputs),
                 toolContext = toolContext,
+                reasoningLevel = request.reasoningLevel,
             )
             outputBuilder.append(merged)
             emit(StreamEvent.Content(merged))
@@ -322,6 +329,7 @@ class AiTextFactoryUseCase(
         systemPrompt: String,
         userContent: String,
         toolContext: AiToolContext?,
+        reasoningLevel: AiReasoningLevel,
     ): String {
         return aiToolAwareGenerationUseCase.generate(
             AiGenerateRequest(
@@ -330,7 +338,10 @@ class AiTextFactoryUseCase(
                     AiMessage(AiMessageRole.SYSTEM, systemPrompt),
                     AiMessage(AiMessageRole.USER, userContent),
                 ),
-                params = preset.params,
+                params = preset.params.copy(
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
+                ),
                 toolContext = toolContext,
             )
         )
@@ -344,6 +355,7 @@ class AiTextFactoryUseCase(
         outputBuilder: StringBuilder,
         reasoningBuilder: StringBuilder,
         emitEvent: suspend (StreamEvent) -> Unit,
+        reasoningLevel: AiReasoningLevel,
     ) {
         aiToolAwareGenerationUseCase.generateStream(
             AiGenerateRequest(
@@ -352,7 +364,10 @@ class AiTextFactoryUseCase(
                     AiMessage(AiMessageRole.SYSTEM, systemPrompt),
                     AiMessage(AiMessageRole.USER, userContent),
                 ),
-                params = preset.params,
+                params = preset.params.copy(
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
+                ),
                 toolContext = toolContext,
             )
         ).collect { event ->

+ 10 - 1
app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt

@@ -10,6 +10,7 @@ import io.legado.app.domain.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiPromptTemplate
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.AiToolContext
@@ -53,6 +54,7 @@ class CleanSelectedTextUseCase(
         selectedText: String,
         contextBefore: String,
         contextAfter: String,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): String {
         val preset = resolvePreset() ?: error("No AI model configured")
         val input = buildUserContent(
@@ -85,7 +87,8 @@ class CleanSelectedTextUseCase(
                 chapterTitle,
                 selectedText,
                 contextBefore,
-                contextAfter
+                contextAfter,
+                reasoningLevel,
             )
                 .collect { event ->
                     when (event) {
@@ -105,6 +108,7 @@ class CleanSelectedTextUseCase(
         selectedText: String,
         contextBefore: String,
         contextAfter: String,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): Result<String> = withContext(Dispatchers.IO) {
         runCatching {
             require(selectedText.isNotBlank()) { "Selected text is empty" }
@@ -145,6 +149,8 @@ class CleanSelectedTextUseCase(
                         maxOutputTokens = preset.params.maxOutputTokens
                             ?.coerceAtMost(MAX_OUTPUT_TOKENS)
                             ?: MAX_OUTPUT_TOKENS,
+                        reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                            ?: preset.params.reasoningLevel,
                     ),
                     toolContext = AiToolContext(
                         bookUrl = bookUrl,
@@ -183,6 +189,7 @@ class CleanSelectedTextUseCase(
         selectedText: String,
         contextBefore: String,
         contextAfter: String,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): Flow<StreamEvent> = flow {
         require(selectedText.isNotBlank()) { "Selected text is empty" }
         require(selectedText.length <= MAX_SELECTED_TEXT_CHARS) {
@@ -228,6 +235,8 @@ class CleanSelectedTextUseCase(
                     maxOutputTokens = preset.params.maxOutputTokens
                         ?.coerceAtMost(MAX_OUTPUT_TOKENS)
                         ?: MAX_OUTPUT_TOKENS,
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
                 ),
                 toolContext = AiToolContext(
                     bookUrl = bookUrl,

+ 28 - 5
app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt

@@ -9,6 +9,7 @@ import io.legado.app.domain.gateway.AiStreamEvent
 import io.legado.app.domain.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.AiToolContext
@@ -46,6 +47,7 @@ class GenerateChapterSummaryUseCase(
         bookChapter: BookChapter,
         contentOverride: String? = null,
         maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): String {
         val content = contentOverride ?: BookHelp.getContent(book, bookChapter)
         ?: error("Failed to read chapter content")
@@ -68,7 +70,13 @@ class GenerateChapterSummaryUseCase(
         )
         return aiTaskManager.submit(artifact) {
             var summary = ""
-            executeStream(book, bookChapter, content, maxCharsPerChunk).collect { event ->
+            executeStream(
+                book,
+                bookChapter,
+                content,
+                maxCharsPerChunk,
+                reasoningLevel
+            ).collect { event ->
                 when (event) {
                     is StreamEvent.Content -> appendContent(event.text)
                     is StreamEvent.Reasoning -> appendReasoning(event.text)
@@ -83,7 +91,8 @@ class GenerateChapterSummaryUseCase(
         book: Book,
         bookChapter: BookChapter,
         contentOverride: String? = null,
-        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK
+        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): Result<String> = withContext(Dispatchers.IO) {
         runCatching {
             val content = contentOverride ?: BookHelp.getContent(book, bookChapter)
@@ -111,6 +120,7 @@ class GenerateChapterSummaryUseCase(
                     preset = preset,
                     userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}",
                     toolContext = toolContext,
+                    reasoningLevel = reasoningLevel,
                 )
             }
             val summary = if (partialSummaries.size == 1) {
@@ -120,6 +130,7 @@ class GenerateChapterSummaryUseCase(
                     preset = preset,
                     userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}",
                     toolContext = toolContext,
+                    reasoningLevel = reasoningLevel,
                 )
             }
             val now = System.currentTimeMillis()
@@ -148,7 +159,8 @@ class GenerateChapterSummaryUseCase(
         book: Book,
         bookChapter: BookChapter,
         contentOverride: String? = null,
-        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK
+        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): Flow<StreamEvent> = flow {
         val content = contentOverride ?: BookHelp.getContent(book, bookChapter)
             ?: error("Failed to read chapter content")
@@ -184,6 +196,7 @@ class GenerateChapterSummaryUseCase(
                 outputBuilder = summaryBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
+                reasoningLevel = reasoningLevel,
             )
         } else {
             val partialSummaries = chunks.map { chunk ->
@@ -191,6 +204,7 @@ class GenerateChapterSummaryUseCase(
                     preset = preset,
                     userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}",
                     toolContext = toolContext,
+                    reasoningLevel = reasoningLevel,
                 )
             }
             collectGenerateStream(
@@ -200,6 +214,7 @@ class GenerateChapterSummaryUseCase(
                 outputBuilder = summaryBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
+                reasoningLevel = reasoningLevel,
             )
         }
 
@@ -233,6 +248,7 @@ class GenerateChapterSummaryUseCase(
         preset: AiTaskPresetConfig,
         userContent: String,
         toolContext: AiToolContext,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     ): String {
         return aiToolAwareGenerationUseCase.generate(
             AiGenerateRequest(
@@ -241,7 +257,10 @@ class GenerateChapterSummaryUseCase(
                     AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
                     AiMessage(AiMessageRole.USER, userContent)
                 ),
-                params = preset.params,
+                params = preset.params.copy(
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
+                ),
                 toolContext = toolContext,
             )
         )
@@ -254,6 +273,7 @@ class GenerateChapterSummaryUseCase(
         outputBuilder: StringBuilder,
         reasoningBuilder: StringBuilder,
         emitEvent: suspend (StreamEvent) -> Unit,
+        reasoningLevel: AiReasoningLevel,
     ) {
         aiToolAwareGenerationUseCase.generateStream(
             AiGenerateRequest(
@@ -262,7 +282,10 @@ class GenerateChapterSummaryUseCase(
                     AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
                     AiMessage(AiMessageRole.USER, userContent)
                 ),
-                params = preset.params,
+                params = preset.params.copy(
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
+                ),
                 toolContext = toolContext,
             )
         ).collect { event ->

+ 15 - 4
app/src/main/java/io/legado/app/domain/usecase/IdentifyBookCharactersUseCase.kt

@@ -9,6 +9,7 @@ import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 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.AiToolContext
 import io.legado.app.utils.GSON
@@ -66,7 +67,10 @@ class IdentifyBookCharactersUseCase(
         taskType = AiTaskType.IDENTIFY_CHARACTERS,
     )
 
-    suspend fun start(bookUrl: String): String {
+    suspend fun start(
+        bookUrl: String,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
+    ): String {
         val identifyPreset = aiProfileGateway.getTaskPreset(AiTaskType.IDENTIFY_CHARACTERS)
         val preset = identifyPreset
             ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
@@ -88,7 +92,7 @@ class IdentifyBookCharactersUseCase(
         )
         return aiTaskManager.submit(artifact) {
             var candidates = emptyList<Candidate>()
-            identifyStream(bookUrl).collect { progress ->
+            identifyStream(bookUrl, reasoningLevel).collect { progress ->
                 when (progress) {
                     is Progress.Reasoning -> appendReasoning(progress.text)
                     is Progress.ToolCall -> reportToolCall(progress.name)
@@ -102,7 +106,10 @@ class IdentifyBookCharactersUseCase(
     fun decodeCandidates(output: String?): List<Candidate> =
         output?.let { GSON.fromJsonArray<Candidate>(it).getOrNull() }.orEmpty()
 
-    fun identifyStream(bookUrl: String): Flow<Progress> = flow {
+    fun identifyStream(
+        bookUrl: String,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
+    ): Flow<Progress> = flow {
         val identifyPreset = aiProfileGateway.getTaskPreset(AiTaskType.IDENTIFY_CHARACTERS)
         val preset = identifyPreset
             ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
@@ -119,7 +126,11 @@ class IdentifyBookCharactersUseCase(
                         "识别本书已下载章节中的人物。请先用工具读取已缓存章节,再只返回要求的 JSON。"
                     ),
                 ),
-                params = preset.params.copy(temperature = 0f),
+                params = preset.params.copy(
+                    temperature = 0f,
+                    reasoningLevel = reasoningLevel.takeUnless { it == AiReasoningLevel.AUTO }
+                        ?: preset.params.reasoningLevel,
+                ),
                 toolContext = AiToolContext(bookUrl = bookUrl),
             )
         ).collect { event ->

+ 87 - 0
app/src/main/java/io/legado/app/ui/ai/AiReasoningModeButton.kt

@@ -0,0 +1,87 @@
+package io.legado.app.ui.ai
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppSlider
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+
+@Composable
+fun AiReasoningModeButton(
+    level: AiReasoningLevel,
+    enabled: Boolean,
+    onLevelChange: (AiReasoningLevel) -> Unit,
+) {
+    var showSheet by rememberSaveable { mutableStateOf(false) }
+    MediumTonalButton(
+        onClick = { showSheet = true },
+        icon = Icons.Default.Lightbulb,
+        enabled = enabled,
+        selected = level != AiReasoningLevel.OFF,
+        contentDescription = stringResource(R.string.ai_thinking_mode),
+    )
+    AppModalBottomSheet(
+        show = showSheet,
+        onDismissRequest = { showSheet = false },
+        title = stringResource(R.string.ai_thinking_mode)
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            horizontalAlignment = Alignment.CenterHorizontally
+        ) {
+            Spacer(Modifier.height(32.dp))
+            TextCard(
+                text = level.label(),
+                textStyle = LegadoTheme.typography.titleLargeEmphasized,
+                backgroundColor = LegadoTheme.colorScheme.onSheetContent
+            )
+            Spacer(Modifier.height(32.dp))
+            AppSlider(
+                value = level.ordinal.toFloat(),
+                onValueChange = { value ->
+                    AiReasoningLevel.entries[value.toInt().coerceIn(
+                        0,
+                        AiReasoningLevel.entries.lastIndex,
+                    )].let(onLevelChange)
+                },
+                modifier = Modifier.fillMaxWidth(),
+                valueRange = 0f..AiReasoningLevel.entries.lastIndex.toFloat(),
+                steps = AiReasoningLevel.entries.size - 2,
+                accessibilityLabel = stringResource(R.string.ai_thinking_mode),
+                accessibilityValue = level.label(),
+            )
+            Spacer(Modifier.height(32.dp))
+        }
+    }
+}
+
+@Composable
+private fun AiReasoningLevel.label(): String = when (this) {
+    AiReasoningLevel.OFF -> stringResource(R.string.ai_thinking_off)
+    AiReasoningLevel.AUTO -> stringResource(R.string.ai_thinking_auto)
+    AiReasoningLevel.LOW -> stringResource(R.string.ai_reasoning_level_low)
+    AiReasoningLevel.MEDIUM -> stringResource(R.string.ai_reasoning_level_medium)
+    AiReasoningLevel.HIGH -> stringResource(R.string.ai_reasoning_level_high)
+    AiReasoningLevel.XHIGH -> stringResource(R.string.ai_reasoning_level_xhigh)
+    AiReasoningLevel.MAX -> stringResource(R.string.ai_reasoning_level_max)
+}

+ 5 - 84
app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt

@@ -1,7 +1,6 @@
 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
@@ -9,7 +8,6 @@ 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.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -35,23 +33,16 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.text.BasicTextField
-import androidx.compose.foundation.text.selection.SelectionContainer
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
-import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
 import androidx.compose.material.icons.automirrored.filled.Send
 import androidx.compose.material.icons.filled.Add
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material.icons.filled.ContentCopy
 import androidx.compose.material.icons.filled.KeyboardArrowDown
-import androidx.compose.material.icons.filled.Lightbulb
 import androidx.compose.material.icons.filled.Menu
-import androidx.compose.material.icons.filled.Refresh
 import androidx.compose.material.icons.filled.Search
 import androidx.compose.material.icons.filled.Stop
 import androidx.compose.material3.DrawerValue
 import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.Icon
 import androidx.compose.material3.ModalDrawerSheet
 import androidx.compose.material3.ModalNavigationDrawer
 import androidx.compose.material3.SnackbarHost
@@ -87,9 +78,9 @@ import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
 import androidx.lifecycle.compose.collectAsStateWithLifecycle
 import io.legado.app.R
-import io.legado.app.domain.model.AiMessagePart
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.ui.ai.AiReasoningModeButton
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
@@ -98,10 +89,7 @@ import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.NormalCard
-import io.legado.app.ui.widget.components.image.cover.CoilBookCover
-import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
-import io.legado.app.ui.widget.components.text.MarkdownBlock
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.first
@@ -851,9 +839,6 @@ private fun ChatInputBar(
     onStop: () -> Unit,
     onUpdateReasoningLevel: (AiReasoningLevel) -> Unit
 ) {
-    var showThinkingSheet by rememberSaveable { mutableStateOf(false) }
-    val thinkingLabel = reasoningLevel.label()
-    val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF
     val isKeyboardVisible =
         WindowInsets.ime.asPaddingValues().calculateBottomPadding() > 0.dp
     val horizontalPadding by animateDpAsState(
@@ -867,56 +852,6 @@ private fun ChatInputBar(
         label = "AiChatInputBottomPadding"
     )
 
-    // Thinking mode bottom sheet
-    AppModalBottomSheet(
-        show = showThinkingSheet,
-        onDismissRequest = { showThinkingSheet = false },
-        title = stringResource(R.string.ai_thinking_mode)
-    ) {
-        AiReasoningLevel.entries.forEach { level ->
-            val isSelected = level == reasoningLevel
-            val label = level.label()
-
-            Row(
-                modifier = Modifier
-                    .fillMaxWidth()
-                    .clip(RoundedCornerShape(12.dp))
-                    .clickable {
-                        onUpdateReasoningLevel(level)
-                        showThinkingSheet = false
-                    }
-                    .background(
-                        if (isSelected) LegadoTheme.colorScheme.primaryContainer
-                        else LegadoTheme.colorScheme.surface
-                    )
-                    .padding(horizontal = 16.dp, vertical = 14.dp),
-                verticalAlignment = Alignment.CenterVertically
-            ) {
-                Column(modifier = Modifier.weight(1f)) {
-                    AppText(
-                        text = label,
-                        style = LegadoTheme.typography.bodyLarge,
-                        fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
-                        color = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer
-                        else LegadoTheme.colorScheme.onSurface
-                    )
-                }
-                if (isSelected) {
-                    Icon(
-                        imageVector = Icons.Default.Check,
-                        contentDescription = null,
-                        modifier = Modifier.size(20.dp),
-                        tint = LegadoTheme.colorScheme.primary
-                    )
-                }
-            }
-            if (level != AiReasoningLevel.entries.last()) {
-                Spacer(modifier = Modifier.height(4.dp))
-            }
-        }
-        Spacer(modifier = Modifier.height(16.dp))
-    }
-
     // Floating capsule input bar
     Surface(
         modifier = Modifier
@@ -937,11 +872,10 @@ private fun ChatInputBar(
             horizontalArrangement = Arrangement.spacedBy(4.dp)
         ) {
             // Thinking mode button
-            MediumTonalButton(
-                onClick = { showThinkingSheet = true },
-                icon = Icons.Default.Lightbulb,
-                selected = isThinkingOn,
-                contentDescription = stringResource(R.string.ai_thinking_mode)
+            AiReasoningModeButton(
+                level = reasoningLevel,
+                enabled = !isSending,
+                onLevelChange = onUpdateReasoningLevel,
             )
 
             // Text field
@@ -993,19 +927,6 @@ private fun ChatInputBar(
     }
 }
 
-@Composable
-private fun AiReasoningLevel.label(): String {
-    return when (this) {
-        AiReasoningLevel.OFF -> stringResource(R.string.ai_thinking_off)
-        AiReasoningLevel.AUTO -> stringResource(R.string.ai_thinking_auto)
-        AiReasoningLevel.LOW -> stringResource(R.string.ai_reasoning_level_low)
-        AiReasoningLevel.MEDIUM -> stringResource(R.string.ai_reasoning_level_medium)
-        AiReasoningLevel.HIGH -> stringResource(R.string.ai_reasoning_level_high)
-        AiReasoningLevel.XHIGH -> stringResource(R.string.ai_reasoning_level_xhigh)
-        AiReasoningLevel.MAX -> stringResource(R.string.ai_reasoning_level_max)
-    }
-}
-
 @Composable
 private fun EmptyChatHint() {
     Box(

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.knowledge
 
 import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.AiReasoningLevel
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -16,6 +17,7 @@ data class CharacterListUiState(
 
 @Stable
 data class CharacterIdentifySheet(
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     val loading: Boolean = false,
     val error: String? = null,
     val candidates: ImmutableList<CharacterIdentifyCandidateUi> = persistentListOf(),
@@ -48,6 +50,7 @@ sealed interface CharacterListIntent {
     data object AddCharacter : CharacterListIntent
     data object OpenAiIdentify : CharacterListIntent
     data object RunAiIdentify : CharacterListIntent
+    data class SetAiIdentifyReasoningLevel(val level: AiReasoningLevel) : CharacterListIntent
     data class ToggleAiCandidate(val id: String) : CharacterListIntent
     data object SaveAiCandidates : CharacterListIntent
     data object DismissAiIdentify : CharacterListIntent

+ 19 - 7
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt

@@ -43,6 +43,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import coil3.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.ui.ai.AiReasoningModeButton
 import io.legado.app.ui.ai.AiTaskResultSheet
 import io.legado.app.ui.ai.chat.ReasoningCard
 import io.legado.app.ui.theme.LegadoTheme
@@ -189,14 +190,25 @@ private fun CharacterIdentifySheet(
                 )
             }
         } else null,
-        endAction = if (sheet?.candidates?.isNotEmpty() == true) {
+        endAction = if (sheet != null) {
             {
-                MediumTonalButton(
-                    onClick = { onIntent(CharacterListIntent.SaveAiCandidates) },
-                    contentDescription = stringResource(R.string.ai_identify_characters_save),
-                    icon = Icons.Default.Save,
-                    enabled = !sheet.loading,
-                )
+                Row {
+                    AiReasoningModeButton(
+                        level = sheet.reasoningLevel,
+                        enabled = !sheet.loading,
+                        onLevelChange = {
+                            onIntent(CharacterListIntent.SetAiIdentifyReasoningLevel(it))
+                        },
+                    )
+                    if (sheet.candidates.isNotEmpty()) {
+                        MediumTonalButton(
+                            onClick = { onIntent(CharacterListIntent.SaveAiCandidates) },
+                            contentDescription = stringResource(R.string.ai_identify_characters_save),
+                            icon = Icons.Default.Save,
+                            enabled = !sheet.loading,
+                        )
+                    }
+                }
             }
         } else null,
     ) {

+ 12 - 1
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListViewModel.kt

@@ -85,6 +85,9 @@ class BookCharacterListViewModel(
 
             CharacterListIntent.DismissAiIdentify -> _uiState.update { it.copy(isAiSheetVisible = false) }
             CharacterListIntent.RunAiIdentify -> identifyCharacters()
+            is CharacterListIntent.SetAiIdentifyReasoningLevel -> _uiState.update { state ->
+                state.copy(aiSheet = state.aiSheet?.copy(reasoningLevel = intent.level))
+            }
             is CharacterListIntent.ToggleAiCandidate -> _uiState.update { state ->
                 state.copy(aiSheet = state.aiSheet?.copy(candidates = state.aiSheet.candidates.map { candidate ->
                     if (candidate.id == intent.id) candidate.copy(selected = !candidate.selected) else candidate
@@ -215,6 +218,8 @@ class BookCharacterListViewModel(
             _uiState.update {
                 it.copy(
                     aiSheet = CharacterIdentifySheet(
+                        reasoningLevel = _uiState.value.aiSheet?.reasoningLevel
+                            ?: io.legado.app.domain.model.AiReasoningLevel.AUTO,
                         loading = true,
                         startedAt = System.currentTimeMillis()
                     ),
@@ -222,7 +227,13 @@ class BookCharacterListViewModel(
                 )
             }
             try {
-                withContext(Dispatchers.IO) { identifyBookCharacters.start(bookUrl) }
+                withContext(Dispatchers.IO) {
+                    identifyBookCharacters.start(
+                        bookUrl,
+                        _uiState.value.aiSheet?.reasoningLevel
+                            ?: io.legado.app.domain.model.AiReasoningLevel.AUTO,
+                    )
+                }
             } catch (e: CancellationException) {
                 throw e
             } catch (e: Throwable) {

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAiContract.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.read
 
 import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.AiReasoningLevel
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -24,6 +25,7 @@ data class ChapterSummaryUiState(
     val bookUrl: String = "",
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     val isLoading: Boolean = false,
     val summary: String = "",
     val reasoningText: String = "",
@@ -36,6 +38,7 @@ data class AiTextCleanUiState(
     val bookUrl: String = "",
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
     val isLoading: Boolean = false,
     val isApplying: Boolean = false,
     val originalText: String = "",
@@ -65,6 +68,7 @@ data class AiTextRewriteUiState(
     val bookUrl: String = "",
     val chapterIndex: Int = -1,
     val chapterTitle: String = "",
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.OFF,
     val isLoading: Boolean = false,
     val isApplying: Boolean = false,
     val originalText: String = "",

+ 16 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiPromptPresetGateway
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.CleanSelectedTextUseCase
@@ -33,7 +34,6 @@ import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.util.Date
 import java.util.UUID
-import kotlin.coroutines.coroutineContext
 
 /**
  * 阅读页 AI 域:章节摘要、划词净化、划词重写、重写预设配置。
@@ -190,6 +190,7 @@ class ReadAiDelegate(
                         book = book,
                         bookChapter = chapter,
                         contentOverride = content,
+                        reasoningLevel = _uiState.value.chapterSummary.reasoningLevel,
                     )
                 }
                 generateChapterSummaryUseCase.observeTask(bookUrl, chapterIndex).collect { task ->
@@ -278,6 +279,10 @@ class ReadAiDelegate(
         }
     }
 
+    fun setChapterSummaryReasoningLevel(level: AiReasoningLevel) {
+        _uiState.update { it.copy(chapterSummary = it.chapterSummary.copy(reasoningLevel = level)) }
+    }
+
     // --- 划词净化 ---
 
     fun openAiTextClean(
@@ -356,6 +361,7 @@ class ReadAiDelegate(
                         selectedText = request.originalText,
                         contextBefore = request.contextBefore,
                         contextAfter = request.contextAfter,
+                        reasoningLevel = _uiState.value.aiTextClean.reasoningLevel,
                     )
                 }
                 cleanSelectedTextUseCase.observeTaskById(taskId).collect { task ->
@@ -493,6 +499,10 @@ class ReadAiDelegate(
         }
     }
 
+    fun setAiTextCleanReasoningLevel(level: AiReasoningLevel) {
+        _uiState.update { it.copy(aiTextClean = it.aiTextClean.copy(reasoningLevel = level)) }
+    }
+
     // --- 划词重写 ---
 
     fun openAiCurrentChapterRewrite() {
@@ -715,6 +725,10 @@ class ReadAiDelegate(
         generateSelectedAiTextRewrite()
     }
 
+    fun setAiTextRewriteReasoningLevel(level: AiReasoningLevel) {
+        _uiState.update { it.copy(aiTextRewrite = it.aiTextRewrite.copy(reasoningLevel = level)) }
+    }
+
     private fun generateAiTextRewrite(
         request: PendingAiTextRewriteRequest,
         preset: AiRewritePresetUi,
@@ -749,6 +763,7 @@ class ReadAiDelegate(
                         referenceText = referenceContext.text,
                         skipCache = true,
                         artifactContentHash = request.sourceContentHash,
+                    reasoningLevel = _uiState.value.aiTextRewrite.reasoningLevel,
                     )
                 val taskId = withContext(IO) { aiTextFactoryUseCase.start(aiRequest) }
                 aiTextFactoryUseCase.observeTaskById(taskId).collect { task ->

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -14,6 +14,7 @@ import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.TextProcessStyle
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
@@ -491,6 +492,7 @@ sealed interface ReadBookIntent {
     data object OpenChapterSummary : ReadBookIntent
     data object OpenAiCurrentChapterRewrite : ReadBookIntent
     data object RetryChapterSummary : ReadBookIntent
+    data class SetChapterSummaryReasoningLevel(val level: AiReasoningLevel) : ReadBookIntent
     data object LoadContentProcesses : ReadBookIntent
     data class ToggleContentProcess(val id: String, val enabled: Boolean) : ReadBookIntent
     data class RequestDeleteContentProcess(val item: ContentProcessItemUi) : ReadBookIntent
@@ -702,6 +704,7 @@ sealed interface ReadBookIntent {
     ) : ReadBookIntent
 
     data object RetryAiTextClean : ReadBookIntent
+    data class SetAiTextCleanReasoningLevel(val level: AiReasoningLevel) : ReadBookIntent
     data object ConfirmAiTextClean : ReadBookIntent
     data class OpenAiTextRewrite(
         val text: String,
@@ -714,6 +717,7 @@ sealed interface ReadBookIntent {
     data class SelectAiRewriteHistory(val artifactId: String) : ReadBookIntent
     data object GenerateAiTextRewrite : ReadBookIntent
     data object RetryAiTextRewrite : ReadBookIntent
+    data class SetAiTextRewriteReasoningLevel(val level: AiReasoningLevel) : ReadBookIntent
     data object ConfirmAiTextRewrite : ReadBookIntent
     data object OpenAiRewritePresetConfig : ReadBookIntent
     data object CloseAiRewritePresetConfig : ReadBookIntent

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -889,6 +889,8 @@ class ReadBookViewModel(
             is ReadBookIntent.OpenChapterSummary -> aiDelegate.openChapterSummary()
             is ReadBookIntent.OpenAiCurrentChapterRewrite -> aiDelegate.openAiCurrentChapterRewrite()
             is ReadBookIntent.RetryChapterSummary -> aiDelegate.retryChapterSummary()
+            is ReadBookIntent.SetChapterSummaryReasoningLevel ->
+                aiDelegate.setChapterSummaryReasoningLevel(intent.level)
             is ReadBookIntent.LoadContentProcesses -> contentProcessDelegate.load()
             is ReadBookIntent.ToggleContentProcess ->
                 contentProcessDelegate.toggle(intent.id, intent.enabled)
@@ -903,6 +905,8 @@ class ReadBookViewModel(
                 aiDelegate.selectAiRewriteHistory(intent.artifactId)
             is ReadBookIntent.GenerateAiTextRewrite -> aiDelegate.generateSelectedAiTextRewrite()
             is ReadBookIntent.RetryAiTextRewrite -> aiDelegate.retryAiTextRewrite()
+            is ReadBookIntent.SetAiTextRewriteReasoningLevel ->
+                aiDelegate.setAiTextRewriteReasoningLevel(intent.level)
             is ReadBookIntent.ConfirmAiTextRewrite -> aiDelegate.confirmAiTextRewrite()
             is ReadBookIntent.OpenAiRewritePresetConfig -> aiDelegate.openAiRewritePresetConfig()
             is ReadBookIntent.CloseAiRewritePresetConfig -> aiDelegate.closeAiRewritePresetConfig()
@@ -1479,6 +1483,8 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.RetryAiTextClean -> aiDelegate.retryAiTextClean()
+            is ReadBookIntent.SetAiTextCleanReasoningLevel ->
+                aiDelegate.setAiTextCleanReasoningLevel(intent.level)
             is ReadBookIntent.ConfirmAiTextClean -> aiDelegate.confirmAiTextClean()
 
             is ReadBookIntent.OpenAiTextRewrite -> {

+ 8 - 0
app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextCleanSheet.kt

@@ -18,6 +18,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.domain.model.AiMessagePart
 import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.ui.ai.AiReasoningModeButton
 import io.legado.app.ui.ai.chat.AiChatMessageUi
 import io.legado.app.ui.ai.chat.AiGeneratedMessageContent
 import io.legado.app.ui.book.read.AiTextCleanUiState
@@ -44,6 +45,13 @@ fun AiTextCleanSheet(
             if (!state.isApplying) onDismissRequest()
         },
         title = stringResource(R.string.ai_text_clean),
+        endAction = {
+            AiReasoningModeButton(
+                level = state.reasoningLevel,
+                enabled = !state.isLoading && !state.isApplying,
+                onLevelChange = { onIntent(ReadBookIntent.SetAiTextCleanReasoningLevel(it)) },
+            )
+        },
     ) {
         Column(
             modifier = Modifier

+ 8 - 2
app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt

@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.domain.model.AiMessagePart
 import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.ui.ai.AiReasoningModeButton
 import io.legado.app.ui.ai.chat.AiChatMessageUi
 import io.legado.app.ui.ai.chat.AiGeneratedMessageContent
 import io.legado.app.ui.book.read.AiRewriteHistoryUi
@@ -41,8 +42,6 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppRadioButton
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.EmptyMessage
-import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
-import io.legado.app.ui.widget.components.button.PrimaryButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallOutlinedButton
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
@@ -135,6 +134,13 @@ fun AiTextRewriteSheet(
                                 modifier = Modifier.weight(1f),
                                 style = LegadoTheme.typography.titleSmall,
                             )
+                            AiReasoningModeButton(
+                                level = state.reasoningLevel,
+                                enabled = !state.isLoading && !state.isApplying,
+                                onLevelChange = {
+                                    onIntent(ReadBookIntent.SetAiTextRewriteReasoningLevel(it))
+                                },
+                            )
                             SmallOutlinedButton(
                                 onClick = { onIntent(ReadBookIntent.OpenAiRewritePresetConfig) },
                                 icon = Icons.Default.Settings,

+ 9 - 1
app/src/main/java/io/legado/app/ui/book/read/sheet/ChapterSummarySheet.kt

@@ -17,13 +17,14 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.domain.model.AiMessagePart
 import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.ui.ai.AiReasoningModeButton
 import io.legado.app.ui.ai.chat.AiChatMessageUi
 import io.legado.app.ui.ai.chat.AiGeneratedMessageContent
 import io.legado.app.ui.book.read.ChapterSummaryUiState
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.theme.LegadoTheme
-import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.card.NormalCard
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.text.AppText
@@ -42,6 +43,13 @@ fun ChapterSummarySheet(
         title = state.chapterTitle.ifBlank {
             stringResource(R.string.ai_chapter_summary)
         },
+        endAction = {
+            AiReasoningModeButton(
+                level = state.reasoningLevel,
+                enabled = !state.isLoading,
+                onLevelChange = { onIntent(ReadBookIntent.SetChapterSummaryReasoningLevel(it)) },
+            )
+        },
     ) {
         when {
             state.errorMessage != null -> {