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

refactor(R2.2): AI 域 1182 行摘出 ReadBookViewModel,状态独立成 ReadAiUiState

ReadBookViewModel 6671 → 5450 行。AI 域(章节摘要 / 划词净化 / 划词重写 /
重写预设)搬进 ReadAiDelegate,自持 MutableStateFlow<ReadAiUiState>,
不再挂在 ReadBookUiState 上——AI 流式输出每秒刷新多次,挂在阅读态上会让
整个 ReadBookUiState 反复 copy。

activeSheet 仍是 ReadBookUiState 的单一持有者:AI sheet 与阅读 sheet 的
互斥、返回键的 `activeSheet != null` 都零改动。delegate 通过 7 个方法的
Host 接口反向触达 sheet 开合 / 关菜单 / 正文重载 / Toast / 章节读取。

逻辑逐行未改,只有机械替换:viewModelScope→scope、6 处外部耦合换 Host
回调、4 个原本内联在 onIntent 里的 preset setter 提成方法。

章节读取(3 处 appDb.bookChapterDao)刻意不跟着搬:
legacyDaoInjectionBaseline 只统计文件名含 ViewModel 的文件,搬进 delegate
会掉进宽松的 legacyUiDaoAccessBaseline,等于把 VM 棘轮上的债洗白。改由
Host 回调,留给 R2.1 随 ReaderSession 一起溶解。两次 getChapter 合并成
一个 Host 方法,基线 36 → 35(棘轮强制下调)。

新护栏 ReadAiDomainBoundaryTest 三条,均已注入回归反证会变红:
1. AI 子状态挂回 ReadBookUiState → 红
2. AI 状态类型回流 ReadBookViewModel → 红
3. ReadAiDelegate 自带 DAO 直连 → 红(gradle verifyConfigArchitecture 也独立拦住)

验证:compileAppDebugKotlin / verifyConfigArchitecture / 417 单测全绿 /
lintAppDebug 0 error / assembleAppDebug 通过。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
5151561 3 месяцев назад
Родитель
Сommit
7d8baa6073

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

@@ -0,0 +1,91 @@
+package io.legado.app.ui.book.read
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+/**
+ * AI 域(章节摘要 / 划词净化 / 划词重写 / 重写预设)的状态契约。
+ *
+ * 由 [ReadAiDelegate] 独立持有,不再挂在 [ReadBookUiState] 上——AI 流式输出每秒刷新多次,
+ * 挂在阅读态上会让整个 ReadBookUiState 反复 copy。
+ * sheet 的开合仍由 [ReadBookUiState.activeSheet] 单一持有,保持互斥与返回键行为不变。
+ */
+@Stable
+data class ReadAiUiState(
+    val chapterSummary: ChapterSummaryUiState = ChapterSummaryUiState(),
+    val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
+    val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
+    val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+)
+
+@Stable
+data class ChapterSummaryUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val summary: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiTextCleanUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val isApplying: Boolean = false,
+    val originalText: String = "",
+    val replacementText: String = "",
+    val streamingText: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiRewritePresetUi(
+    val id: String,
+    val name: String,
+    val instruction: String,
+)
+
+@Stable
+data class AiRewriteHistoryUi(
+    val artifactId: String,
+    val text: String,
+    val timeText: String,
+)
+
+@Stable
+data class AiTextRewriteUiState(
+    val bookUrl: String = "",
+    val chapterIndex: Int = -1,
+    val chapterTitle: String = "",
+    val isLoading: Boolean = false,
+    val isApplying: Boolean = false,
+    val originalText: String = "",
+    val rewrittenText: String = "",
+    val reasoningText: String = "",
+    val thinkingDuration: Int = 0,
+    val selectedPresetId: String = "",
+    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
+    val temporaryInstruction: String = "",
+    val history: ImmutableList<AiRewriteHistoryUi> = persistentListOf(),
+    val referenceCount: Int = 0,
+    val errorMessage: String? = null,
+)
+
+@Stable
+data class AiRewritePresetConfigUiState(
+    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
+    val editing: Boolean = false,
+    val editingPresetId: String? = null,
+    val editingName: String = "",
+    val editingInstruction: String = "",
+    val deletePreset: AiRewritePresetUi? = null,
+    val errorMessage: String? = null,
+)

+ 1348 - 0
app/src/main/java/io/legado/app/ui/book/read/ReadAiDelegate.kt

@@ -0,0 +1,1348 @@
+package io.legado.app.ui.book.read
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.data.entities.AiPromptPreset
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.data.entities.BookContentProcess
+import io.legado.app.domain.gateway.AiArtifactGateway
+import io.legado.app.domain.gateway.AiPromptPresetGateway
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.usecase.AiTextFactoryUseCase
+import io.legado.app.domain.usecase.CleanSelectedTextUseCase
+import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
+import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.help.config.ReadBookConfig
+import io.legado.app.model.ReadBook
+import io.legado.app.model.translation.TranslationManager
+import io.legado.app.utils.MD5Utils
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers.IO
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.ensureActive
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.Date
+import java.util.UUID
+import kotlin.coroutines.coroutineContext
+
+/**
+ * 阅读页 AI 域:章节摘要、划词净化、划词重写、重写预设配置。
+ *
+ * 自持 [ReadAiUiState],与阅读态解耦;只通过 [Host] 反向触达阅读页的
+ * sheet 开合、菜单关闭、正文重载与 Toast。
+ *
+ * 章节读取仍走 [Host]——AI 域不自带 DAO 直连,避免把 `legacyDaoInjectionBaseline`
+ * 的 ViewModel 棘轮债洗进宽松的 `legacyUiDaoAccessBaseline`。这几处随 R2.1
+ * 的 `ReaderSession` 溶解一并清理。
+ */
+class ReadAiDelegate(
+    private val context: Context,
+    private val scope: CoroutineScope,
+    private val host: Host,
+    private val generateChapterSummaryUseCase: GenerateChapterSummaryUseCase,
+    private val cleanSelectedTextUseCase: CleanSelectedTextUseCase,
+    private val aiTextFactoryUseCase: AiTextFactoryUseCase,
+    private val saveBookContentProcessUseCase: SaveBookContentProcessUseCase,
+    private val aiArtifactGateway: AiArtifactGateway,
+    private val aiPromptPresetGateway: AiPromptPresetGateway,
+) {
+
+    /** AI 域对阅读页的全部反向依赖,实现在 `ReadBookViewModel`。 */
+    interface Host {
+        /** 当前活动 sheet,用于判断流式回调是否还属于当前会话。 */
+        val activeSheet: ReadBookSheet?
+
+        /** 当前章节名,开摘要/净化时作为标题回退值。 */
+        val chapterName: String
+
+        fun setActiveSheet(sheet: ReadBookSheet?)
+
+        fun closeReadMenu()
+
+        fun showToast(message: String)
+
+        fun reloadChapterAfterContentProcessChanged(bookUrl: String, chapterIndex: Int)
+
+        fun findChapter(bookUrl: String, chapterIndex: Int): BookChapter?
+
+        fun listChapters(bookUrl: String): List<BookChapter>
+    }
+
+    private val _uiState = MutableStateFlow(ReadAiUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private var chapterSummaryJob: Job? = null
+    private var aiTextCleanJob: Job? = null
+    private var aiTextRewriteJob: Job? = null
+    private var pendingAiTextCleanRequest: PendingAiTextCleanRequest? = null
+    private var pendingAiTextRewriteRequest: PendingAiTextRewriteRequest? = null
+
+    /**
+     * 关闭 sheet 时的收尾:取消在途任务并清空对应子状态。
+     * `activeSheet` 本身由 `ReadBookViewModel` 统一置空。
+     */
+    fun onSheetDismissed(sheet: ReadBookSheet?) {
+        when (sheet) {
+            is ReadBookSheet.ChapterSummary -> {
+                chapterSummaryJob?.cancel()
+                _uiState.update { it.copy(chapterSummary = ChapterSummaryUiState()) }
+            }
+
+            is ReadBookSheet.AiTextClean -> {
+                aiTextCleanJob?.cancel()
+                pendingAiTextCleanRequest = null
+                _uiState.update { it.copy(aiTextClean = AiTextCleanUiState()) }
+            }
+
+            is ReadBookSheet.AiRewritePresetConfig -> {
+                _uiState.update { it.copy(aiRewritePresetConfig = AiRewritePresetConfigUiState()) }
+            }
+
+            else -> Unit
+        }
+    }
+
+    // --- 章节摘要 ---
+
+    fun openChapterSummary() {
+        val book = ReadBook.book ?: return
+        val chapterIndex = ReadBook.durChapterIndex
+        val chapterTitle = host.chapterName
+        host.closeReadMenu()
+        _uiState.update {
+            it.copy(
+                chapterSummary = ChapterSummaryUiState(
+                    bookUrl = book.bookUrl,
+                    chapterIndex = chapterIndex,
+                    chapterTitle = chapterTitle,
+                    isLoading = true,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.ChapterSummary)
+        generateChapterSummary(book.bookUrl, chapterIndex)
+    }
+
+    fun retryChapterSummary() {
+        val summary = _uiState.value.chapterSummary
+        if (summary.bookUrl.isBlank() || summary.chapterIndex < 0) return
+        _uiState.update {
+            it.copy(
+                chapterSummary = summary.copy(
+                    isLoading = true,
+                    summary = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateChapterSummary(summary.bookUrl, summary.chapterIndex)
+    }
+
+    private fun generateChapterSummary(bookUrl: String, chapterIndex: Int) {
+        chapterSummaryJob?.cancel()
+        chapterSummaryJob = scope.launch {
+            val book = ReadBook.book
+            if (book == null || book.bookUrl != bookUrl) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.ai_chapter_changed),
+                )
+                return@launch
+            }
+            val chapter = withContext(IO) {
+                host.findChapter(bookUrl, chapterIndex)
+            }
+            if (chapter == null) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.no_chapter),
+                )
+                return@launch
+            }
+            val content = withContext(IO) {
+                getEffectiveChapterContent(book, chapter)
+            }
+            if (content.isBlank()) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    context.getString(R.string.ai_chapter_content_unavailable),
+                )
+                return@launch
+            }
+            try {
+                withContext(IO) {
+                    generateChapterSummaryUseCase.start(
+                        book = book,
+                        bookChapter = chapter,
+                        contentOverride = content,
+                    )
+                }
+                generateChapterSummaryUseCase.observeTask(bookUrl, chapterIndex).collect { task ->
+                    if (!isCurrentChapterSummary(
+                            bookUrl,
+                            chapterIndex
+                        ) || task == null
+                    ) return@collect
+                    _uiState.update { state ->
+                        val summary = state.chapterSummary
+                        when (task.status) {
+                            AiArtifact.STATUS_RUNNING -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = true,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
+
+                            AiArtifact.STATUS_SUCCESS -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
+
+                            AiArtifact.STATUS_FAILED -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    errorMessage = task.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                ),
+                            )
+
+                            else -> state
+                        }
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                updateChapterSummaryError(
+                    bookUrl,
+                    chapterIndex,
+                    aiErrorMessage(error),
+                )
+            }
+        }
+    }
+
+    private fun getEffectiveChapterContent(book: Book, chapter: BookChapter): String {
+        val sourceContent = if (book.getTranslationMode()) {
+            TranslationManager.getCachedTranslation(book, chapter)
+                ?: BookHelp.getContent(book, chapter)
+        } else {
+            BookHelp.getContent(book, chapter)
+        } ?: return ""
+        return ContentProcessor.get(book)
+            .getContent(book, chapter, sourceContent, includeTitle = false)
+            .toString()
+    }
+
+    private fun isCurrentChapterSummary(bookUrl: String, chapterIndex: Int): Boolean {
+        val summary = _uiState.value.chapterSummary
+        return host.activeSheet is ReadBookSheet.ChapterSummary &&
+                summary.bookUrl == bookUrl &&
+                summary.chapterIndex == chapterIndex
+    }
+
+    private fun updateChapterSummaryError(
+        bookUrl: String,
+        chapterIndex: Int,
+        message: String,
+    ) {
+        if (!isCurrentChapterSummary(bookUrl, chapterIndex)) return
+        _uiState.update {
+            it.copy(
+                chapterSummary = it.chapterSummary.copy(
+                    isLoading = false,
+                    errorMessage = message,
+                )
+            )
+        }
+    }
+
+    // --- 划词净化 ---
+
+    fun openAiTextClean(
+        text: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+    ) {
+        val book = ReadBook.book ?: return
+        if (text.isBlank()) {
+            host.showToast(context.getString(R.string.ai_text_clean_empty_selection))
+            return
+        }
+        if (chapterIndex != ReadBook.durChapterIndex) {
+            host.showToast(context.getString(R.string.ai_chapter_changed))
+            return
+        }
+        val chapterTitle = host.chapterName
+        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val (contextBefore, contextAfter) = buildSelectionContext(
+            content = visibleContent,
+            selectedText = text,
+            approximatePosition = chapterPosition,
+        )
+        val request = PendingAiTextCleanRequest(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            chapterTitle = chapterTitle,
+            chapterPosition = chapterPosition,
+            originalText = text,
+            contextBefore = contextBefore,
+            contextAfter = contextAfter,
+        )
+        pendingAiTextCleanRequest = request
+        host.closeReadMenu()
+        _uiState.update {
+            it.copy(
+                aiTextClean = AiTextCleanUiState(
+                    bookUrl = request.bookUrl,
+                    chapterIndex = request.chapterIndex,
+                    chapterTitle = request.chapterTitle,
+                    isLoading = true,
+                    originalText = request.originalText,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiTextClean)
+        generateAiTextClean(request)
+    }
+
+    fun retryAiTextClean() {
+        val request = pendingAiTextCleanRequest ?: return
+        _uiState.update {
+            it.copy(
+                aiTextClean = it.aiTextClean.copy(
+                    isLoading = true,
+                    replacementText = "",
+                    streamingText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateAiTextClean(request)
+    }
+
+    private fun generateAiTextClean(request: PendingAiTextCleanRequest) {
+        aiTextCleanJob?.cancel()
+        aiTextCleanJob = scope.launch {
+            try {
+                val taskId = withContext(IO) {
+                    cleanSelectedTextUseCase.start(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        selectedText = request.originalText,
+                        contextBefore = request.contextBefore,
+                        contextAfter = request.contextAfter,
+                    )
+                }
+                cleanSelectedTextUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
+                    if (!isCurrentAiTextClean(request)) return@collect
+                    val clean = _uiState.value.aiTextClean
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = true,
+                                    streamingText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_SUCCESS -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    replacementText = snapshot.output.orEmpty(),
+                                    streamingText = "",
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                )
+                            )
+                        }
+
+                        else -> Unit
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                if (isCurrentAiTextClean(request)) {
+                    _uiState.update {
+                        it.copy(
+                            aiTextClean = it.aiTextClean.copy(
+                                isLoading = false,
+                                errorMessage = aiErrorMessage(error),
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun confirmAiTextClean() {
+        val cleanState = _uiState.value.aiTextClean
+        val book = ReadBook.book ?: return
+        if (cleanState.isLoading || cleanState.isApplying || cleanState.errorMessage != null) return
+        if (book.bookUrl != cleanState.bookUrl ||
+            ReadBook.durChapterIndex != cleanState.chapterIndex
+        ) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_chapter_changed)
+                    )
+                )
+            }
+            return
+        }
+        val pattern = normalizeAiReplacementText(cleanState.originalText)
+        val replacement = normalizeAiReplacementText(cleanState.replacementText)
+        if (pattern.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_empty_selection)
+                    )
+                )
+            }
+            return
+        }
+        if (pattern == replacement) {
+            _uiState.update {
+                it.copy(
+                    aiTextClean = it.aiTextClean.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_no_change)
+                    )
+                )
+            }
+            return
+        }
+
+        _uiState.update {
+            it.copy(aiTextClean = it.aiTextClean.copy(isApplying = true))
+        }
+        scope.launch {
+            try {
+                saveBookContentProcessUseCase.saveReplacement(
+                    bookUrl = cleanState.bookUrl,
+                    chapterIndex = cleanState.chapterIndex,
+                    chapterPosition = pendingAiTextCleanRequest?.chapterPosition ?: 0,
+                    selectedText = pattern,
+                    contextBefore = pendingAiTextCleanRequest?.contextBefore.orEmpty(),
+                    contextAfter = pendingAiTextCleanRequest?.contextAfter.orEmpty(),
+                    replacementText = replacement,
+                ).getOrThrow()
+                host.reloadChapterAfterContentProcessChanged(
+                    bookUrl = cleanState.bookUrl,
+                    chapterIndex = cleanState.chapterIndex,
+                )
+                pendingAiTextCleanRequest = null
+                host.setActiveSheet(null)
+                _uiState.update { it.copy(aiTextClean = AiTextCleanUiState()) }
+                host.showToast(context.getString(R.string.ai_text_clean_rule_created))
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                _uiState.update {
+                    it.copy(
+                        aiTextClean = it.aiTextClean.copy(
+                            isApplying = false,
+                            errorMessage = error.localizedMessage
+                                ?: context.getString(R.string.error),
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    // --- 划词重写 ---
+
+    fun openAiCurrentChapterRewrite() {
+        val book = ReadBook.book ?: return
+        val chapter = ReadBook.curTextChapter?.chapter ?: return
+        scope.launch {
+            val text = withContext(IO) {
+                getEffectiveChapterContent(book, chapter).trim()
+            }
+            if (text.isBlank()) {
+                host.showToast(context.getString(R.string.ai_chapter_content_unavailable))
+                return@launch
+            }
+            openAiTextRewrite(
+                text = text,
+                chapterIndex = ReadBook.durChapterIndex,
+                chapterPosition = 0,
+            )
+        }
+    }
+
+    fun openAiTextRewrite(
+        text: String,
+        chapterIndex: Int,
+        chapterPosition: Int,
+    ) {
+        val book = ReadBook.book ?: return
+        val chapterTitle = host.findChapter(book.bookUrl, chapterIndex)?.title
+            ?: host.chapterName
+        val visibleContent = ReadBook.curTextChapter?.getContent().orEmpty()
+        val (contextBefore, contextAfter) = buildSelectionContext(
+            content = visibleContent,
+            selectedText = text,
+            approximatePosition = chapterPosition,
+        )
+        val presets = loadAiRewritePresets()
+        val selectedPresetId = _uiState.value.aiTextRewrite.selectedPresetId
+            .takeIf { id -> presets.any { it.id == id } }
+            ?: presets.firstOrNull()?.id.orEmpty()
+        val request = PendingAiTextRewriteRequest(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            chapterTitle = chapterTitle,
+            chapterPosition = chapterPosition,
+            originalText = text,
+            sourceContentHash = buildAiRewriteSourceContentHash(text),
+            contextBefore = contextBefore,
+            contextAfter = contextAfter,
+        )
+        pendingAiTextRewriteRequest = request
+        host.closeReadMenu()
+        val currentRewriteState = _uiState.value.aiTextRewrite
+        val isSameRewriteTarget = currentRewriteState.bookUrl == request.bookUrl &&
+                currentRewriteState.chapterIndex == request.chapterIndex &&
+                currentRewriteState.originalText == request.originalText
+        if (!isSameRewriteTarget) {
+            aiTextRewriteJob?.cancel()
+        }
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = if (isSameRewriteTarget) {
+                    it.aiTextRewrite.copy(
+                        chapterTitle = request.chapterTitle,
+                        selectedPresetId = selectedPresetId,
+                        presets = presets.toImmutableList(),
+                    )
+                } else {
+                    AiTextRewriteUiState(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        originalText = request.originalText,
+                        selectedPresetId = selectedPresetId,
+                        presets = presets.toImmutableList(),
+                    )
+                },
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiTextRewrite)
+        loadAiRewriteHistory(request, selectLatest = !isSameRewriteTarget)
+    }
+
+    fun selectAiRewritePreset(presetId: String) {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    selectedPresetId = presetId,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewriteTemporaryInstruction(instruction: String) {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    temporaryInstruction = instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun selectAiRewriteHistory(artifactId: String) {
+        val historyItem = _uiState.value.aiTextRewrite.history
+            .firstOrNull { it.artifactId == artifactId }
+            ?: return
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    rewrittenText = historyItem.text,
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    private fun loadAiRewriteHistory(
+        request: PendingAiTextRewriteRequest,
+        selectLatest: Boolean,
+    ) {
+        scope.launch {
+            val history = withContext(IO) {
+                aiArtifactGateway.getArtifactsByContentHash(
+                    bookUrl = request.bookUrl,
+                    chapterIndex = request.chapterIndex,
+                    taskType = AiTaskType.REWRITE_TEXT,
+                    contentHash = request.sourceContentHash,
+                ).mapNotNull { artifact ->
+                    val text = artifact.output?.takeIf { it.isNotBlank() }
+                        ?: return@mapNotNull null
+                    AiRewriteHistoryUi(
+                        artifactId = artifact.id,
+                        text = text,
+                        timeText = formatAiRewriteHistoryTime(artifact.updatedAt),
+                    )
+                }
+            }
+            val latest = history.firstOrNull()
+            _uiState.update { state ->
+                if (
+                    state.aiTextRewrite.bookUrl != request.bookUrl ||
+                    state.aiTextRewrite.chapterIndex != request.chapterIndex ||
+                    state.aiTextRewrite.originalText != request.originalText
+                ) {
+                    state
+                } else {
+                    state.copy(
+                        aiTextRewrite = state.aiTextRewrite.copy(
+                            history = history.toImmutableList(),
+                            rewrittenText = if (
+                                selectLatest &&
+                                !state.aiTextRewrite.isLoading &&
+                                state.aiTextRewrite.rewrittenText.isBlank() &&
+                                latest != null
+                            ) {
+                                latest.text
+                            } else {
+                                state.aiTextRewrite.rewrittenText
+                            },
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    private fun formatAiRewriteHistoryTime(timestamp: Long): String {
+        val date = Date(timestamp)
+        val dateText = android.text.format.DateFormat.getDateFormat(context).format(date)
+        val timeText = android.text.format.DateFormat.getTimeFormat(context).format(date)
+        return "$dateText $timeText"
+    }
+
+    private fun buildAiRewriteSourceContentHash(text: String): String {
+        return MD5Utils.md5Encode(normalizeAiReplacementText(text))
+    }
+
+    fun generateSelectedAiTextRewrite() {
+        val state = _uiState.value.aiTextRewrite
+        val preset = state.presets.firstOrNull { it.id == state.selectedPresetId }
+        if (preset == null) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_no_preset)
+                    )
+                )
+            }
+            return
+        }
+        val request = pendingAiTextRewriteRequest ?: return
+        generateAiTextRewrite(
+            request = request,
+            preset = preset,
+            temporaryInstruction = state.temporaryInstruction,
+        )
+    }
+
+    fun retryAiTextRewrite() {
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    isLoading = false,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        generateSelectedAiTextRewrite()
+    }
+
+    private fun generateAiTextRewrite(
+        request: PendingAiTextRewriteRequest,
+        preset: AiRewritePresetUi,
+        temporaryInstruction: String,
+    ) {
+        aiTextRewriteJob?.cancel()
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    isLoading = true,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                )
+            )
+        }
+        aiTextRewriteJob = scope.launch {
+            try {
+                val referenceContext = buildAiRewriteReferenceContext(request)
+                val aiRequest = AiTextFactoryUseCase.Request(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        inputText = request.originalText,
+                        taskType = AiTaskType.REWRITE_TEXT,
+                        userInstruction = buildAiRewriteInstruction(
+                            preset.instruction,
+                            temporaryInstruction,
+                        ),
+                        referenceText = referenceContext.text,
+                        skipCache = true,
+                        artifactContentHash = request.sourceContentHash,
+                    )
+                val taskId = withContext(IO) { aiTextFactoryUseCase.start(aiRequest) }
+                aiTextFactoryUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
+                    if (!isCurrentAiTextRewrite(request)) return@collect
+                    val rewrite = _uiState.value.aiTextRewrite
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = true,
+                                    rewrittenText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    referenceCount = referenceContext.count,
+                                    errorMessage = null,
+                                )
+                            )
+                        }
+
+                        AiArtifact.STATUS_SUCCESS -> {
+                            _uiState.update {
+                                it.copy(
+                                    aiTextRewrite = rewrite.copy(
+                                        isLoading = false,
+                                        rewrittenText = snapshot.output.orEmpty(),
+                                        reasoningText = snapshot.reasoning,
+                                        referenceCount = referenceContext.count,
+                                        errorMessage = null,
+                                    )
+                                )
+                            }
+                            loadAiRewriteHistory(request, selectLatest = false)
+                        }
+
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                )
+                            )
+                        }
+
+                        else -> Unit
+                    }
+                }
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                if (isCurrentAiTextRewrite(request)) {
+                    _uiState.update {
+                        it.copy(
+                            aiTextRewrite = it.aiTextRewrite.copy(
+                                isLoading = false,
+                                errorMessage = aiErrorMessage(error),
+                            )
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun confirmAiTextRewrite() {
+        val rewriteState = _uiState.value.aiTextRewrite
+        val book = ReadBook.book ?: return
+        if (rewriteState.isLoading || rewriteState.isApplying || rewriteState.errorMessage != null) return
+        if (book.bookUrl != rewriteState.bookUrl ||
+            ReadBook.durChapterIndex != rewriteState.chapterIndex
+        ) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_chapter_changed)
+                    )
+                )
+            }
+            return
+        }
+        val pattern = normalizeAiReplacementText(rewriteState.originalText)
+        val replacement = normalizeAiReplacementText(rewriteState.rewrittenText)
+        if (pattern.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_empty_selection)
+                    )
+                )
+            }
+            return
+        }
+        if (replacement.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_empty_result)
+                    )
+                )
+            }
+            return
+        }
+        if (pattern == replacement) {
+            _uiState.update {
+                it.copy(
+                    aiTextRewrite = it.aiTextRewrite.copy(
+                        errorMessage = context.getString(R.string.ai_text_clean_no_change)
+                    )
+                )
+            }
+            return
+        }
+
+        _uiState.update {
+            it.copy(aiTextRewrite = it.aiTextRewrite.copy(isApplying = true))
+        }
+        scope.launch {
+            try {
+                saveBookContentProcessUseCase.saveReplacement(
+                    bookUrl = rewriteState.bookUrl,
+                    chapterIndex = rewriteState.chapterIndex,
+                    chapterPosition = pendingAiTextRewriteRequest?.chapterPosition ?: 0,
+                    selectedText = pattern,
+                    contextBefore = pendingAiTextRewriteRequest?.contextBefore.orEmpty(),
+                    contextAfter = pendingAiTextRewriteRequest?.contextAfter.orEmpty(),
+                    replacementText = replacement,
+                    kind = BookContentProcess.KIND_AI_REWRITE,
+                ).getOrThrow()
+                host.reloadChapterAfterContentProcessChanged(
+                    bookUrl = rewriteState.bookUrl,
+                    chapterIndex = rewriteState.chapterIndex,
+                )
+                pendingAiTextRewriteRequest = null
+                host.setActiveSheet(null)
+                _uiState.update { it.copy(aiTextRewrite = AiTextRewriteUiState()) }
+                host.showToast(context.getString(R.string.ai_text_rewrite_saved))
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Exception) {
+                _uiState.update {
+                    it.copy(
+                        aiTextRewrite = it.aiTextRewrite.copy(
+                            isApplying = false,
+                            errorMessage = error.localizedMessage
+                                ?: context.getString(R.string.error),
+                        )
+                    )
+                }
+            }
+        }
+    }
+
+    // --- 重写预设配置 ---
+
+    fun openAiRewritePresetConfig() {
+        val presets = loadAiRewritePresets()
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = false,
+                    presets = presets.toImmutableList(),
+                    errorMessage = null,
+                ),
+            )
+        }
+        host.setActiveSheet(ReadBookSheet.AiRewritePresetConfig)
+    }
+
+    fun closeAiRewritePresetConfig() {
+        val nextSheet = if (pendingAiTextRewriteRequest != null) {
+            ReadBookSheet.AiTextRewrite
+        } else {
+            null
+        }
+        host.setActiveSheet(nextSheet)
+        _uiState.update { it.copy(aiRewritePresetConfig = AiRewritePresetConfigUiState()) }
+    }
+
+    fun startAddAiRewritePreset() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = true,
+                    editingPresetId = null,
+                    editingName = "",
+                    editingInstruction = "",
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun startEditAiRewritePreset(preset: AiRewritePresetUi) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = true,
+                    editingPresetId = preset.id,
+                    editingName = preset.name,
+                    editingInstruction = preset.instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewritePresetName(name: String) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editingName = name,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun setAiRewritePresetInstruction(instruction: String) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editingInstruction = instruction,
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun saveAiRewritePreset() {
+        val config = _uiState.value.aiRewritePresetConfig
+        val name = config.editingName.trim()
+        val instruction = config.editingInstruction.trim()
+        if (name.isBlank() || instruction.isBlank()) {
+            _uiState.update {
+                it.copy(
+                    aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                        errorMessage = context.getString(R.string.ai_rewrite_preset_empty)
+                    )
+                )
+            }
+            return
+        }
+        val editingId = config.editingPresetId
+        val savedPresets = if (editingId == null) {
+            config.presets + AiRewritePresetUi(
+                id = UUID.randomUUID().toString(),
+                name = name,
+                instruction = instruction,
+            )
+        } else {
+            config.presets.map { preset ->
+                if (preset.id == editingId) {
+                    preset.copy(name = name, instruction = instruction)
+                } else {
+                    preset
+                }
+            }
+        }
+        saveAiRewritePresets(savedPresets)
+        syncAiRewritePresets(savedPresets)
+        clearAiRewritePresetDraft()
+    }
+
+    fun clearAiRewritePresetDraft() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    editing = false,
+                    editingPresetId = null,
+                    editingName = "",
+                    editingInstruction = "",
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    fun requestDeleteAiRewritePreset(preset: AiRewritePresetUi) {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = preset)
+            )
+        }
+    }
+
+    fun dismissDeleteAiRewritePreset() {
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = null)
+            )
+        }
+    }
+
+    fun deleteAiRewritePreset() {
+        val deletePreset = _uiState.value.aiRewritePresetConfig.deletePreset ?: return
+        val savedPresets = _uiState.value.aiRewritePresetConfig.presets
+            .filterNot { it.id == deletePreset.id }
+        aiPromptPresetGateway.deletePresetSync(deletePreset.id)
+        syncAiRewritePresets(savedPresets)
+        _uiState.update {
+            it.copy(
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(deletePreset = null)
+            )
+        }
+    }
+
+    private fun syncAiRewritePresets(presets: List<AiRewritePresetUi>) {
+        val selected = _uiState.value.aiTextRewrite.selectedPresetId
+            .takeIf { id -> presets.any { it.id == id } }
+            ?: presets.firstOrNull()?.id.orEmpty()
+        _uiState.update {
+            it.copy(
+                aiTextRewrite = it.aiTextRewrite.copy(
+                    presets = presets.toImmutableList(),
+                    selectedPresetId = selected,
+                    rewrittenText = "",
+                    reasoningText = "",
+                    thinkingDuration = 0,
+                    referenceCount = 0,
+                    errorMessage = null,
+                ),
+                aiRewritePresetConfig = it.aiRewritePresetConfig.copy(
+                    presets = presets.toImmutableList(),
+                ),
+            )
+        }
+    }
+
+    private fun loadAiRewritePresets(): List<AiRewritePresetUi> {
+        if (aiPromptPresetGateway.countByTaskTypeSync(AiTaskType.REWRITE_TEXT) == 0) {
+            aiPromptPresetGateway.savePresetsSync(
+                defaultAiRewritePresets().mapIndexed { index, preset ->
+                    preset.toAiPromptPreset(index)
+                }
+            )
+        }
+        return aiPromptPresetGateway.getEnabledByTaskType(AiTaskType.REWRITE_TEXT)
+            .map { it.toAiRewritePresetUi() }
+    }
+
+    private fun saveAiRewritePresets(presets: List<AiRewritePresetUi>) {
+        aiPromptPresetGateway.savePresetsSync(
+            presets.mapIndexed { index, preset ->
+                preset.toAiPromptPreset(index)
+            }
+        )
+    }
+
+    private fun defaultAiRewritePresets(): List<AiRewritePresetUi> {
+        return listOf(
+            AiRewritePresetUi(
+                id = "default_polish",
+                name = context.getString(R.string.ai_rewrite_preset_polish_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_polish_instruction),
+            ),
+            AiRewritePresetUi(
+                id = "default_concise",
+                name = context.getString(R.string.ai_rewrite_preset_concise_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_concise_instruction),
+            ),
+            AiRewritePresetUi(
+                id = "default_dialogue",
+                name = context.getString(R.string.ai_rewrite_preset_dialogue_name),
+                instruction = context.getString(R.string.ai_rewrite_preset_dialogue_instruction),
+            ),
+        )
+    }
+
+    private fun AiRewritePresetUi.toAiPromptPreset(sortNumber: Int): AiPromptPreset {
+        val now = System.currentTimeMillis()
+        return AiPromptPreset(
+            id = id,
+            taskType = AiTaskType.REWRITE_TEXT,
+            name = name,
+            instruction = instruction,
+            builtIn = id.startsWith("default_"),
+            sortNumber = sortNumber,
+            createdAt = now,
+            updatedAt = now,
+        )
+    }
+
+    private fun AiPromptPreset.toAiRewritePresetUi(): AiRewritePresetUi {
+        return AiRewritePresetUi(
+            id = id,
+            name = name,
+            instruction = instruction,
+        )
+    }
+
+    private fun buildAiRewriteInstruction(
+        presetInstruction: String,
+        temporaryInstruction: String,
+    ): String {
+        val temporary = temporaryInstruction.trim()
+        if (temporary.isBlank()) return presetInstruction
+        return buildString {
+            append(presetInstruction)
+            append("\n\nTemporary instruction for this rewrite only:\n")
+            append(temporary)
+        }
+    }
+
+    private suspend fun buildAiRewriteReferenceContext(
+        request: PendingAiTextRewriteRequest,
+    ): AiRewriteReferenceContext = withContext(IO) {
+        val book = ReadBook.book
+            ?.takeIf { it.bookUrl == request.bookUrl }
+            ?: return@withContext AiRewriteReferenceContext()
+        val terms = extractAiRewriteReferenceTerms(request.originalText)
+        if (terms.isEmpty()) return@withContext AiRewriteReferenceContext()
+
+        val chapters = host.listChapters(request.bookUrl)
+            .asSequence()
+            .filter { it.index != request.chapterIndex }
+            .sortedWith(
+                compareBy<BookChapter> { kotlin.math.abs(it.index - request.chapterIndex) }
+                    .thenBy { it.index }
+            )
+            .take(AI_REWRITE_REFERENCE_SCAN_CHAPTERS)
+            .toList()
+
+        val excerpts = mutableListOf<String>()
+        for (chapter in chapters) {
+            coroutineContext.ensureActive()
+            val content = BookHelp.getContent(book, chapter) ?: continue
+            val term = terms.firstOrNull { term ->
+                chapter.title.contains(term) || content.contains(term)
+            } ?: continue
+            val excerpt = extractAiRewriteReferenceExcerpt(content, term)
+            if (excerpt.isBlank()) continue
+            excerpts += buildString {
+                append("Chapter ")
+                append(chapter.index + 1)
+                if (chapter.title.isNotBlank()) {
+                    append(": ")
+                    append(chapter.title)
+                }
+                append("\nKeyword: ")
+                append(term)
+                append("\n")
+                append(excerpt)
+            }
+            if (excerpts.size >= AI_REWRITE_REFERENCE_MAX_EXCERPTS) break
+        }
+
+        if (excerpts.isEmpty()) {
+            AiRewriteReferenceContext()
+        } else {
+            AiRewriteReferenceContext(
+                text = excerpts.joinToString("\n\n---\n\n"),
+                count = excerpts.size,
+            )
+        }
+    }
+
+    private fun extractAiRewriteReferenceTerms(text: String): List<String> {
+        val stopWords = setOf(
+            "自己", "他们", "她们", "你们", "我们", "这个", "那个", "什么", "只是", "没有",
+            "不是", "已经", "知道", "起来", "一下", "心里", "眼前", "声音", "时候", "突然",
+            "微微", "终于", "如果", "因为", "所以", "但是", "然后", "似乎", "仿佛", "开始",
+        )
+        val counts = linkedMapOf<String, Int>()
+        fun addTerm(term: String) {
+            val normalized = term.trim()
+            if (normalized.length < 2 || normalized in stopWords) return
+            counts[normalized] = (counts[normalized] ?: 0) + 1
+        }
+
+        Regex("""([\u4e00-\u9fa5]{2,4})(?:说|问|道|喊|叫|笑|答|叹|想|看|望|皱眉|点头|摇头)""")
+            .findAll(text)
+            .forEach { match -> addTerm(match.groupValues[1]) }
+        Regex("""\b[A-Z][A-Za-z]{2,}\b""")
+            .findAll(text)
+            .forEach { match -> addTerm(match.value) }
+
+        return counts.entries
+            .sortedWith(compareByDescending<Map.Entry<String, Int>> { it.value }.thenBy { it.key })
+            .map { it.key }
+            .take(6)
+    }
+
+    private fun extractAiRewriteReferenceExcerpt(content: String, term: String): String {
+        val paragraphs = content.lines()
+            .map { it.trim() }
+            .filter { it.isNotBlank() }
+        val paragraphIndex = paragraphs.indexOfFirst { it.contains(term) }
+        if (paragraphIndex >= 0) {
+            val start = (paragraphIndex - 1).coerceAtLeast(0)
+            val end = (paragraphIndex + 2).coerceAtMost(paragraphs.size)
+            return trimAiRewriteReferenceExcerpt(
+                paragraphs.subList(start, end).joinToString("\n"),
+                term,
+            )
+        }
+        return trimAiRewriteReferenceExcerpt(content, term)
+    }
+
+    private fun trimAiRewriteReferenceExcerpt(text: String, term: String): String {
+        if (text.length <= AI_REWRITE_REFERENCE_EXCERPT_CHARS) return text.trim()
+        val index = text.indexOf(term).takeIf { it >= 0 } ?: 0
+        val start = (index - AI_REWRITE_REFERENCE_EXCERPT_CHARS / 2).coerceAtLeast(0)
+        val end = (start + AI_REWRITE_REFERENCE_EXCERPT_CHARS).coerceAtMost(text.length)
+        return text.substring(start, end).trim()
+    }
+
+    private fun isCurrentAiTextClean(request: PendingAiTextCleanRequest): Boolean {
+        val clean = _uiState.value.aiTextClean
+        return host.activeSheet is ReadBookSheet.AiTextClean &&
+                clean.bookUrl == request.bookUrl &&
+                clean.chapterIndex == request.chapterIndex &&
+                clean.originalText == request.originalText
+    }
+
+    private fun isCurrentAiTextRewrite(request: PendingAiTextRewriteRequest): Boolean {
+        val rewrite = _uiState.value.aiTextRewrite
+        return rewrite.bookUrl == request.bookUrl &&
+                rewrite.chapterIndex == request.chapterIndex &&
+                rewrite.originalText == request.originalText
+    }
+
+    private fun buildSelectionContext(
+        content: String,
+        selectedText: String,
+        approximatePosition: Int,
+    ): Pair<String, String> {
+        if (content.isBlank()) return "" to ""
+        val start = findClosestOccurrence(content, selectedText, approximatePosition)
+        if (start < 0) return "" to ""
+        val end = start + selectedText.length
+        return content.substring((start - AI_TEXT_CONTEXT_CHARS).coerceAtLeast(0), start) to
+                content.substring(end, (end + AI_TEXT_CONTEXT_CHARS).coerceAtMost(content.length))
+    }
+
+    private fun findClosestOccurrence(
+        content: String,
+        selectedText: String,
+        approximatePosition: Int,
+    ): Int {
+        var match = content.indexOf(selectedText)
+        if (match < 0) return -1
+        var closest = match
+        var closestDistance = kotlin.math.abs(match - approximatePosition)
+        while (match >= 0) {
+            val distance = kotlin.math.abs(match - approximatePosition)
+            if (distance < closestDistance) {
+                closest = match
+                closestDistance = distance
+            }
+            match = content.indexOf(selectedText, match + 1)
+        }
+        return closest
+    }
+
+    private fun normalizeAiReplacementText(text: String): String {
+        val indent = ReadBookConfig.paragraphIndent
+        return text.lines()
+            .joinToString("\n") { line -> line.removePrefix(indent).trim() }
+            .trim()
+    }
+
+    private fun aiErrorMessage(error: Throwable): String {
+        return when {
+            error.message?.contains("No AI model configured", ignoreCase = true) == true ->
+                context.getString(R.string.ai_model_not_configured)
+
+            else -> error.localizedMessage ?: context.getString(R.string.error)
+        }
+    }
+}
+
+private const val AI_TEXT_CONTEXT_CHARS = 1000
+private const val AI_REWRITE_REFERENCE_SCAN_CHAPTERS = 80
+private const val AI_REWRITE_REFERENCE_MAX_EXCERPTS = 6
+private const val AI_REWRITE_REFERENCE_EXCERPT_CHARS = 600
+
+private data class PendingAiTextCleanRequest(
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapterPosition: Int,
+    val originalText: String,
+    val contextBefore: String,
+    val contextAfter: String,
+)
+
+private data class PendingAiTextRewriteRequest(
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val chapterTitle: String,
+    val chapterPosition: Int,
+    val originalText: String,
+    val sourceContentHash: String,
+    val contextBefore: String,
+    val contextAfter: String,
+)
+
+private data class AiRewriteReferenceContext(
+    val text: String = "",
+    val count: Int = 0,
+)

+ 1 - 75
app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt

@@ -191,77 +191,6 @@ data class ReadSheetConfigUiState(
     val configNames: ImmutableList<String> = persistentListOf(),
 )
 
-@Stable
-data class ChapterSummaryUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val summary: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiTextCleanUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val isApplying: Boolean = false,
-    val originalText: String = "",
-    val replacementText: String = "",
-    val streamingText: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiRewritePresetUi(
-    val id: String,
-    val name: String,
-    val instruction: String,
-)
-
-@Stable
-data class AiRewriteHistoryUi(
-    val artifactId: String,
-    val text: String,
-    val timeText: String,
-)
-
-@Stable
-data class AiTextRewriteUiState(
-    val bookUrl: String = "",
-    val chapterIndex: Int = -1,
-    val chapterTitle: String = "",
-    val isLoading: Boolean = false,
-    val isApplying: Boolean = false,
-    val originalText: String = "",
-    val rewrittenText: String = "",
-    val reasoningText: String = "",
-    val thinkingDuration: Int = 0,
-    val selectedPresetId: String = "",
-    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
-    val temporaryInstruction: String = "",
-    val history: ImmutableList<AiRewriteHistoryUi> = persistentListOf(),
-    val referenceCount: Int = 0,
-    val errorMessage: String? = null,
-)
-
-@Stable
-data class AiRewritePresetConfigUiState(
-    val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
-    val editing: Boolean = false,
-    val editingPresetId: String? = null,
-    val editingName: String = "",
-    val editingInstruction: String = "",
-    val deletePreset: AiRewritePresetUi? = null,
-    val errorMessage: String? = null,
-)
-
 @Stable
 data class ReadBookUiState(
     val book: Book? = null,
@@ -363,10 +292,7 @@ data class ReadBookUiState(
     val menuConfig: ReadMenuConfig = ReadMenuConfig(),
     val highlightRuleConfig: HighlightRuleConfigUiState = HighlightRuleConfigUiState(),
     val contentProcessConfig: ContentProcessConfigUiState = ContentProcessConfigUiState(),
-    val chapterSummary: ChapterSummaryUiState = ChapterSummaryUiState(),
-    val aiTextClean: AiTextCleanUiState = AiTextCleanUiState(),
-    val aiTextRewrite: AiTextRewriteUiState = AiTextRewriteUiState(),
-    val aiRewritePresetConfig: AiRewritePresetConfigUiState = AiRewritePresetConfigUiState(),
+    // AI 域状态见 ReadAiUiState —— 由 ReadAiDelegate 独立持有
     val eyeProtection: EyeProtectionUiState = EyeProtectionUiState(),
 ) {
     val menuVisible: Boolean

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

@@ -134,6 +134,7 @@ fun ReadBookRouteScreen(
     onOpenTtsCache: () -> Unit = {},
 ) {
     val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val aiState by viewModel.aiState.collectAsStateWithLifecycle()
     val readPreferences by viewModel.readPreferences.collectAsStateWithLifecycle()
     val textMenuState by controller.textMenuState.collectAsStateWithLifecycle()
     val context = LocalContext.current
@@ -615,6 +616,7 @@ fun ReadBookRouteScreen(
             }
             ReadBookScreen(
                 state = state,
+                aiState = aiState,
                 preferences = readPreferences,
                 onIntent = viewModel::onIntent,
                 onBack = { controller.closeReadBook() },

+ 5 - 4
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -66,6 +66,7 @@ import io.legado.app.model.BookCover as BookCoverModel
 @Composable
 fun ReadBookScreen(
     state: ReadBookUiState,
+    aiState: ReadAiUiState,
     preferences: ReadPreferences,
     onIntent: (ReadBookIntent) -> Unit,
     onBack: () -> Unit,
@@ -276,25 +277,25 @@ fun ReadBookScreen(
     )
     ChapterSummarySheet(
         show = state.activeSheet is ReadBookSheet.ChapterSummary,
-        state = state.chapterSummary,
+        state = aiState.chapterSummary,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiTextCleanSheet(
         show = state.activeSheet is ReadBookSheet.AiTextClean,
-        state = state.aiTextClean,
+        state = aiState.aiTextClean,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiTextRewriteSheet(
         show = state.activeSheet is ReadBookSheet.AiTextRewrite,
-        state = state.aiTextRewrite,
+        state = aiState.aiTextRewrite,
         onIntent = onIntent,
         onDismissRequest = dismissSheet,
     )
     AiRewritePresetConfigSheet(
         show = state.activeSheet is ReadBookSheet.AiRewritePresetConfig,
-        state = state.aiRewritePresetConfig,
+        state = aiState.aiRewritePresetConfig,
         onIntent = onIntent,
         onDismissRequest = { onIntent(ReadBookIntent.CloseAiRewritePresetConfig) },
     )

Разница между файлами не показана из-за своего большого размера
+ 194 - 1385
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt


+ 106 - 0
app/src/test/java/io/legado/app/ui/book/read/ReadAiDomainBoundaryTest.kt

@@ -0,0 +1,106 @@
+package io.legado.app.ui.book.read
+
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+import kotlin.reflect.full.primaryConstructor
+
+/**
+ * R2.2 —— AI 域摘除后的边界不变式。
+ *
+ * AI 域(章节摘要 / 划词净化 / 划词重写 / 重写预设)已从 `ReadBookViewModel` 摘成
+ * [ReadAiDelegate],状态独立成 [ReadAiUiState]。三条会悄悄失效的边界:
+ *
+ * 1. AI 子状态被重新塞回 [ReadBookUiState]——流式输出又开始 copy 整个阅读态;
+ * 2. AI 实现回流进 `ReadBookViewModel`——god object 重新长回来;
+ * 3. `ReadAiDelegate` 自己拿 DAO——`legacyDaoInjectionBaseline` 只认文件名含 `ViewModel`
+ *    的文件,delegate 里的 DAO 直连会掉进宽松的 `legacyUiDaoAccessBaseline`,
+ *    等于把 VM 棘轮上的债洗白。章节读取必须继续走 `ReadAiDelegate.Host`。
+ */
+class ReadAiDomainBoundaryTest {
+
+    @Test
+    fun `AI 子状态只挂在 ReadAiUiState 上`() {
+        assertEquals(
+            "ReadAiUiState 的字段与 AI 子状态不一致——新增/改名 AI 子状态时请同步本测试",
+            AI_STATE_FIELDS,
+            constructorParameterNames(ReadAiUiState::class),
+        )
+
+        val leaked = constructorParameterNames(ReadBookUiState::class)
+            .intersect(AI_STATE_FIELDS)
+        assertTrue(
+            "AI 子状态又挂回了 ReadBookUiState:${leaked.joinToString()}。\n" +
+                "AI 流式输出每秒刷新多次,挂在阅读态上会让整个 ReadBookUiState 反复 copy——" +
+                "请放进 ReadAiUiState,由 ReadAiDelegate 持有。",
+            leaked.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `ReadBookViewModel 不再持有 AI 域实现`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadBookViewModel.kt").readText()
+        val leaked = AI_STATE_TYPES.filter { it in source }
+        assertTrue(
+            "ReadBookViewModel 里又出现了 AI 域状态类型:${leaked.joinToString()}。\n" +
+                "AI 逻辑属于 ReadAiDelegate,VM 只做 `aiDelegate.xxx()` 转发和 Host 实现。",
+            leaked.isEmpty(),
+        )
+    }
+
+    @Test
+    fun `ReadAiDelegate 不自带 DAO 直连`() {
+        val source = mainSourceFile("io/legado/app/ui/book/read/ReadAiDelegate.kt").readText()
+        val violations = buildList {
+            if (APP_DB_DAO.containsMatchIn(source)) add("appDb.xxxDao 直连")
+            if (DAO_IMPORT.containsMatchIn(source)) add("import io.legado.app.data.dao.*")
+        }
+        assertTrue(
+            "ReadAiDelegate 出现了 ${violations.joinToString()}。\n" +
+                "build.gradle.kts 的 legacyDaoInjectionBaseline 只统计文件名含 `ViewModel` 的文件," +
+                "delegate 里的 DAO 直连会掉进宽松的 legacyUiDaoAccessBaseline,等于把 VM 棘轮上的债洗白。\n" +
+                "章节读取请继续走 ReadAiDelegate.Host,等 R2.1 随 ReaderSession 一起溶解。",
+            violations.isEmpty(),
+        )
+    }
+
+    private fun constructorParameterNames(type: kotlin.reflect.KClass<*>): Set<String> =
+        type.primaryConstructor?.parameters?.mapNotNull { it.name }?.toSet().orEmpty()
+
+    private companion object {
+        val AI_STATE_FIELDS = setOf(
+            "chapterSummary",
+            "aiTextClean",
+            "aiTextRewrite",
+            "aiRewritePresetConfig",
+        )
+
+        val AI_STATE_TYPES = listOf(
+            "ChapterSummaryUiState",
+            "AiTextCleanUiState",
+            "AiTextRewriteUiState",
+            "AiRewritePresetConfigUiState",
+            "AiRewritePresetUi",
+            "AiRewriteHistoryUi",
+        )
+
+        val APP_DB_DAO = Regex("""\bappDb\.[A-Za-z0-9_]*Dao\b""")
+        val DAO_IMPORT = Regex(
+            """^import io\.legado\.app\.data\.dao\.[A-Za-z0-9_*]+$""",
+            RegexOption.MULTILINE,
+        )
+
+        fun mainSourceFile(relativePath: String): File {
+            var directory: File? = File("").absoluteFile
+            while (directory != null) {
+                for (prefix in listOf("src/main/java", "app/src/main/java")) {
+                    val candidate = File(directory, "$prefix/$relativePath")
+                    if (candidate.isFile) return candidate
+                }
+                directory = directory.parentFile
+            }
+            error("从 ${File("").absolutePath} 向上找不到 $relativePath")
+        }
+    }
+}

+ 1 - 1
build.gradle.kts

@@ -219,7 +219,7 @@ val verifyConfigArchitecture = tasks.register<VerifyConfigArchitectureTask>(
     )
     legacyDaoInjectionBaseline.set(
         mapOf(
-            "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 36,
+            "io/legado/app/ui/book/read/ReadBookViewModel.kt" to 35,
             // 护栏缺席期间(MAD-3 未合并窗口)main 新增的直连,随合并冻结,清理归 Track A/F2
             "io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt" to 13,
         )

Некоторые файлы не были показаны из-за большого количества измененных файлов