Răsfoiți Sursa

feat: 改进翻译流式预览和术语字典

- 串行处理章节分块,支持流式译文预览
- 按 1 KiB 和换行边界攒批刷新,减少页面重排
- 增加模型思考状态及呼吸提示胶囊
- 原子合并术语字典,保留旧译并筛选相关术语
duanhl 3 luni în urmă
părinte
comite
0983396416
21 a modificat fișierele cu 878 adăugiri și 197 ștergeri
  1. 50 25
      app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt
  2. 1 1
      app/src/main/java/io/legado/app/domain/gateway/DictionaryGateway.kt
  3. 13 1
      app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt
  4. 140 0
      app/src/main/java/io/legado/app/domain/model/TranslationDictionaryPolicy.kt
  5. 80 0
      app/src/main/java/io/legado/app/domain/model/TranslationResultPreviewParser.kt
  6. 185 155
      app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt
  7. 22 10
      app/src/main/java/io/legado/app/model/ReadBook.kt
  8. 2 1
      app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt
  9. 25 4
      app/src/main/java/io/legado/app/model/translation/TranslationManager.kt
  10. 2 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  11. 8 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookRouteScreen.kt
  12. 44 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  13. 57 0
      app/src/main/java/io/legado/app/ui/book/read/TranslationThinkingCapsule.kt
  14. 1 0
      app/src/main/res/values-zh-rCN/strings.xml
  15. 1 0
      app/src/main/res/values-zh-rHK/strings.xml
  16. 1 0
      app/src/main/res/values-zh-rTW/strings.xml
  17. 1 0
      app/src/main/res/values/strings.xml
  18. 74 0
      app/src/test/java/io/legado/app/data/repository/DictionaryRepositoryImplTest.kt
  19. 35 0
      app/src/test/java/io/legado/app/domain/model/PartialTranslationAssemblerTest.kt
  20. 88 0
      app/src/test/java/io/legado/app/domain/model/TranslationDictionaryPolicyTest.kt
  21. 48 0
      app/src/test/java/io/legado/app/domain/model/TranslationResultPreviewParserTest.kt

+ 50 - 25
app/src/main/java/io/legado/app/data/repository/DictionaryRepositoryImpl.kt

@@ -4,14 +4,20 @@ import io.legado.app.data.entities.Book
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.model.BookDictionary
 import io.legado.app.domain.model.BookDictionary
 import io.legado.app.domain.model.DictPair
 import io.legado.app.domain.model.DictPair
+import io.legado.app.domain.model.TranslationDictionaryPolicy
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import java.io.File
 import java.io.File
+import java.nio.file.AtomicMoveNotSupportedException
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+import java.util.concurrent.ConcurrentHashMap
 
 
 class DictionaryRepositoryImpl : DictionaryGateway {
 class DictionaryRepositoryImpl : DictionaryGateway {
 
 
     private companion object {
     private companion object {
         const val DICT_FILE_NAME = "translation_dictionary.json"
         const val DICT_FILE_NAME = "translation_dictionary.json"
+        val bookLocks = ConcurrentHashMap<String, Any>()
     }
     }
 
 
     private fun getDictFile(book: Book): File {
     private fun getDictFile(book: Book): File {
@@ -20,7 +26,16 @@ class DictionaryRepositoryImpl : DictionaryGateway {
         return File(bookFolder, DICT_FILE_NAME)
         return File(bookFolder, DICT_FILE_NAME)
     }
     }
 
 
+    private fun getBookLock(book: Book): Any =
+        bookLocks.computeIfAbsent(getDictFile(book).absolutePath) { Any() }
+
     override fun getBookDictionaries(book: Book): BookDictionary {
     override fun getBookDictionaries(book: Book): BookDictionary {
+        return synchronized(getBookLock(book)) {
+            readDictionary(book)
+        }
+    }
+
+    private fun readDictionary(book: Book): BookDictionary {
         val dictFile = getDictFile(book)
         val dictFile = getDictFile(book)
         return if (dictFile.exists()) {
         return if (dictFile.exists()) {
             try {
             try {
@@ -33,40 +48,50 @@ class DictionaryRepositoryImpl : DictionaryGateway {
         }
         }
     }
     }
 
 
-    override fun updateBookDic(book: Book, newPairs: List<DictPair>) {
-        val existingDict = getBookDictionaries(book)
-        @Suppress("SENSELESS_COMPARISON")
-        val updatedPairs = (existingDict.pairs ?: emptyList()).toMutableList()
-
-        for (newPair in newPairs) {
-            val existingIndex = updatedPairs.indexOfFirst {
-                it.original == newPair.original
-            }
-            if (existingIndex >= 0) {
-                updatedPairs[existingIndex] = newPair
-            } else {
-                updatedPairs.add(newPair)
-            }
+    override fun mergeDiscoveredPairs(book: Book, newPairs: List<DictPair>): BookDictionary {
+        return synchronized(getBookLock(book)) {
+            val existing = readDictionary(book)
+            @Suppress("USELESS_ELVIS")
+            val existingPairs = existing.pairs ?: emptyList()
+            val mergedPairs = TranslationDictionaryPolicy.mergeDiscoveredPairs(existingPairs, newPairs)
+            if (mergedPairs == existingPairs) return@synchronized existing
+            val updated = existing.copy(pairs = mergedPairs, updatedAt = System.currentTimeMillis())
+            saveDictionary(book, updated)
+            updated
         }
         }
-
-        val updatedDict = existingDict.copy(
-            pairs = updatedPairs,
-            updatedAt = System.currentTimeMillis()
-        )
-
-        saveDictionary(book, updatedDict)
     }
     }
 
 
     private fun saveDictionary(book: Book, dictionary: BookDictionary) {
     private fun saveDictionary(book: Book, dictionary: BookDictionary) {
         val dictFile = getDictFile(book)
         val dictFile = getDictFile(book)
         dictFile.parentFile?.mkdirs()
         dictFile.parentFile?.mkdirs()
-        dictFile.writeText(GSON.toJson(dictionary))
+        val tempFile = File.createTempFile("$DICT_FILE_NAME.", ".tmp", dictFile.parentFile)
+        try {
+            tempFile.writeText(GSON.toJson(dictionary))
+            try {
+                Files.move(
+                    tempFile.toPath(),
+                    dictFile.toPath(),
+                    StandardCopyOption.ATOMIC_MOVE,
+                    StandardCopyOption.REPLACE_EXISTING,
+                )
+            } catch (_: AtomicMoveNotSupportedException) {
+                Files.move(
+                    tempFile.toPath(),
+                    dictFile.toPath(),
+                    StandardCopyOption.REPLACE_EXISTING,
+                )
+            }
+        } finally {
+            tempFile.delete()
+        }
     }
     }
 
 
     override fun clearBookDictionary(book: Book) {
     override fun clearBookDictionary(book: Book) {
-        val dictFile = getDictFile(book)
-        if (dictFile.exists()) {
-            dictFile.delete()
+        synchronized(getBookLock(book)) {
+            val dictFile = getDictFile(book)
+            if (dictFile.exists()) {
+                dictFile.delete()
+            }
         }
         }
     }
     }
 }
 }

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

@@ -6,6 +6,6 @@ import io.legado.app.domain.model.DictPair
 
 
 interface DictionaryGateway {
 interface DictionaryGateway {
     fun getBookDictionaries(book: Book): BookDictionary
     fun getBookDictionaries(book: Book): BookDictionary
-    fun updateBookDic(book: Book, newPairs: List<DictPair>)
+    fun mergeDiscoveredPairs(book: Book, newPairs: List<DictPair>): BookDictionary
     fun clearBookDictionary(book: Book)
     fun clearBookDictionary(book: Book)
 }
 }

+ 13 - 1
app/src/main/java/io/legado/app/domain/model/PartialTranslationAssembler.kt

@@ -6,6 +6,11 @@ package io.legado.app.domain.model
  */
  */
 object PartialTranslationAssembler {
 object PartialTranslationAssembler {
 
 
+    data class PartialChunkTranslation(
+        val chunkIndex: Int,
+        val accumulatedText: String,
+    )
+
     /**
     /**
      * Assemble mixed content from original chunks and translated portions.
      * Assemble mixed content from original chunks and translated portions.
      *
      *
@@ -13,12 +18,19 @@ object PartialTranslationAssembler {
      * @param translatedMap Map of chunk index to translated content
      * @param translatedMap Map of chunk index to translated content
      * @return Mixed content string with translated chunks replacing originals
      * @return Mixed content string with translated chunks replacing originals
      */
      */
-    fun assemble(originalChunks: List<TextChunk>, translatedMap: Map<Int, String>): String {
+    fun assemble(
+        originalChunks: List<TextChunk>,
+        translatedMap: Map<Int, String>,
+        partialChunk: PartialChunkTranslation? = null,
+    ): String {
         if (originalChunks.isEmpty()) return ""
         if (originalChunks.isEmpty()) return ""
 
 
         val result = StringBuilder()
         val result = StringBuilder()
         for ((index, chunk) in originalChunks.withIndex()) {
         for ((index, chunk) in originalChunks.withIndex()) {
             val translated = translatedMap[chunk.index]
             val translated = translatedMap[chunk.index]
+                ?: partialChunk
+                    ?.takeIf { it.chunkIndex == chunk.index && it.accumulatedText.isNotEmpty() }
+                    ?.accumulatedText
             val content = translated ?: chunk.content
             val content = translated ?: chunk.content
 
 
             if (result.isNotEmpty()) {
             if (result.isNotEmpty()) {

+ 140 - 0
app/src/main/java/io/legado/app/domain/model/TranslationDictionaryPolicy.kt

@@ -0,0 +1,140 @@
+package io.legado.app.domain.model
+
+import java.util.Locale
+
+object TranslationDictionaryPolicy {
+
+    private val honorifics = setOf(
+        "mr", "mrs", "ms", "miss", "dr", "sir", "lady", "lord",
+        "professor", "prof", "captain", "capt", "king", "queen",
+        "prince", "princess",
+    )
+    private val splitMatchStopWords = honorifics + setOf(
+        "the", "a", "an", "of", "and", "to", "or", "for", "in", "on", "at", "by",
+    )
+    private val whitespace = Regex("\\s+")
+    private val tokenRegex = Regex("[\\p{L}\\p{N}]+")
+
+    fun normalizeOriginal(value: String): String {
+        val collapsed = value.trim().replace(whitespace, " ")
+        val tokens = collapsed.split(' ').toMutableList()
+        if (tokens.isNotEmpty()) {
+            val first = tokens.first().removeSuffix(".")
+            if (first.lowercase(Locale.ROOT) in honorifics) {
+                tokens[0] = first
+            }
+        }
+        return tokens.joinToString(" ").lowercase(Locale.ROOT)
+    }
+
+    fun isValidNewOriginal(value: String): Boolean {
+        val normalized = value.trim().replace(whitespace, " ")
+        if (normalized.isEmpty()) return false
+        if (normalized.codePoints().toArray().none { Character.isLetter(it) }) return false
+        if (!isPrimarilyLatin(normalized)) return true
+
+        val tokens = tokenRegex.findAll(normalized).map { it.value }.toList()
+        if (tokens.size == 2 &&
+            tokens.first().lowercase(Locale.ROOT) in setOf("the", "a", "an") &&
+            tokens.last().codePointCount() == 1 &&
+            tokens.last().firstCodePointIsUppercase()
+        ) {
+            return true
+        }
+        val contentTokens = tokens.filterNot { it.lowercase(Locale.ROOT) in splitMatchStopWords }
+        if (contentTokens.any(::isProperNameToken)) return true
+
+        return tokens.size > 1 &&
+            contentTokens.size == 1 &&
+            contentTokens.single().codePointCount() == 1 &&
+            contentTokens.single().firstCodePointIsUppercase()
+    }
+
+    fun selectRelevantPairs(pairs: List<DictPair>, chunk: String): List<DictPair> = pairs.filter { pair ->
+        val original = pair.original.trim().replace(whitespace, " ")
+        if (original.isEmpty()) return@filter false
+        if (containsTokenSequence(chunk, original)) return@filter true
+
+        val baseName = stripLeadingHonorific(original)
+        if (baseName != null && containsTokenSequence(chunk, baseName)) return@filter true
+
+        val tokens = tokenRegex.findAll(original)
+            .map { it.value }
+            .filterNot { it.lowercase(Locale.ROOT) in splitMatchStopWords }
+            .filter(::isProperNameToken)
+            .toList()
+        tokens.size > 1 && tokens.any { containsTokenSequence(chunk, it) }
+    }
+
+    fun mergeDiscoveredPairs(existing: List<DictPair>, discovered: List<DictPair>): List<DictPair> {
+        val merged = existing.toMutableList()
+        discovered.forEach { rawPair ->
+            val pair = DictPair(
+                original = rawPair.original.trim().replace(whitespace, " "),
+                translation = rawPair.translation.trim(),
+            )
+            if (pair.original.isEmpty() || pair.translation.isEmpty()) return@forEach
+
+            val normalized = normalizeOriginal(pair.original)
+            if (merged.any { normalizeOriginal(it.original) == normalized }) return@forEach
+
+            val candidateBase = stripLeadingHonorific(pair.original)
+            if (candidateBase != null) {
+                val normalizedBase = normalizeOriginal(candidateBase)
+                if (merged.any { existingPair ->
+                        normalizeOriginal(existingPair.original) == normalizedBase ||
+                            stripLeadingHonorific(existingPair.original)
+                                ?.let(::normalizeOriginal) == normalizedBase
+                    }
+                ) {
+                    return@forEach
+                }
+            } else {
+                merged.removeAll { existingPair ->
+                    stripLeadingHonorific(existingPair.original)
+                        ?.let(::normalizeOriginal) == normalized
+                }
+            }
+            merged += pair
+        }
+        return merged
+    }
+
+    private fun stripLeadingHonorific(value: String): String? {
+        val normalized = value.trim().replace(whitespace, " ")
+        val firstSpace = normalized.indexOf(' ')
+        if (firstSpace <= 0) return null
+        val first = normalized.substring(0, firstSpace).removeSuffix(".").lowercase(Locale.ROOT)
+        if (first !in honorifics) return null
+        return normalized.substring(firstSpace + 1).trim().takeIf { it.isNotEmpty() }
+    }
+
+    private fun containsTokenSequence(text: String, term: String): Boolean {
+        val termPattern = term.split(whitespace).joinToString("\\s+") { Regex.escape(it) }
+        return Regex(
+            pattern = "(?<![\\p{L}\\p{N}])$termPattern(?![\\p{L}\\p{N}])",
+            options = setOf(RegexOption.IGNORE_CASE),
+        ).containsMatchIn(text)
+    }
+
+    private fun isPrimarilyLatin(value: String): Boolean {
+        val letters = value.codePoints().toArray().filter { Character.isLetter(it) }
+        if (letters.isEmpty()) return false
+        val latinLetters = letters.count { Character.UnicodeScript.of(it) == Character.UnicodeScript.LATIN }
+        return latinLetters * 2 >= letters.size
+    }
+
+    private fun isProperNameToken(token: String): Boolean {
+        val codePoints = token.codePoints().toArray()
+        if (codePoints.size <= 1) return false
+        return Character.isUpperCase(codePoints.first()) ||
+            codePoints.drop(1).any { Character.isUpperCase(it) } ||
+            (codePoints.count { Character.isLetter(it) } > 1 &&
+                codePoints.filter { Character.isLetter(it) }.all { Character.isUpperCase(it) })
+    }
+
+    private fun String.codePointCount(): Int = codePointCount(0, length)
+
+    private fun String.firstCodePointIsUppercase(): Boolean =
+        isNotEmpty() && Character.isUpperCase(codePointAt(0))
+}

+ 80 - 0
app/src/main/java/io/legado/app/domain/model/TranslationResultPreviewParser.kt

@@ -0,0 +1,80 @@
+package io.legado.app.domain.model
+
+class TranslationResultPreviewParser {
+
+    private val raw = StringBuilder()
+    private var resultStart = -1
+    private var scanIndex = 0
+    private var lastPublishedBoundaryEnd = 0
+    private var lastSnapshot: String? = null
+
+    fun feed(delta: String): List<String> {
+        if (delta.isEmpty()) return emptyList()
+        raw.append(delta)
+        findResultMarker()
+        if (resultStart < 0) return emptyList()
+        return consumeBoundaries(final = false)
+    }
+
+    fun finish(): List<String> {
+        findResultMarker()
+        if (resultStart < 0) return emptyList()
+        val snapshots = consumeBoundaries(final = true).toMutableList()
+        val finalSnapshot = normalizeSnapshot(raw.substring(resultStart))
+        if (finalSnapshot.isNotEmpty() && finalSnapshot != lastSnapshot) {
+            lastSnapshot = finalSnapshot
+            snapshots += finalSnapshot
+        }
+        return snapshots
+    }
+
+    private fun findResultMarker() {
+        if (resultStart >= 0) return
+        val markerIndex = raw.indexOf(RESULT_MARKER, ignoreCase = true)
+        if (markerIndex >= 0) {
+            resultStart = markerIndex + RESULT_MARKER.length
+            scanIndex = resultStart
+            lastPublishedBoundaryEnd = resultStart
+        }
+    }
+
+    private fun consumeBoundaries(final: Boolean): List<String> {
+        val snapshots = mutableListOf<String>()
+        while (scanIndex < raw.length) {
+            val current = raw[scanIndex]
+            val boundaryEnd = when (current) {
+                '\n' -> scanIndex + 1
+                '\r' -> when {
+                    scanIndex + 1 < raw.length && raw[scanIndex + 1] == '\n' -> scanIndex + 2
+                    scanIndex + 1 < raw.length || final -> scanIndex + 1
+                    else -> break
+                }
+                else -> {
+                    scanIndex++
+                    continue
+                }
+            }
+            val pendingCharCount = scanIndex - lastPublishedBoundaryEnd
+            val snapshot = if (pendingCharCount >= MIN_PREVIEW_BATCH_CHARS) {
+                normalizeSnapshot(raw.substring(resultStart, scanIndex))
+            } else {
+                null
+            }
+            scanIndex = boundaryEnd
+            if (!snapshot.isNullOrEmpty() && snapshot != lastSnapshot) {
+                lastSnapshot = snapshot
+                lastPublishedBoundaryEnd = boundaryEnd
+                snapshots += snapshot
+            }
+        }
+        return snapshots
+    }
+
+    private fun normalizeSnapshot(value: String): String =
+        value.replace("\r\n", "\n").replace('\r', '\n').trim()
+
+    private companion object {
+        const val RESULT_MARKER = "[result]"
+        const val MIN_PREVIEW_BATCH_CHARS = 1_024
+    }
+}

+ 185 - 155
app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt

@@ -5,6 +5,7 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.TranslationCache
 import io.legado.app.data.entities.TranslationCache
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiStreamEvent
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
@@ -17,19 +18,20 @@ import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.ContentChunker
 import io.legado.app.domain.model.ContentChunker
 import io.legado.app.domain.model.DictPair
 import io.legado.app.domain.model.DictPair
 import io.legado.app.domain.model.PartialTranslationAssembler
 import io.legado.app.domain.model.PartialTranslationAssembler
+import io.legado.app.domain.model.PartialTranslationAssembler.PartialChunkTranslation
 import io.legado.app.domain.model.RetryReason
 import io.legado.app.domain.model.RetryReason
 import io.legado.app.domain.model.TextChunk
 import io.legado.app.domain.model.TextChunk
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
 import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
+import io.legado.app.domain.model.TranslationDictionaryPolicy
+import io.legado.app.domain.model.TranslationResultPreviewParser
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.http.newCallStrResponse
 import io.legado.app.help.http.newCallStrResponse
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.help.http.okHttpClient
 import io.legado.app.utils.GSON
 import io.legado.app.utils.GSON
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.coroutineScope
+import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
 import java.net.URLEncoder
 import java.net.URLEncoder
 
 
@@ -48,17 +50,12 @@ class TranslateChapterUseCase(
         val translatedChunkIndices: Set<Int> = emptySet()
         val translatedChunkIndices: Set<Int> = emptySet()
     )
     )
 
 
-    companion object {
-        private const val MAX_DICTIONARY_PAIRS = 80
-    }
-
-    private val dictionaryLock = Any()
-
     suspend fun execute(
     suspend fun execute(
         book: Book,
         book: Book,
         bookChapter: BookChapter,
         bookChapter: BookChapter,
         onProgress: (TranslationProgress) -> Unit,
         onProgress: (TranslationProgress) -> Unit,
-        onTranslateStarted: () -> Unit
+        onTranslateStarted: () -> Unit,
+        onThinkingChanged: (Boolean) -> Unit = {},
     ): Result<String> = withContext(Dispatchers.IO) {
     ): Result<String> = withContext(Dispatchers.IO) {
         try {
         try {
             val settings = translationSettingsGateway.currentSettings
             val settings = translationSettingsGateway.currentSettings
@@ -83,22 +80,6 @@ class TranslateChapterUseCase(
 
 
             val contentHash = translationCacheGateway.computeContentHash(originalContent)
             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(
             val chunks = ContentChunker.chunk(
                 originalContent,
                 originalContent,
                 settings.maxCharsPerChunk.coerceAtLeast(1000)
                 settings.maxCharsPerChunk.coerceAtLeast(1000)
@@ -131,7 +112,7 @@ class TranslateChapterUseCase(
                     translatedChunks.size,
                     translatedChunks.size,
                     chunks.size,
                     chunks.size,
                     mixedContent,
                     mixedContent,
-                    translatedChunks.keys
+                    translatedChunks.keys.toSet()
                 ))
                 ))
             }
             }
 
 
@@ -149,41 +130,61 @@ class TranslateChapterUseCase(
             }
             }
 
 
             onTranslateStarted()
             onTranslateStarted()
-            var translationError: Throwable? = null
-            coroutineScope {
-                val concurrentChunks = settings.concurrentChunks.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, provider,
-                                preset, dictionaries, onDictionaryUpdate, settings.retryCount
+            for (chunk in pendingChunks.sortedBy { it.index }) {
+                val publishWithoutPartial = {
+                    val mixedContent = PartialTranslationAssembler.assemble(chunks, translatedChunks)
+                    onProgress(
+                        TranslationProgress(
+                            currentChunk = translatedChunks.size,
+                            totalChunks = chunks.size,
+                            mixedContent = mixedContent,
+                            translatedChunkIndices = translatedChunks.keys.toSet(),
+                        )
+                    )
+                }
+                val result = try {
+                    translateAndCacheChunk(
+                        chunk = chunk,
+                        book = book,
+                        bookChapter = bookChapter,
+                        targetLanguage = targetLanguage,
+                        contentHash = contentHash,
+                        provider = provider,
+                        preset = preset,
+                        retryCount = settings.retryCount,
+                        onPreviewSnapshot = { preview ->
+                            val mixedContent = PartialTranslationAssembler.assemble(
+                                originalChunks = chunks,
+                                translatedMap = translatedChunks,
+                                partialChunk = PartialChunkTranslation(chunk.index, preview),
                             )
                             )
-                        }
-                    }.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 {
-                            translationError = result.exceptionOrNull() ?: Exception("Translation failed")
-                            return@coroutineScope
-                        }
-                    }
+                            onProgress(
+                                TranslationProgress(
+                                    currentChunk = translatedChunks.size,
+                                    totalChunks = chunks.size,
+                                    mixedContent = mixedContent,
+                                    translatedChunkIndices = translatedChunks.keys.toSet(),
+                                )
+                            )
+                        },
+                        onPreviewReset = publishWithoutPartial,
+                        onThinkingChanged = onThinkingChanged,
+                    )
+                } catch (e: CancellationException) {
+                    publishWithoutPartial()
+                    throw e
+                } catch (e: Exception) {
+                    Result.failure(e)
+                }
+                if (result.isFailure) {
+                    publishWithoutPartial()
+                    return@withContext Result.failure(
+                        result.exceptionOrNull() ?: Exception("Translation failed")
+                    )
                 }
                 }
-            }
 
 
-            translationError?.let {
-                return@withContext Result.failure(it)
+                translatedChunks[chunk.index] = result.getOrThrow()
+                publishWithoutPartial()
             }
             }
 
 
             if (translatedChunks.size != chunks.size) {
             if (translatedChunks.size != chunks.size) {
@@ -205,40 +206,6 @@ class TranslateChapterUseCase(
         }
         }
     }
     }
 
 
-    /**
-     * 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 early important names and the most recently discovered terms.
-        if (existing.size > MAX_DICTIONARY_PAIRS) {
-            val headCount = MAX_DICTIONARY_PAIRS / 2
-            val tailCount = MAX_DICTIONARY_PAIRS - headCount
-            val trimmed = existing.take(headCount) + existing.takeLast(tailCount)
-            existing.clear()
-            existing.addAll(trimmed)
-            changed = true
-        }
-        return changed
-    }
-
     private suspend fun translateAndCacheChunk(
     private suspend fun translateAndCacheChunk(
         chunk: TextChunk,
         chunk: TextChunk,
         book: Book,
         book: Book,
@@ -247,9 +214,10 @@ class TranslateChapterUseCase(
         contentHash: String,
         contentHash: String,
         provider: String,
         provider: String,
         preset: AiTaskPresetConfig?,
         preset: AiTaskPresetConfig?,
-        dictionaries: MutableList<DictPair>,
-        onDictionaryUpdate: (List<DictPair>) -> Unit,
         retryCount: Int,
         retryCount: Int,
+        onPreviewSnapshot: (String) -> Unit,
+        onPreviewReset: () -> Unit,
+        onThinkingChanged: (Boolean) -> Unit,
     ): Result<String> {
     ): Result<String> {
         val existingCache =
         val existingCache =
             translationCacheGateway.getCachedChunk(book, bookChapter, targetLanguage, chunk.index)
             translationCacheGateway.getCachedChunk(book, bookChapter, targetLanguage, chunk.index)
@@ -258,57 +226,85 @@ class TranslateChapterUseCase(
         }
         }
 
 
         val result = translateChunkWithRetry(
         val result = translateChunkWithRetry(
-            chunk, targetLanguage, provider, preset, dictionaries, onDictionaryUpdate, retryCount
+            chunk = chunk,
+            book = book,
+            targetLanguage = targetLanguage,
+            provider = provider,
+            preset = preset,
+            retryCount = retryCount,
+            onPreviewSnapshot = onPreviewSnapshot,
+            onPreviewReset = onPreviewReset,
+            onThinkingChanged = onThinkingChanged,
         )
         )
         if (result.isSuccess) {
         if (result.isSuccess) {
+            val translated = result.getOrThrow()
+            if (translated.discoveredPairs.isNotEmpty()) {
+                dictionaryGateway.mergeDiscoveredPairs(book, translated.discoveredPairs)
+            }
             translationCacheGateway.saveChunk(
             translationCacheGateway.saveChunk(
                 book, bookChapter, targetLanguage,
                 book, bookChapter, targetLanguage,
                 chunk.index, chunk.content, contentHash,
                 chunk.index, chunk.content, contentHash,
                 provider,
                 provider,
-                TranslationCache.STATUS_SUCCESS, result.getOrThrow(), null
-            )
-        } else {
-            val errorMessage = result.exceptionOrNull()?.message ?: "Translation failed"
-            translationCacheGateway.saveChunk(
-                book, bookChapter, targetLanguage,
-                chunk.index, chunk.content, contentHash,
-                provider,
-                TranslationCache.STATUS_FAILED, null, errorMessage
+                TranslationCache.STATUS_SUCCESS, translated.text, null
             )
             )
         }
         }
-        return result
+        return result.map { it.text }
     }
     }
 
 
+    private data class ChunkTranslation(
+        val text: String,
+        val discoveredPairs: List<DictPair> = emptyList(),
+    )
+
     private suspend fun translateChunkWithRetry(
     private suspend fun translateChunkWithRetry(
         chunk: TextChunk,
         chunk: TextChunk,
+        book: Book,
         targetLanguage: String,
         targetLanguage: String,
         provider: String,
         provider: String,
         preset: AiTaskPresetConfig?,
         preset: AiTaskPresetConfig?,
-        dictionaries: MutableList<DictPair>,
-        onDictionaryUpdate: (List<DictPair>) -> Unit,
         retryCount: Int,
         retryCount: Int,
-    ): Result<String> {
+        onPreviewSnapshot: (String) -> Unit,
+        onPreviewReset: () -> Unit,
+        onThinkingChanged: (Boolean) -> Unit,
+    ): Result<ChunkTranslation> {
         var lastError: Exception? = null
         var lastError: Exception? = null
         var lastRetryReason: RetryReason? = null
         var lastRetryReason: RetryReason? = null
-        for (attempt in 0..retryCount.coerceIn(0, 5)) {
-            val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() }
-            val result = when (provider) {
-                TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(chunk.content, targetLanguage)
-                TranslationConstants.PROVIDER_APP_AI -> translateWithAiGateway(
-                    text = chunk.content,
-                    targetLanguage = targetLanguage,
-                    preset = preset ?: return Result.failure(Exception("No AI translation preset configured")),
-                    dictionaries = dictSnapshot,
-                    onUpdate = onDictionaryUpdate,
-                    retryReason = lastRetryReason
-                )
-                else -> Result.failure(IllegalArgumentException("Unknown translation provider: $provider"))
-            }
-            if (result.isSuccess) {
-                return result
+        try {
+            for (attempt in 0..retryCount.coerceIn(0, 5)) {
+                val result = when (provider) {
+                    TranslationConstants.PROVIDER_GOOGLE ->
+                        translateWithGoogle(chunk.content, targetLanguage).map { ChunkTranslation(it) }
+                    TranslationConstants.PROVIDER_APP_AI -> {
+                        val allDictionaries = dictionaryGateway.getBookDictionaries(book).pairs
+                        translateWithAiGateway(
+                            text = chunk.content,
+                            targetLanguage = targetLanguage,
+                            preset = preset
+                                ?: return Result.failure(Exception("No AI translation preset configured")),
+                            allDictionaries = allDictionaries,
+                            relevantDictionaries = TranslationDictionaryPolicy.selectRelevantPairs(
+                                allDictionaries,
+                                chunk.content,
+                            ),
+                            retryReason = lastRetryReason,
+                            onPreviewSnapshot = onPreviewSnapshot,
+                            onThinkingChanged = onThinkingChanged,
+                        )
+                    }
+                    else -> Result.failure(
+                        IllegalArgumentException("Unknown translation provider: $provider")
+                    )
+                }
+                if (result.isSuccess) {
+                    return result
+                }
+                onPreviewReset()
+                lastError = result.exceptionOrNull() as? Exception
+                lastRetryReason = parseRetryReason(lastError)
             }
             }
-            lastError = result.exceptionOrNull() as? Exception
-            lastRetryReason = parseRetryReason(lastError)
+        } catch (e: CancellationException) {
+            onPreviewReset()
+            throw e
         }
         }
         return Result.failure(lastError ?: Exception("Translation failed after retries"))
         return Result.failure(lastError ?: Exception("Translation failed after retries"))
     }
     }
@@ -347,15 +343,17 @@ class TranslateChapterUseCase(
         text: String,
         text: String,
         targetLanguage: String,
         targetLanguage: String,
         preset: AiTaskPresetConfig,
         preset: AiTaskPresetConfig,
-        dictionaries: List<DictPair>,
-        onUpdate: ((List<DictPair>) -> Unit)?,
-        retryReason: RetryReason?
-    ): Result<String> {
+        allDictionaries: List<DictPair>,
+        relevantDictionaries: List<DictPair>,
+        retryReason: RetryReason?,
+        onPreviewSnapshot: (String) -> Unit,
+        onThinkingChanged: (Boolean) -> Unit,
+    ): Result<ChunkTranslation> {
         if (targetLanguage == "en" && isMostlyEnglish(text)) {
         if (targetLanguage == "en" && isMostlyEnglish(text)) {
-            return Result.success(text)
+            return Result.success(ChunkTranslation(text))
         }
         }
         if (targetLanguage == "zh" && isMostlyChinese(text)) {
         if (targetLanguage == "zh" && isMostlyChinese(text)) {
-            return Result.success(text)
+            return Result.success(ChunkTranslation(text))
         }
         }
         if (preset.model.provider.baseUrl.isBlank() ||
         if (preset.model.provider.baseUrl.isBlank() ||
             preset.model.provider.apiKey.isBlank() ||
             preset.model.provider.apiKey.isBlank() ||
@@ -364,7 +362,7 @@ class TranslateChapterUseCase(
             return Result.failure(IllegalArgumentException("AI model configuration is incomplete"))
             return Result.failure(IllegalArgumentException("AI model configuration is incomplete"))
         }
         }
 
 
-        val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
+        val dictionaryInstruction = buildDictionaryInstruction(relevantDictionaries)
         val retryInstruction = buildRetryInstruction(retryReason)
         val retryInstruction = buildRetryInstruction(retryReason)
         val systemPrompt = buildSystemPrompt(
         val systemPrompt = buildSystemPrompt(
             prompt = preset.promptTemplate,
             prompt = preset.promptTemplate,
@@ -378,33 +376,49 @@ class TranslateChapterUseCase(
                 ?: preset.model.defaultParams.temperature
                 ?: preset.model.defaultParams.temperature
                 ?: TranslationConstants.DEFAULT_TEMPERATURE
                 ?: TranslationConstants.DEFAULT_TEMPERATURE
         )
         )
-        val result = aiTextGateway.generate(
-            AiGenerateRequest(
-                model = preset.model,
-                messages = listOf(
-                    AiMessage(AiMessageRole.SYSTEM, systemPrompt),
-                    AiMessage(AiMessageRole.USER, "Translate the following text:\n\n$text")
-                ),
-                params = params
-            )
+        val request = AiGenerateRequest(
+            model = preset.model,
+            messages = listOf(
+                AiMessage(AiMessageRole.SYSTEM, systemPrompt),
+                AiMessage(AiMessageRole.USER, "Translate the following text:\n\n$text"),
+            ),
+            params = params,
         )
         )
-        if (result.isFailure) {
-            return Result.failure(result.exceptionOrNull() ?: Exception("Translation failed"))
+        val rawContent = StringBuilder()
+        val previewParser = TranslationResultPreviewParser()
+        try {
+            aiTextGateway.generateStream(request).collect { event ->
+                when (event) {
+                    is AiStreamEvent.Reasoning -> onThinkingChanged(true)
+                    is AiStreamEvent.Content -> {
+                        onThinkingChanged(false)
+                        rawContent.append(event.text)
+                        previewParser.feed(event.text).forEach(onPreviewSnapshot)
+                    }
+                    is AiStreamEvent.ToolCallDelta -> Unit
+                }
+            }
+            previewParser.finish().forEach(onPreviewSnapshot)
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            return Result.failure(e)
+        } finally {
+            onThinkingChanged(false)
         }
         }
-        val rawContent = result.getOrThrow().text
         if (rawContent.isBlank()) {
         if (rawContent.isBlank()) {
             return Result.failure(Exception("Empty translation result"))
             return Result.failure(Exception("Empty translation result"))
         }
         }
-        val parseResult = parseLlmOutput(rawContent, dictionaries)
-        if (parseResult.extractedPairs.isNotEmpty()) {
-            onUpdate?.invoke(parseResult.extractedPairs.take(10))
-        }
+        val parseResult = parseLlmOutput(rawContent.toString(), allDictionaries)
         val finalText = if (targetLanguage == "zh") {
         val finalText = if (targetLanguage == "zh") {
             filterHighEnglishParagraphs(parseResult.translatedText)
             filterHighEnglishParagraphs(parseResult.translatedText)
         } else {
         } else {
             parseResult.translatedText
             parseResult.translatedText
         }
         }
-        return Result.success(finalText)
+        if (finalText.isBlank()) {
+            return Result.failure(Exception("Empty translation result"))
+        }
+        return Result.success(ChunkTranslation(finalText, parseResult.extractedPairs.take(10)))
     }
     }
 
 
     private data class ParseOutputResult(
     private data class ParseOutputResult(
@@ -416,7 +430,9 @@ class TranslateChapterUseCase(
         rawOutput: String,
         rawOutput: String,
         existingDictionaries: List<DictPair>
         existingDictionaries: List<DictPair>
     ): ParseOutputResult {
     ): ParseOutputResult {
-        val existingOriginals = existingDictionaries.map { it.original }.toSet()
+        val existingOriginals = existingDictionaries
+            .map { TranslationDictionaryPolicy.normalizeOriginal(it.original) }
+            .toSet()
         var dictionarySection: String? = null
         var dictionarySection: String? = null
         var resultSection: String? = null
         var resultSection: String? = null
         var currentSection: String? = null
         var currentSection: String? = null
@@ -424,8 +440,18 @@ class TranslateChapterUseCase(
         for (line in rawOutput.split('\n')) {
         for (line in rawOutput.split('\n')) {
             val trimmedLine = line.trim()
             val trimmedLine = line.trim()
             when {
             when {
-                trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> currentSection = "dictionary"
-                trimmedLine.startsWith("[result]", ignoreCase = true) -> currentSection = "result"
+                trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> {
+                    currentSection = "dictionary"
+                    trimmedLine.drop("[dictionary]".length).trim()
+                        .takeIf { it.isNotEmpty() }
+                        ?.let { dictionarySection = it + "\n" }
+                }
+                trimmedLine.startsWith("[result]", ignoreCase = true) -> {
+                    currentSection = "result"
+                    trimmedLine.drop("[result]".length).trimStart()
+                        .takeIf { it.isNotEmpty() }
+                        ?.let { resultSection = it + "\n" }
+                }
                 currentSection == "dictionary" -> dictionarySection = (dictionarySection ?: "") + line + "\n"
                 currentSection == "dictionary" -> dictionarySection = (dictionarySection ?: "") + line + "\n"
                 currentSection == "result" -> resultSection = (resultSection ?: "") + line + "\n"
                 currentSection == "result" -> resultSection = (resultSection ?: "") + line + "\n"
             }
             }
@@ -466,7 +492,11 @@ class TranslateChapterUseCase(
                 if (parts.size == 2) {
                 if (parts.size == 2) {
                     val original = parts[0].trim()
                     val original = parts[0].trim()
                     val translation = parts[1].trim()
                     val translation = parts[1].trim()
-                    if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
+                    if (original.isNotEmpty() &&
+                        translation.isNotEmpty() &&
+                        TranslationDictionaryPolicy.normalizeOriginal(original) !in existingOriginals &&
+                        TranslationDictionaryPolicy.isValidNewOriginal(original)
+                    ) {
                         pairs.add(DictPair(original, translation))
                         pairs.add(DictPair(original, translation))
                         if (pairs.size >= 10) break
                         if (pairs.size >= 10) break
                     }
                     }

+ 22 - 10
app/src/main/java/io/legado/app/model/ReadBook.kt

@@ -63,6 +63,7 @@ import kotlinx.coroutines.delay
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.ensureActive
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.Mutex
@@ -1161,20 +1162,31 @@ object ReadBook : CoroutineScope by MainScope(), KoinComponent {
         translationObserverJobs[chapterIndex]?.cancel()
         translationObserverJobs[chapterIndex]?.cancel()
 
 
         val job = launch {
         val job = launch {
-            taskFlow.collect { state ->
-                when (state.status) {
-                    TranslationChapterStatus.Translating -> {
-                        state.mixedContent?.let { mixed ->
-                            contentLoadFinish(book, chapter, mixed, upContent = true, resetPageOffset = false)
+            try {
+                taskFlow.takeWhile { state ->
+                    when (state.status) {
+                        TranslationChapterStatus.Translating,
+                        TranslationChapterStatus.Thinking -> {
+                            state.mixedContent?.let { mixed ->
+                                contentLoadFinish(
+                                    book,
+                                    chapter,
+                                    mixed,
+                                    upContent = true,
+                                    resetPageOffset = false,
+                                )
+                            }
                         }
                         }
+                        else -> Unit
                     }
                     }
-                    else -> {
-                        // no-op
-                    }
+                    state.status == TranslationChapterStatus.Translating ||
+                        state.status == TranslationChapterStatus.Thinking
+                }.collect {}
+            } finally {
+                coroutineContext[Job]?.let { job ->
+                    translationObserverJobs.remove(chapterIndex, job)
                 }
                 }
             }
             }
-            // Clean up when coroutine finishes
-            translationObserverJobs.remove(chapterIndex)
         }
         }
         translationObserverJobs[chapterIndex] = job
         translationObserverJobs[chapterIndex] = job
     }
     }

+ 2 - 1
app/src/main/java/io/legado/app/model/translation/TranslationChapterState.kt

@@ -15,6 +15,7 @@ data class TranslationChapterKey(
 enum class TranslationChapterStatus {
 enum class TranslationChapterStatus {
     Idle,
     Idle,
     Translating,
     Translating,
+    Thinking,
     Translated,
     Translated,
     Failed
     Failed
 }
 }
@@ -31,4 +32,4 @@ data class TranslationChapterState(
     val mixedContent: String? = null,
     val mixedContent: String? = null,
     val translatedContent: String? = null,
     val translatedContent: String? = null,
     val errorMessage: String? = null
     val errorMessage: String? = null
-)
+)

+ 25 - 4
app/src/main/java/io/legado/app/model/translation/TranslationManager.kt

@@ -36,7 +36,7 @@ object TranslationManager : KoinComponent {
      */
      */
     fun getChapterTaskStateFlow(bookUrl: String, chapterIndex: Int): StateFlow<TranslationChapterState>? {
     fun getChapterTaskStateFlow(bookUrl: String, chapterIndex: Int): StateFlow<TranslationChapterState>? {
         val key = TranslationChapterKey(bookUrl, chapterIndex)
         val key = TranslationChapterKey(bookUrl, chapterIndex)
-        return _taskStateFlows[key]?.takeIf { it.value.status == TranslationChapterStatus.Translating }
+        return _taskStateFlows[key]?.takeIf { it.value.status.isActive() }
     }
     }
 
 
     /**
     /**
@@ -74,7 +74,7 @@ object TranslationManager : KoinComponent {
 
 
         // Check if already translating
         // Check if already translating
         _taskStateFlows[key]?.let { taskFlow ->
         _taskStateFlows[key]?.let { taskFlow ->
-            if (taskFlow.value.status == TranslationChapterStatus.Translating) {
+            if (taskFlow.value.status.isActive()) {
                 return taskFlow
                 return taskFlow
             }
             }
         }
         }
@@ -90,7 +90,9 @@ object TranslationManager : KoinComponent {
         }
         }
 
 
         // Create new task flow
         // Create new task flow
-        val taskFlow = MutableStateFlow(TranslationChapterState(key, status = TranslationChapterStatus.Idle))
+        val taskFlow = MutableStateFlow(
+            TranslationChapterState(key, status = TranslationChapterStatus.Translating)
+        )
         _taskStateFlows[key] = taskFlow
         _taskStateFlows[key] = taskFlow
 
 
         // Start translation in background
         // Start translation in background
@@ -123,7 +125,22 @@ object TranslationManager : KoinComponent {
                     )
                     )
                 }
                 }
             },
             },
-            onTranslateStarted = onTranslateStarted
+            onTranslateStarted = onTranslateStarted,
+            onThinkingChanged = { thinking ->
+                taskFlow.update { state ->
+                    if (state.status.isActive()) {
+                        state.copy(
+                            status = if (thinking) {
+                                TranslationChapterStatus.Thinking
+                            } else {
+                                TranslationChapterStatus.Translating
+                            }
+                        )
+                    } else {
+                        state
+                    }
+                }
+            },
         )
         )
 
 
         result.onSuccess { content ->
         result.onSuccess { content ->
@@ -142,6 +159,7 @@ object TranslationManager : KoinComponent {
                 )
                 )
             }
             }
         }
         }
+        _taskStateFlows.remove(key, taskFlow)
     }
     }
 
 
     /**
     /**
@@ -181,4 +199,7 @@ object TranslationManager : KoinComponent {
         return translationSettingsGateway.currentSettings.targetLanguage
         return translationSettingsGateway.currentSettings.targetLanguage
     }
     }
 
 
+    private fun TranslationChapterStatus.isActive(): Boolean =
+        this == TranslationChapterStatus.Translating || this == TranslationChapterStatus.Thinking
+
 }
 }

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

@@ -16,6 +16,7 @@ import io.legado.app.data.entities.ReplaceRule
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.readaloud.SpeechRoleType
 import io.legado.app.domain.model.settings.ReadStyleItem
 import io.legado.app.domain.model.settings.ReadStyleItem
+import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPage
 import io.legado.app.ui.book.read.page.entities.TextPos
 import io.legado.app.ui.book.read.page.entities.TextPos
@@ -267,6 +268,7 @@ data class ReadBookUiState(
     val chineseConverterActive: Boolean = false,
     val chineseConverterActive: Boolean = false,
     // Translation
     // Translation
     val translationMode: Boolean = false,
     val translationMode: Boolean = false,
+    val translationStatus: TranslationChapterStatus = TranslationChapterStatus.Idle,
     // Chapter info
     // Chapter info
     val curTextChapter: TextChapter? = null,
     val curTextChapter: TextChapter? = null,
     // Time / battery (from EventBus)
     // Time / battery (from EventBus)

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

@@ -48,6 +48,7 @@ import io.legado.app.constant.BookType
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.help.IntentHelp
 import io.legado.app.help.IntentHelp
 import io.legado.app.model.ReadBook
 import io.legado.app.model.ReadBook
+import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.SourceCallBack
 import io.legado.app.model.SourceCallBack
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.info.BookInfoActivity
 import io.legado.app.ui.book.read.page.ContentTextView
 import io.legado.app.ui.book.read.page.ContentTextView
@@ -552,6 +553,13 @@ fun ReadBookRouteScreen(
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
                 hazeState = if (useMenuHazeSource) menuHazeState else null,
             )
             )
             ReadBookSearchBar(state = state, onIntent = viewModel::onIntent)
             ReadBookSearchBar(state = state, onIntent = viewModel::onIntent)
+            AnimatedVisibility(
+                visible = state.translationStatus == TranslationChapterStatus.Thinking,
+                enter = fadeIn(tween(180)) + scaleIn(tween(220), initialScale = 0.88f),
+                exit = fadeOut(tween(140)) + scaleOut(tween(180), targetScale = 0.88f),
+            ) {
+                TranslationThinkingCapsule()
+            }
             AnimatedVisibility(
             AnimatedVisibility(
                 visible = state.isReadAloudRunning &&
                 visible = state.isReadAloudRunning &&
                     state.showReadAloudCapsule &&
                     state.showReadAloudCapsule &&

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

@@ -99,6 +99,8 @@ import io.legado.app.model.analyzeRule.AnalyzeRule
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setChapter
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.analyzeRule.AnalyzeRule.Companion.setCoroutineContext
 import io.legado.app.model.localBook.LocalBook
 import io.legado.app.model.localBook.LocalBook
+import io.legado.app.model.translation.TranslationChapterKey
+import io.legado.app.model.translation.TranslationChapterStatus
 import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.translation.TranslationManager
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.model.webBook.WebBook
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.BaseReadAloudService
@@ -151,6 +153,7 @@ import kotlinx.coroutines.flow.onEach
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.onEmpty
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.onStart
 import kotlinx.coroutines.flow.take
 import kotlinx.coroutines.flow.take
+import kotlinx.coroutines.flow.takeWhile
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import kotlinx.coroutines.withContext
@@ -243,6 +246,8 @@ class ReadBookViewModel(
     private var aiTextRewriteJob: Job? = null
     private var aiTextRewriteJob: Job? = null
     private var pendingAiTextCleanRequest: PendingAiTextCleanRequest? = null
     private var pendingAiTextCleanRequest: PendingAiTextCleanRequest? = null
     private var pendingAiTextRewriteRequest: PendingAiTextRewriteRequest? = null
     private var pendingAiTextRewriteRequest: PendingAiTextRewriteRequest? = null
+    private var translationStatusJob: Job? = null
+    private var observedTranslationKey: TranslationChapterKey? = null
 
 
     val isInitFinish: Boolean get() = _uiState.value.isInitFinish
     val isInitFinish: Boolean get() = _uiState.value.isInitFinish
 
 
@@ -2432,6 +2437,7 @@ class ReadBookViewModel(
     private fun syncFromReadBook(current: ReadBookUiState): ReadBookUiState {
     private fun syncFromReadBook(current: ReadBookUiState): ReadBookUiState {
         val book = ReadBook.book
         val book = ReadBook.book
         val textChapter = ReadBook.curTextChapter
         val textChapter = ReadBook.curTextChapter
+        val translationStatus = observeCurrentTranslation(book, ReadBook.durChapterIndex)
         return current.copy(
         return current.copy(
             book = book,
             book = book,
             bookSource = ReadBook.bookSource,
             bookSource = ReadBook.bookSource,
@@ -2455,6 +2461,7 @@ class ReadBookViewModel(
             effectiveReplaceRules = textChapter?.effectiveReplaceRules.orEmpty().toImmutableList(),
             effectiveReplaceRules = textChapter?.effectiveReplaceRules.orEmpty().toImmutableList(),
             chineseConverterActive = readSettingsRepository.currentSettings.chineseConverterType > 0,
             chineseConverterActive = readSettingsRepository.currentSettings.chineseConverterType > 0,
             translationMode = book?.getTranslationMode() ?: false,
             translationMode = book?.getTranslationMode() ?: false,
+            translationStatus = translationStatus,
             isLocalTxt = book?.isLocalTxt == true,
             isLocalTxt = book?.isLocalTxt == true,
             isEpub = book?.isEpub == true,
             isEpub = book?.isEpub == true,
             useReplaceRule = book?.getUseReplaceRule(
             useReplaceRule = book?.getUseReplaceRule(
@@ -2511,6 +2518,42 @@ class ReadBookViewModel(
         )
         )
     }
     }
 
 
+    private fun observeCurrentTranslation(
+        book: Book?,
+        chapterIndex: Int,
+    ): TranslationChapterStatus {
+        val key = book
+            ?.takeIf { it.getTranslationMode() }
+            ?.let { TranslationChapterKey(it.bookUrl, chapterIndex) }
+        if (key == observedTranslationKey && translationStatusJob?.isActive == true) {
+            return _uiState.value.translationStatus
+        }
+
+        translationStatusJob?.cancel()
+        val taskFlow = key?.let {
+            TranslationManager.getChapterTaskStateFlow(it.bookUrl, it.chapterIndex)
+        }
+        if (taskFlow == null) {
+            observedTranslationKey = null
+            return TranslationChapterStatus.Idle
+        }
+
+        observedTranslationKey = key
+        translationStatusJob = viewModelScope.launch {
+            taskFlow.takeWhile { taskState ->
+                if (observedTranslationKey == taskState.key) {
+                    _uiState.update { state ->
+                        state.copy(translationStatus = taskState.status)
+                    }
+                }
+                taskState.status == TranslationChapterStatus.Translating ||
+                    taskState.status == TranslationChapterStatus.Thinking
+            }.collect {}
+            if (observedTranslationKey == key) observedTranslationKey = null
+        }
+        return taskFlow.value.status
+    }
+
     private fun refreshButtonConfigs() {
     private fun refreshButtonConfigs() {
         val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY)
         val titleBarButtons = loadButtonConfig(TITLE_BAR_ICON_PREFS, TITLE_BAR_ICON_KEY)
         val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY)
         val bottomBarButtons = loadButtonConfig(TOOL_BUTTON_PREFS, TOOL_BUTTON_KEY)
@@ -6366,6 +6409,7 @@ class ReadBookViewModel(
     }
     }
 
 
     override fun onCleared() {
     override fun onCleared() {
+        translationStatusJob?.cancel()
         super.onCleared()
         super.onCleared()
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
         if (BaseReadAloudService.isRun && BaseReadAloudService.pause) {
             ReadAloud.stop(context)
             ReadAloud.stop(context)

+ 57 - 0
app/src/main/java/io/legado/app/ui/book/read/TranslationThinkingCapsule.kt

@@ -0,0 +1,57 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.navigationBarsPadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+
+@Composable
+fun TranslationThinkingCapsule() {
+    val transition = rememberInfiniteTransition(label = "translationThinking")
+    val capsuleAlpha by transition.animateFloat(
+        initialValue = 0.5f,
+        targetValue = 0.8f,
+        animationSpec = infiniteRepeatable(
+            animation = tween(durationMillis = 2_500),
+            repeatMode = RepeatMode.Reverse,
+        ),
+        label = "translationThinkingAlpha",
+    )
+
+    Box(modifier = Modifier.fillMaxSize()) {
+        Surface(
+            modifier = Modifier
+                .align(Alignment.BottomCenter)
+                .navigationBarsPadding()
+                .padding(bottom = 88.dp)
+                .graphicsLayer { alpha = capsuleAlpha },
+            shape = RoundedCornerShape(24.dp),
+            color = LegadoTheme.colorScheme.surfaceContainer.copy(alpha = 0.72f),
+            contentColor = LegadoTheme.colorScheme.onSurface,
+            tonalElevation = 2.dp,
+        ) {
+            Text(
+                text = stringResource(R.string.translation_model_thinking),
+                modifier = Modifier.padding(horizontal = 18.dp, vertical = 10.dp),
+                style = LegadoTheme.typography.labelLarge,
+            )
+        }
+    }
+}

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

@@ -2882,4 +2882,5 @@
     <string name="whole_book_page_format">全文 %1$d / %2$d</string>
     <string name="whole_book_page_format">全文 %1$d / %2$d</string>
     <string name="whole_book_page_estimated_format">全文 %1$s / %2$s</string>
     <string name="whole_book_page_estimated_format">全文 %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">全文 - / -</string>
     <string name="whole_book_page_unavailable">全文 - / -</string>
+    <string name="translation_model_thinking">模型思考中</string>
 </resources>
 </resources>

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

@@ -2667,4 +2667,5 @@
     <string name="lab_page_estimate_diagnostics_count">%1$d 條</string>
     <string name="lab_page_estimate_diagnostics_count">%1$d 條</string>
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
+    <string name="translation_model_thinking">模型思考中</string>
 </resources>
 </resources>

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

@@ -2672,4 +2672,5 @@
     <string name="lab_page_estimate_diagnostics_count">%1$d 筆</string>
     <string name="lab_page_estimate_diagnostics_count">%1$d 筆</string>
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
+    <string name="translation_model_thinking">模型思考中</string>
 </resources>
 </resources>

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

@@ -2893,4 +2893,5 @@
     <string name="whole_book_page_format">Book %1$d / %2$d</string>
     <string name="whole_book_page_format">Book %1$d / %2$d</string>
     <string name="whole_book_page_estimated_format">Book %1$s / %2$s</string>
     <string name="whole_book_page_estimated_format">Book %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">Book - / -</string>
     <string name="whole_book_page_unavailable">Book - / -</string>
+    <string name="translation_model_thinking">Model is thinking</string>
 </resources>
 </resources>

+ 74 - 0
app/src/test/java/io/legado/app/data/repository/DictionaryRepositoryImplTest.kt

@@ -0,0 +1,74 @@
+package io.legado.app.data.repository
+
+import android.app.Application
+import io.legado.app.data.entities.Book
+import io.legado.app.domain.model.DictPair
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+import java.util.UUID
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.Executors
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class DictionaryRepositoryImplTest {
+
+    private lateinit var repository: DictionaryRepositoryImpl
+    private lateinit var book: Book
+
+    @Before
+    fun setUp() {
+        repository = DictionaryRepositoryImpl()
+        book = Book(
+            bookUrl = "dictionary-test-${UUID.randomUUID()}",
+            name = "Dictionary test",
+            author = "Codex",
+        )
+        repository.clearBookDictionary(book)
+    }
+
+    @After
+    fun tearDown() {
+        repository.clearBookDictionary(book)
+    }
+
+    @Test
+    fun `concurrent incremental merges retain every distinct term`() {
+        val executor = Executors.newFixedThreadPool(8)
+        val start = CountDownLatch(1)
+        try {
+            val futures = (1..24).map { index ->
+                executor.submit {
+                    start.await()
+                    repository.mergeDiscoveredPairs(
+                        book,
+                        listOf(DictPair("Name$index", "名字$index")),
+                    )
+                }
+            }
+            start.countDown()
+            futures.forEach { it.get() }
+
+            val dictionary = repository.getBookDictionaries(book)
+            assertEquals(24, dictionary.pairs.size)
+            assertEquals((1..24).map { "Name$it" }.toSet(), dictionary.pairs.map { it.original }.toSet())
+        } finally {
+            executor.shutdownNow()
+        }
+    }
+
+    @Test
+    fun `conflicting rediscovery keeps old value and timestamp`() {
+        val first = repository.mergeDiscoveredPairs(book, listOf(DictPair("Jack", "杰克")))
+        Thread.sleep(5)
+        val second = repository.mergeDiscoveredPairs(book, listOf(DictPair("jack", "杰克逊")))
+
+        assertEquals(first.updatedAt, second.updatedAt)
+        assertEquals(listOf(DictPair("Jack", "杰克")), second.pairs)
+    }
+}

+ 35 - 0
app/src/test/java/io/legado/app/domain/model/PartialTranslationAssemblerTest.kt

@@ -0,0 +1,35 @@
+package io.legado.app.domain.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class PartialTranslationAssemblerTest {
+
+    private val chunks = listOf(
+        TextChunk(0, "original zero", emptyList()),
+        TextChunk(1, "original one", emptyList()),
+        TextChunk(2, "original two", emptyList()),
+    )
+
+    @Test
+    fun `partial chunk replaces the whole current chunk`() {
+        val result = PartialTranslationAssembler.assemble(
+            originalChunks = chunks,
+            translatedMap = mapOf(0 to "translated zero"),
+            partialChunk = PartialTranslationAssembler.PartialChunkTranslation(1, "partial one"),
+        )
+
+        assertEquals("translated zero\n\npartial one\n\noriginal two", result)
+    }
+
+    @Test
+    fun `empty partial keeps original chunk`() {
+        val result = PartialTranslationAssembler.assemble(
+            originalChunks = chunks,
+            translatedMap = emptyMap(),
+            partialChunk = PartialTranslationAssembler.PartialChunkTranslation(1, ""),
+        )
+
+        assertEquals("original zero\n\noriginal one\n\noriginal two", result)
+    }
+}

+ 88 - 0
app/src/test/java/io/legado/app/domain/model/TranslationDictionaryPolicyTest.kt

@@ -0,0 +1,88 @@
+package io.legado.app.domain.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class TranslationDictionaryPolicyTest {
+
+    @Test
+    fun `normalization ignores whitespace case and honorific period`() {
+        assertEquals("mr jack", TranslationDictionaryPolicy.normalizeOriginal("  Mr.   Jack "))
+        assertEquals("mr jack", TranslationDictionaryPolicy.normalizeOriginal("mr jack"))
+    }
+
+    @Test
+    fun `existing translation wins normalized conflict`() {
+        val existing = listOf(DictPair("Jack", "杰克"))
+
+        val merged = TranslationDictionaryPolicy.mergeDiscoveredPairs(
+            existing,
+            listOf(DictPair("jack", "杰克逊")),
+        )
+
+        assertEquals(existing, merged)
+    }
+
+    @Test
+    fun `base name replaces an existing honorific form`() {
+        val merged = TranslationDictionaryPolicy.mergeDiscoveredPairs(
+            listOf(DictPair("Dr. Smith", "史密斯博士")),
+            listOf(DictPair("Smith", "史密斯")),
+        )
+
+        assertEquals(listOf(DictPair("Smith", "史密斯")), merged)
+    }
+
+    @Test
+    fun `honorific form is ignored when base name exists`() {
+        val existing = listOf(DictPair("Jack", "杰克"))
+        val merged = TranslationDictionaryPolicy.mergeDiscoveredPairs(
+            existing,
+            listOf(DictPair("Mr. Jack", "杰克先生")),
+        )
+        assertEquals(existing, merged)
+    }
+
+    @Test
+    fun `ordinary containing terms are both preserved`() {
+        val merged = TranslationDictionaryPolicy.mergeDiscoveredPairs(
+            listOf(DictPair("York", "约克")),
+            listOf(DictPair("New York", "纽约")),
+        )
+        assertEquals(2, merged.size)
+    }
+
+    @Test
+    fun `proper noun validation rejects lowercase common terms`() {
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("Jack"))
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("Mr. Jack"))
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("New York"))
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("iPhone"))
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("NASA"))
+        assertTrue(TranslationDictionaryPolicy.isValidNewOriginal("The A"))
+        assertFalse(TranslationDictionaryPolicy.isValidNewOriginal("apple"))
+        assertFalse(TranslationDictionaryPolicy.isValidNewOriginal("beautiful house"))
+        assertFalse(TranslationDictionaryPolicy.isValidNewOriginal("A"))
+        assertFalse(TranslationDictionaryPolicy.isValidNewOriginal("123"))
+        assertFalse(TranslationDictionaryPolicy.isValidNewOriginal("..."))
+    }
+
+    @Test
+    fun `relevance uses word boundaries and filters generic split tokens`() {
+        val pairs = listOf(
+            DictPair("Ann", "安"),
+            DictPair("Mr. Jack", "杰克"),
+            DictPair("New York", "纽约"),
+            DictPair("The A", "A"),
+        )
+
+        val relevant = TranslationDictionaryPolicy.selectRelevantPairs(
+            pairs,
+            "Anna met Jack in York. The answer was a surprise.",
+        )
+
+        assertEquals(listOf("Mr. Jack", "New York"), relevant.map { it.original })
+    }
+}

+ 48 - 0
app/src/test/java/io/legado/app/domain/model/TranslationResultPreviewParserTest.kt

@@ -0,0 +1,48 @@
+package io.legado.app.domain.model
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class TranslationResultPreviewParserTest {
+
+    @Test
+    fun `marker and text can span deltas`() {
+        val parser = TranslationResultPreviewParser()
+        val firstBatch = "甲".repeat(1_024)
+
+        assertEquals(emptyList<String>(), parser.feed("[dictionary]\nJack -> 杰克\n[res"))
+        assertEquals(emptyList<String>(), parser.feed("ult]${firstBatch.dropLast(1)}"))
+        assertEquals(listOf(firstBatch), parser.feed("甲\n第二行"))
+    }
+
+    @Test
+    fun `preview waits for the next newline after batch threshold`() {
+        val parser = TranslationResultPreviewParser()
+
+        assertEquals(emptyList<String>(), parser.feed("[result]${"一".repeat(1_024)}"))
+        assertEquals(emptyList<String>(), parser.feed("二"))
+        assertEquals(
+            listOf("${"一".repeat(1_024)}二"),
+            parser.feed("\n"),
+        )
+    }
+
+    @Test
+    fun `one delta can publish multiple cumulative batches`() {
+        val parser = TranslationResultPreviewParser()
+        val firstBatch = "一".repeat(1_024)
+        val secondBatch = "二".repeat(1_024)
+
+        assertEquals(
+            listOf(firstBatch, "$firstBatch\n$secondBatch"),
+            parser.feed("[RESULT]$firstBatch\r\n$secondBatch\n"),
+        )
+    }
+
+    @Test
+    fun `finish publishes final line without newline`() {
+        val parser = TranslationResultPreviewParser()
+        parser.feed("[result]\n最后一行")
+        assertEquals(listOf("最后一行"), parser.finish())
+    }
+}