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

fix(readaloud): AI 朗读分析补推理级别开关,默认关闭思考 (#2244)

作者已在 3f8a25f70 让 OpenAI 协议在智谱/GLM 上发送 thinking 对象,但开关只在
「推理级别 = 关闭」时生效。朗读分析(对白归属 / AI 理解)这条链路没有任何推理
级别入口:请求参数取自 preset.mergeWithFallback(模型默认),而模型编辑页的下拉只
给 LOW~MAX、新模型默认 MEDIUM,合并结果必然 != OFF。于是 GLM-4.7-Flash 等默认
思考的模型仍会收到 thinking: enabled,JSON 全落进 reasoning_content,分析必然失败
(issue #2031)。

- ReadAloudSettings/PreferKey/ReadAloudSettingsRepository/ReadConfig:新增
  speechAnalysisReasoningLevel(默认 off),沿用 speechAnalysisMode 的键与兼容读法
- BaseReadAloudService → PrepareChapterSpeechPlanUseCase → RefineSpeechWithAiUseCase
  逐层转发级别;speechAnalysisParams 让调用方的档位覆盖 preset/模型默认,AUTO 仍
  交回 preset 决定,与 IdentifyBookCharactersUseCase.identifyStream 同款语义
- 朗读设置面板「章节分片方式」下新增「分析推理级别」下拉(关闭/自动/低~最高)
- AiReasoningLevel 补 storageValue / fromStorage(value, default),未知值回退调用方
  指定档位,避免脏值静默打开思考
- applyZhipuThinking:GLM-5.3 系列强制思考,官方不接受 disabled,关闭档改为不发
  thinking 参数,避免默认关闭把 5.3 用户打成错误响应
- ReadBookViewModel 两处意图分支压成单行,保持 2704 行不增长(该文件有行数验收线)
- 测试:RefineSpeechWithAiParamsTest(档位覆盖/AUTO 回退)、OpenAiChatThinkingTest
  (强制思考模型不发 disabled)、AiReasoningLevelTest 存取往返、朗读设置 28 键映射
kungunier 1 месяц назад
Родитель
Сommit
32482384fd
21 измененных файлов с 236 добавлено и 14 удалено
  1. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  2. 8 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  3. 13 5
      app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt
  4. 12 0
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  5. 8 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  6. 3 0
      app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt
  7. 33 5
      app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt
  8. 5 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  9. 12 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  10. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  11. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  12. 23 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  13. 1 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  14. 2 0
      app/src/main/res/values-zh-rCN/strings.xml
  15. 2 0
      app/src/main/res/values-zh-rHK/strings.xml
  16. 2 0
      app/src/main/res/values-zh-rTW/strings.xml
  17. 2 0
      app/src/main/res/values/strings.xml
  18. 12 2
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  19. 18 0
      app/src/test/java/io/legado/app/data/repository/ai/OpenAiChatThinkingTest.kt
  20. 16 0
      app/src/test/java/io/legado/app/domain/model/AiReasoningLevelTest.kt
  21. 59 0
      app/src/test/java/io/legado/app/domain/usecase/RefineSpeechWithAiParamsTest.kt

+ 1 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -275,6 +275,7 @@ object PreferKey {
     const val readBarStyleFollowPage = "readBarStyleFollowPage"
     const val contentSelectSpeakMod = "contentReadAloudMod"
     const val speechAnalysisMode = "speechAnalysisMode"
+    const val speechAnalysisReasoningLevel = "speechAnalysisReasoningLevel"
     const val useMultiSpeaker = "useMultiSpeaker"
     const val themePref = "themePref"
     const val bookshelfSortOrder = "bookshelfSortOrder"

+ 8 - 0
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -7,6 +7,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import io.legado.app.help.config.AppConfigStore
@@ -67,6 +68,10 @@ internal fun Preferences.toReadAloudSettings(): ReadAloudSettings = ReadAloudSet
     ttsFollowSys = compatDsValue(ReadAloudKeys.TtsFollowSys, true),
     ttsSpeechRate = compatDsValue(ReadAloudKeys.TtsSpeechRate, 5),
     speechAnalysisMode = compatDsValue(ReadAloudKeys.SpeechAnalysisMode, "rule"),
+    speechAnalysisReasoningLevel = compatDsValue(
+        ReadAloudKeys.SpeechAnalysisReasoningLevel,
+        AiReasoningLevel.OFF.storageValue,
+    ),
     useMultiSpeaker = compatDsValue(ReadAloudKeys.UseMultiSpeaker, true),
     defaultInterface = compatDsValue(
         ReadAloudKeys.DefaultInterface,
@@ -100,6 +105,7 @@ internal fun ReadAloudSettings.toPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,
+    PreferKey.speechAnalysisReasoningLevel to speechAnalysisReasoningLevel,
     PreferKey.useMultiSpeaker to useMultiSpeaker,
     PreferKey.defaultReadAloudInterface to defaultInterface,
     PreferKey.contentSelectSpeakMod to contentSelectSpeakMode,
@@ -133,6 +139,8 @@ private object ReadAloudKeys {
     val TtsFollowSys = booleanPreferencesKey(PreferKey.ttsFollowSys)
     val TtsSpeechRate = intPreferencesKey(PreferKey.ttsSpeechRate)
     val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)
+    val SpeechAnalysisReasoningLevel =
+        stringPreferencesKey(PreferKey.speechAnalysisReasoningLevel)
     val UseMultiSpeaker = booleanPreferencesKey(PreferKey.useMultiSpeaker)
     val DefaultInterface = stringPreferencesKey(PreferKey.defaultReadAloudInterface)
     val ContentSelectSpeakMode = intPreferencesKey(PreferKey.contentSelectSpeakMod)

+ 13 - 5
app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt

@@ -210,13 +210,21 @@ internal fun MutableMap<String, Any?>.applyZhipuThinking(
     val identity = "${provider.id} ${provider.name} ${provider.baseUrl}".lowercase()
     val isZhipuProvider = "zhipu" in identity || "bigmodel" in identity
     val isGlmModel = modelId.lowercase().startsWith("glm-")
-    if (isZhipuProvider || isGlmModel) {
-        this["thinking"] = mapOf(
-            "type" to if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "enabled"
-        )
-    }
+    if (!isZhipuProvider && !isGlmModel) return
+    // GLM-5.3 系列官方标注为强制思考,thinking.type=disabled 会被拒绝;想关只能不发这个参数。
+    if (reasoningLevel == AiReasoningLevel.OFF && isAlwaysThinkingGlm(modelId)) return
+    this["thinking"] = mapOf(
+        "type" to if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "enabled"
+    )
 }
 
+/**
+ * GLM-5.3 / GLM-5.3-Flash 强制思考,无法通过 thinking.type 关闭(z.ai 文档),
+ * 对这些模型发 disabled 只会换回一个错误响应。
+ */
+internal fun isAlwaysThinkingGlm(modelId: String): Boolean =
+    modelId.lowercase().startsWith("glm-5.3")
+
 // ---- Message & tool format converters ----
 
 internal fun List<AiMessage>.toOpenAiChatMessages(): List<Map<String, Any?>> {

+ 12 - 0
app/src/main/java/io/legado/app/domain/model/AiModels.kt

@@ -226,6 +226,8 @@ enum class AiReasoningLevel(val effort: String, val budgetTokens: Int) {
 
     val isEnabled: Boolean get() = this != OFF
 
+    val storageValue: String get() = name.lowercase()
+
     fun effortFor(provider: AiProviderConfig): String? {
         val identity = "${provider.id} ${provider.name} ${provider.baseUrl}".lowercase()
         return when {
@@ -248,6 +250,16 @@ enum class AiReasoningLevel(val effort: String, val budgetTokens: Int) {
         fun fromEffort(effort: String): AiReasoningLevel =
             entries.firstOrNull { it.effort == effort } ?: AUTO
 
+        /**
+         * Task level settings (read-aloud analysis, …) persist the level themselves and use OFF as
+         * their fallback: a corrupted value must never silently re-enable reasoning, because models
+         * that think by default return no content and break strict JSON tasks.
+         */
+        fun fromStorage(
+            value: String,
+            default: AiReasoningLevel = AUTO
+        ): AiReasoningLevel = entries.firstOrNull { it.storageValue == value } ?: default
+
         fun fromThinkingStrength(mode: String, strength: Int): AiReasoningLevel {
             return when (mode) {
                 "off" -> OFF

+ 8 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -1,5 +1,7 @@
 package io.legado.app.domain.model.settings
 
+import io.legado.app.domain.model.AiReasoningLevel
+
 data class ReadAloudSettings(
     val ttsEngine: String? = null,
     val ttsParagraphInterval: Int = 0,
@@ -23,6 +25,12 @@ data class ReadAloudSettings(
     val ttsFollowSys: Boolean = true,
     val ttsSpeechRate: Int = 5,
     val speechAnalysisMode: String = "rule",
+    /**
+     * Reasoning level for AI speech analysis (dialogue attribution / AI understanding).
+     * OFF by default: models that think by default (Zhipu GLM, …) return the JSON only in
+     * `reasoning_content` and the analysis fails, so thinking must be opted into explicitly.
+     */
+    val speechAnalysisReasoningLevel: String = AiReasoningLevel.OFF.storageValue,
     val useMultiSpeaker: Boolean = true,
     val defaultInterface: String = "classic",
     val contentSelectSpeakMode: Int = 0,

+ 3 - 0
app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt

@@ -1,5 +1,6 @@
 package io.legado.app.domain.usecase
 
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.SpeechPlanItem
 import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
@@ -25,6 +26,7 @@ class PrepareChapterSpeechPlanUseCase(
         paragraphs: List<CanonicalSpeechParagraph>,
         preferredDefaultVoiceId: String? = null,
         analysisMode: SpeechAnalysisMode = SpeechAnalysisMode.Rule,
+        analysisReasoningLevel: AiReasoningLevel = AiReasoningLevel.OFF,
         useMultiSpeaker: Boolean = true,
     ): List<SpeechPlanItem> {
         if (paragraphs.isEmpty()) return emptyList()
@@ -58,6 +60,7 @@ class PrepareChapterSpeechPlanUseCase(
                     analysisResult = locallyResolved,
                     paragraphs = paragraphs,
                     mode = effectiveMode,
+                    reasoningLevel = analysisReasoningLevel,
                 )
             }.getOrDefault(locallyResolved)
         }

+ 33 - 5
app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt

@@ -8,8 +8,10 @@ import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerationParams
 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.readaloud.CanonicalSpeechParagraph
@@ -56,6 +58,7 @@ class RefineSpeechWithAiUseCase(
         analysisResult: ChapterSpeechAnalysisResult,
         paragraphs: List<CanonicalSpeechParagraph>,
         mode: SpeechAnalysisMode,
+        reasoningLevel: AiReasoningLevel = AiReasoningLevel.OFF,
         now: Long = System.currentTimeMillis(),
     ): ChapterSpeechAnalysisResult {
         if (mode == SpeechAnalysisMode.Rule) return analysisResult
@@ -72,13 +75,14 @@ class RefineSpeechWithAiUseCase(
                 analysisResult = analysisResult,
                 profiles = profiles,
                 preset = preset,
+                reasoningLevel = reasoningLevel,
                 now = now,
             )
             SpeechAnalysisMode.AiUnderstanding -> {
                 if (analysisResult.segments.any(ChapterSpeechSegment::userLocked)) {
-                    completeRuleSegments(analysisResult, profiles, preset, now)
+                    completeRuleSegments(analysisResult, profiles, preset, reasoningLevel, now)
                 } else {
-                    understandAtoms(analysisResult, paragraphs, profiles, preset, now)
+                    understandAtoms(analysisResult, paragraphs, profiles, preset, reasoningLevel, now)
                 }
             }
         }
@@ -105,6 +109,7 @@ class RefineSpeechWithAiUseCase(
         analysisResult: ChapterSpeechAnalysisResult,
         profiles: List<BookCharacterProfile>,
         preset: AiTaskPresetConfig,
+        reasoningLevel: AiReasoningLevel,
         now: Long,
     ): List<ChapterSpeechSegment> {
         val candidates = analysisResult.segments.filter { segment ->
@@ -132,7 +137,9 @@ class RefineSpeechWithAiUseCase(
                     )
                 },
             )
-            parseSegmentDecisions(generate(preset, SpeechAnalysisMode.RuleWithAi, payload))
+            parseSegmentDecisions(
+                generate(preset, SpeechAnalysisMode.RuleWithAi, payload, reasoningLevel)
+            )
                 .forEach { decision ->
                     require(decision.segmentId in chunk.map(ChapterSpeechSegment::id)) {
                         "AI returned an unknown segmentId: ${decision.segmentId}"
@@ -171,6 +178,7 @@ class RefineSpeechWithAiUseCase(
         paragraphs: List<CanonicalSpeechParagraph>,
         profiles: List<BookCharacterProfile>,
         preset: AiTaskPresetConfig,
+        reasoningLevel: AiReasoningLevel,
         now: Long,
     ): List<ChapterSpeechSegment> {
         val atoms = paragraphs.flatMap(AiSpeechAtomizer::atomize)
@@ -184,7 +192,8 @@ class RefineSpeechWithAiUseCase(
                     mapOf("atomId" to atom.id, "text" to atom.text)
                 },
             )
-            val groups = parseAtomGroups(generate(preset, SpeechAnalysisMode.AiUnderstanding, payload))
+            val groups =
+                parseAtomGroups(generate(preset, SpeechAnalysisMode.AiUnderstanding, payload, reasoningLevel))
             validateCoverage(chunk, groups)
             require(groups.all { group ->
                 group.characterId == null || profilesById.containsKey(group.characterId)
@@ -232,6 +241,7 @@ class RefineSpeechWithAiUseCase(
         preset: AiTaskPresetConfig,
         mode: SpeechAnalysisMode,
         payload: Any,
+        reasoningLevel: AiReasoningLevel,
     ): String = aiTextGateway.generate(
         AiGenerateRequest(
             model = preset.model,
@@ -239,7 +249,7 @@ class RefineSpeechWithAiUseCase(
                 AiMessage(AiMessageRole.SYSTEM, systemPrompt(preset, mode)),
                 AiMessage(AiMessageRole.USER, GSON.toJson(payload)),
             ),
-            params = preset.params.copy(temperature = 0f),
+            params = speechAnalysisParams(preset, reasoningLevel),
         )
     ).getOrThrow().text
 
@@ -391,3 +401,21 @@ class RefineSpeechWithAiUseCase(
                 "character IDs, infer emotion conservatively, and use null when uncertain."
     }
 }
+
+/**
+ * Request params for AI speech analysis.
+ *
+ * The caller's level wins because the preset/model default (MEDIUM) would otherwise force thinking
+ * on for every analysis: models that think by default (Zhipu GLM, DeepSeek) then spend the answer on
+ * `reasoning_content` and the strict JSON contract of this task fails. AUTO keeps the presets in
+ * charge, mirroring [io.legado.app.domain.usecase.IdentifyBookCharactersUseCase.identifyStream].
+ */
+internal fun speechAnalysisParams(
+    preset: AiTaskPresetConfig,
+    reasoningLevel: AiReasoningLevel,
+): AiGenerationParams = preset.params.copy(
+    temperature = 0f,
+    reasoningLevel = reasoningLevel
+        .takeUnless { it == AiReasoningLevel.AUTO }
+        ?: preset.params.reasoningLevel,
+)

+ 5 - 0
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -36,6 +36,7 @@ import io.legado.app.constant.IntentAction
 import io.legado.app.constant.NotificationId
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Status
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
 import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
@@ -528,6 +529,10 @@ abstract class BaseReadAloudService : BaseService(),
                 chapterIndex = chapterIndex,
                 paragraphs = paragraphs,
                 analysisMode = SpeechAnalysisMode.fromStorage(ReadConfig.speechAnalysisMode),
+                analysisReasoningLevel = AiReasoningLevel.fromStorage(
+                    ReadConfig.speechAnalysisReasoningLevel,
+                    AiReasoningLevel.OFF,
+                ),
                 useMultiSpeaker = ReadConfig.useMultiSpeaker,
             )
         }.onFailure {

+ 12 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt

@@ -9,6 +9,7 @@ import io.legado.app.data.repository.HttpTtsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
@@ -96,6 +97,7 @@ class ReadAloudDelegate(
                         readAloudFinishCurrentChapterAfterTimer =
                             prefs.finishCurrentChapterAfterTimer,
                         speechAnalysisMode = prefs.speechAnalysisMode,
+                        speechAnalysisReasoningLevel = prefs.speechAnalysisReasoningLevel,
                         useMultiSpeaker = prefs.useMultiSpeaker,
                         defaultReadAloudInterface = prefs.defaultInterface,
                         preDownloadNum = host.preDownloadNum,
@@ -433,6 +435,16 @@ class ReadAloudDelegate(
         }
     }
 
+    /**
+     * 朗读分析的推理级别。关闭思考模式是 AI 朗读分析的默认值:默认思考的模型(智谱 GLM 等)
+     * 只把内容放在 reasoning_content 里,分析会直接失败。
+     */
+    fun setSpeechAnalysisReasoningLevel(value: String) {
+        val level = AiReasoningLevel.fromStorage(value, AiReasoningLevel.OFF)
+        updateSettings { it.copy(speechAnalysisReasoningLevel = level.storageValue) }
+        host.updateState { it.copy(speechAnalysisReasoningLevel = level.storageValue) }
+    }
+
     /**
      * 多角色朗读开关。正在朗读时必须重启朗读服务才能换掉合成管线,
      * 重启前记住页内位置,等服务真的回到 Idle 再重放,避免新旧管线叠音。

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

@@ -291,6 +291,7 @@ data class ReadBookUiState(
     val readAloudTtsTimer: Int = 0,
     val readAloudFinishCurrentChapterAfterTimer: Boolean = false,
     val speechAnalysisMode: String = "rule",
+    val speechAnalysisReasoningLevel: String = AiReasoningLevel.OFF.storageValue,
     val useMultiSpeaker: Boolean = true,
     val defaultReadAloudInterface: String = ReadAloudSettingsRepository.DEFAULT_INTERFACE_CLASSIC,
     val readAloudParagraphInterval: Int = 0,
@@ -791,6 +792,7 @@ sealed interface ReadBookIntent {
     data class SetReadAloudTtsFollowSys(val value: Boolean) : ReadBookIntent
     data class SetReadAloudTtsSpeechRate(val value: Int) : ReadBookIntent
     data class SetSpeechAnalysisMode(val value: String) : ReadBookIntent
+    data class SetSpeechAnalysisReasoningLevel(val value: String) : ReadBookIntent
     data class SetUseMultiSpeaker(val value: Boolean) : ReadBookIntent
     data class SetDefaultReadAloudInterface(val value: String) : ReadBookIntent
     data object OpenSystemTtsSettings : ReadBookIntent

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

@@ -1355,8 +1355,8 @@ class ReadBookViewModel(
                 readAloudDelegate.setTtsFollowSys(intent.value)
             is ReadBookIntent.SetReadAloudTtsSpeechRate ->
                 readAloudDelegate.setTtsSpeechRate(intent.value)
-            is ReadBookIntent.SetSpeechAnalysisMode ->
-                readAloudDelegate.setSpeechAnalysisMode(intent.value)
+            is ReadBookIntent.SetSpeechAnalysisMode -> readAloudDelegate.setSpeechAnalysisMode(intent.value)
+            is ReadBookIntent.SetSpeechAnalysisReasoningLevel -> readAloudDelegate.setSpeechAnalysisReasoningLevel(intent.value)
             is ReadBookIntent.SetUseMultiSpeaker ->
                 readAloudDelegate.setUseMultiSpeaker(intent.value)
             is ReadBookIntent.SetDefaultReadAloudInterface ->

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

@@ -17,6 +17,7 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.constant.ReadAloudBgMode
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
@@ -215,6 +216,28 @@ fun ReadAloudConfigContent(
                         },
                         onValueChange = { onIntent(ReadBookIntent.SetSpeechAnalysisMode(it)) },
                     )
+                    TinyDropdownSettingItem(
+                        title = stringResource(R.string.speech_analysis_reasoning_level),
+                        selectedValue = state.speechAnalysisReasoningLevel,
+                        displayEntries = arrayOf(
+                            stringResource(R.string.ai_thinking_off),
+                            stringResource(R.string.ai_thinking_auto),
+                            stringResource(R.string.ai_reasoning_level_low),
+                            stringResource(R.string.ai_reasoning_level_medium),
+                            stringResource(R.string.ai_reasoning_level_high),
+                            stringResource(R.string.ai_reasoning_level_xhigh),
+                            stringResource(R.string.ai_reasoning_level_max),
+                        ),
+                        entryValues = AiReasoningLevel.entries
+                            .map { it.storageValue }
+                            .toTypedArray(),
+                        description = stringResource(
+                            R.string.speech_analysis_reasoning_level_summary
+                        ),
+                        onValueChange = {
+                            onIntent(ReadBookIntent.SetSpeechAnalysisReasoningLevel(it))
+                        },
+                    )
                     TinySwitchSettingItem(
                         title = stringResource(R.string.use_multi_speaker),
                         description = stringResource(R.string.use_multi_speaker_summary),

+ 1 - 0
app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt

@@ -100,6 +100,7 @@ object ReadConfig {
     val audioPreDownloadNum get() = aloud.audioPreDownloadNum
     val audioCacheCleanTime get() = aloud.audioCacheCleanTime * 60 * 1000L
     val speechAnalysisMode get() = aloud.speechAnalysisMode
+    val speechAnalysisReasoningLevel get() = aloud.speechAnalysisReasoningLevel
     val useMultiSpeaker get() = aloud.useMultiSpeaker
     val defaultInterface get() = aloud.defaultInterface
 

+ 2 - 0
app/src/main/res/values-zh-rCN/strings.xml

@@ -2888,6 +2888,8 @@
     <string name="speech_analysis_rule_ai_summary">保留稳定规则边界,仅由 AI 补全不确定的人物、角色和情感</string>
     <string name="speech_analysis_ai_summary">AI 综合理解分片边界、说话人和情感,速度较慢且可能产生模型费用</string>
     <string name="speech_analysis_ai_model_required">请先配置 AI 模型,再启用 AI 章节分片</string>
+    <string name="speech_analysis_reasoning_level">分析推理级别</string>
+    <string name="speech_analysis_reasoning_level_summary">默认关闭。默认开启思考的模型(智谱 GLM、DeepSeek 等)只把内容放在推理字段里,会导致分析失败</string>
     <string name="use_multi_speaker">多角色朗读</string>
     <string name="use_multi_speaker_summary">为旁白、对话等不同角色使用不同音色</string>
     <string name="read_aloud_adjust_speed">调整语速</string>

+ 2 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -2671,6 +2671,8 @@
     <string name="speech_analysis_rule_ai_summary">保留穩定規則邊界,僅由 AI 補全不確定的人物、角色和情感</string>
     <string name="speech_analysis_ai_summary">AI 綜合理解分片邊界、說話人和情感,速度較慢且可能產生模型費用</string>
     <string name="speech_analysis_ai_model_required">請先設定 AI 模型,再啟用 AI 章節分片</string>
+    <string name="speech_analysis_reasoning_level">分析推理級別</string>
+    <string name="speech_analysis_reasoning_level_summary">預設關閉。預設開啟思考的模型(智譜 GLM、DeepSeek 等)只把內容放在推理欄位裡,會導致分析失敗</string>
     <string name="use_multi_speaker">多角色朗讀</string>
     <string name="use_multi_speaker_summary">為旁白、對話等不同角色使用不同音色</string>
     <string name="read_aloud_adjust_speed">調整語速</string>

+ 2 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -2676,6 +2676,8 @@
     <string name="speech_analysis_rule_ai_summary">保留穩定規則邊界,僅由 AI 補全不確定的人物、角色和情感</string>
     <string name="speech_analysis_ai_summary">AI 綜合理解分片邊界、說話人和情感,速度較慢且可能產生模型費用</string>
     <string name="speech_analysis_ai_model_required">請先設定 AI 模型,再啟用 AI 章節分片</string>
+    <string name="speech_analysis_reasoning_level">分析推理級別</string>
+    <string name="speech_analysis_reasoning_level_summary">預設關閉。預設開啟思考的模型(智譜 GLM、DeepSeek 等)只把內容放在推理欄位裡,會導致分析失敗</string>
     <string name="use_multi_speaker">多角色朗讀</string>
     <string name="use_multi_speaker_summary">為旁白、對話等不同角色使用不同音色</string>
     <string name="read_aloud_adjust_speed">調整語速</string>

+ 2 - 0
app/src/main/res/values/strings.xml

@@ -2893,6 +2893,8 @@
     <string name="speech_analysis_rule_ai_summary">Rules preserve stable boundaries; AI only completes uncertain speakers, roles and emotions</string>
     <string name="speech_analysis_ai_summary">AI understands boundaries, speakers and emotions; slower and may incur model costs</string>
     <string name="speech_analysis_ai_model_required">Configure an AI model before enabling AI chapter segmentation</string>
+    <string name="speech_analysis_reasoning_level">Analysis reasoning</string>
+    <string name="speech_analysis_reasoning_level_summary">Off by default. Models that think by default (Zhipu GLM, DeepSeek, …) return the answer only in the reasoning field, which makes analysis fail</string>
     <string name="use_multi_speaker">Multi-speaker reading</string>
     <string name="use_multi_speaker_summary">Use different voices for narration, dialogue and other roles</string>
     <string name="read_aloud_adjust_speed">Adjust reading speed</string>

+ 12 - 2
app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt

@@ -1,6 +1,7 @@
 package io.legado.app.data.repository
 
 import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.settings.ReadAloudSettings
 import org.junit.Assert.assertEquals
 import org.junit.Test
@@ -8,14 +9,14 @@ import org.junit.Test
 class ReadAloudSettingsMappingTest {
 
     @Test
-    fun `朗读设置 27 键写映射逐字段对应`() {
+    fun `朗读设置 28 键写映射逐字段对应`() {
         readAloudMappingSamples().forEach { settings ->
             assertEquals(settings.expectedPrefMap(), settings.toPrefMap())
         }
     }
 
     @Test
-    fun `朗读设置 27 键读映射逐字段对应`() {
+    fun `朗读设置 28 键读映射逐字段对应`() {
         readAloudMappingSamples().forEach { expected ->
             assertEquals(expected, expected.expectedPrefMap().toTestPreferences().toReadAloudSettings())
         }
@@ -59,6 +60,13 @@ class ReadAloudSettingsMappingTest {
 
         assertEquals(false, settings.finishCurrentChapterAfterTimer)
     }
+
+    @Test
+    fun `朗读分析推理级别默认关闭`() {
+        val settings = emptyMap<String, Any?>().toTestPreferences().toReadAloudSettings()
+
+        assertEquals(AiReasoningLevel.OFF.storageValue, settings.speechAnalysisReasoningLevel)
+    }
 }
 
 private const val CAPSULE_OFFSET_X = "read_aloud_capsule_offset_x"
@@ -75,6 +83,7 @@ private fun readAloudMappingSamples(): List<ReadAloudSettings> {
         ttsTimer = 33,
         ttsSpeechRate = 44,
         speechAnalysisMode = "ai",
+        speechAnalysisReasoningLevel = "medium",
         defaultInterface = "player",
         contentSelectSpeakMode = 55,
         audioPreDownloadNum = 66,
@@ -123,6 +132,7 @@ private fun ReadAloudSettings.expectedPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.ttsFollowSys to ttsFollowSys,
     PreferKey.ttsSpeechRate to ttsSpeechRate,
     PreferKey.speechAnalysisMode to speechAnalysisMode,
+    PreferKey.speechAnalysisReasoningLevel to speechAnalysisReasoningLevel,
     PreferKey.useMultiSpeaker to useMultiSpeaker,
     PreferKey.defaultReadAloudInterface to defaultInterface,
     PreferKey.contentSelectSpeakMod to contentSelectSpeakMode,

+ 18 - 0
app/src/test/java/io/legado/app/data/repository/ai/OpenAiChatThinkingTest.kt

@@ -43,6 +43,24 @@ class OpenAiChatThinkingTest {
         assertFalse(body.containsKey("thinking"))
     }
 
+    @Test
+    fun forcedThinkingGlmIsNotSentDisabledThinking() {
+        val body = mutableMapOf<String, Any?>()
+
+        body.applyZhipuThinking(zhipuProvider(), "glm-5.3", AiReasoningLevel.OFF)
+
+        assertFalse(body.containsKey("thinking"))
+    }
+
+    @Test
+    fun forcedThinkingGlmStillReceivesEnabledThinking() {
+        val body = mutableMapOf<String, Any?>()
+
+        body.applyZhipuThinking(zhipuProvider(), "glm-5.3-flash", AiReasoningLevel.HIGH)
+
+        assertEquals(mapOf("type" to "enabled"), body["thinking"])
+    }
+
     private fun zhipuProvider() = AiProviderConfig(
         id = "zhipu",
         name = "Zhipu AI",

+ 16 - 0
app/src/test/java/io/legado/app/domain/model/AiReasoningLevelTest.kt

@@ -17,4 +17,20 @@ class AiReasoningLevelTest {
 
         assertEquals("medium", AiReasoningLevel.MEDIUM.effortFor(provider))
     }
+
+    @Test
+    fun storageValueRoundTripsForEveryLevel() {
+        AiReasoningLevel.entries.forEach { level ->
+            assertEquals(level, AiReasoningLevel.fromStorage(level.storageValue))
+        }
+    }
+
+    @Test
+    fun unknownStorageValuesFallBackToTheGivenDefault() {
+        assertEquals(AiReasoningLevel.OFF, AiReasoningLevel.fromStorage("", AiReasoningLevel.OFF))
+        assertEquals(
+            AiReasoningLevel.OFF,
+            AiReasoningLevel.fromStorage("medium ", AiReasoningLevel.OFF),
+        )
+    }
 }

+ 59 - 0
app/src/test/java/io/legado/app/domain/usecase/RefineSpeechWithAiParamsTest.kt

@@ -0,0 +1,59 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelConfig
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiTaskPresetConfig
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class RefineSpeechWithAiParamsTest {
+
+    @Test
+    fun `off disables thinking even when the model defaults to medium`() {
+        val params = speechAnalysisParams(preset(AiReasoningLevel.MEDIUM), AiReasoningLevel.OFF)
+
+        assertEquals(AiReasoningLevel.OFF, params.reasoningLevel)
+        assertEquals(0f, params.temperature ?: -1f)
+    }
+
+    @Test
+    fun `auto keeps the preset and model level`() {
+        val params = speechAnalysisParams(preset(AiReasoningLevel.MEDIUM), AiReasoningLevel.AUTO)
+
+        assertEquals(AiReasoningLevel.MEDIUM, params.reasoningLevel)
+    }
+
+    @Test
+    fun `an explicit level overrides the model default`() {
+        val params = speechAnalysisParams(preset(AiReasoningLevel.MEDIUM), AiReasoningLevel.HIGH)
+
+        assertEquals(AiReasoningLevel.HIGH, params.reasoningLevel)
+    }
+
+    private fun preset(reasoningLevel: AiReasoningLevel) = AiTaskPresetConfig(
+        id = "preset-chat",
+        taskType = "chat",
+        name = "Default Chat",
+        model = AiModelConfig(
+            id = "model-1",
+            provider = AiProviderConfig(
+                id = "zhipu",
+                name = "Zhipu AI",
+                protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+                baseUrl = "https://open.bigmodel.cn/api/paas/v4",
+                apiKey = "test"
+            ),
+            displayName = "GLM-4.7-Flash",
+            modelId = "glm-4.7-flash",
+            defaultParams = AiGenerationParams(reasoningLevel = reasoningLevel)
+        ),
+        promptTemplate = "prompt",
+        params = AiGenerationParams(
+            temperature = 0.7f,
+            reasoningLevel = reasoningLevel
+        )
+    )
+}