Преглед изворни кода

加入小说翻译功能 (#1094)

* feature: add translation full content

---------

Co-authored-by: duanhl <duanhl7749@gmail.com>
Co-authored-by: HapeLee <63206378+HapeLee@users.noreply.github.com>
Duanhl пре 5 месеци
родитељ
комит
197039d25a
35 измењених фајлова са 2194 додато и 77 уклоњено
  1. 3 0
      app/proguard-rules.pro
  2. 12 1
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 11 1
      app/src/main/java/io/legado/app/data/entities/Book.kt
  4. 26 0
      app/src/main/java/io/legado/app/data/entities/TranslationCache.kt
  5. 71 0
      app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt
  6. 378 0
      app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt
  7. 193 0
      app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt
  8. 1 23
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  9. 19 1
      app/src/main/java/io/legado/app/di/appModule.kt
  10. 11 0
      app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt
  11. 19 0
      app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt
  12. 70 0
      app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt
  13. 199 0
      app/src/main/java/io/legado/app/domain/model/ContentChunker.kt
  14. 46 0
      app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt
  15. 50 0
      app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt
  16. 61 0
      app/src/main/java/io/legado/app/domain/model/TranslationModels.kt
  17. 275 0
      app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt
  18. 65 2
      app/src/main/java/io/legado/app/model/ReadBook.kt
  19. 34 0
      app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt
  20. 179 0
      app/src/main/java/io/legado/app/model/translation/TranslationManager.kt
  21. 116 36
      app/src/main/java/io/legado/app/service/ExportBookService.kt
  22. 34 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt
  23. 4 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  24. 24 4
      app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt
  25. 2 1
      app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt
  26. 7 2
      app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
  27. 67 0
      app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt
  28. 125 0
      app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt
  29. 7 1
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  30. 4 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  31. 3 1
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  32. 13 1
      app/src/main/java/io/legado/app/ui/widget/components/image/cover/CoilBookCover.kt
  33. 1 0
      app/src/main/res/menu/book_read.xml
  34. 32 0
      app/src/main/res/values-zh-rCN/strings.xml
  35. 32 0
      app/src/main/res/values/strings.xml

+ 3 - 0
app/proguard-rules.pro

@@ -61,6 +61,9 @@
 -keep class * extends io.legado.app.help.JsExtensions{*;}
 # 数据类
 -keep class **.data.entities.**{*;}
+# Gson反序列化用的数据传输类
+-keep class io.legado.app.model.translation.**{*;}
+-keep class io.legado.app.data.entities.TranslationCache{*;}
 # hutool-core hutool-crypto
 -keep class
 !cn.hutool.core.util.RuntimeUtil,

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

@@ -62,7 +62,6 @@ object PreferKey {
     const val episodeExportFileName = "episodeExportFileName"
     const val recordLog = "recordLog"
     const val processText = "process_text"
-    const val cleanCache = "cleanCache"
     const val saveTabPosition = "saveTabPosition"
     const val fontFolder = "fontFolder"
     const val backupPath = "backupUri"
@@ -322,6 +321,18 @@ object PreferKey {
     const val navIconRss = "navIconRss"
     const val navIconMy = "navIconMy"
 
+    // Translation
+    const val llmTranslateEnabled = "llmTranslateEnabled"
+    const val llmProvider = "llmProvider"
+    const val llmBaseUrl = "llmBaseUrl"
+    const val llmApiKey = "llmApiKey"
+    const val llmModel = "llmModel"
+    const val llmTargetLanguage = "llmTargetLanguage"
+    const val llmMaxCharsPerChunk = "llmMaxCharsPerChunk"
+    const val llmConcurrentChunks = "llmConcurrentChunks"
+    const val llmRetryCount = "llmRetryCount"
+    const val llmPrompt = "llmPrompt"
+
     const val homepageModuleOrder = "homepageModuleOrder"
     const val homepageModuleHidden = "homepageModuleHidden"
     const val homepageLayoutMode = "homepageLayoutMode"

+ 11 - 1
app/src/main/java/io/legado/app/data/entities/Book.kt

@@ -293,6 +293,14 @@ data class Book(
         return this.durChapterIndex
     }
 
+    fun setTranslationMode(enabled: Boolean) {
+        config.translationMode = enabled
+    }
+
+    fun getTranslationMode(): Boolean {
+        return config.translationMode
+    }
+
     // dailyChapters 的 setter 和 getter
     fun setDailyChapters(dailyChapters: Int) {
         config.dailyChapters = dailyChapters
@@ -424,7 +432,9 @@ data class Book(
         var webtoonSidePaddingDp: Int? = null,
         var mangaBackground: String? = null,
 
-        var fixedType: Boolean = false // 固定书籍类型,不随书源更新
+        var fixedType: Boolean = false, // 固定书籍类型,不随书源更新
+
+        var translationMode: Boolean = false // 是否启用翻译阅读模式
 
     ) : Parcelable
 

+ 26 - 0
app/src/main/java/io/legado/app/data/entities/TranslationCache.kt

@@ -0,0 +1,26 @@
+package io.legado.app.data.entities
+
+/**
+ * Chunk-level translation cache record stored in .chunks.jsonl files.
+ * File path already contains bookUrl/chapterIndex/targetLanguage — chunkIndex is the key.
+ */
+data class TranslationCache(
+    val chunkIndex: Int,
+    val originalChunkContent: String,
+    val translatedChunkContent: String?,
+    val status: Int = STATUS_PENDING,
+    val errorMessage: String? = null,
+    val originalContentHash: String,
+    val provider: String = ""
+) {
+    companion object {
+        const val STATUS_PENDING = 0
+        const val STATUS_TRANSLATING = 1
+        const val STATUS_SUCCESS = 2
+        const val STATUS_FAILED = 3
+    }
+
+    val isSuccess: Boolean get() = status == STATUS_SUCCESS
+    val isFailed: Boolean get() = status == STATUS_FAILED
+    val isPending: Boolean get() = status == STATUS_PENDING
+}

+ 71 - 0
app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt

@@ -0,0 +1,71 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.entities.Book
+import io.legado.app.domain.gateway.DictionaryGateway
+import io.legado.app.domain.model.BookDictionary
+import io.legado.app.domain.model.DictPair
+import io.legado.app.help.book.BookHelp
+import io.legado.app.utils.GSON
+import java.io.File
+
+class DictionaryRepositoryImpl : DictionaryGateway {
+
+    private companion object {
+        const val DICT_FILE_NAME = "translation_dictionary.json"
+    }
+
+    private fun getDictFile(book: Book): File {
+        val cacheDir = BookHelp.cachePath
+        val bookFolder = File(cacheDir, book.getFolderName())
+        return File(bookFolder, DICT_FILE_NAME)
+    }
+
+    override fun getBookDictionaries(book: Book): BookDictionary {
+        val dictFile = getDictFile(book)
+        return if (dictFile.exists()) {
+            try {
+                GSON.fromJson(dictFile.readText(), BookDictionary::class.java)
+            } catch (e: Exception) {
+                BookDictionary(book.bookUrl)
+            }
+        } else {
+            BookDictionary(book.bookUrl)
+        }
+    }
+
+    override fun updateBookDic(book: Book, newPairs: List<DictPair>) {
+        val existingDict = getBookDictionaries(book)
+        val updatedPairs = existingDict.pairs.toMutableList()
+
+        for (newPair in newPairs) {
+            val existingIndex = updatedPairs.indexOfFirst {
+                it.original == newPair.original
+            }
+            if (existingIndex >= 0) {
+                updatedPairs[existingIndex] = newPair
+            } else {
+                updatedPairs.add(newPair)
+            }
+        }
+
+        val updatedDict = existingDict.copy(
+            pairs = updatedPairs,
+            updatedAt = System.currentTimeMillis()
+        )
+
+        saveDictionary(book, updatedDict)
+    }
+
+    private fun saveDictionary(book: Book, dictionary: BookDictionary) {
+        val dictFile = getDictFile(book)
+        dictFile.parentFile?.mkdirs()
+        dictFile.writeText(GSON.toJson(dictionary))
+    }
+
+    override fun clearBookDictionary(book: Book) {
+        val dictFile = getDictFile(book)
+        if (dictFile.exists()) {
+            dictFile.delete()
+        }
+    }
+}

+ 378 - 0
app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt

@@ -0,0 +1,378 @@
+package io.legado.app.data.repository
+
+import io.legado.app.domain.gateway.LlmGateway
+import io.legado.app.domain.model.DictPair
+import io.legado.app.domain.model.RetryReason
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
+import io.legado.app.help.http.addHeaders
+import io.legado.app.help.http.newCallStrResponse
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.postJson
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class LlmTranslateRepositoryImpl : LlmGateway {
+
+    /**
+     * Get display name for a language code, e.g., "zh" -> "简体中文"
+     */
+    private fun getLanguageDisplayName(code: String): String {
+        return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code
+    }
+
+    override suspend fun translate(
+        text: String,
+        targetLanguage: String,
+        provider: String,
+        baseUrl: String,
+        apiKey: String,
+        model: String,
+        prompt: String,
+        dictionaries: List<DictPair>,
+        onUpdate: ((List<DictPair>) -> Unit)?,
+        retryReason: RetryReason?
+    ): Result<String> = withContext(Dispatchers.IO) {
+        // 如果目标语言是英语,且文本已经是英文(英文字符及标点占比超过80%),跳过翻译直接返回原文
+        if (targetLanguage == "en" && isMostlyEnglish(text)) {
+            return@withContext Result.success(text)
+        }
+
+        // 如果目标语言是其他语言(如中文),且文本已经是该语言,跳过翻译直接返回原文
+        if (targetLanguage == "zh" && isMostlyChinese(text)) {
+            return@withContext Result.success(text)
+        }
+
+        try {
+            when (provider) {
+                TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(text, targetLanguage)
+                TranslationConstants.PROVIDER_OPENAI -> translateWithOpenAI(
+                    text,
+                    targetLanguage,
+                    baseUrl,
+                    apiKey,
+                    model,
+                    prompt,
+                    dictionaries,
+                    onUpdate,
+                    retryReason
+                )
+
+                else -> Result.failure(IllegalArgumentException("Unknown provider: $provider"))
+            }
+        } catch (e: Exception) {
+            Result.failure(e)
+        }
+    }
+
+    /**
+     * 判断文本是否大部分为英文(英文字母及标点符号占比超过80%)
+     */
+    private fun isMostlyEnglish(text: String): Boolean {
+        if (text.isEmpty()) return false
+        val englishChars =
+            text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}–—…" }
+        return englishChars.toDouble() / text.length > 0.8
+    }
+
+    /**
+     * 判断文本是否大部分为中文(中文及中文标点占比超过80%)
+     */
+    private fun isMostlyChinese(text: String): Boolean {
+        if (text.isEmpty()) return false
+        val chinesePunctuation = "。,!?;:“”‘’()【】《》——…"
+        val chineseChars = text.count {
+            it in '一'..'鿿' || it in chinesePunctuation
+        }
+        return chineseChars.toDouble() / text.length > 0.8
+    }
+
+    /**
+     * 如果翻译结果是中文,删除其中英文含量超过80%的段落(LLM可能生成中英对照内容)
+     */
+    private fun filterHighEnglishParagraphs(text: String): String {
+        val paragraphs = text.split("\n")
+        val filtered = paragraphs.filter { paragraph ->
+            !isMostlyEnglish(paragraph)
+        }
+        return filtered.joinToString("\n")
+    }
+
+    private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result<String> {
+        val encodedText = java.net.URLEncoder.encode(text, "UTF-8")
+        val url =
+            "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText"
+        val response = okHttpClient.newCallStrResponse {
+            url(url)
+        }
+        return if (response.isSuccessful()) {
+            try {
+                val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
+                val translatedText = json.sentences.mapNotNull { it.trans }.joinToString("")
+                if (translatedText.isNotEmpty()) {
+                    Result.success(translatedText)
+                } else {
+                    Result.failure(Exception("Empty translation result"))
+                }
+            } catch (e: Exception) {
+                Result.failure(e)
+            }
+        } else {
+            Result.failure(Exception("HTTP ${response.code()}: ${response.message()}"))
+        }
+    }
+
+    private suspend fun translateWithOpenAI(
+        text: String,
+        targetLanguage: String,
+        baseUrl: String,
+        apiKey: String,
+        model: String,
+        prompt: String,
+        dictionaries: List<DictPair>,
+        onUpdate: ((List<DictPair>) -> Unit)?,
+        retryReason: RetryReason?
+    ): Result<String> {
+        if (baseUrl.isBlank() || apiKey.isBlank() || model.isBlank()) {
+            return Result.failure(IllegalArgumentException("OpenAI configuration incomplete: baseUrl, apiKey, and model are required"))
+        }
+
+        // Build dictionary instruction for consistent terminology
+        val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
+
+        val systemPrompt =
+            buildSystemPrompt(prompt, targetLanguage, dictionaryInstruction, OUTPUT_FORMAT)
+        val requestBody = mapOf(
+            "model" to model,
+            "messages" to listOf(
+                mapOf("role" to "system", "content" to systemPrompt),
+                mapOf("role" to "user", "content" to "Translate the following text:\n\n$text")
+            ),
+            "temperature" to 0.8
+        )
+        val jsonBody = GSON.toJson(requestBody)
+        val fullUrl = baseUrl.trimEnd('/') + "/v1/chat/completions"
+        val response = okHttpClient.newCallStrResponse {
+            url(fullUrl)
+            postJson(jsonBody)
+            addHeaders(
+                mapOf(
+                    "Authorization" to "Bearer $apiKey",
+                    "Content-Type" to "application/json"
+                )
+            )
+        }
+        return if (response.isSuccessful()) {
+            try {
+                val json = GSON.fromJson(response.body, OpenAIResponse::class.java)
+                val rawContent = json.choices.firstOrNull()?.message?.content
+                if (rawContent != null) {
+                    // Parse the output with [dictionary] and [result] sections
+                    val parseResult = parseLlmOutput(rawContent, dictionaries)
+
+                    // Report extracted pairs for dictionary update (max 10)
+                    if (parseResult.extractedPairs.isNotEmpty()) {
+                        val limitedPairs = parseResult.extractedPairs.take(10)
+                        onUpdate?.invoke(limitedPairs)
+                    }
+
+                    // Always return the result, even if dictionary parsing failed
+                    val finalText = if (targetLanguage == "zh") {
+                        filterHighEnglishParagraphs(parseResult.translatedText)
+                    } else {
+                        parseResult.translatedText
+                    }
+                    Result.success(finalText)
+                } else {
+                    Result.failure(Exception("Empty translation result"))
+                }
+            } catch (e: Exception) {
+                Result.failure(e)
+            }
+        } else {
+            val errorMsg = "HTTP ${response.code()}: ${response.message()}"
+            Result.failure(Exception(errorMsg))
+        }
+    }
+
+    /**
+     * Result of parsing LLM output
+     */
+    data class ParseOutputResult(
+        val translatedText: String,
+        val extractedPairs: List<DictPair>
+    )
+
+    /**
+     * Parse LLM output that should contain [dictionary] and [result] sections.
+     * Fallback: if parsing fails or format is wrong, still return the result part.
+     * Don't use regex for parsing.
+     */
+    private fun parseLlmOutput(
+        rawOutput: String,
+        existingDictionaries: List<DictPair>
+    ): ParseOutputResult {
+        val existingOriginals = existingDictionaries.map { it.original }.toSet()
+
+        var dictionarySection: String? = null
+        var resultSection: String? = null
+
+        val lines = rawOutput.split('\n')
+        var currentSection: String? = null
+
+        for (line in lines) {
+            val trimmedLine = line.trim()
+
+            // Detect section header
+            when {
+                trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> {
+                    currentSection = "dictionary"
+                }
+
+                trimmedLine.startsWith("[result]", ignoreCase = true) -> {
+                    currentSection = "result"
+                }
+
+                currentSection == "dictionary" -> {
+                    dictionarySection = (dictionarySection ?: "") + line + "\n"
+                }
+
+                currentSection == "result" -> {
+                    resultSection = (resultSection ?: "") + line + "\n"
+                }
+            }
+        }
+
+        // Extract pairs from dictionary section
+        val extractedPairs = mutableListOf<DictPair>()
+        if (dictionarySection != null) {
+            extractedPairs.addAll(parseDictionarySection(dictionarySection, existingOriginals))
+        }
+
+        // Determine translated text
+        val translatedText = when {
+            resultSection != null -> resultSection.trim()
+            else -> rawOutput.trim() // ultimate fallback: return raw output
+        }
+
+        return ParseOutputResult(translatedText, extractedPairs)
+    }
+
+    /**
+     * Parse dictionary section line by line without regex.
+     * Format: "Original -> Translation" or "Original: Translation"
+     */
+    private fun parseDictionarySection(
+        section: String,
+        existingOriginals: Set<String>
+    ): List<DictPair> {
+        val pairs = mutableListOf<DictPair>()
+        val lines = section.split('\n')
+
+        for (line in lines) {
+            val trimmedLine = line.trim()
+            if (trimmedLine.isEmpty()) continue
+
+            // Skip section headers
+            if (trimmedLine.startsWith("[") || trimmedLine.startsWith(
+                    "dictionary",
+                    ignoreCase = true
+                )
+            ) {
+                continue
+            }
+
+            // Try to find separator: "->" or ":"
+            val separator = when {
+                trimmedLine.contains(" -> ") -> " -> "
+                trimmedLine.contains(" ->") -> " ->"
+                trimmedLine.contains("-> ") -> "-> "
+                trimmedLine.contains("->") -> "->"
+                trimmedLine.contains(" : ") -> " : "
+                trimmedLine.contains(": ") -> ": "
+                trimmedLine.contains(" :") -> " :"
+                trimmedLine.contains(":") -> ":"
+                else -> null
+            }
+
+            if (separator != null) {
+                val parts = trimmedLine.split(separator, limit = 2)
+                if (parts.size == 2) {
+                    val original = parts[0].trim()
+                    val translation = parts[1].trim()
+
+                    // Skip if already in existing dictionary
+                    if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
+                        pairs.add(DictPair(original, translation))
+                        // Max 10 pairs
+                        if (pairs.size >= 10) break
+                    }
+                }
+            }
+        }
+
+        return pairs
+    }
+
+    /**
+     * Build a dictionary instruction string for consistent terminology.
+     */
+    private fun buildDictionaryInstruction(dictionaries: List<DictPair>): String {
+        if (dictionaries.isEmpty()) return ""
+
+        val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" }
+        return """
+
+Terminology Dictionary (use these exact translations):
+$terms
+"""
+    }
+
+    /**
+     * Build the full system prompt with dictionary instructions.
+     */
+    private fun buildSystemPrompt(
+        prompt: String,
+        targetLanguage: String,
+        dictionaryInstruction: String,
+        outputFormat: String,
+    ): String {
+        return buildString {
+            append(prompt)
+            append("\n Target language: ").append(getLanguageDisplayName(targetLanguage))
+            if (dictionaryInstruction.isNotEmpty()) {
+                append(dictionaryInstruction)
+            }
+            append("\n ").append(outputFormat)
+        }
+    }
+}
+
+data class GoogleTranslateResponse(
+    val sentences: List<GoogleSentence>,
+    val src: String?,
+    val spell: GoogleSpell?
+)
+
+data class GoogleSentence(
+    val trans: String?,
+    val orig: String?,
+    val backend: Int?
+)
+
+data class GoogleSpell(
+    val spell: String?
+)
+
+data class OpenAIResponse(
+    val choices: List<OpenAIChoice>
+)
+
+data class OpenAIChoice(
+    val message: OpenAIMessage
+)
+
+data class OpenAIMessage(
+    val content: String
+)

+ 193 - 0
app/src/main/java/io/legado/app/data/repository/TranslationCacheRepositoryImpl.kt

@@ -0,0 +1,193 @@
+package io.legado.app.data.repository
+
+import com.google.gson.Gson
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.TranslationCache
+import io.legado.app.domain.gateway.TranslationCacheGateway
+import io.legado.app.help.book.BookHelp
+import io.legado.app.utils.MD5Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.io.File
+
+class TranslationCacheRepositoryImpl : TranslationCacheGateway {
+
+    private val cacheDir: File = File(BookHelp.cachePath)
+    private val gson = Gson()
+
+    override fun getCacheFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File {
+        val bookFolder = File(cacheDir, book.getFolderName())
+        // getFileName() returns "{index}-{titleMD5}.nb", remove .nb to avoid double extension
+        val chapterFileName = bookChapter.getFileName().removeSuffix(".nb")
+        val translationFileName = "$chapterFileName.$targetLanguage.nb"
+        return File(bookFolder, translationFileName)
+    }
+
+    private fun getChunkFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File {
+        val bookFolder = File(cacheDir, book.getFolderName())
+        val chapterFileName = bookChapter.getFileName().removeSuffix(".nb")
+        return File(bookFolder, "$chapterFileName.$targetLanguage.chunks.jsonl")
+    }
+
+    override suspend fun readTranslation(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String
+    ): String? = withContext(Dispatchers.IO) {
+        val cacheFile = getCacheFile(book, bookChapter, targetLanguage)
+        if (cacheFile.exists()) {
+            val content = cacheFile.readText()
+            content.ifEmpty { null }
+        } else {
+            null
+        }
+    }
+
+    override suspend fun writeTranslation(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        content: String
+    ) = withContext(Dispatchers.IO) {
+        val cacheFile = getCacheFile(book, bookChapter, targetLanguage)
+        cacheFile.parentFile?.mkdirs()
+        cacheFile.writeText(content)
+    }
+
+    override suspend fun deleteTranslation(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String
+    ) = withContext(Dispatchers.IO) {
+        val cacheFile = getCacheFile(book, bookChapter, targetLanguage)
+        cacheFile.delete()
+        clearChunkCacheForChapter(book, bookChapter, targetLanguage)
+        Unit
+    }
+
+    override suspend fun deleteTranslationForBook(book: Book, targetLanguage: String) = withContext(Dispatchers.IO) {
+        val bookFolder = File(cacheDir, book.getFolderName())
+        if (bookFolder.exists()) {
+            bookFolder.listFiles()?.filter { it.name.endsWith(".$targetLanguage.nb") }?.forEach { it.delete() }
+        }
+        clearChunkCacheForBook(book, targetLanguage)
+        Unit
+    }
+
+    override suspend fun deleteAllTranslation() = withContext(Dispatchers.IO) {
+        clearAllChunkCache()
+        Unit
+    }
+
+    override fun getTranslationCacheSize(): Long {
+        var totalSize = 0L
+        cacheDir.listFiles()?.forEach { bookFolder ->
+            bookFolder.listFiles()?.filter { it.name.endsWith(".nb") && it.name.contains(".") }?.forEach { file ->
+                totalSize += file.length()
+            }
+        }
+        return totalSize
+    }
+
+    override fun computeContentHash(content: String): String {
+        return MD5Utils.md5Encode(content)
+    }
+
+    override fun computeCacheKey(
+        bookUrl: String,
+        chapterIndex: Int,
+        chunkIndex: Int,
+        targetLanguage: String
+    ): String {
+        return "${bookUrl}_${chapterIndex}_${chunkIndex}_$targetLanguage"
+    }
+
+    private suspend fun readAllChunks(book: Book, bookChapter: BookChapter, targetLanguage: String): Map<Int, TranslationCache> = withContext(Dispatchers.IO) {
+        val chunkFile = getChunkFile(book, bookChapter, targetLanguage)
+        if (!chunkFile.exists()) return@withContext emptyMap()
+        val result = mutableMapOf<Int, TranslationCache>()
+        chunkFile.forEachLine { line ->
+            try {
+                val chunk = gson.fromJson(line, TranslationCache::class.java)
+                result[chunk.chunkIndex] = chunk
+            } catch (_: Exception) {
+                // skip malformed line
+            }
+        }
+        result
+    }
+
+    override suspend fun getCachedChunks(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        contentHash: String
+    ): List<TranslationCache> = withContext(Dispatchers.IO) {
+        val allChunks = readAllChunks(book, bookChapter, targetLanguage)
+        allChunks.values
+            .filter { it.originalContentHash == contentHash && it.isSuccess }
+            .sortedBy { it.chunkIndex }
+    }
+
+    override suspend fun getCachedChunk(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        chunkIndex: Int
+    ): TranslationCache? = withContext(Dispatchers.IO) {
+        val allChunks = readAllChunks(book, bookChapter, targetLanguage)
+        allChunks[chunkIndex]
+    }
+
+    override suspend fun saveChunk(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        chunkIndex: Int,
+        originalChunkContent: String,
+        originalContentHash: String,
+        provider: String,
+        status: Int,
+        translatedContent: String?,
+        errorMessage: String?
+    ) = withContext(Dispatchers.IO) {
+        val chunk = TranslationCache(
+            chunkIndex = chunkIndex,
+            originalChunkContent = originalChunkContent,
+            translatedChunkContent = translatedContent,
+            status = status,
+            errorMessage = errorMessage,
+            originalContentHash = originalContentHash,
+            provider = provider
+        )
+        val chunkFile = getChunkFile(book, bookChapter, targetLanguage)
+        chunkFile.parentFile?.mkdirs()
+        chunkFile.appendText(gson.toJson(chunk) + "\n")
+    }
+
+    override suspend fun clearChunkCacheForChapter(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String
+    ) = withContext(Dispatchers.IO) {
+        val chunkFile = getChunkFile(book, bookChapter, targetLanguage)
+        chunkFile.delete()
+        Unit
+    }
+
+    override suspend fun clearChunkCacheForBook(book: Book, targetLanguage: String) = withContext(Dispatchers.IO) {
+        val bookFolder = File(cacheDir, book.getFolderName())
+        if (bookFolder.exists()) {
+            bookFolder.listFiles()?.filter { it.name.endsWith(".$targetLanguage.chunks.jsonl") }?.forEach { it.delete() }
+        }
+        Unit
+    }
+
+    override suspend fun clearAllChunkCache() = withContext(Dispatchers.IO) {
+        cacheDir.listFiles()?.forEach { bookFolder ->
+            bookFolder.listFiles()?.filter { it.name.endsWith(".chunks.jsonl") }?.forEach { it.delete() }
+        }
+        Unit
+    }
+}

+ 1 - 23
app/src/main/java/io/legado/app/di/appDatabaseModule.kt

@@ -2,29 +2,7 @@ package io.legado.app.di
 
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.appDb
-import io.legado.app.data.dao.BookChapterDao
-import io.legado.app.data.dao.BookDao
-import io.legado.app.data.dao.BookGroupDao
-import io.legado.app.data.dao.BookSourceDao
-import io.legado.app.data.dao.BookmarkDao
-import io.legado.app.data.dao.CacheDao
-import io.legado.app.data.dao.CookieDao
-import io.legado.app.data.dao.DictRuleDao
-import io.legado.app.data.dao.HomepageCustomSetDao
-import io.legado.app.data.dao.HomepageModuleDao
-import io.legado.app.data.dao.HttpTTSDao
-import io.legado.app.data.dao.KeyboardAssistsDao
-import io.legado.app.data.dao.ReadRecordDao
-import io.legado.app.data.dao.ReplaceRuleDao
-import io.legado.app.data.dao.RssArticleDao
-import io.legado.app.data.dao.RssReadRecordDao
-import io.legado.app.data.dao.RssSourceDao
-import io.legado.app.data.dao.RssStarDao
-import io.legado.app.data.dao.RuleSubDao
-import io.legado.app.data.dao.SearchBookDao
-import io.legado.app.data.dao.SearchKeywordDao
-import io.legado.app.data.dao.ServerDao
-import io.legado.app.data.dao.TxtTocRuleDao
+import io.legado.app.data.dao.*
 import org.koin.dsl.module
 
 /**

+ 19 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -18,10 +18,12 @@ import io.legado.app.data.repository.BookshelfRepository
 import io.legado.app.data.repository.CacheBookDownloadRepository
 import io.legado.app.data.repository.DatabaseMaintenanceRepository
 import io.legado.app.data.repository.DictRuleRepository
+import io.legado.app.data.repository.DictionaryRepositoryImpl
 import io.legado.app.data.repository.DirectLinkUploadRepository
 import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.HomepageModulesRepository
+import io.legado.app.data.repository.LlmTranslateRepositoryImpl
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
@@ -30,6 +32,7 @@ import io.legado.app.data.repository.SearchContentRepository
 import io.legado.app.data.repository.SearchRepository
 import io.legado.app.data.repository.SearchRepositoryImpl
 import io.legado.app.data.repository.SettingsRepository
+import io.legado.app.data.repository.TranslationCacheRepositoryImpl
 import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
@@ -39,10 +42,13 @@ import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
+import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
+import io.legado.app.domain.gateway.LlmGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
+import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
@@ -63,6 +69,7 @@ import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
+import io.legado.app.domain.usecase.TranslateChapterUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
 import io.legado.app.domain.usecase.WebDavBackupUseCase
@@ -164,6 +171,7 @@ val appModule = module {
     singleOf(::BookshelfManageScreenConfig)
 
     single<UploadRepository> { DirectLinkUploadRepository() }
+    single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
     single<AppStartupGateway> { AppStartupRepository(get()) }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
     single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
@@ -184,6 +192,9 @@ val appModule = module {
     single<SearchRepository> { get<SearchRepositoryImpl>() }
     single<BookSearchGateway> { get<SearchRepositoryImpl>() }
     singleOf(::SearchBooksUseCase)
+    single<LlmGateway> { LlmTranslateRepositoryImpl() }
+    single<DictionaryGateway> { DictionaryRepositoryImpl() }
+    singleOf(::TranslateChapterUseCase)
 
     single<ImageLoader> {
         ImageLoader.Builder(get())
@@ -234,7 +245,14 @@ val appModule = module {
     viewModelOf(::ServersViewModel)
     viewModelOf(::BookInfoViewModel)
     viewModelOf(::ReadMangaViewModel)
-    viewModelOf(::ReadBookViewModel)
+    viewModel {
+        ReadBookViewModel(
+            application = get(),
+            getReadingProgressUseCase = get(),
+            uploadReadingProgressUseCase = get(),
+            translateChapterUseCase = get()
+        )
+    }
     viewModelOf(::ChangeCoverViewModel)
     viewModelOf(::ChangeBookSourceComposeViewModel)
     viewModelOf(::ChangeBookSourceViewModel)

+ 11 - 0
app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt

@@ -0,0 +1,11 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.Book
+import io.legado.app.domain.model.BookDictionary
+import io.legado.app.domain.model.DictPair
+
+interface DictionaryGateway {
+    fun getBookDictionaries(book: Book): BookDictionary
+    fun updateBookDic(book: Book, newPairs: List<DictPair>)
+    fun clearBookDictionary(book: Book)
+}

+ 19 - 0
app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt

@@ -0,0 +1,19 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.DictPair
+import io.legado.app.domain.model.RetryReason
+
+interface LlmGateway {
+    suspend fun translate(
+        text: String,
+        targetLanguage: String,
+        provider: String,
+        baseUrl: String,
+        apiKey: String,
+        model: String,
+        prompt: String,
+        dictionaries: List<DictPair> = emptyList(),
+        onUpdate: ((List<DictPair>) -> Unit)? = null,
+        retryReason: RetryReason? = null
+    ): Result<String>
+}

+ 70 - 0
app/src/main/java/io/legado/app/domain/gateway/TranslationCacheGateway.kt

@@ -0,0 +1,70 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.TranslationCache
+import java.io.File
+
+interface TranslationCacheGateway {
+    fun getCacheFile(book: Book, bookChapter: BookChapter, targetLanguage: String): File
+    suspend fun readTranslation(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String
+    ): String?
+
+    suspend fun writeTranslation(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        content: String
+    )
+
+    suspend fun deleteTranslation(book: Book, bookChapter: BookChapter, targetLanguage: String)
+    suspend fun deleteTranslationForBook(book: Book, targetLanguage: String)
+    suspend fun deleteAllTranslation()
+    fun getTranslationCacheSize(): Long
+    fun computeContentHash(content: String): String
+    fun computeCacheKey(
+        bookUrl: String,
+        chapterIndex: Int,
+        chunkIndex: Int,
+        targetLanguage: String
+    ): String
+
+    suspend fun getCachedChunks(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        contentHash: String
+    ): List<TranslationCache>
+
+    suspend fun getCachedChunk(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        chunkIndex: Int
+    ): TranslationCache?
+
+    suspend fun saveChunk(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        chunkIndex: Int,
+        originalChunkContent: String,
+        originalContentHash: String,
+        provider: String,
+        status: Int,
+        translatedContent: String?,
+        errorMessage: String?
+    )
+
+    suspend fun clearChunkCacheForChapter(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String
+    )
+
+    suspend fun clearChunkCacheForBook(book: Book, targetLanguage: String)
+    suspend fun clearAllChunkCache()
+}

+ 199 - 0
app/src/main/java/io/legado/app/domain/model/ContentChunker.kt

@@ -0,0 +1,199 @@
+package io.legado.app.domain.model
+
+object ContentChunker {
+
+    private val sentenceDelimiters = listOf('。', '!', '?', '.', '!', '?', ';', ';', '\n')
+
+    fun chunk(text: String, maxCharsPerChunk: Int = 3000): List<TextChunk> {
+        if (text.isBlank()) return emptyList()
+
+        val paragraphs = text.split("\n\n").filter { it.isNotBlank() }
+        if (paragraphs.isEmpty()) return emptyList()
+
+        val chunks = mutableListOf<TextChunk>()
+        var currentChunk = StringBuilder()
+        var currentParagraphIndices = mutableListOf<Int>()
+        var currentChunkParagraphCount = 0
+
+        for ((paragraphIndex, paragraph) in paragraphs.withIndex()) {
+            if (currentChunk.isEmpty()) {
+                currentChunk.append(paragraph)
+                currentParagraphIndices.add(paragraphIndex)
+                currentChunkParagraphCount = 1
+            } else if (currentChunk.length + paragraph.length + 2 <= maxCharsPerChunk) {
+                currentChunk.append("\n\n").append(paragraph)
+                currentParagraphIndices.add(paragraphIndex)
+                currentChunkParagraphCount++
+            } else {
+                if (currentChunk.isNotEmpty()) {
+                    chunks.add(
+                        TextChunk(
+                            chunks.size,
+                            currentChunk.toString(),
+                            currentParagraphIndices.toList()
+                        )
+                    )
+                }
+                currentChunk = StringBuilder(paragraph)
+                currentParagraphIndices = mutableListOf(paragraphIndex)
+                currentChunkParagraphCount = 1
+            }
+        }
+
+        if (currentChunk.isNotEmpty()) {
+            chunks.add(
+                TextChunk(
+                    chunks.size,
+                    currentChunk.toString(),
+                    currentParagraphIndices.toList()
+                )
+            )
+        }
+
+        val result = mutableListOf<TextChunk>()
+        for (chunk in chunks) {
+            if (chunk.content.length > maxCharsPerChunk) {
+                result.addAll(splitOversizedChunk(chunk, maxCharsPerChunk))
+            } else {
+                result.add(chunk)
+            }
+        }
+
+        return result.mapIndexed { index, chunk -> chunk.copy(index = index) }
+    }
+
+    private fun splitOversizedChunk(chunk: TextChunk, maxCharsPerChunk: Int): List<TextChunk> {
+        val result = mutableListOf<TextChunk>()
+        val paragraphs = chunk.content.split("\n\n")
+        var currentSubChunk = StringBuilder()
+        var currentParagraphIndices = mutableListOf<Int>()
+        var subChunkIndex = chunk.index * 100
+
+        for ((idx, paragraph) in paragraphs.withIndex()) {
+            if (paragraph.length > maxCharsPerChunk) {
+                if (currentSubChunk.isNotEmpty()) {
+                    result.add(
+                        TextChunk(
+                            subChunkIndex++,
+                            currentSubChunk.toString(),
+                            currentParagraphIndices.toList()
+                        )
+                    )
+                    currentSubChunk = StringBuilder()
+                    currentParagraphIndices = mutableListOf()
+                }
+                result.addAll(
+                    splitOversizedParagraph(
+                        paragraph,
+                        chunk.paragraphIndices.getOrElse(idx) { idx },
+                        subChunkIndex,
+                        maxCharsPerChunk
+                    )
+                )
+                subChunkIndex += 100
+            } else if (currentSubChunk.length + paragraph.length + 2 <= maxCharsPerChunk) {
+                currentParagraphIndices.add(chunk.paragraphIndices.getOrElse(idx) { idx })
+                if (currentSubChunk.isNotEmpty()) {
+                    currentSubChunk.append("\n\n")
+                }
+                currentSubChunk.append(paragraph)
+            } else {
+                if (currentSubChunk.isNotEmpty()) {
+                    result.add(
+                        TextChunk(
+                            subChunkIndex++,
+                            currentSubChunk.toString(),
+                            currentParagraphIndices.toList()
+                        )
+                    )
+                }
+                currentSubChunk = StringBuilder(paragraph)
+                currentParagraphIndices =
+                    mutableListOf(chunk.paragraphIndices.getOrElse(idx) { idx })
+            }
+        }
+
+        if (currentSubChunk.isNotEmpty()) {
+            result.add(
+                TextChunk(
+                    subChunkIndex,
+                    currentSubChunk.toString(),
+                    currentParagraphIndices.toList()
+                )
+            )
+        }
+
+        return result
+    }
+
+    private fun splitOversizedParagraph(
+        paragraph: String,
+        paragraphIndex: Int,
+        startIndex: Int,
+        maxCharsPerChunk: Int
+    ): List<TextChunk> {
+        val result = mutableListOf<TextChunk>()
+        val sentences = mutableListOf<String>()
+        var currentSentence = StringBuilder()
+
+        for (char in paragraph) {
+            currentSentence.append(char)
+            if (char in sentenceDelimiters) {
+                sentences.add(currentSentence.toString())
+                currentSentence = StringBuilder()
+            }
+        }
+        if (currentSentence.isNotEmpty()) {
+            sentences.add(currentSentence.toString())
+        }
+
+        var currentChunk = StringBuilder()
+        var currentChunkSentences = 0
+        var subIndex = startIndex
+
+        for (sentence in sentences) {
+            if (currentChunk.length + sentence.length <= maxCharsPerChunk) {
+                currentChunk.append(sentence)
+                currentChunkSentences++
+            } else {
+                if (currentChunk.isNotEmpty()) {
+                    result.add(
+                        TextChunk(
+                            subIndex++,
+                            currentChunk.toString(),
+                            listOf(paragraphIndex)
+                        )
+                    )
+                    currentChunkSentences = 0
+                }
+                if (sentence.length > maxCharsPerChunk) {
+                    var remaining = sentence
+                    while (remaining.length > maxCharsPerChunk) {
+                        result.add(
+                            TextChunk(
+                                subIndex++,
+                                remaining.substring(0, maxCharsPerChunk),
+                                listOf(paragraphIndex)
+                            )
+                        )
+                        remaining = remaining.substring(maxCharsPerChunk)
+                    }
+                    currentChunk = StringBuilder(remaining)
+                } else {
+                    currentChunk = StringBuilder(sentence)
+                    currentChunkSentences = 1
+                }
+            }
+        }
+
+        if (currentChunk.isNotEmpty()) {
+            result.add(TextChunk(subIndex, currentChunk.toString(), listOf(paragraphIndex)))
+        }
+
+        return result
+    }
+
+    fun merge(chunks: List<TextChunk>): String {
+        return chunks.sortedBy { it.index }.joinToString("\n\n") { it.content }
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt

@@ -0,0 +1,46 @@
+package io.legado.app.domain.model
+
+/**
+ * Assembles mixed original/translated content as translation progresses.
+ * Uses original chunk order and replaces chunks with translations when available.
+ */
+object PartialTranslationAssembler {
+
+    /**
+     * Assemble mixed content from original chunks and translated portions.
+     *
+     * @param originalChunks The original text chunks in order
+     * @param translatedMap Map of chunk index to translated content
+     * @return Mixed content string with translated chunks replacing originals
+     */
+    fun assemble(originalChunks: List<TextChunk>, translatedMap: Map<Int, String>): String {
+        if (originalChunks.isEmpty()) return ""
+
+        val result = StringBuilder()
+        for ((index, chunk) in originalChunks.withIndex()) {
+            val translated = translatedMap[chunk.index]
+            val content = translated ?: chunk.content
+
+            if (result.isNotEmpty()) {
+                result.append("\n\n")
+            }
+            result.append(content)
+        }
+
+        return result.toString()
+    }
+
+    /**
+     * Check if we have any translations yet.
+     */
+    fun hasPartialTranslation(translatedMap: Map<Int, String>): Boolean {
+        return translatedMap.isNotEmpty()
+    }
+
+    /**
+     * Get the count of translated chunks vs total.
+     */
+    fun progress(translatedMap: Map<Int, String>, totalChunks: Int): Pair<Int, Int> {
+        return Pair(translatedMap.size, totalChunks)
+    }
+}

+ 50 - 0
app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt

@@ -0,0 +1,50 @@
+package io.legado.app.domain.model
+
+object TranslationConstants {
+
+    const val PROVIDER_OPENAI = "openai"
+    const val PROVIDER_GOOGLE = "google"
+
+    val providerDisplayNames = listOf("Google Translate", "OpenAI适配接口")
+    val providerValues = listOf(PROVIDER_GOOGLE, PROVIDER_OPENAI)
+
+    val targetLanguages = listOf(
+        "zh" to "简体中文",
+        "en" to "English",
+        "ja" to "日本語",
+        "ko" to "한국어",
+        "fr" to "Français",
+        "de" to "Deutsch",
+        "es" to "Español",
+        "ru" to "Русский",
+        "ar" to "العربية"
+    )
+
+    const val DEFAULT_PROMPT =
+        """You are a professional literary translator, please translate according to the following requirements:
+
+1. Keep the original paragraph count and order unchanged
+2. Maintain the literary style and tone of the original text
+3. Do not summarize, condense, or omit any content
+4. Only output the translation result, do not add comments or explanations
+5. Keep name consistency across abbreviations/nicknames (e.g., Alexander → Alex → same name). Add nickname mapping to dictionary.
+
+"""
+
+    const val OUTPUT_FORMAT = """Output is divided into two parts:
+
+**New** proper nouns, place names that need to be recorded for context, and the translation result.
+
+Only select the most common and important terms (max 10) to include in the dictionary.
+
+Output format as follows, IMPORTANT, **dictionary** part must begin with english word **[dictionary]**, MUST NOT start with any other words. **result** part must begin with english word **[result]**,  MUST NOT start with any other words:
+<example>
+[dictionary]
+Jack -> 杰克
+Harry Port -> 哈利波特
+
+[result]
+...
+</example>
+    """
+}

+ 61 - 0
app/src/main/java/io/legado/app/domain/model/TranslationModels.kt

@@ -0,0 +1,61 @@
+package io.legado.app.domain.model
+
+/**
+ * A pair of original text and its translation, used for maintaining
+ * consistent terminology across multiple translation chunks.
+ */
+data class DictPair(
+    val original: String,
+    val translation: String
+)
+
+/**
+ * Collection of dictionary pairs with metadata.
+ */
+data class BookDictionary(
+    val bookUrl: String,
+    val pairs: List<DictPair> = emptyList(),
+    val updatedAt: Long = System.currentTimeMillis()
+)
+
+/**
+ * A chunk of text for translation, with its index and paragraph mapping.
+ */
+data class TextChunk(
+    val index: Int,
+    val content: String,
+    val paragraphIndices: List<Int>
+)
+
+/**
+ * Reasons for retrying a translation request.
+ * Used to analyze failures and decide appropriate retry strategies.
+ */
+enum class RetryReason {
+    /** Network connectivity issues */
+    NETWORK_ERROR,
+
+    /** API returned rate limit error (429) */
+    RATE_LIMIT,
+
+    /** API returned server error (5xx) */
+    SERVER_ERROR,
+
+    /** API returned authentication/permission error (401, 403) */
+    AUTH_ERROR,
+
+    /** Request timeout */
+    TIMEOUT,
+
+    /** API returned empty response */
+    EMPTY_RESPONSE,
+
+    /** Malformed response that couldn't be parsed */
+    PARSE_ERROR,
+
+    /** Unknown error that might be transient */
+    UNKNOWN,
+
+    /** No retry needed, permanent failure */
+    PERMANENT_FAILURE
+}

+ 275 - 0
app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt

@@ -0,0 +1,275 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.TranslationCache
+import io.legado.app.domain.gateway.DictionaryGateway
+import io.legado.app.domain.gateway.LlmGateway
+import io.legado.app.domain.gateway.TranslationCacheGateway
+import io.legado.app.domain.model.ContentChunker
+import io.legado.app.domain.model.DictPair
+import io.legado.app.domain.model.PartialTranslationAssembler
+import io.legado.app.domain.model.RetryReason
+import io.legado.app.domain.model.TextChunk
+import io.legado.app.help.book.BookHelp
+import io.legado.app.ui.config.translation.TranslationConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.async
+import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.withContext
+
+class TranslateChapterUseCase(
+    private val llmGateway: LlmGateway,
+    private val translationCacheGateway: TranslationCacheGateway,
+    private val dictionaryGateway: DictionaryGateway
+) {
+
+    data class TranslationProgress(
+        val currentChunk: Int,
+        val totalChunks: Int,
+        val mixedContent: String? = null,
+        val translatedChunkIndices: Set<Int> = emptySet()
+    )
+
+    companion object {
+        private const val MAX_DICTIONARY_PAIRS = 50
+    }
+
+    private val dictionaryLock = Any()
+
+    suspend fun execute(
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        onProgress: (TranslationProgress) -> Unit,
+        onTranslateStarted: () -> Unit
+    ): Result<String> = withContext(Dispatchers.IO) {
+        val originalContent = BookHelp.getContent(book, bookChapter)
+            ?: return@withContext Result.failure(Exception("Failed to read original content"))
+
+        val cachedTranslation =
+            translationCacheGateway.readTranslation(book, bookChapter, targetLanguage)
+        if (cachedTranslation != null) {
+            onProgress(TranslationProgress(1, 1, cachedTranslation, emptySet()))
+            return@withContext Result.success(cachedTranslation)
+        }
+
+        val contentHash = translationCacheGateway.computeContentHash(originalContent)
+
+        // Load book dictionary for consistent terminology
+        val bookDictionary = dictionaryGateway.getBookDictionaries(book)
+        val dictionaries = bookDictionary.pairs.toMutableList()
+
+        // Callback to update dictionary pairs immediately (persist as soon as discovered)
+        val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
+            val merged = synchronized(dictionaryLock) {
+                mergeDictionaryPairs(dictionaries, newPairs)
+            }
+            if (merged) {
+                synchronized(dictionaryLock) {
+                    dictionaryGateway.updateBookDic(book, dictionaries.toList())
+                }
+            }
+        }
+
+        val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk)
+        if (chunks.isEmpty()) {
+            return@withContext Result.failure(Exception("Failed to chunk content"))
+        }
+
+        val cachedChunks =
+            translationCacheGateway.getCachedChunks(book, bookChapter, targetLanguage, contentHash)
+        val cachedChunkMap = cachedChunks.filter { it.isSuccess }.associateBy { it.chunkIndex }
+
+        val translatedChunks = mutableMapOf<Int, String>()
+        val pendingChunks = mutableListOf<TextChunk>()
+
+        // Load already cached chunks
+        for (chunk in chunks) {
+            val cached = cachedChunkMap[chunk.index]
+            if (cached != null && cached.translatedChunkContent != null) {
+                translatedChunks[chunk.index] = cached.translatedChunkContent
+            } else {
+                pendingChunks.add(chunk)
+            }
+        }
+
+        // If we have partial cached chunks, report initial mixed content
+        if (translatedChunks.isNotEmpty()) {
+            val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
+            onProgress(TranslationProgress(
+                translatedChunks.size,
+                chunks.size,
+                mixedContent,
+                translatedChunks.keys
+            ))
+        }
+
+        if (pendingChunks.isEmpty()) {
+            val sortedChunks = chunks.sortedBy { it.index }.mapNotNull { translatedChunks[it.index]?.let { content -> TextChunk(it.index, content, it.paragraphIndices) } }
+            val mergedContent = ContentChunker.merge(sortedChunks)
+            translationCacheGateway.writeTranslation(
+                book,
+                bookChapter,
+                targetLanguage,
+                mergedContent
+            )
+            onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
+            return@withContext Result.success(mergedContent)
+        }
+
+        onTranslateStarted()
+        coroutineScope {
+            val concurrentChunks = TranslationConfig.llmConcurrentChunks.coerceIn(1, 4)
+            val chunkGroups = pendingChunks.chunked(concurrentChunks)
+
+            for ((groupIndex, group) in chunkGroups.withIndex()) {
+                val results = group.map { chunk ->
+                    async {
+                        translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate)
+                    }
+                }.awaitAll()
+
+                for ((chunk, result) in group.zip(results)) {
+                    if (result.isSuccess) {
+                        translatedChunks[chunk.index] = result.getOrThrow()
+                        val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
+                        onProgress(TranslationProgress(
+                            translatedChunks.size + cachedChunkMap.size,
+                            chunks.size,
+                            mixedContent,
+                            translatedChunks.keys
+                        ))
+                    } else {
+                        val error = result.exceptionOrNull() ?: Exception("Translation failed")
+                        return@coroutineScope
+                    }
+                }
+            }
+        }
+
+        if (translatedChunks.size != chunks.size) {
+            return@withContext Result.failure(Exception("Translation incomplete"))
+        }
+
+        val allTranslatedChunks = chunks.sortedBy { it.index }.mapNotNull { chunk ->
+            translatedChunks[chunk.index]?.let { content -> TextChunk(chunk.index, content, chunk.paragraphIndices) }
+        }
+        val mergedContent = ContentChunker.merge(allTranslatedChunks)
+        translationCacheGateway.writeTranslation(book, bookChapter, targetLanguage, mergedContent)
+
+        onProgress(TranslationProgress(chunks.size, chunks.size, mergedContent, chunks.map { it.index }.toSet()))
+        Result.success(mergedContent)
+    }
+
+    /**
+     * Merge new pairs into existing list:
+     * - If original exists, replace the translation
+     * - If new, add to list
+     * - Keep at most MAX_DICTIONARY_PAIRS
+     * @return true if any changes were made
+     */
+    private fun mergeDictionaryPairs(existing: MutableList<DictPair>, newPairs: List<DictPair>): Boolean {
+        var changed = false
+        for (newPair in newPairs) {
+            val existingIndex = existing.indexOfFirst { it.original == newPair.original }
+            if (existingIndex >= 0) {
+                if (existing[existingIndex].translation != newPair.translation) {
+                    existing[existingIndex] = newPair
+                    changed = true
+                }
+            } else {
+                existing.add(newPair)
+                changed = true
+            }
+        }
+
+        // Keep only the most recent MAX_DICTIONARY_PAIRS
+        if (existing.size > MAX_DICTIONARY_PAIRS) {
+            val trimmed = existing.takeLast(MAX_DICTIONARY_PAIRS)
+            existing.clear()
+            existing.addAll(trimmed)
+            changed = true
+        }
+        return changed
+    }
+
+    private suspend fun translateAndCacheChunk(
+        chunk: TextChunk,
+        book: Book,
+        bookChapter: BookChapter,
+        targetLanguage: String,
+        contentHash: String,
+        dictionaries: MutableList<DictPair>,
+        onDictionaryUpdate: (List<DictPair>) -> Unit
+    ): Result<String> {
+        val existingCache =
+            translationCacheGateway.getCachedChunk(book, bookChapter, targetLanguage, chunk.index)
+        if (existingCache?.isSuccess == true && existingCache.translatedChunkContent != null) {
+            return Result.success(existingCache.translatedChunkContent)
+        }
+
+        val result = translateChunkWithRetry(chunk, targetLanguage, dictionaries, onDictionaryUpdate)
+        if (result.isSuccess) {
+            translationCacheGateway.saveChunk(
+                book, bookChapter, targetLanguage,
+                chunk.index, chunk.content, contentHash,
+                TranslationConfig.llmProvider,
+                TranslationCache.STATUS_SUCCESS, result.getOrThrow(), null
+            )
+        } else {
+            val errorMessage = result.exceptionOrNull()?.message ?: "Translation failed"
+            translationCacheGateway.saveChunk(
+                book, bookChapter, targetLanguage,
+                chunk.index, chunk.content, contentHash,
+                TranslationConfig.llmProvider,
+                TranslationCache.STATUS_FAILED, null, errorMessage
+            )
+        }
+        return result
+    }
+
+    private suspend fun translateChunkWithRetry(
+        chunk: TextChunk,
+        targetLanguage: String,
+        dictionaries: MutableList<DictPair>,
+        onDictionaryUpdate: (List<DictPair>) -> Unit
+    ): Result<String> {
+        var lastError: Exception? = null
+        var lastRetryReason: RetryReason? = null
+        for (attempt in 0..TranslationConfig.llmRetryCount) {
+            val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() }
+            val result = llmGateway.translate(
+                text = chunk.content,
+                targetLanguage = targetLanguage,
+                provider = TranslationConfig.llmProvider,
+                baseUrl = TranslationConfig.llmBaseUrl,
+                apiKey = TranslationConfig.llmApiKey,
+                model = TranslationConfig.llmModel,
+                prompt = TranslationConfig.llmPrompt,
+                dictionaries = dictSnapshot,
+                onUpdate = onDictionaryUpdate,
+                retryReason = lastRetryReason
+            )
+            if (result.isSuccess) {
+                return result
+            }
+            lastError = result.exceptionOrNull() as? Exception
+            lastRetryReason = parseRetryReason(lastError)
+        }
+        return Result.failure(lastError ?: Exception("Translation failed after retries"))
+    }
+
+    private fun parseRetryReason(error: Exception?): RetryReason? {
+        val message = error?.message ?: return null
+        return when {
+            message.contains("429") -> RetryReason.RATE_LIMIT
+            message.contains("500") || message.contains("502") || message.contains("503") || message.contains("504") -> RetryReason.SERVER_ERROR
+            message.contains("401") || message.contains("403") -> RetryReason.AUTH_ERROR
+            message.contains("timeout", ignoreCase = true) -> RetryReason.TIMEOUT
+            message.contains("HTTP") -> RetryReason.UNKNOWN
+            else -> null
+        }
+    }
+}

+ 65 - 2
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -26,6 +26,10 @@ import io.legado.app.help.config.ReadBookConfig
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.globalExecutor
 import io.legado.app.model.localBook.TextFile
+import io.legado.app.model.translation.TranslationChapterState
+import io.legado.app.model.translation.TranslationChapterStatus
+import io.legado.app.model.translation.TranslationManager
+import kotlinx.coroutines.flow.MutableStateFlow
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.CacheBookService
@@ -84,6 +88,7 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
     private val loadingChapters = arrayListOf<Int>()
     private val readRecord = ReadRecord()
     private val chapterLoadingJobs = ConcurrentHashMap<Int, Coroutine<*>>()
+    private val translationObserverJobs = ConcurrentHashMap<Int, Job>()
     private val prevChapterLoadingLock = Mutex()
     private val curChapterLoadingLock = Mutex()
     private val nextChapterLoadingLock = Mutex()
@@ -242,11 +247,20 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
 
     fun clearTextChapter() {
         clearExpiredChapterLoadingJob(true)
+        clearTranslationObserverJobs()
         prevTextChapter = null
         curTextChapter = null
         nextTextChapter = null
     }
 
+    private fun clearTranslationObserverJobs() {
+        translationObserverJobs.entries.filter { it.key !in durChapterIndex - 1..durChapterIndex + 1 }
+            .forEach { (index, job) ->
+                job.cancel()
+                translationObserverJobs.remove(index)
+            }
+    }
+
     fun clearSearchResult() {
         curTextChapter?.clearSearchResult()
         prevTextChapter?.clearSearchResult()
@@ -672,7 +686,18 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
                 return@async
             }
             if (addLoading(index)) {
-                BookHelp.getContent(book, chapter)?.let {
+                val content = if (book.getTranslationMode()) {
+                    TranslationManager.getCachedTranslation(book, chapter)
+                        ?: run {
+                            TranslationManager.startTranslation(book, chapter)?.let { taskFlow ->
+                                startTranslationObserver(taskFlow, book, chapter)
+                            }
+                            BookHelp.getContent(book, chapter)
+                        }
+                } else {
+                    BookHelp.getContent(book, chapter)
+                }
+                content?.let {
                     contentLoadFinish(
                         book,
                         chapter,
@@ -706,7 +731,17 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
             try {
                 val book = book!!
                 val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, index)!!
-                val content = BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
+                val content = if (book.getTranslationMode()) {
+                    TranslationManager.getCachedTranslation(book, chapter)
+                        ?: run {
+                            TranslationManager.startTranslation(book, chapter)?.let { taskFlow ->
+                                startTranslationObserver(taskFlow, book, chapter)
+                            }
+                            BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
+                        }
+                } else {
+                    BookHelp.getContent(book, chapter) ?: downloadAwait(chapter)
+                }
                 contentLoadFinishAwait(book, chapter, content, upContent, resetPageOffset)
                 success?.invoke()
             } catch (e: Exception) {
@@ -779,6 +814,34 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         }
     }
 
+    /**
+     * Start observing a translation task for real-time UI updates.
+     * Collects mixedContent updates and calls contentLoadFinish to refresh the page.
+     * The observer stops automatically when translation completes or fails.
+     */
+    private fun startTranslationObserver(taskFlow: MutableStateFlow<TranslationChapterState>, book: Book, chapter: BookChapter) {
+        val chapterIndex = chapter.index
+        translationObserverJobs[chapterIndex]?.cancel()
+
+        val job = launch {
+            taskFlow.collect { state ->
+                when (state.status) {
+                    TranslationChapterStatus.Translating -> {
+                        state.mixedContent?.let { mixed ->
+                            contentLoadFinish(book, chapter, mixed, upContent = true, resetPageOffset = false)
+                        }
+                    }
+                    else -> {
+                        // no-op
+                    }
+                }
+            }
+            // Clean up when coroutine finishes
+            translationObserverJobs.remove(chapterIndex)
+        }
+        translationObserverJobs[chapterIndex] = job
+    }
+
     @Synchronized
     private fun addLoading(index: Int): Boolean {
         if (loadingChapters.contains(index)) return false

+ 34 - 0
app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt

@@ -0,0 +1,34 @@
+package io.legado.app.model.translation
+
+/**
+ * Key for per-chapter translation display state.
+ * Used as task key for looking up ongoing translation tasks in TranslationManager.
+ */
+data class TranslationChapterKey(
+    val bookUrl: String,
+    val chapterIndex: Int
+)
+
+/**
+ * Per-chapter translation status.
+ */
+enum class TranslationChapterStatus {
+    Idle,
+    Translating,
+    Translated,
+    Failed
+}
+
+/**
+ * Per-chapter translation state stored in TranslationManager.
+ * Runtime-only, derived from translation cache on app restart.
+ */
+data class TranslationChapterState(
+    val key: TranslationChapterKey,
+    val status: TranslationChapterStatus = TranslationChapterStatus.Idle,
+    val currentChunk: Int = 0,
+    val totalChunks: Int = 0,
+    val mixedContent: String? = null,
+    val translatedContent: String? = null,
+    val errorMessage: String? = null
+)

+ 179 - 0
app/src/main/java/io/legado/app/model/translation/TranslationManager.kt

@@ -0,0 +1,179 @@
+package io.legado.app.model.translation
+
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.gateway.TranslationCacheGateway
+import io.legado.app.domain.usecase.TranslateChapterUseCase
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.coroutine.Coroutine
+import io.legado.app.ui.config.translation.TranslationConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.withContext
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
+import java.util.concurrent.ConcurrentHashMap
+
+object TranslationManager : KoinComponent {
+
+    private val translationCacheGateway: TranslationCacheGateway by inject()
+    private val translateChapterUseCase: TranslateChapterUseCase by inject()
+
+    /** Per-chapter task state flows: bookUrl+chapterIndex -> StateFlow (only for in-progress tasks) */
+    private val _taskStateFlows =
+        ConcurrentHashMap<TranslationChapterKey, MutableStateFlow<TranslationChapterState>>()
+
+    private fun getChapterKey(book: Book, chapter: BookChapter): TranslationChapterKey {
+        return TranslationChapterKey(book.bookUrl, chapter.index)
+    }
+
+    /**
+     * Get task StateFlow for a chapter if translation is in progress.
+     * Returns null if no in-progress translation exists.
+     */
+    fun getChapterTaskStateFlow(bookUrl: String, chapterIndex: Int): StateFlow<TranslationChapterState>? {
+        val key = TranslationChapterKey(bookUrl, chapterIndex)
+        return _taskStateFlows[key]?.takeIf { it.value.status == TranslationChapterStatus.Translating }
+    }
+
+    /**
+     * Check if translated cache file exists for a chapter.
+     */
+    fun hasTranslatedCache(book: Book, chapter: BookChapter): Boolean {
+        val cacheFile =
+            translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
+        return cacheFile.exists()
+    }
+
+    /**
+     * Get finished cached translation for a chapter.
+     */
+    fun getCachedTranslation(book: Book, chapter: BookChapter): String? {
+        val cacheFile =
+            translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
+        return if (cacheFile.exists()) cacheFile.readText() else null
+    }
+
+    /**
+     * Start translation for a chapter.
+     * - If translation is already in progress, return existing task flow.
+     * - If cache already exists, do nothing and return null.
+     * - If original content doesn't exist, return null.
+     * The returned flow updates with mixedContent during translation.
+     */
+    @Synchronized
+    fun startTranslation(
+        book: Book,
+        chapter: BookChapter,
+        onTranslateStarted: () -> Unit = {}
+    ): MutableStateFlow<TranslationChapterState>? {
+        val key = getChapterKey(book, chapter)
+
+        // Check if already translating
+        _taskStateFlows[key]?.let { taskFlow ->
+            if (taskFlow.value.status == TranslationChapterStatus.Translating) {
+                return taskFlow
+            }
+        }
+
+        // Skip if cache already exists
+        if (hasTranslatedCache(book, chapter)) {
+            return null
+        }
+
+        // Check if original content exists
+        if (BookHelp.getContent(book, chapter) == null) {
+            return null
+        }
+
+        // Create new task flow
+        val taskFlow = MutableStateFlow(TranslationChapterState(key, status = TranslationChapterStatus.Idle))
+        _taskStateFlows[key] = taskFlow
+
+        // Start translation in background
+        Coroutine.async {
+            translateChapter(book, chapter, onTranslateStarted)
+        }
+
+        return taskFlow
+    }
+
+    private suspend fun translateChapter(
+        book: Book,
+        bookChapter: BookChapter,
+        onTranslateStarted: () -> Unit
+    ) = withContext(Dispatchers.IO) {
+        val key = getChapterKey(book, bookChapter)
+        val taskFlow = _taskStateFlows[key] ?: return@withContext
+
+        taskFlow.update { it.copy(status = TranslationChapterStatus.Translating) }
+
+        val result = translateChapterUseCase.execute(
+            book = book,
+            bookChapter = bookChapter,
+            targetLanguage = TranslationConfig.llmTargetLanguage,
+            onProgress = { progress ->
+                taskFlow.update {
+                    it.copy(
+                        currentChunk = progress.currentChunk,
+                        totalChunks = progress.totalChunks,
+                        mixedContent = progress.mixedContent
+                    )
+                }
+            },
+            onTranslateStarted = onTranslateStarted
+        )
+
+        result.onSuccess { content ->
+            taskFlow.update {
+                it.copy(
+                    status = TranslationChapterStatus.Translated,
+                    translatedContent = content,
+                    mixedContent = null
+                )
+            }
+        }.onFailure { error ->
+            taskFlow.update {
+                it.copy(
+                    status = TranslationChapterStatus.Failed,
+                    errorMessage = error.message ?: "Translation failed"
+                )
+            }
+        }
+    }
+
+    /**
+     * Clear state for a single chapter.
+     */
+    fun clearChapterState(bookUrl: String, chapterIndex: Int) {
+        val key = TranslationChapterKey(bookUrl, chapterIndex)
+        _taskStateFlows.remove(key)
+    }
+
+    /**
+     * Clear all chapter states.
+     */
+    fun clearAllChapterStates() {
+        _taskStateFlows.clear()
+    }
+
+    /**
+     * Delete translation cache and state for a chapter.
+     */
+    suspend fun deleteTranslationCache(book: Book, bookChapter: BookChapter) {
+        translationCacheGateway.deleteTranslation(
+            book,
+            bookChapter,
+            TranslationConfig.llmTargetLanguage
+        )
+        translationCacheGateway.clearChunkCacheForChapter(
+            book,
+            bookChapter,
+            TranslationConfig.llmTargetLanguage
+        )
+        clearChapterState(book.bookUrl, bookChapter.index)
+    }
+
+}

+ 116 - 36
app/src/main/java/io/legado/app/service/ExportBookService.kt

@@ -16,6 +16,7 @@ import io.legado.app.constant.NotificationId
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.AppWebDav
 import io.legado.app.help.book.BookHelp
@@ -25,6 +26,7 @@ import io.legado.app.help.book.isLocalModified
 import io.legado.app.help.config.AppConfig
 import io.legado.app.model.ReadBook
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.ui.config.translation.TranslationConfig
 import io.legado.app.ui.main.MainActivity
 import io.legado.app.utils.FileDoc
 import io.legado.app.utils.FileUtils
@@ -66,6 +68,8 @@ import me.ag2s.epublib.domain.TOCReference
 import me.ag2s.epublib.epub.EpubWriter
 import me.ag2s.epublib.epub.EpubWriterProcessor
 import me.ag2s.epublib.util.ResourceUtil
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
 import splitties.init.appCtx
 import splitties.systemservices.notificationManager
 import java.nio.charset.Charset
@@ -76,7 +80,7 @@ import kotlin.math.min
 /**
  * 导出书籍服务
  */
-class ExportBookService : BaseService() {
+class ExportBookService : BaseService(), KoinComponent {
 
     companion object {
         val exportProgress = ConcurrentHashMap<String, Int>()
@@ -92,6 +96,16 @@ class ExportBookService : BaseService() {
         val epubScope: String? = null
     )
 
+    /**
+     * Content source for export - Original or Translation with target language.
+     */
+    private enum class ContentSource {
+        Original,
+        Translation
+    }
+
+    private val translationCacheRepository: TranslationCacheGateway by inject()
+
     private val groupKey = "${appCtx.packageName}.exportBook"
     private val waitExportBooks = linkedMapOf<String, ExportConfig>()
     private var exportJob: Job? = null
@@ -199,6 +213,10 @@ class ExportBookService : BaseService() {
                     if (exportConfig.type == "epub") {
                         if (exportConfig.epubScope.isNullOrBlank()) {
                             exportEpub(exportConfig.path, book)
+                            // Also export translation if cache exists
+                            if (hasAnyTranslatedChapter(book, TranslationConfig.llmTargetLanguage)) {
+                                exportEpub(exportConfig.path, book, ContentSource.Translation)
+                            }
                         } else {
                             CustomExporter(
                                 exportConfig.epubScope,
@@ -207,6 +225,11 @@ class ExportBookService : BaseService() {
                         }
                     } else {
                         exportTxt(exportConfig.path, book)
+                        // Also export translation if cache exists
+                        if (hasAnyTranslatedChapter(book, TranslationConfig.llmTargetLanguage)) {
+                            val fileDoc = FileDoc.fromDir(exportConfig.path)
+                            exportTxt(fileDoc, book, ContentSource.Translation)
+                        }
                     }
                     exportMsg[book.bookUrl] = getString(R.string.export_success)
                 } catch (e: Throwable) {
@@ -245,29 +268,36 @@ class ExportBookService : BaseService() {
         exportMsg.remove(book.bookUrl)
         notifyExportBookChanged(book.bookUrl)
         val fileDoc = FileDoc.fromDir(path)
-        exportTxt(fileDoc, book)
+        exportTxt(fileDoc, book, ContentSource.Original)
     }
 
-    private suspend fun exportTxt(fileDoc: FileDoc, book: Book) {
-        val filename = book.getExportFileName("txt")
+    private suspend fun exportTxt(fileDoc: FileDoc, book: Book, source: ContentSource) {
+        val targetLanguage = TranslationConfig.llmTargetLanguage
+        val filename = when (source) {
+            ContentSource.Original -> book.getExportFileName("txt")
+            ContentSource.Translation -> getTranslatedFileName(book.getExportFileName("txt"), targetLanguage)
+        }
         fileDoc.find(filename)?.delete()
 
         val bookDoc = fileDoc.createFileIfNotExist(filename)
         val charset = Charset.forName(AppConfig.exportCharset)
         bookDoc.openOutputStream().getOrThrow().bufferedWriter(charset).use { bw ->
-            getAllContents(book) { text, srcList ->
+            getAllContents(book, source) { text, srcList ->
                 bw.write(text)
-                srcList?.forEach {
-                    val vFile = BookHelp.getImage(book, it.src)
-                    if (vFile.exists()) {
-                        fileDoc.createFileIfNotExist(
-                            "${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
-                            subDirs = arrayOf(
-                                "${book.name}_${book.author}",
-                                "images",
-                                it.chapterTitle
-                            )
-                        ).writeFile(vFile)
+                // Only export images for original source
+                if (source == ContentSource.Original) {
+                    srcList?.forEach {
+                        val vFile = BookHelp.getImage(book, it.src)
+                        if (vFile.exists()) {
+                            fileDoc.createFileIfNotExist(
+                                "${it.index}-${MD5Utils.md5Encode16(it.src)}.jpg",
+                                subDirs = arrayOf(
+                                    "${book.name}_${book.author}",
+                                    "images",
+                                    it.chapterTitle
+                                )
+                            ).writeFile(vFile)
+                        }
                     }
                 }
             }
@@ -278,8 +308,32 @@ class ExportBookService : BaseService() {
         }
     }
 
+    /**
+     * Get translated filename by inserting target language before extension.
+     * e.g., "book.txt" -> "book.zh.txt"
+     */
+    private fun getTranslatedFileName(originalName: String, targetLanguage: String): String {
+        val lastDot = originalName.lastIndexOf('.')
+        return if (lastDot > 0) {
+            "${originalName.substring(0, lastDot)}.$targetLanguage${originalName.substring(lastDot)}"
+        } else {
+            "$originalName.$targetLanguage"
+        }
+    }
+
+    /**
+     * Check if book has any translated chapters for the target language.
+     */
+    private suspend fun hasAnyTranslatedChapter(book: Book, targetLanguage: String): Boolean {
+        val chapters = appDb.bookChapterDao.getChapterList(book.bookUrl)
+        return chapters.any { chapter ->
+            translationCacheRepository.getCacheFile(book, chapter, targetLanguage).exists()
+        }
+    }
+
     private suspend fun getAllContents(
         book: Book,
+        source: ContentSource,
         append: (text: String, srcList: ArrayList<SrcData>?) -> Unit
     ) = coroutineScope {
         val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
@@ -303,7 +357,7 @@ class ExportBookService : BaseService() {
                 emit(chapter)
             }
         }.mapAsync(threads) { chapter ->
-            getExportData(book, chapter, contentProcessor, useReplace)
+            getExportData(book, chapter, contentProcessor, useReplace, source)
         }.collectIndexed { index, result ->
             notifyExportBookChanged(book.bookUrl)
             exportProgress[book.bookUrl] = index
@@ -317,14 +371,19 @@ class ExportBookService : BaseService() {
         _exportBookUpdateFlow.tryEmit(bookUrl)
     }
 
-    private fun getExportData(
+    private suspend fun getExportData(
         book: Book,
         chapter: BookChapter,
         contentProcessor: ContentProcessor,
-        useReplace: Boolean
+        useReplace: Boolean,
+        source: ContentSource
     ): Pair<String, ArrayList<SrcData>?> {
-        val content = BookHelp.getContent(book, chapter)
-        val content1 = contentProcessor
+        val targetLanguage = TranslationConfig.llmTargetLanguage
+        val content = when (source) {
+            ContentSource.Original -> BookHelp.getContent(book, chapter)
+            ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage)
+        }
+        val processedContent = contentProcessor
             .getContent(
                 book,
                 // 不导出vip标识
@@ -335,8 +394,8 @@ class ExportBookService : BaseService() {
                 chineseConvert = false,
                 reSegment = false
             ).toString()
-        if (AppConfig.exportPictureFile) {
-            //txt导出图片文件
+        if (AppConfig.exportPictureFile && source == ContentSource.Original) {
+            //txt导出图片文件 - only for original source
             val srcList = arrayListOf<SrcData>()
             content?.split("\n")?.forEachIndexed { index, text ->
                 val matcher = AppPattern.imgPattern.matcher(text)
@@ -347,9 +406,9 @@ class ExportBookService : BaseService() {
                     }
                 }
             }
-            return Pair("\n\n$content1", srcList)
+            return Pair("\n\n$processedContent", srcList)
         } else {
-            return Pair("\n\n$content1", null)
+            return Pair("\n\n$processedContent", null)
         }
     }
 
@@ -360,11 +419,22 @@ class ExportBookService : BaseService() {
         exportMsg.remove(book.bookUrl)
         postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
         val fileDoc = FileDoc.fromDir(path)
-        exportEpub(fileDoc, book)
+        exportEpub(fileDoc, book, ContentSource.Original)
+    }
+
+    private suspend fun exportEpub(path: String, book: Book, source: ContentSource) {
+        exportMsg.remove(book.bookUrl)
+        postEvent(EventBus.EXPORT_BOOK, book.bookUrl)
+        val fileDoc = FileDoc.fromDir(path)
+        exportEpub(fileDoc, book, source)
     }
 
-    private suspend fun exportEpub(fileDoc: FileDoc, book: Book) {
-        val filename = book.getExportFileName("epub")
+    private suspend fun exportEpub(fileDoc: FileDoc, book: Book, source: ContentSource) {
+        val targetLanguage = TranslationConfig.llmTargetLanguage
+        val filename = when (source) {
+            ContentSource.Original -> book.getExportFileName("epub")
+            ContentSource.Translation -> getTranslatedFileName(book.getExportFileName("epub"), targetLanguage)
+        }
         fileDoc.find(filename)?.delete()
 
         val epubBook = EpubBook()
@@ -377,7 +447,7 @@ class ExportBookService : BaseService() {
         val contentModel = setAssets(fileDoc, book, epubBook)
 
         //设置正文
-        setEpubContent(contentModel, book, epubBook)
+        setEpubContent(contentModel, book, epubBook, source)
 
         val bookDoc = fileDoc.createFileIfNotExist(filename)
         bookDoc.openOutputStream().getOrThrow().buffered().use { bookOs ->
@@ -533,7 +603,8 @@ class ExportBookService : BaseService() {
     private suspend fun setEpubContent(
         contentModel: String,
         book: Book,
-        epubBook: EpubBook
+        epubBook: EpubBook,
+        source: ContentSource = ContentSource.Original
     ) = coroutineScope {
         //正文
         val useReplace = AppConfig.exportUseReplace && book.getUseReplaceRule()
@@ -543,18 +614,27 @@ class ExportBookService : BaseService() {
         } else {
             1
         }
+        val targetLanguage = TranslationConfig.llmTargetLanguage
         var parentSection: TOCReference? = null
         flow {
             appDb.bookChapterDao.getChapterList(book.bookUrl).forEach { chapter ->
                 emit(chapter)
             }
         }.mapAsyncIndexed(threads) { index, chapter ->
-            val content = BookHelp.getContent(book, chapter)
-            val (contentFix, resources) = fixPic(
-                book,
-                content ?: if (chapter.isVolume) "" else "null",
-                chapter
-            )
+            val content = when (source) {
+                ContentSource.Original -> BookHelp.getContent(book, chapter)
+                ContentSource.Translation -> translationCacheRepository.readTranslation(book, chapter, targetLanguage)
+            }
+            // For translation source, don't extract images (skip fixPic)
+            val (contentFix, resources) = if (source == ContentSource.Translation) {
+                Pair(content ?: if (chapter.isVolume) "" else "null", arrayListOf())
+            } else {
+                fixPic(
+                    book,
+                    content ?: if (chapter.isVolume) "" else "null",
+                    chapter
+                )
+            }
             // 不导出vip标识
             chapter.isVip = false
             val content1 = contentProcessor

+ 34 - 1
app/src/main/java/io/legado/app/ui/book/read/ReadBookActivity.kt

@@ -60,6 +60,8 @@ import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.analyzeRule.AnalyzeRule
+import io.legado.app.model.translation.TranslationChapterStatus
+import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeUrl.Companion.paramPattern
@@ -139,6 +141,7 @@ import io.legado.app.utils.startActivityForBook
 import io.legado.app.utils.sysScreenOffTime
 import io.legado.app.utils.throttle
 import io.legado.app.utils.toastOnUi
+import io.legado.app.utils.longToastOnUi
 import io.legado.app.utils.visible
 import kotlinx.coroutines.Dispatchers.IO
 import kotlinx.coroutines.Dispatchers.Main
@@ -714,6 +717,36 @@ class ReadBookActivity : BaseReadBookActivity(),
         viewModel.refreshContentAll(book)
     }
 
+    override fun onTranslationClick() {
+        val book = ReadBook.book ?: return
+        book.setTranslationMode(!book.getTranslationMode())
+        book.save()
+        binding.readMenu.updateTranslationButton(book.getTranslationMode())
+        ReadBook.loadContent(false)
+    }
+
+    override fun onTranslationLongClick() {
+        val book = ReadBook.book ?: return
+        val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return
+
+        if (TranslationManager.hasTranslatedCache(book, chapter)) {
+            alert(title = getString(R.string.retranslate_chapter), message = getString(R.string.retranslate_confirm)) {
+                positiveButton(getString(R.string.ok)) { retranslateCurrentChapter() }
+                negativeButton(getString(R.string.cancel))
+            }.show()
+        }
+    }
+
+    fun retranslateCurrentChapter() {
+        val book = ReadBook.book ?: return
+        lifecycleScope.launch {
+            TranslationManager.deleteTranslationCache(book, appDb.bookChapterDao.getChapter(book.bookUrl, ReadBook.durChapterIndex) ?: return@launch)
+            book.setTranslationMode(true)
+            book.save()
+            ReadBook.loadContent(false)
+        }
+    }
+
     override fun onMenuItemClick(item: MenuItem): Boolean {
         return onCompatOptionsItemSelected(item)
     }
@@ -816,7 +849,7 @@ class ReadBookActivity : BaseReadBookActivity(),
                 handleKeyPage(PageDirection.NEXT, longPress)
                 return true
             }
-             // 手柄方向键控制翻页
+            // 手柄方向键控制翻页
             KeyEvent.KEYCODE_DPAD_UP, KeyEvent.KEYCODE_DPAD_LEFT -> {
                 handleKeyPage(PageDirection.PREV, longPress)
                 return true

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

@@ -57,7 +57,8 @@ import kotlin.coroutines.coroutineContext
 class ReadBookViewModel(
     application: Application,
     private val getReadingProgressUseCase: GetReadingProgressUseCase,
-    private val uploadReadingProgressUseCase: UploadReadingProgressUseCase
+    private val uploadReadingProgressUseCase: UploadReadingProgressUseCase,
+    val translateChapterUseCase: io.legado.app.domain.usecase.TranslateChapterUseCase
 ) : BaseViewModel(application) {
     val permissionDenialLiveData = MutableLiveData<Int>()
     var isInitFinish = false
@@ -133,6 +134,7 @@ class ReadBookViewModel(
             return
         }
         ReadBook.upMsg(null)
+
         if (!isSameBook) {
             ReadBook.loadContent(resetPageOffset = true) {
                 ReadBook.bookSource?.let {
@@ -352,7 +354,7 @@ class ReadBookViewModel(
                 }
             }.onStart {
                 ReadBook.upMsg(context.getString(R.string.source_auto_changing))
-        }.mapParallelSafe(OtherConfig.threadCount) { source ->
+            }.mapParallelSafe(OtherConfig.threadCount) { source ->
                 val book = WebBook.preciseSearchAwait(source, name, author).getOrThrow()
                 if (book.tocUrl.isEmpty()) {
                     WebBook.getBookInfoAwait(source, book)

+ 24 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadMenu.kt

@@ -640,10 +640,11 @@ class ReadMenu @JvmOverloads constructor(
                 onClick = { runMenuOut{ callBack.openSearchActivity(null) } }
             ),
             ToolButton(
-                id = "auto_page",
-                iconRes = R.drawable.ic_auto_page,
-                description = context.getString(R.string.auto_next_page),
-                onClick = { runMenuOut { callBack.autoPage() } }
+                id = "translate",
+                iconRes = R.drawable.ic_translate,
+                description = context.getString(R.string.translate),
+                onClick = { runMenuOut { callBack.onTranslationClick() } },
+                onLongClick = { runMenuOut { callBack.onTranslationLongClick() } }
             ),
             ToolButton(
                 id = "catalog",
@@ -709,6 +710,12 @@ class ReadMenu @JvmOverloads constructor(
                 onLongClick = { runMenuOut { callBack.openReplaceRule() } },
                 onCheck = { runMenuOut { callBack.changeReplaceRuleState() } },
                 onClick = {  }
+            ),
+            ToolButton(
+                id = "auto_page",
+                iconRes = R.drawable.ic_auto_page,
+                description = context.getString(R.string.auto_next_page),
+                onClick = { runMenuOut { callBack.autoPage() } }
             )
         )
     }
@@ -761,6 +768,17 @@ class ReadMenu @JvmOverloads constructor(
         }
     }
 
+    fun updateTranslationButton(translationMode: Boolean) {
+        val btn = buttonMap["translate"] ?: return
+
+        // Update icon based on mode
+        if (translationMode) {
+            btn.setIconResource(R.drawable.ic_return)
+        } else {
+            btn.setIconResource(R.drawable.ic_translate)
+        }
+    }
+
     private fun initAnimation() {
         menuTopIn.setAnimationListener(menuInListener)
         menuTopOut.setAnimationListener(menuOutListener)
@@ -914,6 +932,8 @@ class ReadMenu @JvmOverloads constructor(
         fun onMenuShow()
         fun onMenuHide()
         fun changeReplaceRuleState()
+        fun onTranslationClick()
+        fun onTranslationLongClick()
     }
 
     data class ToolButton(

+ 2 - 1
app/src/main/java/io/legado/app/ui/book/read/config/ToolButtonConfigDialog.kt

@@ -114,7 +114,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
     }
 
     private fun getAllButtonIds(): List<String> {
-        return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "replace", "replace_badge")
+        return listOf("search", "auto_page", "catalog", "read_aloud", "setting", "addBookmark", "theme", "prev_chapter", "next_chapter", "translate_chapter", "replace", "replace_badge")
     }
 
     private fun getButtonInfo(id: String): Pair<Int, String> {
@@ -128,6 +128,7 @@ class ToolButtonConfigDialog : BaseBottomSheetDialogFragment(R.layout.dialog_too
             "theme" -> R.drawable.ic_daytime to getString(R.string.day_night_switch)
             "prev_chapter" -> R.drawable.ic_previous to getString(R.string.previous_chapter)
             "next_chapter" -> R.drawable.ic_next to getString(R.string.next_chapter)
+            "translate_chapter" -> R.drawable.ic_translate to getString(R.string.translate_chapter)
             "replace" -> R.drawable.ic_find_replace to getString(R.string.replace_purify)
             "replace_badge" -> R.drawable.ic_find_replace to getString(R.string.replace_purify_badge)
             else -> R.drawable.ic_help to id

+ 7 - 2
app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt

@@ -12,10 +12,10 @@ import io.legado.app.R
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
-import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -26,7 +26,8 @@ fun ConfigNavScreen(
     onNavigateToCover: () -> Unit,
     onNavigateToTheme: () -> Unit,
     onNavigateToBackup: () -> Unit,
-    onNavigateToDownloadCache: () -> Unit
+    onNavigateToDownloadCache: () -> Unit,
+    onNavigateToTranslation: () -> Unit
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
@@ -75,6 +76,10 @@ fun ConfigNavScreen(
                         title = stringResource(R.string.backup_restore),
                         onClick = onNavigateToBackup
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.translation_config),
+                        onClick = onNavigateToTranslation
+                    )
                 }
             }
         }

+ 67 - 0
app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt

@@ -0,0 +1,67 @@
+package io.legado.app.ui.config.translation
+
+import io.legado.app.constant.PreferKey
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.ui.config.prefDelegate
+
+object TranslationConfig {
+
+    var llmTranslateEnabled by prefDelegate(
+        PreferKey.llmTranslateEnabled,
+        false
+    )
+
+    var llmProvider by prefDelegate(
+        PreferKey.llmProvider,
+        "google"
+    )
+
+    var llmBaseUrl by prefDelegate(
+        PreferKey.llmBaseUrl,
+        ""
+    )
+
+    var llmApiKey by prefDelegate(
+        PreferKey.llmApiKey,
+        ""
+    )
+
+    var llmModel by prefDelegate(
+        PreferKey.llmModel,
+        ""
+    )
+
+    var llmTargetLanguage by prefDelegate(
+        PreferKey.llmTargetLanguage,
+        "zh"
+    )
+
+    var llmMaxCharsPerChunk by prefDelegate(
+        PreferKey.llmMaxCharsPerChunk,
+        10000
+    )
+
+    var llmConcurrentChunks by prefDelegate(
+        PreferKey.llmConcurrentChunks,
+        1
+    )
+
+    var llmRetryCount by prefDelegate(
+        PreferKey.llmRetryCount,
+        2
+    )
+
+    var llmPrompt by prefDelegate(
+        PreferKey.llmPrompt,
+        TranslationConstants.DEFAULT_PROMPT
+    )
+
+    // Delegate constants to domain layer
+    const val PROVIDER_OPENAI = TranslationConstants.PROVIDER_OPENAI
+    const val PROVIDER_GOOGLE = TranslationConstants.PROVIDER_GOOGLE
+    val providerDisplayNames get() = TranslationConstants.providerDisplayNames
+    val providerValues get() = TranslationConstants.providerValues
+    val targetLanguages get() = TranslationConstants.targetLanguages
+    const val DEFAULT_PROMPT = TranslationConstants.DEFAULT_PROMPT
+    const val OUTPUT_FORMAT = TranslationConstants.OUTPUT_FORMAT
+}

+ 125 - 0
app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt

@@ -0,0 +1,125 @@
+package io.legado.app.ui.config.translation
+
+import android.annotation.SuppressLint
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.InputSettingItem
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
+import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+
+@SuppressLint("RememberReturnType")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun TranslationConfigScreen(
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+
+    var tempPrompt by remember { mutableStateOf(TranslationConfig.llmPrompt) }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.translation_config),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.translation_provider)) {
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.llm_provider),
+                        selectedValue = TranslationConfig.llmProvider,
+                        displayEntries = TranslationConfig.providerDisplayNames.toTypedArray(),
+                        entryValues = TranslationConfig.providerValues.toTypedArray(),
+                        onValueChange = { TranslationConfig.llmProvider = it }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.translation_options)) {
+                    val languageEntries = TranslationConfig.targetLanguages.map { it.second }.toTypedArray()
+                    val languageValues = TranslationConfig.targetLanguages.map { it.first }.toTypedArray()
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.llm_target_language),
+                        selectedValue = TranslationConfig.llmTargetLanguage,
+                        displayEntries = languageEntries,
+                        entryValues = languageValues,
+                        onValueChange = { TranslationConfig.llmTargetLanguage = it }
+                    )
+
+                    SliderSettingItem(
+                        title = stringResource(R.string.llm_max_chars_per_chunk),
+                        value = TranslationConfig.llmMaxCharsPerChunk.toFloat(),
+                        defaultValue = 10000f,
+                        valueRange = 1000f..10000f,
+                        steps = 17,
+                        onValueChange = { TranslationConfig.llmMaxCharsPerChunk = it.toInt() }
+                    )
+                }
+            }
+
+            if (TranslationConfig.llmProvider == TranslationConfig.PROVIDER_OPENAI) {
+                item {
+                    SplicedColumnGroup(title = stringResource(R.string.openai_config)) {
+                        InputSettingItem(
+                            title = stringResource(R.string.llm_base_url),
+                            value = TranslationConfig.llmBaseUrl,
+                            onConfirm = { TranslationConfig.llmBaseUrl = it }
+                        )
+
+                        InputSettingItem(
+                            title = stringResource(R.string.llm_api_key),
+                            value = TranslationConfig.llmApiKey,
+                            onConfirm = { TranslationConfig.llmApiKey = it }
+                        )
+
+                        InputSettingItem(
+                            title = stringResource(R.string.llm_model),
+                            value = TranslationConfig.llmModel,
+                            onConfirm = { TranslationConfig.llmModel = it }
+                        )
+
+                        InputSettingItem(
+                            title = stringResource(R.string.llm_prompt),
+                            value = tempPrompt,
+                            onConfirm = {
+                                tempPrompt = it
+                                TranslationConfig.llmPrompt = it
+                            }
+                        )
+                    }
+                }
+            }
+        }
+    }
+}

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

@@ -39,6 +39,7 @@ import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigScreen
 import io.legado.app.ui.config.otherConfig.OtherConfigScreen
 import io.legado.app.ui.config.readConfig.ReadConfigScreen
 import io.legado.app.ui.config.themeConfig.ThemeConfigScreen
+import io.legado.app.ui.config.translation.TranslationConfigScreen
 import io.legado.app.ui.config.themeManage.ThemeManageScreen
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
@@ -155,7 +156,8 @@ fun MainActivity.mainEntryProvider(
             onNavigateToCover = { backStack.add(MainRouteSettingsCover) },
             onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
             onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) },
-            onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) }
+            onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) },
+            onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
         )
     }
 
@@ -187,6 +189,10 @@ fun MainActivity.mainEntryProvider(
         DownloadCacheConfigScreen(onBackClick = { onNavigateBack() })
     }
 
+    entry<MainRouteSettingsTranslation> {
+        TranslationConfigScreen(onBackClick = { onNavigateBack() })
+    }
+
     entry<MainRouteSettingsCustomTheme> {
         CustomThemeScreen(
             onBackClick = { onNavigateBack() }

+ 4 - 0
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -36,6 +36,9 @@ data object MainRouteSettingsThemeManage : MainRoute
 @Serializable
 data object MainRouteSettingsDownloadCache : MainRoute
 
+@Serializable
+data object MainRouteSettingsTranslation : MainRoute
+
 @Serializable
 data object MainRouteImportLocal : MainRoute
 
@@ -96,6 +99,7 @@ object MainRouteConst {
     const val ROUTE_SETTINGS_BACKUP = "settings/backup"
     const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
     const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache"
+    const val ROUTE_SETTINGS_TRANSLATION = "settings/translation"
     const val ROUTE_IMPORT_LOCAL = "import/local"
     const val ROUTE_IMPORT_REMOTE = "import/remote"
     const val ROUTE_CACHE = "cache"

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

@@ -35,7 +35,8 @@ object MainNavigator {
             MainRouteSettingsBackup,
             MainRouteSettingsCustomTheme,
             MainRouteSettingsThemeManage,
-            MainRouteSettingsDownloadCache -> {
+            MainRouteSettingsDownloadCache,
+            MainRouteSettingsTranslation -> {
                 backStack.clear()
                 backStack.add(MainRouteHome)
                 backStack.add(MainRouteSettings)
@@ -233,6 +234,7 @@ object MainNavigator {
             MainRouteConst.ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
             MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
             MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
+            MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation
             MainRouteConst.ROUTE_IMPORT_LOCAL -> MainRouteImportLocal
             MainRouteConst.ROUTE_IMPORT_REMOTE -> MainRouteImportRemote
             MainRouteConst.ROUTE_CACHE -> MainRouteCache(

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

@@ -233,6 +233,16 @@ private fun rememberSharedCoverTransitionRadius(
     return animatedRadiusValue.dp
 }
 
+/**
+ * Determine if text is primarily Latin-script.
+ * Returns true if more than 30% of characters are Latin letters.
+ */
+private fun isLatinBasedText(text: String?): Boolean {
+    if (text.isNullOrBlank()) return false
+    val latinRatio = text.count { it in 'A'..'Z' || it in 'a'..'z' }.toFloat() / text.length
+    return latinRatio > 0.3f
+}
+
 @Composable
 private fun CoverTextOverlay(
     name: String?,
@@ -251,7 +261,9 @@ private fun CoverTextOverlay(
         if (isNight) CoverConfig.coverTextColorN else CoverConfig.coverTextColor
     }
     val shadowColor = if (isNight) CoverConfig.coverShadowColorN else CoverConfig.coverShadowColor
-    val isHorizontal = CoverConfig.coverInfoOrientation == "1"
+    val configIsHorizontal = CoverConfig.coverInfoOrientation == "1"
+    // If text contains Latin letters, force horizontal layout
+    val isHorizontal = configIsHorizontal || isLatinBasedText(name)
 
     Canvas(modifier = Modifier.fillMaxSize()) {
         val viewWidth = size.width

+ 1 - 0
app/src/main/res/menu/book_read.xml

@@ -162,4 +162,5 @@
         android:title="@string/help"
         app:showAsAction="never" />
 
+
 </menu>

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

@@ -1739,4 +1739,36 @@
     <string name="homepage_status_in_effect"> · 当前生效</string>
     <string name="homepage_status_blocked"> · 已被屏蔽</string>
     <string name="homepage_status_joined"> · 已加入</string>
+
+    <!-- Translation -->
+    <string name="translate">翻译</string>
+    <string name="translation_config">翻译设置</string>
+    <string name="llm_translate_enabled">启用章节翻译</string>
+    <string name="translation_provider">翻译提供商</string>
+    <string name="llm_provider">提供商</string>
+    <string name="openai_config">OpenAI 配置</string>
+    <string name="llm_base_url">基础 URL</string>
+    <string name="llm_api_key">API 密钥</string>
+    <string name="llm_model">模型</string>
+    <string name="translation_options">翻译选项</string>
+    <string name="llm_target_language">目标语言</string>
+    <string name="llm_max_chars_per_chunk">每块最大字符数</string>
+    <string name="llm_concurrent_chunks">并发块数</string>
+    <string name="llm_retry_count">重试次数</string>
+    <string name="translation_prompt">翻译提示词</string>
+    <string name="llm_prompt">提示词</string>
+    <string name="translate_chapter">翻译章节</string>
+    <string name="show_original">显示原文</string>
+    <string name="retranslate_chapter">重新翻译章节</string>
+    <string name="retranslate_confirm">这将删除现有翻译并重新翻译。是否继续?</string>
+    <string name="translation_in_progress">翻译中: %1$d/%2$d</string>
+    <string name="translation_failed">翻译失败: %1$s</string>
+    <string name="no_translation_cache">无翻译缓存</string>
+    <string name="delete_translation_cache">删除翻译缓存</string>
+    <string name="translation_cache_size">缓存大小: %1$s</string>
+    <string name="clear_translation_cache">清除缓存</string>
+    <string name="translation_cache">翻译缓存</string>
+    <string name="return_to_original">返回原文</string>
+    <string name="translation_progress">翻译中 %1$d%%</string>
+    <string name="translation_started">开始进行翻译,请耐心等待……</string>
 </resources>

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

@@ -1681,6 +1681,38 @@
     <string name="download_setting">Download</string>
     <string name="image_cache">Image Cache</string>
     <string name="network">Network</string>
+
+    <!-- Translation -->
+    <string name="translation_config">Translation Settings</string>
+    <string name="llm_translate_enabled">Enable Chapter Translation</string>
+    <string name="translation_provider">Translation Provider</string>
+    <string name="llm_provider">Provider</string>
+    <string name="openai_config">OpenAI Configuration</string>
+    <string name="llm_base_url">Base URL</string>
+    <string name="llm_api_key">API Key</string>
+    <string name="llm_model">Model</string>
+    <string name="translation_options">Translation Options</string>
+    <string name="llm_target_language">Target Language</string>
+    <string name="llm_max_chars_per_chunk">Max Chars Per Chunk</string>
+    <string name="llm_concurrent_chunks">Concurrent Chunks</string>
+    <string name="llm_retry_count">Retry Count</string>
+    <string name="translation_prompt">Translation Prompt</string>
+    <string name="llm_prompt">Prompt</string>
+    <string name="translate_chapter">Translate Chapter</string>
+    <string name="show_original">Show Original</string>
+    <string name="retranslate_chapter">Retranslate Chapter</string>
+    <string name="retranslate_confirm">This will delete the existing translation and re-translate. Continue?</string>
+    <string name="translation_in_progress">Translating: %1$d%%</string>
+    <string name="translation_failed">Translation failed: %1$s</string>
+    <string name="no_translation_cache">No translation cache</string>
+    <string name="delete_translation_cache">Delete Translation Cache</string>
+    <string name="translation_cache_size">Cache Size: %1$s</string>
+    <string name="clear_translation_cache">Clear Cache</string>
+    <string name="translation_cache">Translation Cache</string>
+    <string name="translate">Translate</string>
+    <string name="return_to_original">Return to Original</string>
+    <string name="translation_progress">Translating %1$d%%</string>
+    <string name="translation_started">Translation started, please wait…</string>
     <string name="homepage_load_more_failed">Failed to load more: %1$s</string>
     <string name="homepage_module_duplicate_infinite">This group already has an infinite loading module</string>
     <string name="homepage_quick_actions">Quick Actions</string>