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

Merge remote-tracking branch 'origin/main'

# Conflicts:
#	app/src/main/java/io/legado/app/help/book/BookHelp.kt
HapeLee 1 месяц назад
Родитель
Сommit
d845912421
53 измененных файлов с 1127 добавлено и 81 удалено
  1. 7 1
      app/src/main/java/io/legado/app/App.kt
  2. 1 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 8 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  4. 13 5
      app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt
  5. 12 0
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  6. 8 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  7. 3 0
      app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt
  8. 33 5
      app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt
  9. 6 4
      app/src/main/java/io/legado/app/help/book/BookHelp.kt
  10. 21 0
      app/src/main/java/io/legado/app/help/coil/CoverExtras.kt
  11. 82 25
      app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt
  12. 134 0
      app/src/main/java/io/legado/app/help/coil/CoverFileCache.kt
  13. 46 0
      app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt
  14. 7 1
      app/src/main/java/io/legado/app/help/http/HttpHelper.kt
  15. 5 0
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  16. 4 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  17. 11 1
      app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt
  18. 6 0
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  19. 3 0
      app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt
  20. 3 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt
  21. 12 0
      app/src/main/java/io/legado/app/ui/book/read/ReadAloudDelegate.kt
  22. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  23. 2 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  24. 23 0
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  25. 18 2
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReaderBookSheet.kt
  26. 1 0
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt
  27. 26 3
      app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt
  28. 8 3
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt
  29. 40 10
      app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt
  30. 1 0
      app/src/main/java/io/legado/app/ui/config/readConfig/ReadConfig.kt
  31. 1 0
      app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt
  32. 1 0
      app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt
  33. 59 7
      app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt
  34. 1 0
      app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt
  35. 1 0
      app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt
  36. 17 1
      app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt
  37. 5 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt
  38. 10 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  39. 7 0
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverImageRequest.kt
  40. 72 0
      app/src/main/java/io/legado/app/utils/HtmlFormatter.kt
  41. 10 0
      app/src/main/res/values-zh-rCN/strings.xml
  42. 10 0
      app/src/main/res/values-zh-rHK/strings.xml
  43. 10 0
      app/src/main/res/values-zh-rTW/strings.xml
  44. 10 0
      app/src/main/res/values/strings.xml
  45. 95 0
      app/src/test/java/io/legado/app/RhinoContextEntryTest.kt
  46. 12 2
      app/src/test/java/io/legado/app/data/repository/ReadAloudSettingsMappingTest.kt
  47. 18 0
      app/src/test/java/io/legado/app/data/repository/ai/OpenAiChatThinkingTest.kt
  48. 16 0
      app/src/test/java/io/legado/app/domain/model/AiReasoningLevelTest.kt
  49. 59 0
      app/src/test/java/io/legado/app/domain/usecase/RefineSpeechWithAiParamsTest.kt
  50. 134 0
      app/src/test/java/io/legado/app/ui/book/searchContent/SearchContentHistoryRecordingTest.kt
  51. 3 3
      modules/rhino/src/main/java/com/script/rhino/RhinoCompiledScript.kt
  52. 28 4
      modules/rhino/src/main/java/com/script/rhino/RhinoExtensions.kt
  53. 2 2
      modules/rhino/src/main/java/com/script/rhino/RhinoScriptEngine.kt

+ 7 - 1
app/src/main/java/io/legado/app/App.kt

@@ -176,6 +176,13 @@ class App : Application(), SingletonImageLoader.Factory {
             ThreadUtils.setThreadAssertsDisabledForTesting(true)
         }
         registerActivityLifecycleCallbacks(LifecycleHelp)
+        // Rhino 的全局 ContextFactory 必须在任何线程执行 Context.enter() 之前安装:
+        // 它只在 RhinoScriptEngine 的 object init 里通过 ContextFactory.initGlobal 生效,
+        // 而 Context.enter() 取的就是这个全局工厂。原先 initRhino() 排在下面那个
+        // Coroutine.async 的末尾,冷启动早期执行的脚本(书源登录/换源/解析)可能先拿到
+        // 普通 Context,强转 RhinoContext 会直接崩溃并把该线程永久污染。
+        // 这里改为在 onCreate 中同步执行;RhinoWrapFactory 注册同理要早于任何脚本执行。
+        initRhino()
         Coroutine.async {
             get<BackupSettingsGateway>().settings
                 .map {
@@ -212,7 +219,6 @@ class App : Application(), SingletonImageLoader.Factory {
             DispatchersMonitor.init()
             URL.setURLStreamHandlerFactory(ObsoleteUrlFactory(okHttpClient))
             launch { installGmsTlsProvider(appCtx) }
-            initRhino()
             //初始化封面
             BookCover.toString()
             //清除过期数据

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

@@ -276,6 +276,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,
+)

+ 6 - 4
app/src/main/java/io/legado/app/help/book/BookHelp.kt

@@ -209,10 +209,12 @@ object BookHelp {
             book.getFolderName(),
             bookChapter.getFileName(),
         ).writeText(content)
-        if (book.isOnLineTxt) {
-            val wordCount = StringUtils.wordCountFormat(
-                HtmlFormatter.textForWordCount(content).length
-            )
+        if (book.isOnLineTxt && readGateway.currentSettings.tocCountWords) {
+            // 正文里携带的 <img src="data:base64">、内联 SVG 等富文本源码会把章节字数虚抬
+            // 几倍(3 页正文显示 3000+ 字)。这里剔除标签/Base64 后按可读纯文本计数,
+            // 取代原先的 StringUtils.wordCountFormat(content.length)。
+            val readableLength = HtmlFormatter.countReadableTextLength(content)
+            val wordCount = StringUtils.wordCountFormat(readableLength)
             bookChapter.wordCount = wordCount
             appDb.bookChapterDao.update(bookChapter)
         }

+ 21 - 0
app/src/main/java/io/legado/app/help/coil/CoverExtras.kt

@@ -26,4 +26,25 @@ object CoverExtras {
 
     /** 漫画图片所属书籍。图片解密必须显式携带,不能读取全局阅读会话。 */
     val MangaBookUrl = Extras.Key<String?>(null)
+
+    /**
+     * CoverInterceptor 在把 request.data 改写为最终解析 URL 之前,把书架/详情页
+     * 存下的原始封面地址放进来。CoverFetcher 回写持久文件缓存时用这个稳定键
+     * (而不是带动态 token 的最终 URL),保证重启后拦截器快速路径仍能命中。
+     */
+    val OriginalUrl = Extras.Key<String?>(null)
+
+    /**
+     * 封面请求所属书籍的 bookUrl,用于别名缓存键:部分书源启动刷新时会把 coverUrl
+     * 重写成带新 token 的链接(图片内容相同),纯 URL 键会 miss,书架只能重新下载。
+     * 别名键=“本书最近一次成功缓存的封面”,与链接无关,书架优先命中它。
+     */
+    val BookUrl = Extras.Key<String?>(null)
+
+    /**
+     * 书架类请求专用:本地有缓存(含别名命中)就直接用,绝不进“解析书源规则 →
+     * 可能跑登录检测脚本/弹 toast/走网络”的慢速路径。
+     * 详情页不设置此标志:在线时仍会拉新链接并刷新别名,保证封面真换图后能更新。
+     */
+    val PreferCache = Extras.Key<Boolean?>(null)
 }

+ 82 - 25
app/src/main/java/io/legado/app/help/coil/CoverFetcher.kt

@@ -84,33 +84,57 @@ class CoverFetcher(
             )
         }
 
-        if (loadOnlyWifi && !appCtx.isWifiConnect) {
-            throw IOException("WiFi not available, loadOnlyWifi enabled")
-        }
-
-        if (isFailed(url)) {
-            throw IOException("URL previously failed, skipping: $url")
-        }
+        // ===== 本地优先:持久化封面文件缓存 =====
+        // 键为【原始封面地址】(由 CoverInterceptor 携带),书架与详情页共享;
+        // 链接带动态 token 的书源重启后也能命中。
+        val originalUrl = options.extras[CoverExtras.OriginalUrl] ?: url
+        // 仅“书”维度请求(书架/详情页等带 bookUrl)写入持久缓存;
+        // 发现页/搜索页/首页模块的临时封面不传 bookUrl,不落盘,
+        // 缓存体积只与书架藏书量成正比。
+        val bookUrl = options.extras[CoverExtras.BookUrl]
 
-        // Try OkHttp HTTP cache: FORCE_CACHE serves from cache or returns 504 on miss
         val requestHeaders = options.extras[CoverExtras.Headers]
-        val cacheRequest = Request.Builder()
-            .url(url)
-            .tag(io.legado.app.data.entities.BaseSource::class.java, source)
-            .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
-            .cacheControl(CacheControl.FORCE_CACHE)
-            .build()
 
+        // ===== 第二级:OkHttp HTTP 缓存(FORCE_CACHE 只读缓存,miss 返回 504,不碰网络)=====
+        // 注意:WiFi 限制与失败冷却不能挡在本地缓存读取之前,
+        // 否则某个 URL 一次失败后 5 分钟内连本地已有缓存都会被强制置灰。
+        var rawBytes: ByteArray? = null
         var fromCache = false
-        val rawBytes = try {
+        try {
             withContext(Dispatchers.IO) {
+                val cacheRequest = Request.Builder()
+                    .url(url)
+                    .tag(io.legado.app.data.entities.BaseSource::class.java, source)
+                    .apply { requestHeaders?.forEach { (key, value) -> addHeader(key, value) } }
+                    .cacheControl(CacheControl.FORCE_CACHE)
+                    .build()
                 val cacheResponse = callFactory.newCall(cacheRequest).execute()
                 if (cacheResponse.isSuccessful) {
                     fromCache = true
-                    cacheResponse.body.use { it.bytes() }
+                    cacheResponse.body.use { rawBytes = it.bytes() }
                 } else {
                     cacheResponse.close()
-                    // Cache miss, fetch from network
+                    // 缓存 miss,下面再走网络
+                }
+            }
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            // HTTP 缓存读取异常(如缓存损坏),降级走网络
+        }
+
+        // ===== 第三级:本地全部 miss,才允许请求网络 =====
+        if (rawBytes == null) {
+            if (loadOnlyWifi && !appCtx.isWifiConnect) {
+                throw IOException("WiFi not available, loadOnlyWifi enabled")
+            }
+
+            if (isFailed(url)) {
+                throw IOException("URL previously failed, skipping: $url")
+            }
+
+            rawBytes = try {
+                withContext(Dispatchers.IO) {
                     val networkRequest = Request.Builder()
                         .url(url)
                         .tag(io.legado.app.data.entities.BaseSource::class.java, source)
@@ -130,28 +154,61 @@ class CoverFetcher(
                     }
                     body.use { it.bytes() }
                 }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Exception) {
+                markFailed(url)
+                // 网络彻底失败(断网/源挂)时回退到本书别名缓存:
+                // 只要这本书曾经成功加载过封面,弱网/断网下仍显示上次缓存的封面,而不是灰图。
+                if (!isManga && bookUrl != null) {
+                    val stale = withContext(Dispatchers.IO) {
+                        CoverFileCache.readByBookUrl(bookUrl)?.let { f ->
+                            try {
+                                if (f.length() in 1..20L * 1024 * 1024) f.readBytes() else null
+                            } catch (ex: Exception) {
+                                null
+                            }
+                        }
+                    }
+                    if (stale != null) {
+                        return SourceFetchResult(
+                            source = ImageSource(
+                                source = Buffer().write(stale),
+                                fileSystem = options.fileSystem
+                            ),
+                            mimeType = null,
+                            dataSource = DataSource.DISK
+                        )
+                    }
+                }
+                throw e
             }
-        } catch (e: CancellationException) {
-            throw e
-        } catch (e: Exception) {
-            markFailed(url)
-            throw e
         }
 
+        // 到这里必定已拿到字节(本地缓存/OkHttp 缓存/网络三选一,否则已抛出)。
+        // rawBytes 在 lambda 内赋值,Kotlin 无法智能转换为非空,这里显式收敛。
+        val fetchedBytes = rawBytes ?: throw IOException("封面数据为空: $url")
+
         // Decrypt if needed (applies to both cached and network bytes)
         val decodedBytes = if (ImageUtils.skipDecode(source, !isManga)) {
-            rawBytes
+            fetchedBytes
         } else {
             withContext(Dispatchers.IO) {
                 if (isManga) {
-                    ImageUtils.decode(url, rawBytes, false, source, mangaBook)
+                    ImageUtils.decode(url, fetchedBytes, false, source, mangaBook)
                 } else {
-                    ImageUtils.decode(url, rawBytes, true, source)
+                    ImageUtils.decode(url, fetchedBytes, true, source)
                 }
             } ?: throw IOException("图片解密失败")
         }
 
         clearFailure(url)
+        // 网络/OkHttp 缓存/解密完成后,按原始 URL 精确键 + bookUrl 别名键双写持久缓存:
+        // 下次冷启动由 CoverInterceptor 快速路径直接命中;书源刷新换了带 token 的新链接时,
+        // 书架靠别名键也能秒出旧图。仅书维度请求(bookUrl 非空)写入。
+        if (!isManga && bookUrl != null) {
+            withContext(Dispatchers.IO) { CoverFileCache.write(originalUrl, decodedBytes, bookUrl) }
+        }
         return SourceFetchResult(
             source = ImageSource(
                 source = Buffer().write(decodedBytes),

+ 134 - 0
app/src/main/java/io/legado/app/help/coil/CoverFileCache.kt

@@ -0,0 +1,134 @@
+package io.legado.app.help.coil
+
+import io.legado.app.utils.MD5Utils
+import splitties.init.appCtx
+import java.io.File
+
+/**
+ * filesDir 下的持久化封面文件缓存(不受“清除缓存”策略与系统低存储回收影响)。
+ *
+ * 存在的理由:封面链路的缓存键传统上落在 AnalyzeUrl 解析出的最终 URL 上,而不少
+ * 书源的封面最终 URL 带时效 token/签名,每次解析都不同 → Coil 与 OkHttp 缓存全部
+ * miss,重启后书架要重新走网络;解析过程还会执行书源 headerRule/coverUrl 里的
+ * JS/Java 检查脚本,触发“未登录”“非官方版本”等书源自定义弹窗。
+ *
+ * 因此这里以【书架/详情页存下的原始封面地址】(稳定)的 MD5 为键保存解密后的图片字节:
+ * - CoverInterceptor 命中时直接改写请求为本地文件,完全跳过规则解析与网络,
+ *   重启加载、编辑页重载封面都不再执行书源脚本。
+ * - 封面链接真的变化(换源/刷新详情)时 key 变化,自动回退到解析+网络路径。
+ * - 总量上限 100MB,LRU 修剪到 80MB;单文件上限 20MB。
+ * - 设置 → 下载与缓存 → “清除封面缓存”会连同本目录一起清空(见 HttpHelper.clearHttpCache)。
+ */
+object CoverFileCache {
+
+    /** 持久缓存目录:filesDir 下,不受"清除缓存"策略与系统低存储清理影响 */
+    private val cacheDir: File by lazy {
+        File(appCtx.filesDir, "cover_cache").apply { if (!exists()) mkdirs() }
+    }
+
+    private const val MAX_CACHE_FILE_BYTES = 20L * 1024 * 1024        // 单封面 20MB 上限
+    private const val CACHE_MAX_BYTES = 100L * 1024L * 1024L          // 总量 100MB 上限
+    private const val CACHE_TRIM_TO_BYTES = 80L * 1024L * 1024L       // 修剪目标 80MB
+    private const val TRIM_INTERVAL_MS = 30 * 60 * 1000L              // 修剪节流 30 分钟
+
+    @Volatile
+    private var lastTrimTime = 0L
+
+    fun fileFor(keyUrl: String): File =
+        File(cacheDir, MD5Utils.md5Encode(keyUrl) + ".img")
+
+    /** 命中返回可读的缓存文件,未命中返回 null */
+    fun read(keyUrl: String): File? {
+        val f = fileFor(keyUrl)
+        val len = f.length()
+        return if (len > 0L && len <= MAX_CACHE_FILE_BYTES) f else null
+    }
+
+    // bookUrl 别名键。链接带 token 轮转的书源每次刷新 coverUrl 都会变,
+    // 纯 URL 键必 miss;别名=“这本书最近一次成功缓存的封面文件”,与链接无关。
+    // 实现:bk_<md5(bookUrl)>.lnk 小文本文件,内容为主缓存文件名,不重复存图片字节。
+    private fun aliasFileFor(bookUrl: String): File =
+        File(cacheDir, "bk_" + MD5Utils.md5Encode(bookUrl) + ".lnk")
+
+    /** 按书籍维度取最近一次成功缓存的封面文件;无别名/目标失效返回 null */
+    fun readByBookUrl(bookUrl: String): File? {
+        return try {
+            val lnk = aliasFileFor(bookUrl)
+            if (!lnk.exists()) return null
+            val targetName = lnk.readText().trim()
+            if (targetName.isEmpty()) return null
+            val f = File(cacheDir, targetName)
+            val len = f.length()
+            if (len > 0L && len <= MAX_CACHE_FILE_BYTES) f else null
+        } catch (e: Exception) {
+            null
+        }
+    }
+
+    /**
+     * 别名回填:拦截器精确键命中时调用,把“本书→该文件”的指针补上
+     * (已有且指向相同则直接返回),之后书源再轮换 URL 也能命中。
+     */
+    fun ensureAlias(bookUrl: String, targetFile: File) {
+        try {
+            val lnk = aliasFileFor(bookUrl)
+            if (lnk.exists() && lnk.readText().trim() == targetFile.name) return
+            lnk.writeText(targetFile.name)
+        } catch (e: Exception) {
+            // 别名写失败不影响本次加载
+        }
+    }
+
+    /** 原子写入:先写 tmp 再 rename,避免滚动并发读到半截文件;bookUrl 非空时同步更新别名 */
+    fun write(keyUrl: String, bytes: ByteArray, bookUrl: String? = null) {
+        if (bytes.isEmpty() || bytes.size.toLong() > MAX_CACHE_FILE_BYTES) return
+        val file = fileFor(keyUrl)
+        try {
+            val tmp = File(file.parentFile, file.name + "." + System.nanoTime() + ".tmp")
+            tmp.writeBytes(bytes)
+            if (!tmp.renameTo(file)) {
+                file.delete()
+                tmp.renameTo(file)
+            }
+        } catch (e: Exception) {
+            // 写缓存失败不影响本次加载
+        }
+        if (bookUrl != null) {
+            try {
+                aliasFileFor(bookUrl).writeText(file.name)
+            } catch (e: Exception) {
+                // 别名写失败不影响主缓存
+            }
+        }
+        maybeTrim()
+    }
+
+    /** 设置页"清除封面缓存"调用 */
+    fun clear() {
+        try {
+            cacheDir.deleteRecursively()
+            cacheDir.mkdirs()
+        } catch (e: Exception) {
+            // 清理失败下次重试
+        }
+    }
+
+    /** 按 lastModified 做 LRU 清理,防止常年累积占满存储 */
+    private fun maybeTrim() {
+        val now = System.currentTimeMillis()
+        if (now - lastTrimTime < TRIM_INTERVAL_MS) return
+        lastTrimTime = now
+        try {
+            val files = cacheDir.listFiles()?.toList() ?: return
+            var total = files.sumOf { it.length() }
+            if (total <= CACHE_MAX_BYTES) return
+            files.sortedBy { it.lastModified() }.forEach { f ->
+                if (total <= CACHE_TRIM_TO_BYTES) return
+                total -= f.length()
+                f.delete()
+            }
+        } catch (e: Exception) {
+            // 清理失败不影响图片加载
+        }
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/help/coil/CoverInterceptor.kt

@@ -1,6 +1,7 @@
 package io.legado.app.help.coil
 
 import coil3.intercept.Interceptor
+import coil3.request.CachePolicy
 import coil3.request.ImageResult
 import io.legado.app.help.source.SourceHelp
 import io.legado.app.model.analyzeRule.AnalyzeUrl
@@ -35,6 +36,40 @@ class CoverInterceptor : Interceptor {
         val data = request.data
 
         if (data is String && data.isNotBlank()) {
+            // 本地封面缓存快速路径:命中就改写为本地文件,跳过 AnalyzeUrl 的书源规则调用
+            // (部分书源的 headerRule/coverUrl 会执行 JS/Java 检查脚本,冷启动或重载封面时
+            // 会弹“未登录/版本检测”提示)与网络请求。
+            //
+            // 命中规则:
+            //   ① 精确命中(md5(原始封面地址))→ 任何页面都直接用本地文件;
+            //   ② 书架类请求(PreferCache)额外接受“本书别名”命中:启动刷新把 coverUrl
+            //     重写成带新 token 的链接时,图片内容其实没变,直接取别名指向的文件,
+            //     不解析规则、不跑脚本、不联网;
+            //   ③ 详情页不设 PreferCache:在线时仍走慢速路径拉新链接,成功后同时刷新
+            //     精确键与别名键 → 下次书架展示的就是新封面。
+            // 漫画模式走独立缓存目录不在此列;data: 内联图无需缓存。
+            val isManga = request.extras[CoverExtras.Manga] == true
+            val bookUrl = request.extras[CoverExtras.BookUrl]
+            val preferCache = request.extras[CoverExtras.PreferCache] == true
+            if (!isManga && !data.startsWith("data:", true)) {
+                val exactFile = CoverFileCache.read(data)
+                val cachedFile = exactFile
+                    ?: bookUrl?.takeIf { preferCache }?.let { CoverFileCache.readByBookUrl(it) }
+                cachedFile?.let { file ->
+                    // 精确命中且带 bookUrl 时,顺手把本书别名指向这个文件,
+                    // 之后书源轮换 URL 也能靠别名命中,不必重新下载。
+                    if (exactFile != null && bookUrl != null) {
+                        withContext(Dispatchers.IO) {
+                            CoverFileCache.ensureAlias(bookUrl, file)
+                        }
+                    }
+                    val localRequest = request.newBuilder()
+                        .data(file)
+                        .build()
+                    return chain.withRequest(localRequest).proceed()
+                }
+            }
+
             val sourceOrigin = request.extras[CoverExtras.SourceOrigin]
             val source = sourceOrigin?.let { origin ->
                 withContext(Dispatchers.IO) {
@@ -60,6 +95,17 @@ class CoverInterceptor : Interceptor {
                 .apply {
                     extras[CoverExtras.Source] = source
                     extras[CoverExtras.Headers] = headers
+                    // 携带原始地址,供 CoverFetcher 回写稳定键的持久缓存
+                    extras[CoverExtras.OriginalUrl] = data
+                    // 关闭 Coil 自带的磁盘缓存(位于 cacheDir/image_cache,系统低存储时会被回收,
+                    // 设置页清缓存也会删)。Coil 磁盘缓存一旦命中就直接返回,CoverFetcher 不会执行,
+                    // filesDir 下的持久缓存也就永远得不到回填;而 cacheDir 被回收后,断网重启会大量丢封面。
+                    // 这里只对“确实会写持久缓存”的请求(带 bookUrl)关闭,保证字节一定经过
+                    // CoverFetcher 落到 filesDir;无 bookUrl 的临时封面(发现页/搜索页)不写持久缓存,
+                    // 保留 Coil 磁盘缓存,避免它们退化成只能重新联网。
+                    if (!isManga && bookUrl != null) {
+                        diskCachePolicy(CachePolicy.DISABLED)
+                    }
                 }
                 .build()
 

+ 7 - 1
app/src/main/java/io/legado/app/help/http/HttpHelper.kt

@@ -179,7 +179,13 @@ fun getHttpCacheSize(type: HttpCacheType): Long {
 
 fun clearHttpCache(type: HttpCacheType) {
     when (type) {
-        HttpCacheType.COVER -> okHttpClient.cache?.delete()
+        // 设置页“封面缓存”条目同时清掉持久化封面文件缓存(CoverFileCache,
+        // 位于 filesDir/cover_cache,不在“清除缓存”目录扫描范围内),
+        // 保证用户能彻底删除封面数据腾空间。
+        HttpCacheType.COVER -> {
+            okHttpClient.cache?.delete()
+            io.legado.app.help.coil.CoverFileCache.clear()
+        }
         HttpCacheType.MANGA -> okHttpClientManga.cache?.delete()
     }
 }

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

+ 4 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -998,6 +998,10 @@ private fun BookInfoHeader(
                         author = book.author,
                         path = if (usesDefaultCover) null else book.coverPath,
                         sourceOrigin = if (usesDefaultCover) null else book.origin,
+                        // 传 bookUrl 供别名缓存键。详情页故意不设 preferCache:
+                        // 在线时仍走完整链路拉新链接并刷新别名,保证封面换图后书架也能更新;
+                        // 精确命中时同样不跑脚本。
+                        bookUrl = book.bookUrl,
                         onError = onNetworkCoverLoadError,
                         modifier = Modifier
                             .width(112.dp)

+ 11 - 1
app/src/main/java/io/legado/app/ui/book/info/BookInfoSheets.kt

@@ -37,6 +37,7 @@ import androidx.compose.runtime.mutableLongStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
+import kotlinx.coroutines.flow.flowOf
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -198,7 +199,16 @@ fun ChangeCoverSheet(
     onSelect: (String) -> Unit,
     viewModel: ChangeCoverViewModel = koinViewModel(key = "cover-$name-$author"),
 ) {
-    val items by viewModel.dataFlow.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
+    val items by remember(show) {
+        // dataFlow 若无条件 collectAsStateWithLifecycle 收集会带来副作用:
+        // ChangeCoverSheet 在编辑页(BookInfoEditContent)里是 show=false 也照样组合的,
+        // callbackFlow 收集体立即执行:查库后若结果 ≤ 1 条就 startSearch() → 用全部启用
+        // 书源并发搜书(每源执行搜索规则/登录检测脚本),这就是“每次点开编辑页都弹某书源
+        // 未登录提示”的根源。
+        // 这里只有用户真正打开换封面面板(show=true)才开始收集/自动搜索;
+        // 隐藏时收集一个空的已完成 Flow,不触发任何网络与脚本。
+        if (show) viewModel.dataFlow else flowOf(emptyList())
+    }.collectAsStateWithLifecycle(initialValue = emptyList<SearchBook>())
     val isSearching by viewModel.isSearching.collectAsStateWithLifecycle()
 
     LaunchedEffect(name, author) {

+ 6 - 0
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -176,6 +176,12 @@ fun BookInfoEditContent(
                 name = uiState.name,
                 author = uiState.author,
                 path = uiState.coverUrl,
+                // 编辑页封面同样本地优先:编辑页封面请求不带书标识时,URL 被启动刷新轮换后
+                // 精确键会 miss,只能走慢速路径解析书源规则(执行 headerRule 里的登录检测脚本),
+                // 每次进编辑页都弹“未登录”提示。
+                // 传 bookUrl 后,书架/详情页缓存过的封面(含别名命中)直接本地返回,不碰书源。
+                bookUrl = uiState.book?.bookUrl,
+                preferCache = true,
                 modifier = Modifier
                     .width(110.dp)
             )

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/manage/BookshelfManageScreen.kt

@@ -1687,6 +1687,9 @@ private fun PreviewBookInfo(
                 author = book.author,
                 path = book.getDisplayCover(),
                 sourceOrigin = book.origin,
+                // 书架管理类页面本地优先,不重复跑书源脚本
+                bookUrl = book.bookUrl,
+                preferCache = true,
                 modifier = Modifier.width(54.dp),
             )
             AppText(

+ 3 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAloudCapsule.kt

@@ -268,6 +268,9 @@ private fun ExpandedCapsuleContent(
             author = book?.author,
             path = book?.getDisplayCover(),
             sourceOrigin = book?.origin,
+            // 听书胶囊也是书维度场景,本地优先不跑书源脚本
+            bookUrl = book?.bookUrl,
+            preferCache = true,
             modifier = Modifier
                 .size(40.dp)
                 .graphicsLayer { rotationZ = coverRotation }

+ 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,
@@ -799,6 +800,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

@@ -1356,8 +1356,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),

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

@@ -62,6 +62,7 @@ import androidx.compose.runtime.remember
 import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
+import io.legado.app.utils.HtmlFormatter
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -420,6 +421,9 @@ internal fun ReaderBookHeader(
             author = book?.author,
             path = book?.getDisplayCover(),
             sourceOrigin = book?.origin,
+            // 阅读抽屉也是书维度场景,本地优先不跑书源脚本
+            bookUrl = book?.bookUrl,
+            preferCache = true,
             modifier = Modifier.width(40.dp),
         )
         Column(
@@ -575,6 +579,18 @@ private fun ReaderBookSourceDropdown(
 private fun ReaderBookInformation(
     book: Book?,
 ) {
+    // 简介直接以 book.intro 原样塑进 Text 会有问题:部分书源的简介带
+    // <usehtml>/<useweb>/<md> 前缀、<button>@onclick:JS</button> 与 HTML 标签,
+    // 展示出来就是一堆调用源码;formatDisplayText 还会给每行补段首缩进
+    // (两个全角空格),与详情页观感不一致。
+    // 这里改用与详情页同一套解析规则的 formatReadableText 清洗——去前缀、
+    // 丢弃 @onclick:JS 片段(只保留按钮文字,如“💬 本书讨论”)、不补缩进。
+    // 注意:LazyColumn 的 content lambda 不是 composable 作用域,remember 必须
+    // 提在本函数体开头调用。
+    val rawIntro = book?.getDisplayIntro()?.takeIf { it.isNotBlank() }
+    val formattedIntro = remember(rawIntro) {
+        rawIntro?.let { HtmlFormatter.formatReadableText(it) }
+    }
     LazyColumn(
         modifier = Modifier.fillMaxSize(),
         contentPadding = PaddingValues(vertical = 8.dp),
@@ -590,8 +606,8 @@ private fun ReaderBookInformation(
         book?.remark?.takeIf { it.isNotBlank() }?.let { remark ->
             item { InformationRow(stringResource(R.string.book_remark), remark) }
         }
-        book?.getDisplayIntro()?.takeIf { it.isNotBlank() }?.let { intro ->
-            item { InformationRow(stringResource(R.string.book_intro), intro) }
+        if (!formattedIntro.isNullOrBlank()) {
+            item { InformationRow(stringResource(R.string.book_intro), formattedIntro) }
         }
     }
 }

+ 1 - 0
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentScreen.kt

@@ -202,6 +202,7 @@ fun SearchContentScreen(
                         autoFocus = autoFocus,
                         scrollState = listState,
                         onQueryChange = { onIntent(SearchContentIntent.UpdateQuery(it)) },
+                        onSearch = { onIntent(SearchContentIntent.SubmitSearch(it)) },
                         trailingIcon = {
                             if (searchQuery.isNotEmpty()) {
                                 SmallPlainButton(

+ 26 - 3
app/src/main/java/io/legado/app/ui/book/searchContent/SearchContentViewModel.kt

@@ -40,6 +40,9 @@ data class SearchContentUiState(
 
 sealed interface SearchContentIntent {
     data class UpdateQuery(val value: String) : SearchContentIntent
+
+    /** 用户在输入法上按下「搜索」:确认这次查询并把关键词写入搜索历史。 */
+    data class SubmitSearch(val value: String) : SearchContentIntent
     data class ToggleReplace(val enabled: Boolean) : SearchContentIntent
     data class ToggleRegex(val enabled: Boolean) : SearchContentIntent
     data object ToggleHistoryScope : SearchContentIntent
@@ -74,6 +77,12 @@ class SearchContentViewModel(
         searchContentRepository.getLastSession(bookUrl)
     } else null
 
+    /**
+     * 从阅读页选中文字进入时,这次查询是用户主动发起的,允许记入历史;
+     * 其余情况(恢复上次会话、输入过程中的自动搜索)只做预览,不污染历史。
+     */
+    private val initialSearchSubmitted = initialSearchWord != null
+
     private val _uiState = MutableStateFlow(
         SearchContentUiState(
             searchQuery = initialSearchWord ?: restoredSession?.query.orEmpty(),
@@ -107,6 +116,10 @@ class SearchContentViewModel(
                 _uiState.update { it.copy(searchQuery = intent.value) }
                 executeSearch()
             }
+            is SearchContentIntent.SubmitSearch -> {
+                _uiState.update { it.copy(searchQuery = intent.value) }
+                executeSearch(recordHistory = true)
+            }
             is SearchContentIntent.ToggleReplace -> {
                 _uiState.update { it.copy(replaceEnabled = intent.enabled) }
                 executeSearch()
@@ -152,7 +165,9 @@ class SearchContentViewModel(
                 )
             }
             observeHistory()
-            if (cachedResults.isNullOrEmpty() && state.searchQuery.isNotBlank()) executeSearch()
+            if (cachedResults.isNullOrEmpty() && state.searchQuery.isNotBlank()) {
+                executeSearch(recordHistory = initialSearchSubmitted)
+            }
         }
     }
 
@@ -167,7 +182,13 @@ class SearchContentViewModel(
         }
     }
 
-    private fun executeSearch() {
+    /**
+     * 执行一次全文搜索。
+     *
+     * [recordHistory] 只在用户确认搜索(输入法搜索键、从阅读页选中文字发起)时为 true:
+     * 输入过程中的自动搜索只用于预览,早期实现会把拼音、半截词的中间态全部写进搜索历史。
+     */
+    private fun executeSearch(recordHistory: Boolean = false) {
         searchJob?.cancel()
         val state = _uiState.value
         if (state.searchQuery.isBlank()) {
@@ -186,7 +207,9 @@ class SearchContentViewModel(
         )
         searchJob = viewModelScope.launch {
             state.book?.let { book ->
-                searchContentRepository.saveHistory(book, state.searchQuery)
+                if (recordHistory) {
+                    searchContentRepository.saveHistory(book, state.searchQuery)
+                }
                 searchContentRepository.search(
                     book,
                     state.searchQuery,

+ 8 - 3
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceScreen.kt

@@ -172,7 +172,7 @@ fun BookSourceScreen(
     var showOnlineImport by remember { mutableStateOf(false) }
     var checkSourceIds by remember { mutableStateOf<Set<String>?>(null) }
     var checkSheet by remember { mutableStateOf<CheckSheet?>(null) }
-    var checkOptionsDraft by remember { mutableStateOf(state.checkOptions) }
+    var checkOptionsDraft by remember(state.checkOptions) { mutableStateOf(state.checkOptions) }
     var pendingExportIds by remember { mutableStateOf<Set<String>>(emptySet()) }
     var showExportSheet by remember { mutableStateOf(false) }
     var showImportOptions by remember { mutableStateOf(false) }
@@ -414,6 +414,7 @@ fun BookSourceScreen(
         deleteIds,
         { deleteIds = null },
         stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
         confirmText = stringResource(R.string.ok),
         onConfirm = { ids -> onIntent(BookSourceIntent.Delete(ids)); deleteIds = null },
         dismissText = stringResource(R.string.cancel),
@@ -513,7 +514,10 @@ fun BookSourceScreen(
                 showExportSheet = true
             },
         ),
-        onDeleteSelected = { deleteIds = @Suppress("UNCHECKED_CAST") (it as Set<String>) },
+        onDeleteSelected = {
+            @Suppress("UNCHECKED_CAST")
+            onIntent(BookSourceIntent.Delete(it as Set<String>))
+        },
         dropDownMenuContent = { dismiss ->
             RoundDropdownMenuItem(
                 text = stringResource(R.string.group_manage),
@@ -766,7 +770,8 @@ private fun CheckBookSourceSheet(
     onOpenSettings: () -> Unit,
     onConfirm: (Set<String>, String, BookSourceCheckOptionsUi) -> Unit,
 ) {
-    var keyword by remember(sourceIds) { mutableStateOf("我的") }
+    val defaultKeyword = stringResource(R.string.book_source_check_default_keyword)
+    var keyword by remember(sourceIds, defaultKeyword) { mutableStateOf(defaultKeyword) }
     AppModalBottomSheet(
         data = sourceIds,
         onDismissRequest = onDismissRequest,

+ 40 - 10
app/src/main/java/io/legado/app/ui/book/source/manage/BookSourceViewModel.kt

@@ -476,7 +476,10 @@ class BookSourceViewModel(
                 )
             }.onSuccess { importState.value = it }
                 .onFailure {
-                    importState.value = BaseImportUiState.Error(it.localizedMessage ?: "导入失败")
+                    importState.value = BaseImportUiState.Error(
+                        it.localizedMessage
+                            ?: application.getString(io.legado.app.R.string.book_source_import_failed)
+                    )
                 }
         }
     }
@@ -503,9 +506,11 @@ class BookSourceViewModel(
                 } else listOf(GSON.fromJsonObject<BookSource>(text).getOrThrow())
             }
 
-            else -> error("格式不正确")
+            else -> error(application.getString(io.legado.app.R.string.invalid_format))
+        }
+        require(sources.all { it.bookSourceUrl.isNotBlank() }) {
+            application.getString(io.legado.app.R.string.book_source_invalid_source)
         }
-        require(sources.all { it.bookSourceUrl.isNotBlank() }) { "不是书源" }
         return sources
     }
 
@@ -569,7 +574,11 @@ class BookSourceViewModel(
             ContentProcessor.upReplaceRules()
             importState.value = BaseImportUiState.Idle
             _effects.tryEmit(BookSourceEffect.ImportFinished)
-            _effects.tryEmit(BookSourceEffect.ShowSnackbar("导入完成"))
+            _effects.tryEmit(
+                BookSourceEffect.ShowSnackbar(
+                    application.getString(io.legado.app.R.string.book_source_import_success)
+                )
+            )
         }
     }
 
@@ -578,9 +587,23 @@ class BookSourceViewModel(
             val selected = repository.getAll().filter { ids.isEmpty() || it.bookSourceUrl in ids }
             application.contentResolver.openOutputStream(uri)?.bufferedWriter()
                 ?.use { it.write(GSON.toJson(selected)) }
-                ?: error("无法打开导出文件")
-        }.onSuccess { _effects.tryEmit(BookSourceEffect.ShowSnackbar("导出成功")) }
-            .onFailure { _effects.tryEmit(BookSourceEffect.ShowSnackbar("导出失败: ${it.localizedMessage}")) }
+                ?: error(application.getString(io.legado.app.R.string.book_source_export_open_failed))
+        }.onSuccess {
+            _effects.tryEmit(
+                BookSourceEffect.ShowSnackbar(
+                    application.getString(io.legado.app.R.string.export_success)
+                )
+            )
+        }.onFailure {
+            _effects.tryEmit(
+                BookSourceEffect.ShowSnackbar(
+                    application.getString(
+                        io.legado.app.R.string.book_source_export_failed,
+                        it.localizedMessage.orEmpty(),
+                    )
+                )
+            )
+        }
     }
 
     private fun uploadSources(ids: Set<String>) = launch {
@@ -594,13 +617,20 @@ class BookSourceViewModel(
         }.onSuccess { url ->
             _effects.tryEmit(
                 BookSourceEffect.ShowSnackbar(
-                    message = "上传成功: $url",
-                    actionLabel = "复制链接",
+                    message = application.getString(io.legado.app.R.string.book_source_upload_success, url),
+                    actionLabel = application.getString(io.legado.app.R.string.copy_url),
                     url = url,
                 )
             )
         }.onFailure {
-            _effects.tryEmit(BookSourceEffect.ShowSnackbar("上传失败: ${it.localizedMessage}"))
+            _effects.tryEmit(
+                BookSourceEffect.ShowSnackbar(
+                    application.getString(
+                        io.legado.app.R.string.book_source_upload_failed,
+                        it.localizedMessage.orEmpty(),
+                    )
+                )
+            )
         }
     }
 

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

@@ -101,6 +101,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
 

+ 1 - 0
app/src/main/java/io/legado/app/ui/dict/rule/DictRuleScreen.kt

@@ -227,6 +227,7 @@ fun DictRuleScreen(
         data = showDeleteRuleDialog,
         onDismissRequest = { showDeleteRuleDialog = null },
         title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
             onIntent(DictRuleIntent.DeleteRule(rule))

+ 1 - 0
app/src/main/java/io/legado/app/ui/highlightTagRule/HighlightTagRuleScreen.kt

@@ -224,6 +224,7 @@ fun HighlightTagRuleScreen(
         data = showDeleteRuleDialog,
         onDismissRequest = { showDeleteRuleDialog = null },
         title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
             onIntent(HighlightTagRuleIntent.DeleteRule(rule))

+ 59 - 7
app/src/main/java/io/legado/app/ui/main/bookshelf/BookItem.kt

@@ -277,6 +277,43 @@ private fun Modifier.bookshelfItemSemantics(label: String, isSelected: Boolean):
         if (isSelected) selected = true
     }
 
+/**
+ * 简介排版缓存:跨 LazyList 回收存活。
+ *
+ * LazyGrid/LazyList 滚出可视范围的条目会离开组合,其 remember 被丢弃,滚回来又重建,
+ * 于是每本含长简介的书会被反复 Jsoup 解析——400+ 本书上下滚动时足够把主线程打爆。
+ * 这里用“行数设置 + 原始简介”做 key,全局只解析一次;上限限制避免无限增长。
+ * 网格模式不展示简介、不参与解析,只有列表模式确实要显示时才解析。
+ */
+private object formattedIntroCache {
+    private const val MAX_ENTRIES = 500
+
+    // accessOrder=true 的 LinkedHashMap 天然 LRU;synchronized 内查写保证同 key 只解析一次。
+    private val cache = object : LinkedHashMap<String, String?>(64, 0.75f, true) {
+        override fun removeEldestEntry(eldest: Map.Entry<String, String?>): Boolean =
+            size > MAX_ENTRIES
+    }
+
+    fun getOrFormat(intro: String?, maxLines: Int): String? {
+        // 空简介直接返回 null,不进缓存,也不触发解析。
+        if (intro.isNullOrBlank()) return null
+        val key = "$maxLines|$intro"
+        return synchronized(cache) {
+            if (cache.containsKey(key)) {
+                cache[key]
+            } else {
+                val formatted = if (maxLines == 0) {
+                    HtmlFormatter.formatIntroText(intro)
+                } else {
+                    HtmlFormatter.formatSummaryText(intro)
+                }.takeIf { it.isNotBlank() }
+                cache[key] = formatted
+                formatted
+            }
+        }
+    }
+}
+
 @Composable
 fun BookGroupCover(
     settings: BookshelfSettings,
@@ -324,6 +361,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     author = it.author,
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                 )
                             }
@@ -339,6 +378,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     author = it.author,
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                 )
                             }
@@ -356,6 +397,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     author = it.author,
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                 )
                             }
@@ -371,6 +414,8 @@ fun BookGroupCover(
                                     name = it.name,
                                     author = it.author,
                                     path = it.getDisplayCover(),
+                                    bookUrl = it.bookUrl,
+                                    preferCache = true,
                                     modifier = Modifier.fillMaxSize()
                                 )
                             }
@@ -594,6 +639,8 @@ fun BookGroupItemHorizontalCovers(
                                 name = book.name,
                                 author = book.author,
                                 path = book.getDisplayCover(),
+                                bookUrl = book.bookUrl,
+                                preferCache = true,
                                 modifier = Modifier.fillMaxSize()
                             )
                         }
@@ -638,16 +685,18 @@ fun BookItem(
     onLongClick: (() -> Unit)?
 ) {
     val book = bookUi.book
-    val intro = remember(book.intro, settings.bookshelfIntroMaxLines) {
-        val formatted = if (settings.bookshelfIntroMaxLines == 0) {
-            HtmlFormatter.formatIntroText(book.intro)
+    val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
+    val showIntroText = showListDetails && settings.bookshelfShowIntro
+    // 简介排版包含 Jsoup 解析 + 多轮正则,是主线程重活;LazyList 回收后 remember 会丢失,
+    // 来回滚动就会反复重解析。这里只在列表模式真要显示时才算,并用跨回收的 LRU 缓存兜住。
+    val intro = remember(showIntroText, book.intro, settings.bookshelfIntroMaxLines) {
+        if (showIntroText) {
+            formattedIntroCache.getOrFormat(book.intro, settings.bookshelfIntroMaxLines)
         } else {
-            HtmlFormatter.formatSummaryText(book.intro)
+            null
         }
-        formatted.takeIf { it.isNotBlank() }
     }
-    val showListDetails = layoutMode == 0 && !isCompact && settings.showBookIntro
-    val showIntro = showListDetails && settings.bookshelfShowIntro && intro != null
+    val showIntro = showIntroText && intro != null
     val showIntroBelowContent = showIntro && settings.bookshelfListIntroBelowContent
     val unreadCount = book.getUnreadChapterNum()
     val unreadText = if (settings.showUnread && unreadCount > 0) unreadCount.toString() else null
@@ -684,6 +733,9 @@ fun BookItem(
                 .fillMaxWidth()
                 .aspectRatio(5f / 7f),
             sourceOrigin = book.origin,
+            // 传本书 bookUrl:封面命中本地(含别名)缓存时
+            // 不解析书源规则、不弹登录提示、不重新下载
+            bookUrl = book.bookUrl,
             badgeText = if (layoutMode != 0) unreadText else null,
             showBadgeDot = showUpdateBadge,
             leftBottomText = matchedSourceLabel ?: bookTypeLabel,

+ 1 - 0
app/src/main/java/io/legado/app/ui/replace/ReplaceRuleScreen.kt

@@ -280,6 +280,7 @@ fun ReplaceRuleScreen(
         data = showDeleteRuleDialog,
         onDismissRequest = { showDeleteRuleDialog = null },
         title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
             onIntent(ReplaceRuleIntent.DeleteRule(rule))

+ 1 - 0
app/src/main/java/io/legado/app/ui/rss/source/manage/RssSourceScreen.kt

@@ -235,6 +235,7 @@ fun RssSourceScreen(
         data = showDeleteRuleDialog,
         onDismissRequest = { showDeleteRuleDialog = null },
         title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
         confirmText = stringResource(R.string.ok),
         onConfirm = { rule ->
             onIntent(RssSourceIntent.Delete(rule))

+ 17 - 1
app/src/main/java/io/legado/app/ui/widget/components/GroupManageBottomSheet.kt

@@ -26,6 +26,7 @@ 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.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.settingItem.SettingItem
@@ -39,6 +40,21 @@ fun GroupManageBottomSheet(
     onUpdateGroup: (oldGroup: String, newGroup: String) -> Unit,
     onDeleteGroup: (group: String) -> Unit
 ) {
+    var groupToDelete by remember { mutableStateOf<String?>(null) }
+    AppAlertDialog(
+        show = groupToDelete != null,
+        onDismissRequest = { groupToDelete = null },
+        title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            groupToDelete?.let(onDeleteGroup)
+            groupToDelete = null
+            onDismissRequest()
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { groupToDelete = null },
+    )
     AppModalBottomSheet(
         show = show,
         onDismissRequest = onDismissRequest,
@@ -49,7 +65,7 @@ fun GroupManageBottomSheet(
                 GroupItem(
                     group = group,
                     onUpdateGroup = onUpdateGroup,
-                    onDeleteGroup = onDeleteGroup
+                    onDeleteGroup = { groupToDelete = it }
                 )
             }
         }

+ 5 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/BookshelfCover.kt

@@ -32,6 +32,9 @@ fun BookshelfCover(
     showBadgeDot: Boolean = false,
     leftBottomText: String? = null,
     sourceOrigin: String? = null,
+    // 本书 bookUrl,供封面别名缓存键;书架组件默认本地优先
+    // (preferCache=true):有缓存(含别名命中)直接显示,不跑书源规则脚本、不联网。
+    bookUrl: String? = null,
     onLoadFinish: (() -> Unit)? = null,
     showLoadingPlaceholder: Boolean = true,
     sharedTransitionScope: SharedTransitionScope? = null,
@@ -45,6 +48,8 @@ fun BookshelfCover(
             path = path,
             modifier = coverModifier,
             sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            preferCache = true,
             onLoadFinish = onLoadFinish,
             showLoadingPlaceholder = showLoadingPlaceholder,
             sharedTransitionScope = sharedTransitionScope,

+ 10 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt

@@ -77,6 +77,9 @@ fun BookCoverImage(
     modifier: Modifier = Modifier,
     sourceOrigin: String? = null,
     memoryCacheKey: String? = null,
+    // 本书 bookUrl(别名缓存键)与书架本地优先标志,透传给 buildCoverImageRequest。
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     ignoreUseDefaultCover: Boolean = false,
     showLoadingPlaceholder: Boolean = true,
     contentScale: ContentScale = ContentScale.Crop,
@@ -186,6 +189,8 @@ fun BookCoverImage(
                     memoryCacheKey = sharedCoverKey?.let {
                         "$it:cover:${memoryCacheKey ?: finalPath}"
                     } ?: memoryCacheKey ?: finalPath,
+                    bookUrl = bookUrl,
+                    preferCache = preferCache,
                     configure = requestBuilder,
                 ),
                 contentDescription = null,
@@ -223,6 +228,9 @@ fun CoilBookCover(
     radius: Dp = 4.dp,
     modifier: Modifier = Modifier.width(64.dp),
     sourceOrigin: String? = null,
+    // 本书 bookUrl + 书架本地优先标志,透传给 BookCoverImage
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     onLoadFinish: (() -> Unit)? = null,
     onError: (() -> Unit)? = null,
     ignoreUseDefaultCover: Boolean = false,
@@ -299,6 +307,8 @@ fun CoilBookCover(
             path = path,
             modifier = Modifier.fillMaxSize(),
             sourceOrigin = sourceOrigin,
+            bookUrl = bookUrl,
+            preferCache = preferCache,
             ignoreUseDefaultCover = ignoreUseDefaultCover,
             showLoadingPlaceholder = showLoadingPlaceholder,
             onSuccess = {

+ 7 - 0
app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoverImageRequest.kt

@@ -12,6 +12,10 @@ fun buildCoverImageRequest(
     loadOnlyWifi: Boolean,
     crossfade: Boolean = true,
     memoryCacheKey: String? = null,
+    // bookUrl 供别名缓存键;preferCache 标记书架类
+    // “本地优先、绝不跑书源脚本”的请求。
+    bookUrl: String? = null,
+    preferCache: Boolean = false,
     configure: ImageRequest.Builder.() -> Unit = {},
 ): ImageRequest {
     return ImageRequest.Builder(context)
@@ -24,6 +28,9 @@ fun buildCoverImageRequest(
             }
             extras[CoverExtras.SourceOrigin] = sourceOrigin
             extras[CoverExtras.LoadOnlyWifi] = loadOnlyWifi
+            // 透传别名键与书架优先标志给 CoverInterceptor/CoverFetcher
+            extras[CoverExtras.BookUrl] = bookUrl
+            extras[CoverExtras.PreferCache] = preferCache
         }
         .apply(configure)
         .build()

+ 72 - 0
app/src/main/java/io/legado/app/utils/HtmlFormatter.kt

@@ -13,6 +13,30 @@ object HtmlFormatter {
     private val commentRegex = "<!--[^>]*-->".toRegex() //注释
     private val notImgHtmlRegex = "</?(?!img)[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
     private val otherHtmlRegex = "</?[a-zA-Z]+(?=[ >])[^<>]*>".toRegex()
+
+    // 字数统计专用:正文里携带的图片 Base64/SVG 源码不能计入字数。
+    // 容器型媒体标签整体连内容一起删除(base64、SVG 路径数据都藏在标签内部)
+    private val mediaBlockRegex = Regex(
+        "(?is)<(script|style|svg|math|video|audio|canvas|picture|iframe|object|embed|figure|template)\\b[^>]*>.*?</\\1\\s*>"
+    )
+    // 未闭合的 <svg>(后面没有 </svg>): 删到结尾, 避免剩余矢量数据被当成正文
+    private val unclosedSvgRegex = Regex("(?is)<svg\\b[^>]*>[\\s\\S]*$")
+    // 自闭合/空元素标签(img 的 src 里就是整段 data:image;base64)
+    private val voidMediaRegex = Regex(
+        "(?is)<(?:img|br|hr|input|source|track|area|col|link|meta)\\b[^>]*/?>"
+    )
+    // 散落在属性之外的 data: URI 与 XML 声明/doctype
+    private val dataUriRegex = Regex(
+        "(?i)['\"(]?data:[a-z0-9.+-]+/[a-z0-9.+,-]+(?:;[a-z0-9.+-]+)?(?:;base64)?[^\\s'\"<>)]*['\")]?"
+    )
+    private val xmlDeclRegex = Regex("(?is)<\\?[\\s\\S]*?\\?>|<!doctype[^>]*>")
+    // Markdown 图片/链接语法 ![alt](url)
+    private val markdownMediaRegex = Regex("!\\[[^\\]]*\\]\\([^)]*\\)")
+
+    // 简介前缀与按钮 onclick 片段,供 formatReadableText 使用
+    private val introPrefixRegex = Regex("^<(usehtml|useweb|md)>", RegexOption.IGNORE_CASE)
+    private val onClickSuffixRegex = Regex("@onclick:[^<\\n]*", RegexOption.IGNORE_CASE)
+
     private val formatImagePattern = Regex(
         "<img[^>]*\\ssrc\\s*=\\s*['\"]([^'\"{>]*\\{(?:[^{}]|\\{[^}>]+\\})+\\})['\"][^>]*>|<img[^>]*\\s(?:data-src|src)\\s*=\\s*['\"]([^'\">]+)['\"][^>]*>|<img[^>]*\\sdata-[^=>]*=\\s*['\"]([^'\">]*)['\"][^>]*>",
         RegexOption.IGNORE_CASE
@@ -157,4 +181,52 @@ object HtmlFormatter {
         )
         return sb.toString()
     }
+
+    /**
+     * 把书源简介渲染成与详情页观感一致的“可读纯文本”,用于
+     * 不支持交互的只读卡片(如阅读页目录侧栏“信息”页):
+     * 1) 去掉 <usehtml>/<useweb>/<md> 前缀(与详情页 parseBookInfoIntro 同一处理,
+     *    否则字面量前缀会被当未知标签丢弃但尾部残留调用代码);
+     * 2) 丢弃按钮里的 @onclick:JS 片段(详情页渲染成可点按钮,只读场景只保留按钮文字,
+     *    如“💬 本书讨论”);
+     * 3) 不补段首缩进(formatDisplayText 会给每行加两个全角空格,只读卡片里显得
+     *    每行缩进,详情页则没有)。
+     */
+    fun formatReadableText(html: String?): String {
+        if (html.isNullOrBlank()) return ""
+        var body = html.trim()
+        introPrefixRegex.find(body)?.let { m ->
+            val lastLt = body.lastIndexOf('<')
+            body = if (lastLt > m.range.last) {
+                body.substring(m.range.last + 1, lastLt)
+            } else {
+                body.substring(m.range.last + 1)
+            }
+        }
+        body = onClickSuffixRegex.replace(body, "")
+        val document = Jsoup.parseBodyFragment(body)
+        document.outputSettings().prettyPrint(false)
+        document.select("script, style, noscript").remove()
+        return format(document.body().html(), otherHtmlRegex, "")
+    }
+
+    /**
+     * 统计正文真实可读字数。部分书源的 content 携带
+     * `<img src="data:image/png;base64,...">`、内联 `<svg>...</svg>` 等富文本源码,
+     * 直接取 content.length 会把整段 Base64/标签算进目录字数(几百字显示成几千字)。
+     * 这里先剔除媒体标签及其内容、残留 data: URI 与 Markdown 图片语法,
+     * 再按纯文本(解码实体、去空白)计算字符数。
+     */
+    fun countReadableTextLength(content: String?): Int {
+        if (content.isNullOrEmpty()) return 0
+        var text = mediaBlockRegex.replace(content, "")
+        text = unclosedSvgRegex.replace(text, "")
+        text = voidMediaRegex.replace(text, "")
+        text = markdownMediaRegex.replace(text, "")
+        text = xmlDeclRegex.replace(text, "")
+        text = dataUriRegex.replace(text, "")
+        // 剩余标签与 HTML 实体交给 Jsoup 按纯文本解出
+        text = Jsoup.parse(text).text()
+        return text.count { !it.isWhitespace() && it != '\u00a0' && it != '\u3000' }
+    }
 }

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

@@ -434,6 +434,14 @@
     <string name="book_source_check_save_failed">校验失败:无法保存结果,%1$s</string>
     <string name="book_source_check_incomplete">校验失败:任务未完成</string>
     <string name="book_source_check_item_cancelled">校验已取消</string>
+    <string name="book_source_check_default_keyword">我的</string>
+    <string name="book_source_import_success">导入完成</string>
+    <string name="book_source_import_failed">导入失败</string>
+    <string name="book_source_invalid_source">不是书源</string>
+    <string name="book_source_export_open_failed">无法打开导出文件</string>
+    <string name="book_source_export_failed">导出失败:%1$s</string>
+    <string name="book_source_upload_success">上传成功:%1$s</string>
+    <string name="book_source_upload_failed">上传失败:%1$s</string>
     <string name="check_select_source">校验所选</string>
     <string name="progress_show">%1$s      进度 %2$d/%3$d</string>
     <string name="tts_fix">请安装并选择中文 TTS!</string>
@@ -2891,6 +2899,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>

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

@@ -373,6 +373,14 @@
     <string name="book_source_check_save_failed">校驗失敗:無法保存結果,%1$s</string>
     <string name="book_source_check_incomplete">校驗失敗:任務未完成</string>
     <string name="book_source_check_item_cancelled">校驗已取消</string>
+    <string name="book_source_check_default_keyword">我的</string>
+    <string name="book_source_import_success">導入完成</string>
+    <string name="book_source_import_failed">導入失敗</string>
+    <string name="book_source_invalid_source">不是書源</string>
+    <string name="book_source_export_open_failed">無法打開導出文件</string>
+    <string name="book_source_export_failed">導出失敗:%1$s</string>
+    <string name="book_source_upload_success">上傳成功:%1$s</string>
+    <string name="book_source_upload_failed">上傳失敗:%1$s</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文 TTS!</string>
@@ -2674,6 +2682,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>

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

@@ -375,6 +375,14 @@
     <string name="book_source_check_save_failed">校驗失敗:無法儲存結果,%1$s</string>
     <string name="book_source_check_incomplete">校驗失敗:工作未完成</string>
     <string name="book_source_check_item_cancelled">校驗已取消</string>
+    <string name="book_source_check_default_keyword">我的</string>
+    <string name="book_source_import_success">匯入完成</string>
+    <string name="book_source_import_failed">匯入失敗</string>
+    <string name="book_source_invalid_source">不是書源</string>
+    <string name="book_source_export_open_failed">無法開啟匯出檔案</string>
+    <string name="book_source_export_failed">匯出失敗:%1$s</string>
+    <string name="book_source_upload_success">上傳成功:%1$s</string>
+    <string name="book_source_upload_failed">上傳失敗:%1$s</string>
     <string name="check_select_source">校驗所選</string>
     <string name="progress_show">%1$s      進度 %2$d/%3$d</string>
     <string name="tts_fix">請安裝並選擇中文TTS!</string>
@@ -2679,6 +2687,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>

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

@@ -464,6 +464,14 @@
     <string name="book_source_check_save_failed">Check failed: unable to save result, %1$s</string>
     <string name="book_source_check_incomplete">Check failed: task did not finish</string>
     <string name="book_source_check_item_cancelled">Check cancelled</string>
+    <string name="book_source_check_default_keyword">My</string>
+    <string name="book_source_import_success">Import completed</string>
+    <string name="book_source_import_failed">Import failed</string>
+    <string name="book_source_invalid_source">Not a book source</string>
+    <string name="book_source_export_open_failed">Unable to open export file</string>
+    <string name="book_source_export_failed">Export failed: %1$s</string>
+    <string name="book_source_upload_success">Upload completed: %1$s</string>
+    <string name="book_source_upload_failed">Upload failed: %1$s</string>
     <string name="check_select_source">Check the selected source</string>
     <string name="progress_show">%1$s      Progress %2$d/%3$d</string>
     <string name="tts_fix">Please install and select Chinese TTS!</string>
@@ -2896,6 +2904,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>

+ 95 - 0
app/src/test/java/io/legado/app/RhinoContextEntryTest.kt

@@ -0,0 +1,95 @@
+package io.legado.app
+
+import com.script.rhino.RhinoContext
+import com.script.rhino.enterRhinoContext
+import com.script.rhino.runScriptWithContext
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNull
+import org.junit.Assert.assertSame
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
+import org.junit.Test
+import org.mozilla.javascript.Context
+import org.mozilla.javascript.ContextFactory
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Rhino 入口必须保证先安装自定义全局 ContextFactory 再 `Context.enter()`。
+ *
+ * 回归对象:Rhino 的 `Context.enter()` 取的是全局工厂,而生产 [RhinoContext] 的工厂只在
+ * RhinoScriptEngine 的 object init 中安装。安装前进入上下文会拿到普通
+ * `org.mozilla.javascript.Context`,直接强转既抛 ClassCastException,又因为没有
+ * `Context.exit()` 把该线程永久留在普通 Context 上。
+ */
+class RhinoContextEntryTest {
+
+    @Test
+    fun enterRhinoContextYieldsRhinoContextAndLeavesNoContext() {
+        inFreshThread {
+            val cx = enterRhinoContext()
+            assertEquals(RhinoContext::class.java.name, cx.javaClass.name)
+            assertSame(cx, Context.getCurrentContext())
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+        }
+    }
+
+    /**
+     * 线程已被非 RhinoContext 占用时(旧实现崩溃后留下的线程):
+     * 必须抛出可诊断的 IllegalStateException,而不是 ClassCastException;
+     * 且不能多退一次 Context,清理干净后线程还能正常进入 Rhino 上下文。
+     */
+    @Test
+    fun foreignContextOnThreadFailsLoudlyWithoutBreakingThreadState() {
+        inFreshThread {
+            val foreignFactory = ContextFactory()
+            val foreign = foreignFactory.enterContext()
+            try {
+                enterRhinoContext()
+                fail("应抛出 IllegalStateException,而不是 ClassCastException")
+            } catch (e: IllegalStateException) {
+                assertTrue(
+                    "错误信息应带上实际 Context 类型,实际为: ${e.message}",
+                    e.message.orEmpty().contains(foreign.javaClass.name)
+                )
+            }
+            // 失败路径不能多退:外来 Context 的 enter 计数必须原样保留
+            assertSame(foreign, Context.getCurrentContext())
+            // 调用方自己退出后线程应彻底干净
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+
+            val cx = enterRhinoContext()
+            assertEquals(RhinoContext::class.java.name, cx.javaClass.name)
+            Context.exit()
+            assertNull(Context.getCurrentContext())
+        }
+    }
+
+    /** 书源登录页计算 headers 用的就是这个 suspend 重载。 */
+    @Test
+    fun suspendRunScriptWithContextEnterAndExitCleanly() = runBlocking {
+        val result = runScriptWithContext { "ok" }
+        assertEquals("ok", result)
+        assertNull(Context.getCurrentContext())
+    }
+
+    private fun inFreshThread(block: () -> Unit) {
+        val failure = AtomicReference<Throwable?>(null)
+        val thread = Thread {
+            try {
+                block()
+            } catch (t: Throwable) {
+                failure.set(t)
+            }
+        }
+        thread.start()
+        thread.join(30_000)
+        if (thread.isAlive) {
+            thread.interrupt()
+            fail("测试线程未在超时时间内结束")
+        }
+        failure.get()?.let { throw it }
+    }
+}

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

+ 134 - 0
app/src/test/java/io/legado/app/ui/book/searchContent/SearchContentHistoryRecordingTest.kt

@@ -0,0 +1,134 @@
+package io.legado.app.ui.book.searchContent
+
+import android.app.Application
+import android.os.Looper
+import androidx.room.Room
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.SearchContentHistory
+import io.legado.app.data.repository.BookRepository
+import io.legado.app.data.repository.SearchContentRepository
+import io.legado.app.domain.gateway.ThemeSettingsGateway
+import io.legado.app.domain.model.settings.ThemeSettings
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+
+/**
+ * 全文搜索的搜索历史只记录「用户确认过的查询」。
+ *
+ * 回归 issue #2113:输入过程中的自动搜索曾把每个拼音、半截词都写进 search_content_history,
+ * 历史列表被中间态灌满。
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class SearchContentHistoryRecordingTest {
+
+    private lateinit var db: AppDatabase
+
+    @Before
+    fun setUp() {
+        db = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java,
+        ).allowMainThreadQueries().build()
+    }
+
+    @After
+    fun tearDown() {
+        db.close()
+    }
+
+    @Test
+    fun `typing only previews and never writes search history`() {
+        val viewModel = viewModel(initialSearchWord = null)
+        awaitBook(viewModel)
+
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yan"))
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yanjing"))
+        idle()
+
+        assertEquals(emptyList<String>(), awaitQueries(atLeast = 1, timeoutMs = 400))
+    }
+
+    @Test
+    fun `the keyboard search key records the committed query`() {
+        val viewModel = viewModel(initialSearchWord = null)
+        awaitBook(viewModel)
+
+        viewModel.onIntent(SearchContentIntent.UpdateQuery("yan"))
+        idle()
+        viewModel.onIntent(SearchContentIntent.SubmitSearch("眼镜"))
+
+        assertEquals(listOf("眼镜"), awaitQueries(atLeast = 1))
+    }
+
+    @Test
+    fun `a search started from a text selection is recorded`() {
+        val viewModel = viewModel(initialSearchWord = "眼镜")
+        awaitBook(viewModel)
+
+        assertEquals(listOf("眼镜"), awaitQueries(atLeast = 1))
+    }
+
+    private fun viewModel(initialSearchWord: String?): SearchContentViewModel {
+        runBlocking {
+            db.bookDao.insert(Book(bookUrl = BOOK_URL, name = "测试书", author = "作者"))
+        }
+        return SearchContentViewModel(
+            bookUrl = BOOK_URL,
+            initialSearchWord = initialSearchWord,
+            searchResultIndex = 0,
+            bookRepository = BookRepository(db.bookDao, db.bookChapterDao, db),
+            searchContentRepository = SearchContentRepository(historyDao = db.searchContentHistoryDao),
+            themeSettingsGateway = FakeThemeSettingsGateway(),
+        )
+    }
+
+    /** initBook() 在 IO 线程取书,书就位后才会有"确认搜索"的写入。 */
+    private fun awaitBook(viewModel: SearchContentViewModel, timeoutMs: Long = 3_000) {
+        val deadline = System.currentTimeMillis() + timeoutMs
+        while (System.currentTimeMillis() < deadline) {
+            idle()
+            if (viewModel.uiState.value.book != null) return
+            Thread.sleep(20)
+        }
+        error("书籍未在 ${timeoutMs}ms 内加载完成")
+    }
+
+    /** 轮询等待历史写入(Room 的挂起写入落在自己的执行器上,主线程 idle 不等它)。 */
+    private fun awaitQueries(atLeast: Int, timeoutMs: Long = 3_000): List<String> {
+        val deadline = System.currentTimeMillis() + timeoutMs
+        var queries = emptyList<String>()
+        while (System.currentTimeMillis() < deadline) {
+            idle()
+            queries = runBlocking { db.searchContentHistoryDao.getAll().first() }
+                .map(SearchContentHistory::query)
+            if (queries.size >= atLeast) break
+            Thread.sleep(20)
+        }
+        return queries
+    }
+
+    private fun idle() = Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+    private class FakeThemeSettingsGateway : ThemeSettingsGateway {
+        override val currentSettings: ThemeSettings = ThemeSettings()
+        override val settings: Flow<ThemeSettings> = MutableStateFlow(ThemeSettings())
+        override suspend fun update(transform: (ThemeSettings) -> ThemeSettings) = Unit
+    }
+
+    private companion object {
+        const val BOOK_URL = "test://book/1"
+    }
+}

+ 3 - 3
modules/rhino/src/main/java/com/script/rhino/RhinoCompiledScript.kt

@@ -55,7 +55,7 @@ internal class RhinoCompiledScript(
     }
 
     override fun eval(scope: Scriptable, coroutineContext: CoroutineContext?): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         val previousCoroutineContext = cx.coroutineContext
         if (coroutineContext != null && coroutineContext[Job] != null) {
             cx.coroutineContext = coroutineContext
@@ -87,7 +87,7 @@ internal class RhinoCompiledScript(
     }
 
     override suspend fun evalSuspend(scope: Scriptable): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         var ret: Any?
         withContext(VMBridgeReflect.contextLocal.asContextElement()) {
             cx.allowScriptRun = true
@@ -132,4 +132,4 @@ internal class RhinoCompiledScript(
         return engine.unwrapReturnValue(ret)
     }
 
-}
+}

+ 28 - 4
modules/rhino/src/main/java/com/script/rhino/RhinoExtensions.kt

@@ -17,6 +17,32 @@ val rhinoContext: RhinoContext
 val rhinoContextOrNull: RhinoContext?
     get() = Context.getCurrentContext() as? RhinoContext
 
+/**
+ * 进入脚本上下文。所有需要 [RhinoContext] 的入口都必须走这里,不要直接写
+ * `Context.enter() as RhinoContext`。
+ *
+ * 原因:`Context.enter()` 取的是 Rhino 的**全局** [org.mozilla.javascript.ContextFactory],
+ * 而生产 [RhinoContext] 的自定义工厂只在 [RhinoScriptEngine] 的 object init 中通过
+ * `ContextFactory.initGlobal` 安装。在安装完成前调用会拿到普通
+ * `org.mozilla.javascript.Context`:强转会抛 ClassCastException,且这个 Context 不会
+ * 被 `Context.exit()` 释放,会把当前线程永久留在普通 Context 上——此后该线程上的任何
+ * 脚本执行都会失败(书源搜索/换源/正文解析等)。
+ */
+fun enterRhinoContext(): RhinoContext {
+    @Suppress("UNUSED_EXPRESSION")
+    RhinoScriptEngine
+    val cx = Context.enter()
+    if (cx is RhinoContext) {
+        return cx
+    }
+    // 无论失败原因是什么都要撤销本次 enter,否则会把非 RhinoContext 留在线程上。
+    Context.exit()
+    error(
+        "无法进入 Rhino 上下文: Context.enter() 返回 ${cx.javaClass.name}," +
+            "当前线程已被非 RhinoContext 占用或 Rhino 全局工厂尚未安装"
+    )
+}
+
 @Suppress("LEAKED_IN_PLACE_LAMBDA", "WRONG_INVOCATION_KIND")
 @OptIn(ExperimentalContracts::class)
 inline fun <T> suspendContinuation(crossinline block: suspend CoroutineScope.() -> T): T {
@@ -40,9 +66,7 @@ inline fun <T> suspendContinuation(crossinline block: suspend CoroutineScope.()
 }
 
 inline fun <T> runScriptWithContext(context: CoroutineContext, block: () -> T): T {
-    @Suppress("UNUSED_EXPRESSION")
-    RhinoScriptEngine
-    val rhinoContext = Context.enter() as RhinoContext
+    val rhinoContext = enterRhinoContext()
     val previousCoroutineContext = rhinoContext.coroutineContext
     rhinoContext.coroutineContext = context.minusKey(ContinuationInterceptor)
     try {
@@ -54,7 +78,7 @@ inline fun <T> runScriptWithContext(context: CoroutineContext, block: () -> T):
 }
 
 suspend inline fun <T> runScriptWithContext(block: () -> T): T {
-    val rhinoContext = Context.enter() as RhinoContext
+    val rhinoContext = enterRhinoContext()
     val previousCoroutineContext = rhinoContext.coroutineContext
     rhinoContext.coroutineContext = currentCoroutineContext().minusKey(ContinuationInterceptor)
     try {

+ 2 - 2
modules/rhino/src/main/java/com/script/rhino/RhinoScriptEngine.kt

@@ -90,7 +90,7 @@ object RhinoScriptEngine : AbstractScriptEngine(), Invocable, Compilable {
         scope: Scriptable,
         coroutineContext: CoroutineContext?
     ): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         val previousCoroutineContext = cx.coroutineContext
         if (coroutineContext != null && coroutineContext[Job] != null) {
             cx.coroutineContext = coroutineContext
@@ -126,7 +126,7 @@ object RhinoScriptEngine : AbstractScriptEngine(), Invocable, Compilable {
 
     @Throws(ContinuationPending::class)
     override suspend fun evalSuspend(reader: Reader, scope: Scriptable): Any? {
-        val cx = Context.enter() as RhinoContext
+        val cx = enterRhinoContext()
         var ret: Any?
         withContext(VMBridgeReflect.contextLocal.asContextElement()) {
             cx.allowScriptRun = true