HapeLee 4 месяцев назад
Родитель
Сommit
4f723dec22
18 измененных файлов с 665 добавлено и 193 удалено
  1. 20 0
      app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt
  2. 16 0
      app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt
  3. 117 0
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  4. 3 0
      app/src/main/java/io/legado/app/di/appModule.kt
  5. 7 0
      app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt
  6. 10 1
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  7. 67 31
      app/src/main/java/io/legado/app/domain/usecase/AiTextFactoryUseCase.kt
  8. 112 0
      app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt
  9. 22 8
      app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt
  10. 36 14
      app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt
  11. 9 0
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  12. 111 25
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  13. 106 52
      app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt
  14. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt
  15. 19 60
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt
  16. 1 1
      app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt
  17. 4 0
      app/src/main/res/values-zh-rCN/strings.xml
  18. 4 0
      app/src/main/res/values/strings.xml

+ 20 - 0
app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt

@@ -53,6 +53,26 @@ interface AiArtifactDao {
         limit: Int
     ): List<AiArtifact>
 
+    @Query(
+        """
+        select * from ai_artifacts
+        where bookUrl = :bookUrl
+          and taskType = :taskType
+          and chapterIndex = :chapterIndex
+          and contentHash = :contentHash
+          and status = ${AiArtifact.STATUS_SUCCESS}
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun queryArtifactsByContentHash(
+        bookUrl: String,
+        taskType: String,
+        chapterIndex: Int,
+        contentHash: String,
+        limit: Int
+    ): List<AiArtifact>
+
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     suspend fun upsert(artifact: AiArtifact)
 

+ 16 - 0
app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt

@@ -31,6 +31,22 @@ class AiArtifactRepository(
         )
     }
 
+    override suspend fun getArtifactsByContentHash(
+        bookUrl: String,
+        chapterIndex: Int,
+        taskType: String,
+        contentHash: String,
+        limit: Int
+    ): List<AiArtifact> {
+        return aiArtifactDao.queryArtifactsByContentHash(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            taskType = taskType,
+            contentHash = contentHash,
+            limit = limit
+        )
+    }
+
     override suspend fun upsertArtifact(artifact: AiArtifact) {
         aiArtifactDao.upsert(artifact)
     }

+ 117 - 0
app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt

@@ -43,6 +43,8 @@ class AiToolRepository(
             TOOL_GET_BOOK_DETAIL -> getBookDetail(args)
             TOOL_LIST_BOOK_CHAPTERS -> listBookChapters(args)
             TOOL_GET_CHAPTER_CONTENT -> getChapterContent(args)
+            TOOL_GET_CHAPTER_WINDOW -> getChapterWindow(args)
+            TOOL_SEARCH_CHAPTER_CONTENT -> searchChapterContent(args)
             TOOL_SEARCH_BOOKMARKS -> searchBookmarks(args)
             TOOL_GET_READING_STATS -> getReadingStats(args)
             TOOL_GET_AI_ARTIFACTS -> getAiArtifacts(args)
@@ -140,6 +142,86 @@ class AiToolRepository(
         )
     }
 
+    private fun getChapterWindow(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val centerChapterIndex = args.int("chapterIndex", book.durChapterIndex).coerceAtLeast(0)
+        val before = args.int("before", 1).coerceIn(0, 5)
+        val after = args.int("after", 1).coerceIn(0, 5)
+        val maxCharsPerChapter = args.int("maxCharsPerChapter", 2500).coerceIn(300, 6000)
+        val start = (centerChapterIndex - before).coerceAtLeast(0)
+        val end = centerChapterIndex + after
+        val chapters = bookChapterDao.getChapterList(book.bookUrl, start, end)
+            .map { chapter ->
+                val rawContent = BookHelp.getContent(book, chapter)
+                val processedContent = rawContent?.let {
+                    ContentProcessor.get(book.name, book.origin)
+                        .getContent(book, chapter, it, includeTitle = false)
+                        .toString()
+                }
+                mapOf(
+                    "index" to chapter.index,
+                    "title" to chapter.title,
+                    "isCenter" to (chapter.index == centerChapterIndex),
+                    "isCached" to (processedContent != null),
+                    "truncated" to ((processedContent?.length ?: 0) > maxCharsPerChapter),
+                    "content" to processedContent.orEmpty().take(maxCharsPerChapter)
+                )
+            }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "centerChapterIndex" to centerChapterIndex,
+                "chapters" to chapters
+            )
+        )
+    }
+
+    private fun searchChapterContent(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val query = args.string("query").orEmpty().trim()
+        if (query.isBlank()) return """{"error":"query is required"}"""
+        val aroundChapterIndex = args.int("aroundChapterIndex", book.durChapterIndex)
+        val limit = args.int("limit", 6).coerceIn(1, 20)
+        val maxChars = args.int("maxChars", 800).coerceIn(200, 2000)
+        val chapters = bookChapterDao.getChapterList(book.bookUrl)
+            .sortedWith(
+                compareBy<io.legado.app.data.entities.BookChapter> {
+                    kotlin.math.abs(it.index - aroundChapterIndex)
+                }.thenBy { it.index }
+            )
+        val matches = mutableListOf<Map<String, Any?>>()
+        for (chapter in chapters) {
+            if (matches.size >= limit) break
+            val rawContent = BookHelp.getContent(book, chapter) ?: continue
+            val content = ContentProcessor.get(book.name, book.origin)
+                .getContent(book, chapter, rawContent, includeTitle = false)
+                .toString()
+            val titleMatch = chapter.title.contains(query, ignoreCase = true)
+            val contentIndex = content.indexOf(query, ignoreCase = true)
+            if (!titleMatch && contentIndex < 0) continue
+            val excerpt = if (contentIndex >= 0) {
+                content.excerptAround(contentIndex, maxChars)
+            } else {
+                content.take(maxChars)
+            }
+            matches += mapOf(
+                "index" to chapter.index,
+                "title" to chapter.title,
+                "matchedTitle" to titleMatch,
+                "excerpt" to excerpt,
+                "truncated" to (excerpt.length < content.length)
+            )
+        }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "query" to query,
+                "aroundChapterIndex" to aroundChapterIndex,
+                "matches" to matches
+            )
+        )
+    }
+
     private fun searchBookmarks(args: JsonObject): String {
         val query = args.string("query").orEmpty().trim()
         val limit = args.int("limit", 10).coerceIn(1, 30)
@@ -381,6 +463,8 @@ class AiToolRepository(
         const val TOOL_GET_BOOK_DETAIL = "get_book_detail"
         const val TOOL_LIST_BOOK_CHAPTERS = "list_book_chapters"
         const val TOOL_GET_CHAPTER_CONTENT = "get_chapter_content"
+        const val TOOL_GET_CHAPTER_WINDOW = "get_chapter_window"
+        const val TOOL_SEARCH_CHAPTER_CONTENT = "search_chapter_content"
         const val TOOL_SEARCH_BOOKMARKS = "search_bookmarks"
         const val TOOL_GET_READING_STATS = "get_reading_stats"
         const val TOOL_GET_AI_ARTIFACTS = "get_ai_artifacts"
@@ -432,6 +516,32 @@ class AiToolRepository(
                     "maxChars" to intSchema("Maximum characters to return, capped by the app.")
                 )
             ),
+            AiToolDefinition(
+                name = TOOL_GET_CHAPTER_WINDOW,
+                description = "Read cached text for a chapter and its neighboring chapters in one call. Useful for continuity checks before rewriting or summarizing. This never downloads network content.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "chapterIndex" to intSchema("Zero-based center chapter index. Defaults to current reading chapter."),
+                    "before" to intSchema("How many previous chapters to include, capped by the app."),
+                    "after" to intSchema("How many following chapters to include, capped by the app."),
+                    "maxCharsPerChapter" to intSchema("Maximum characters to return per chapter, capped by the app.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SEARCH_CHAPTER_CONTENT,
+                description = "Search cached chapter text in a local bookshelf book by character name, plot keyword, place, or phrase. This never downloads network content.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "query" to stringSchema("Character name, plot keyword, place, or phrase to search in cached chapter text."),
+                    "aroundChapterIndex" to intSchema("Prefer matches closest to this zero-based chapter index. Defaults to current reading chapter."),
+                    "limit" to intSchema("Maximum number of matching chapters to return."),
+                    "maxChars" to intSchema("Maximum excerpt characters per match, capped by the app.")
+                )
+            ),
             AiToolDefinition(
                 name = TOOL_SEARCH_BOOKMARKS,
                 description = "Search local bookmarks and notes across the bookshelf or within one book.",
@@ -518,3 +628,10 @@ class AiToolRepository(
         }
     }
 }
+
+private fun String.excerptAround(index: Int, maxChars: Int): String {
+    if (length <= maxChars) return trim()
+    val start = (index - maxChars / 2).coerceAtLeast(0)
+    val end = (start + maxChars).coerceAtMost(length)
+    return substring(start, end).trim()
+}

+ 3 - 0
app/src/main/java/io/legado/app/di/appModule.kt

@@ -82,6 +82,7 @@ import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AiChatGenerationUseCase
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
+import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -269,6 +270,7 @@ val appModule = module {
     singleOf(::SearchBooksUseCase)
     singleOf(::ChangeSourceSearchUseCase)
     singleOf(::GetChapterContentUseCase)
+    singleOf(::AiToolAwareGenerationUseCase)
     singleOf(::GenerateChapterSummaryUseCase)
     singleOf(::AiTextFactoryUseCase)
     singleOf(::CleanSelectedTextUseCase)
@@ -369,6 +371,7 @@ val appModule = module {
             aiTextFactoryUseCase = get(),
             saveBookContentProcessUseCase = get(),
             bookContentProcessGateway = get(),
+            aiArtifactGateway = get(),
             aiPromptPresetGateway = get(),
         )
     }

+ 7 - 0
app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt

@@ -13,5 +13,12 @@ interface AiArtifactGateway {
         promptHash: String,
         modelProfileId: String
     ): AiArtifact?
+    suspend fun getArtifactsByContentHash(
+        bookUrl: String,
+        chapterIndex: Int,
+        taskType: String,
+        contentHash: String,
+        limit: Int = 20
+    ): List<AiArtifact>
     suspend fun upsertArtifact(artifact: AiArtifact)
 }

+ 10 - 1
app/src/main/java/io/legado/app/domain/model/AiModels.kt

@@ -323,7 +323,16 @@ data class AiGenerateRequest(
     val model: AiModelConfig,
     val messages: List<AiMessage>,
     val params: AiGenerationParams = AiGenerationParams(),
-    val tools: List<AiToolDefinition> = emptyList()
+    val tools: List<AiToolDefinition> = emptyList(),
+    val toolContext: AiToolContext? = null,
+)
+
+@Keep
+data class AiToolContext(
+    val bookUrl: String? = null,
+    val bookName: String? = null,
+    val chapterIndex: Int? = null,
+    val chapterTitle: String? = null,
 )
 
 @Keep

+ 67 - 31
app/src/main/java/io/legado/app/domain/usecase/AiTextFactoryUseCase.kt

@@ -4,13 +4,13 @@ import io.legado.app.data.entities.AiArtifact
 import io.legado.app.domain.gateway.AiArtifactGateway
 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.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiPromptTemplate
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.AiToolContext
 import io.legado.app.domain.model.ContentChunker
 import io.legado.app.utils.MD5Utils
 import kotlinx.coroutines.CancellationException
@@ -22,7 +22,7 @@ import kotlinx.coroutines.withContext
 
 class AiTextFactoryUseCase(
     private val aiProfileGateway: AiProfileGateway,
-    private val aiTextGateway: AiTextGateway,
+    private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
     private val aiArtifactGateway: AiArtifactGateway,
 ) {
 
@@ -35,6 +35,8 @@ class AiTextFactoryUseCase(
         val userInstruction: String,
         val referenceText: String = "",
         val maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
+        val skipCache: Boolean = false,
+        val artifactContentHash: String? = null,
     )
 
     sealed interface StreamEvent {
@@ -56,17 +58,21 @@ class AiTextFactoryUseCase(
                 text = request.inputText,
                 referenceText = request.referenceText,
             )
-            val contentHash = MD5Utils.md5Encode(userInput)
-            val promptHash = MD5Utils.md5Encode(systemPrompt)
+            val contentHash = request.artifactContentHash ?: MD5Utils.md5Encode(userInput)
+            val promptHash = MD5Utils.md5Encode(
+                systemPrompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+            )
 
-            aiArtifactGateway.getCachedArtifact(
-                bookUrl = request.bookUrl,
-                chapterIndex = request.chapterIndex,
-                taskType = request.taskType,
-                contentHash = contentHash,
-                promptHash = promptHash,
-                modelProfileId = preset.model.id,
-            )?.output?.let { return@runCatching it }
+            if (!request.skipCache) {
+                aiArtifactGateway.getCachedArtifact(
+                    bookUrl = request.bookUrl,
+                    chapterIndex = request.chapterIndex,
+                    taskType = request.taskType,
+                    contentHash = contentHash,
+                    promptHash = promptHash,
+                    modelProfileId = preset.model.id,
+                )?.output?.let { return@runCatching it }
+            }
 
             val chunks = ContentChunker.chunk(
                 request.inputText,
@@ -74,6 +80,7 @@ class AiTextFactoryUseCase(
             )
             if (chunks.isEmpty()) error("Failed to chunk input text")
 
+            val toolContext = request.toToolContext()
             val partialOutputs = chunks.map { chunk ->
                 generate(
                     preset = preset,
@@ -83,6 +90,7 @@ class AiTextFactoryUseCase(
                         text = chunk.content,
                         referenceText = request.referenceText,
                     ),
+                    toolContext = toolContext,
                 )
             }
             val output = if (partialOutputs.size == 1) {
@@ -92,12 +100,13 @@ class AiTextFactoryUseCase(
                     preset = preset,
                     systemPrompt = systemPrompt,
                     userContent = buildMergeUserInput(partialOutputs),
+                    toolContext = toolContext,
                 )
             }
             val now = System.currentTimeMillis()
             aiArtifactGateway.upsertArtifact(
                 AiArtifact(
-                    id = "${request.bookUrl}_${request.chapterIndex}_${request.taskType}_${contentHash}_${preset.model.id}",
+                    id = request.buildArtifactId(contentHash, preset.model.id, now),
                     taskType = request.taskType,
                     bookUrl = request.bookUrl,
                     chapterIndex = request.chapterIndex,
@@ -128,20 +137,24 @@ class AiTextFactoryUseCase(
             text = request.inputText,
             referenceText = request.referenceText,
         )
-        val contentHash = MD5Utils.md5Encode(userInput)
-        val promptHash = MD5Utils.md5Encode(systemPrompt)
+        val contentHash = request.artifactContentHash ?: MD5Utils.md5Encode(userInput)
+        val promptHash = MD5Utils.md5Encode(
+            systemPrompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+        )
 
-        aiArtifactGateway.getCachedArtifact(
-            bookUrl = request.bookUrl,
-            chapterIndex = request.chapterIndex,
-            taskType = request.taskType,
-            contentHash = contentHash,
-            promptHash = promptHash,
-            modelProfileId = preset.model.id,
-        )?.output?.let { cached ->
-            emit(StreamEvent.Content(cached))
-            emit(StreamEvent.Done(cached, ""))
-            return@flow
+        if (!request.skipCache) {
+            aiArtifactGateway.getCachedArtifact(
+                bookUrl = request.bookUrl,
+                chapterIndex = request.chapterIndex,
+                taskType = request.taskType,
+                contentHash = contentHash,
+                promptHash = promptHash,
+                modelProfileId = preset.model.id,
+            )?.output?.let { cached ->
+                emit(StreamEvent.Content(cached))
+                emit(StreamEvent.Done(cached, ""))
+                return@flow
+            }
         }
 
         val chunks = ContentChunker.chunk(
@@ -152,12 +165,14 @@ class AiTextFactoryUseCase(
 
         val outputBuilder = StringBuilder()
         val reasoningBuilder = StringBuilder()
+        val toolContext = request.toToolContext()
 
         if (chunks.size == 1) {
             collectGenerateStream(
                 preset = preset,
                 systemPrompt = systemPrompt,
                 userContent = userInput,
+                toolContext = toolContext,
                 outputBuilder = outputBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
@@ -172,12 +187,14 @@ class AiTextFactoryUseCase(
                         text = chunk.content,
                         referenceText = request.referenceText,
                     ),
+                    toolContext = toolContext,
                 )
             }
             val merged = generate(
                 preset = preset,
                 systemPrompt = systemPrompt,
                 userContent = buildMergeUserInput(partialOutputs),
+                toolContext = toolContext,
             )
             outputBuilder.append(merged)
             emit(StreamEvent.Content(merged))
@@ -189,7 +206,7 @@ class AiTextFactoryUseCase(
         val now = System.currentTimeMillis()
         aiArtifactGateway.upsertArtifact(
             AiArtifact(
-                id = "${request.bookUrl}_${request.chapterIndex}_${request.taskType}_${contentHash}_${preset.model.id}",
+                id = request.buildArtifactId(contentHash, preset.model.id, now),
                 taskType = request.taskType,
                 bookUrl = request.bookUrl,
                 chapterIndex = request.chapterIndex,
@@ -264,8 +281,9 @@ class AiTextFactoryUseCase(
         preset: AiTaskPresetConfig,
         systemPrompt: String,
         userContent: String,
+        toolContext: AiToolContext?,
     ): String {
-        val response = aiTextGateway.generate(
+        return aiToolAwareGenerationUseCase.generate(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
@@ -273,21 +291,21 @@ class AiTextFactoryUseCase(
                     AiMessage(AiMessageRole.USER, userContent),
                 ),
                 params = preset.params,
+                toolContext = toolContext,
             )
         )
-        return response.getOrThrow().text.trim()
-            .ifEmpty { error("AI returned empty text") }
     }
 
     private suspend fun collectGenerateStream(
         preset: AiTaskPresetConfig,
         systemPrompt: String,
         userContent: String,
+        toolContext: AiToolContext?,
         outputBuilder: StringBuilder,
         reasoningBuilder: StringBuilder,
         emitEvent: suspend (StreamEvent) -> Unit,
     ) {
-        aiTextGateway.generateStream(
+        aiToolAwareGenerationUseCase.generateStream(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
@@ -295,6 +313,7 @@ class AiTextFactoryUseCase(
                     AiMessage(AiMessageRole.USER, userContent),
                 ),
                 params = preset.params,
+                toolContext = toolContext,
             )
         ).collect { event ->
             when (event) {
@@ -313,6 +332,23 @@ class AiTextFactoryUseCase(
         }
     }
 
+    private fun Request.toToolContext(): AiToolContext {
+        return AiToolContext(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            chapterTitle = chapterTitle.takeIf { it.isNotBlank() },
+        )
+    }
+
+    private fun Request.buildArtifactId(
+        contentHash: String,
+        modelProfileId: String,
+        createdAt: Long,
+    ): String {
+        val baseId = "${bookUrl}_${chapterIndex}_${taskType}_${contentHash}_${modelProfileId}"
+        return if (skipCache) "${baseId}_$createdAt" else baseId
+    }
+
     private companion object {
         const val DEFAULT_MAX_CHARS_PER_CHUNK = 8000
     }

+ 112 - 0
app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt

@@ -0,0 +1,112 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.AiToolGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiToolContext
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+
+class AiToolAwareGenerationUseCase(
+    private val aiTextGateway: AiTextGateway,
+    private val aiToolGateway: AiToolGateway,
+) {
+
+    suspend fun generate(request: AiGenerateRequest): String {
+        val output = StringBuilder()
+        generateStream(request).collect { event ->
+            if (event is AiStreamEvent.Content) {
+                output.append(event.text)
+            }
+        }
+        return output.toString().trim()
+            .ifEmpty { error("AI returned empty text") }
+    }
+
+    fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent> = flow {
+        var currentRequest = request.withReadOnlyTools()
+        while (true) {
+            val toolTrace = ToolTraceBuilder()
+            val roundContent = StringBuilder()
+            toolTrace.beginResponse()
+
+            aiTextGateway.generateStream(currentRequest).collect { event ->
+                when (event) {
+                    is AiStreamEvent.Content -> {
+                        roundContent.append(event.text)
+                        emit(event)
+                    }
+
+                    is AiStreamEvent.Reasoning -> emit(event)
+                    is AiStreamEvent.ToolCallDelta -> toolTrace.append(event)
+                }
+            }
+
+            val toolCalls = toolTrace.pendingToolCalls()
+            if (toolCalls.isEmpty()) {
+                return@flow
+            }
+
+            val toolResultMessages = toolCalls.map { toolCall ->
+                val result = aiToolGateway.execute(toolCall)
+                AiMessage(
+                    role = AiMessageRole.TOOL,
+                    content = result.content.truncateToolOutput(),
+                    toolCallId = result.callId,
+                    name = result.name,
+                )
+            }
+            currentRequest = currentRequest.copy(
+                messages = currentRequest.messages +
+                    AiMessage(
+                        role = AiMessageRole.ASSISTANT,
+                        content = roundContent.toString(),
+                        toolCalls = toolCalls,
+                    ) +
+                    toolResultMessages,
+            )
+        }
+    }
+
+    private fun AiGenerateRequest.withReadOnlyTools(): AiGenerateRequest {
+        val readOnlyTools = aiToolGateway.availableTools()
+            .filterNot { aiToolGateway.requiresConfirmation(it.name) }
+        if (readOnlyTools.isEmpty()) return this
+        return copy(
+            messages = buildList {
+                add(TOOL_CONTEXT_MESSAGE)
+                toolContext?.toMessage()?.let { add(it) }
+                addAll(messages)
+            },
+            tools = readOnlyTools,
+        )
+    }
+
+    private fun AiToolContext.toMessage(): AiMessage? {
+        val lines = buildList {
+            bookUrl?.takeIf { it.isNotBlank() }?.let { add("bookUrl: $it") }
+            bookName?.takeIf { it.isNotBlank() }?.let { add("bookName: $it") }
+            chapterIndex?.let { add("chapterIndex: $it") }
+            chapterTitle?.takeIf { it.isNotBlank() }?.let { add("chapterTitle: $it") }
+        }
+        if (lines.isEmpty()) return null
+        return AiMessage(
+            role = AiMessageRole.SYSTEM,
+            content = "Current local book context for read-only tools:\n" +
+                lines.joinToString("\n") +
+                "\nWhen calling book tools, prefer these exact identifiers unless the user explicitly asks for another book.",
+        )
+    }
+
+    companion object {
+        const val CACHE_PROMPT_VERSION = "tool-aware-generation-v1"
+
+        private val TOOL_CONTEXT_MESSAGE = AiMessage(
+            role = AiMessageRole.SYSTEM,
+            content = "Read-only local book tools are available. Use them when needed to inspect the current book, list chapters, search cached chapter text by character or plot keyword, or read cached neighboring chapters for continuity. Use tools silently; the final response must still follow the original task output format.",
+        )
+    }
+}

+ 22 - 8
app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt

@@ -6,13 +6,13 @@ import io.legado.app.data.entities.AiArtifact
 import io.legado.app.domain.gateway.AiArtifactGateway
 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.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiPromptTemplate
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.AiToolContext
 import io.legado.app.utils.MD5Utils
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -23,7 +23,7 @@ import kotlinx.coroutines.withContext
 
 class CleanSelectedTextUseCase(
     private val aiProfileGateway: AiProfileGateway,
-    private val aiTextGateway: AiTextGateway,
+    private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
     private val aiArtifactGateway: AiArtifactGateway,
 ) {
 
@@ -60,7 +60,9 @@ class CleanSelectedTextUseCase(
                 contextAfter = contextAfter.take(MAX_CONTEXT_CHARS),
             )
             val contentHash = MD5Utils.md5Encode(input)
-            val promptHash = MD5Utils.md5Encode(prompt)
+            val promptHash = MD5Utils.md5Encode(
+                prompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+            )
 
             aiArtifactGateway.getCachedArtifact(
                 bookUrl = bookUrl,
@@ -71,7 +73,7 @@ class CleanSelectedTextUseCase(
                 modelProfileId = preset.model.id,
             )?.output?.let { return@runCatching it }
 
-            val response = aiTextGateway.generate(
+            val responseText = aiToolAwareGenerationUseCase.generate(
                 AiGenerateRequest(
                     model = preset.model,
                     messages = listOf(
@@ -83,9 +85,14 @@ class CleanSelectedTextUseCase(
                             ?.coerceAtMost(MAX_OUTPUT_TOKENS)
                             ?: MAX_OUTPUT_TOKENS,
                     ),
+                    toolContext = AiToolContext(
+                        bookUrl = bookUrl,
+                        chapterIndex = chapterIndex,
+                        chapterTitle = chapterTitle,
+                    ),
                 )
-            ).getOrThrow()
-            val replacement = parseCleanReplacement(response.text)
+            )
+            val replacement = parseCleanReplacement(responseText)
             val now = System.currentTimeMillis()
             aiArtifactGateway.upsertArtifact(
                 AiArtifact(
@@ -130,7 +137,9 @@ class CleanSelectedTextUseCase(
             contextAfter = contextAfter.take(MAX_CONTEXT_CHARS),
         )
         val contentHash = MD5Utils.md5Encode(input)
-        val promptHash = MD5Utils.md5Encode(prompt)
+        val promptHash = MD5Utils.md5Encode(
+            prompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+        )
 
         aiArtifactGateway.getCachedArtifact(
             bookUrl = bookUrl,
@@ -147,7 +156,7 @@ class CleanSelectedTextUseCase(
 
         val rawBuilder = StringBuilder()
         val reasoningBuilder = StringBuilder()
-        aiTextGateway.generateStream(
+        aiToolAwareGenerationUseCase.generateStream(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
@@ -159,6 +168,11 @@ class CleanSelectedTextUseCase(
                         ?.coerceAtMost(MAX_OUTPUT_TOKENS)
                         ?: MAX_OUTPUT_TOKENS,
                 ),
+                toolContext = AiToolContext(
+                    bookUrl = bookUrl,
+                    chapterIndex = chapterIndex,
+                    chapterTitle = chapterTitle,
+                ),
             )
         ).collect { event ->
             when (event) {

+ 36 - 14
app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt

@@ -6,12 +6,12 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.domain.gateway.AiArtifactGateway
 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.model.AiGenerateRequest
 import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.AiToolContext
 import io.legado.app.domain.model.ContentChunker
 import io.legado.app.help.book.BookHelp
 import io.legado.app.utils.MD5Utils
@@ -24,7 +24,7 @@ import kotlinx.coroutines.withContext
 
 class GenerateChapterSummaryUseCase(
     private val aiProfileGateway: AiProfileGateway,
-    private val aiTextGateway: AiTextGateway,
+    private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
     private val aiArtifactGateway: AiArtifactGateway
 ) {
 
@@ -45,7 +45,9 @@ class GenerateChapterSummaryUseCase(
                 ?: error("Failed to read chapter content")
             val preset = resolvePreset() ?: error("No AI model configured for chapter summary")
             val contentHash = MD5Utils.md5Encode(content)
-            val promptHash = MD5Utils.md5Encode(preset.promptTemplate)
+            val promptHash = MD5Utils.md5Encode(
+                preset.promptTemplate + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+            )
             aiArtifactGateway.getCachedArtifact(
                 bookUrl = book.bookUrl,
                 chapterIndex = bookChapter.index,
@@ -58,10 +60,12 @@ class GenerateChapterSummaryUseCase(
             val chunks = ContentChunker.chunk(content, maxCharsPerChunk)
             if (chunks.isEmpty()) error("Failed to chunk chapter content")
 
+            val toolContext = book.toToolContext(bookChapter)
             val partialSummaries = chunks.map { chunk ->
                 generate(
                     preset = preset,
-                    userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}"
+                    userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}",
+                    toolContext = toolContext,
                 )
             }
             val summary = if (partialSummaries.size == 1) {
@@ -69,7 +73,8 @@ class GenerateChapterSummaryUseCase(
             } else {
                 generate(
                     preset = preset,
-                    userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}"
+                    userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}",
+                    toolContext = toolContext,
                 )
             }
             val now = System.currentTimeMillis()
@@ -104,7 +109,9 @@ class GenerateChapterSummaryUseCase(
             ?: error("Failed to read chapter content")
         val preset = resolvePreset() ?: error("No AI model configured for chapter summary")
         val contentHash = MD5Utils.md5Encode(content)
-        val promptHash = MD5Utils.md5Encode(preset.promptTemplate)
+        val promptHash = MD5Utils.md5Encode(
+            preset.promptTemplate + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION
+        )
         aiArtifactGateway.getCachedArtifact(
             bookUrl = book.bookUrl,
             chapterIndex = bookChapter.index,
@@ -123,10 +130,12 @@ class GenerateChapterSummaryUseCase(
 
         val summaryBuilder = StringBuilder()
         val reasoningBuilder = StringBuilder()
+        val toolContext = book.toToolContext(bookChapter)
         if (chunks.size == 1) {
             collectGenerateStream(
                 preset = preset,
                 userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${content}",
+                toolContext = toolContext,
                 outputBuilder = summaryBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
@@ -135,12 +144,14 @@ class GenerateChapterSummaryUseCase(
             val partialSummaries = chunks.map { chunk ->
                 generate(
                     preset = preset,
-                    userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}"
+                    userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}",
+                    toolContext = toolContext,
                 )
             }
             collectGenerateStream(
                 preset = preset,
                 userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}",
+                toolContext = toolContext,
                 outputBuilder = summaryBuilder,
                 reasoningBuilder = reasoningBuilder,
                 emitEvent = { emit(it) },
@@ -175,37 +186,39 @@ class GenerateChapterSummaryUseCase(
 
     private suspend fun generate(
         preset: AiTaskPresetConfig,
-        userContent: String
+        userContent: String,
+        toolContext: AiToolContext,
     ): String {
-        val response = aiTextGateway.generate(
+        return aiToolAwareGenerationUseCase.generate(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
                     AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
                     AiMessage(AiMessageRole.USER, userContent)
                 ),
-                params = preset.params
+                params = preset.params,
+                toolContext = toolContext,
             )
         )
-        return response.getOrThrow().text.trim()
-            .ifEmpty { error("AI returned an empty chapter summary") }
     }
 
     private suspend fun collectGenerateStream(
         preset: AiTaskPresetConfig,
         userContent: String,
+        toolContext: AiToolContext,
         outputBuilder: StringBuilder,
         reasoningBuilder: StringBuilder,
         emitEvent: suspend (StreamEvent) -> Unit,
     ) {
-        aiTextGateway.generateStream(
+        aiToolAwareGenerationUseCase.generateStream(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
                     AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
                     AiMessage(AiMessageRole.USER, userContent)
                 ),
-                params = preset.params
+                params = preset.params,
+                toolContext = toolContext,
             )
         ).collect { event ->
             when (event) {
@@ -224,6 +237,15 @@ class GenerateChapterSummaryUseCase(
         }
     }
 
+    private fun Book.toToolContext(bookChapter: BookChapter): AiToolContext {
+        return AiToolContext(
+            bookUrl = bookUrl,
+            bookName = name,
+            chapterIndex = bookChapter.index,
+            chapterTitle = bookChapter.title,
+        )
+    }
+
     private companion object {
         const val DEFAULT_MAX_CHARS_PER_CHUNK = 8000
     }

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

@@ -128,6 +128,13 @@ data class AiRewritePresetUi(
     val instruction: String,
 )
 
+@Stable
+data class AiRewriteHistoryUi(
+    val artifactId: String,
+    val text: String,
+    val timeText: String,
+)
+
 @Stable
 data class AiTextRewriteUiState(
     val bookUrl: String = "",
@@ -142,6 +149,7 @@ data class AiTextRewriteUiState(
     val selectedPresetId: String = "",
     val presets: ImmutableList<AiRewritePresetUi> = persistentListOf(),
     val temporaryInstruction: String = "",
+    val history: ImmutableList<AiRewriteHistoryUi> = persistentListOf(),
     val referenceCount: Int = 0,
     val errorMessage: String? = null,
 )
@@ -590,6 +598,7 @@ sealed interface ReadBookIntent {
 
     data class SelectAiRewritePreset(val presetId: String) : ReadBookIntent
     data class SetAiRewriteTemporaryInstruction(val instruction: String) : ReadBookIntent
+    data class SelectAiRewriteHistory(val artifactId: String) : ReadBookIntent
     data object GenerateAiTextRewrite : ReadBookIntent
     data object RetryAiTextRewrite : ReadBookIntent
     data object ConfirmAiTextRewrite : ReadBookIntent

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

@@ -37,6 +37,7 @@ import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadPreferences
 import io.legado.app.data.repository.ReadSettingsRepository
 import io.legado.app.data.repository.UploadRepository
+import io.legado.app.domain.gateway.AiArtifactGateway
 import io.legado.app.domain.gateway.AiPromptPresetGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
 import io.legado.app.domain.model.AiTaskType
@@ -94,6 +95,7 @@ import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
 import io.legado.app.ui.widget.components.importComponents.ImportStatus
 import io.legado.app.utils.GSON
 import io.legado.app.utils.ImageSaveUtils
+import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.NetworkUtils
 import io.legado.app.utils.StringUtils
 import io.legado.app.utils.fromJsonArray
@@ -137,6 +139,7 @@ import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
 import java.io.FileNotFoundException
+import java.util.Date
 import java.util.UUID
 import kotlin.coroutines.coroutineContext
 import kotlin.time.Duration.Companion.milliseconds
@@ -165,6 +168,7 @@ class ReadBookViewModel(
     private val aiTextFactoryUseCase: AiTextFactoryUseCase,
     private val saveBookContentProcessUseCase: SaveBookContentProcessUseCase,
     private val bookContentProcessGateway: BookContentProcessGateway,
+    private val aiArtifactGateway: AiArtifactGateway,
     private val aiPromptPresetGateway: AiPromptPresetGateway,
 ) : BaseViewModel(application), ReadBook.CallBack {
 
@@ -383,6 +387,7 @@ class ReadBookViewModel(
             is ReadBookIntent.SetAiRewriteTemporaryInstruction -> setAiRewriteTemporaryInstruction(
                 intent.instruction
             )
+            is ReadBookIntent.SelectAiRewriteHistory -> selectAiRewriteHistory(intent.artifactId)
             is ReadBookIntent.GenerateAiTextRewrite -> generateSelectedAiTextRewrite()
             is ReadBookIntent.RetryAiTextRewrite -> retryAiTextRewrite()
             is ReadBookIntent.ConfirmAiTextRewrite -> confirmAiTextRewrite()
@@ -494,11 +499,6 @@ class ReadBookViewModel(
                         aiTextCleanJob?.cancel()
                         pendingAiTextCleanRequest = null
                     }
-                    is ReadBookSheet.AiTextRewrite -> {
-                        aiTextRewriteJob?.cancel()
-                        pendingAiTextRewriteRequest = null
-                    }
-
                     else -> Unit
                 }
                 _uiState.update {
@@ -513,10 +513,7 @@ class ReadBookViewModel(
                             aiTextClean = AiTextCleanUiState(),
                         )
                     } else if (it.activeSheet is ReadBookSheet.AiTextRewrite) {
-                        it.copy(
-                            activeSheet = null,
-                            aiTextRewrite = AiTextRewriteUiState(),
-                        )
+                        it.copy(activeSheet = null)
                     } else if (it.activeSheet is ReadBookSheet.AiRewritePresetConfig) {
                         it.copy(
                             activeSheet = null,
@@ -3125,24 +3122,41 @@ class ReadBookViewModel(
             chapterTitle = chapterTitle,
             chapterPosition = chapterPosition,
             originalText = text,
+            sourceContentHash = buildAiRewriteSourceContentHash(text),
             contextBefore = contextBefore,
             contextAfter = contextAfter,
         )
         pendingAiTextRewriteRequest = request
         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(
                 activeSheet = ReadBookSheet.AiTextRewrite,
-                aiTextRewrite = AiTextRewriteUiState(
-                    bookUrl = request.bookUrl,
-                    chapterIndex = request.chapterIndex,
-                    chapterTitle = request.chapterTitle,
-                    originalText = request.originalText,
-                    selectedPresetId = selectedPresetId,
-                    presets = presets.toImmutableList(),
-                ),
+                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(),
+                    )
+                },
             )
         }
+        loadAiRewriteHistory(request, selectLatest = !isSameRewriteTarget)
     }
 
     private fun selectAiRewritePreset(presetId: String) {
@@ -3150,10 +3164,6 @@ class ReadBookViewModel(
             it.copy(
                 aiTextRewrite = it.aiTextRewrite.copy(
                     selectedPresetId = presetId,
-                    rewrittenText = "",
-                    reasoningText = "",
-                    thinkingDuration = 0,
-                    referenceCount = 0,
                     errorMessage = null,
                 )
             )
@@ -3165,16 +3175,89 @@ class ReadBookViewModel(
             it.copy(
                 aiTextRewrite = it.aiTextRewrite.copy(
                     temporaryInstruction = instruction,
-                    rewrittenText = "",
+                    errorMessage = null,
+                )
+            )
+        }
+    }
+
+    private 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,
-                    referenceCount = 0,
                     errorMessage = null,
                 )
             )
         }
     }
 
+    private fun loadAiRewriteHistory(
+        request: PendingAiTextRewriteRequest,
+        selectLatest: Boolean,
+    ) {
+        viewModelScope.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))
+    }
+
     private fun generateSelectedAiTextRewrite() {
         val state = _uiState.value.aiTextRewrite
         val preset = state.presets.firstOrNull { it.id == state.selectedPresetId }
@@ -3248,6 +3331,8 @@ class ReadBookViewModel(
                             temporaryInstruction,
                         ),
                         referenceText = referenceContext.text,
+                        skipCache = true,
+                        artifactContentHash = request.sourceContentHash,
                     )
                 ).collect { event ->
                     if (!isCurrentAiTextRewrite(request)) return@collect
@@ -3305,6 +3390,7 @@ class ReadBookViewModel(
                                     )
                                 )
                             }
+                            loadAiRewriteHistory(request, selectLatest = false)
                         }
                     }
                 }
@@ -3749,8 +3835,7 @@ class ReadBookViewModel(
 
     private fun isCurrentAiTextRewrite(request: PendingAiTextRewriteRequest): Boolean {
         val state = _uiState.value
-        return state.activeSheet is ReadBookSheet.AiTextRewrite &&
-                state.aiTextRewrite.bookUrl == request.bookUrl &&
+        return state.aiTextRewrite.bookUrl == request.bookUrl &&
                 state.aiTextRewrite.chapterIndex == request.chapterIndex &&
                 state.aiTextRewrite.originalText == request.originalText
     }
@@ -5919,6 +6004,7 @@ private data class PendingAiTextRewriteRequest(
     val chapterTitle: String,
     val chapterPosition: Int,
     val originalText: String,
+    val sourceContentHash: String,
     val contextBefore: String,
     val contextAfter: String,
 )

+ 106 - 52
app/src/main/java/io/legado/app/ui/book/read/sheet/AiTextRewriteSheet.kt

@@ -13,6 +13,9 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.text.selection.SelectionContainer
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Autorenew
+import androidx.compose.material.icons.filled.Save
 import androidx.compose.material.icons.filled.Settings
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.getValue
@@ -30,6 +33,7 @@ import io.legado.app.domain.model.AiMessagePart
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.ui.ai.chat.AiChatMessageUi
 import io.legado.app.ui.ai.chat.AiGeneratedMessageContent
+import io.legado.app.ui.book.read.AiRewriteHistoryUi
 import io.legado.app.ui.book.read.AiRewritePresetUi
 import io.legado.app.ui.book.read.AiTextRewriteUiState
 import io.legado.app.ui.book.read.ReadBookIntent
@@ -56,12 +60,35 @@ fun AiTextRewriteSheet(
     onIntent: (ReadBookIntent) -> Unit,
     onDismissRequest: () -> Unit,
 ) {
+    val hasRewriteForCurrentText = state.rewrittenText.isNotBlank() || state.history.isNotEmpty()
     AppModalBottomSheet(
         show = show,
         onDismissRequest = {
             if (!state.isApplying) onDismissRequest()
         },
         title = stringResource(R.string.ai_text_rewrite),
+        startAction = {
+            MediumTonalButton(
+                onClick = { onIntent(ReadBookIntent.GenerateAiTextRewrite) },
+                icon = if (hasRewriteForCurrentText)
+                    Icons.Default.AutoAwesome
+                else
+                    Icons.Default.Autorenew,
+                enabled = state.presets.isNotEmpty() &&
+                        !state.isLoading &&
+                        !state.isApplying
+            )
+        },
+        endAction = {
+            MediumTonalButton(
+                onClick = { onIntent(ReadBookIntent.ConfirmAiTextRewrite) },
+                icon = Icons.Default.Save,
+                enabled = !state.isLoading &&
+                        !state.isApplying &&
+                        state.errorMessage == null &&
+                        state.rewrittenText.isNotBlank(),
+            )
+        }
     ) {
         Column(
             modifier = Modifier
@@ -80,6 +107,7 @@ fun AiTextRewriteSheet(
                     tabTitles = listOf(
                         stringResource(R.string.ai_text_clean_before),
                         stringResource(R.string.ai_text_clean_after),
+                        stringResource(R.string.ai_rewrite_history),
                     ),
                     selectedTabIndex = selectedTabIndex,
                     onTabSelected = { selectedTabIndex = it },
@@ -95,21 +123,6 @@ fun AiTextRewriteSheet(
                         )
                         Spacer(Modifier.height(16.dp))
 
-                        PrimaryButton(
-                            onClick = { onIntent(ReadBookIntent.GenerateAiTextRewrite) },
-                            modifier = Modifier.fillMaxWidth(),
-                            enabled = state.presets.isNotEmpty() &&
-                                    !state.isLoading &&
-                                    !state.isApplying,
-                            text = if (state.rewrittenText.isBlank()) {
-                                stringResource(R.string.ai_generate)
-                            } else {
-                                stringResource(R.string.ai_regenerate)
-                            },
-                        )
-
-                        Spacer(Modifier.height(16.dp))
-
                         Row(
                             modifier = Modifier.fillMaxWidth(),
                             verticalAlignment = Alignment.CenterVertically,
@@ -240,6 +253,38 @@ fun AiTextRewriteSheet(
                             }
                         }
                     }
+
+                    2 -> {
+                        if (state.history.isEmpty()) {
+                            Box(
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .height(160.dp),
+                                contentAlignment = Alignment.Center,
+                            ) {
+                                EmptyMessage(
+                                    messageResId = R.string.ai_rewrite_history_empty,
+                                )
+                            }
+                        } else {
+                            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                                state.history.forEach { item ->
+                                    RewriteHistoryItem(
+                                        item = item,
+                                        enabled = !state.isLoading && !state.isApplying,
+                                        onSelect = {
+                                            onIntent(
+                                                ReadBookIntent.SelectAiRewriteHistory(
+                                                    item.artifactId
+                                                )
+                                            )
+                                            selectedTabIndex = 1
+                                        },
+                                    )
+                                }
+                            }
+                        }
+                    }
                 }
 
                 Spacer(Modifier.height(16.dp))
@@ -250,18 +295,53 @@ fun AiTextRewriteSheet(
                 )
                 Spacer(Modifier.height(20.dp))
             }
+        }
+    }
+}
 
-            ConfirmDismissButtonsRow(
-                onDismiss = onDismissRequest,
-                onConfirm = { onIntent(ReadBookIntent.ConfirmAiTextRewrite) },
-                dismissText = stringResource(R.string.cancel),
-                confirmText = stringResource(R.string.ok),
-                dismissEnabled = !state.isApplying,
-                confirmEnabled = !state.isLoading &&
-                        !state.isApplying &&
-                        state.errorMessage == null &&
-                        state.rewrittenText.isNotBlank(),
-            )
+@Composable
+private fun RewriteHistoryItem(
+    item: AiRewriteHistoryUi,
+    enabled: Boolean,
+    onSelect: () -> Unit,
+) {
+    NormalCard(
+        modifier = Modifier.fillMaxWidth(),
+        onClick = if (enabled) onSelect else null,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
+    ) {
+        Column(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                AppText(
+                    text = item.timeText,
+                    modifier = Modifier.weight(1f),
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+                SmallTonalButton(
+                    onClick = onSelect,
+                    enabled = enabled,
+                    text = stringResource(R.string.ai_rewrite_use_history),
+                )
+            }
+            SelectionContainer {
+                AppText(
+                    text = item.text,
+                    maxLines = 5,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
         }
     }
 }
@@ -343,32 +423,6 @@ private fun RewritePresetOption(
     }
 }
 
-@Composable
-private fun TextPreview(
-    text: String,
-    title: String? = null,
-    showTitle: Boolean = true,
-) {
-    if (showTitle && title != null) {
-        AppText(
-            text = title,
-            style = LegadoTheme.typography.titleSmall,
-        )
-        Spacer(Modifier.height(8.dp))
-    }
-    NormalCard(
-        modifier = Modifier.fillMaxWidth(),
-        containerColor = LegadoTheme.colorScheme.surfaceContainerHigh,
-    ) {
-        SelectionContainer {
-            AppText(
-                text = text,
-                modifier = Modifier.padding(16.dp),
-            )
-        }
-    }
-}
-
 @Composable
 private fun CollapsibleTextPreview(
     text: String,

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/series/MediumOutlinedButton.kt

@@ -34,7 +34,7 @@ fun MediumOutlinedButton(
             iconSize = MediumSeriesIconSize,
             textStyle = LegadoTheme.typography.labelMedium,
             contentColor = contentColor,
-            padding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
+            padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
             spacing = 8.dp
         )
     }

+ 19 - 60
app/src/main/java/io/legado/app/ui/widget/components/button/series/SeriesIconButton.kt

@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme.composeEngine
 import io.legado.app.ui.theme.ThemeResolver
+import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.text.AppText
 import top.yukonga.miuix.kmp.basic.Icon as MiuixIcon
 import top.yukonga.miuix.kmp.basic.Text as MiuixText
@@ -161,8 +162,8 @@ internal fun SeriesIconButton(
         selectedContainerColor = selectedContainerColor,
         selectedContentColor = selectedContentColor
     ) { resolvedContentColor ->
-        SeriesIcon(
-            icon = icon,
+        AppIcon(
+            imageVector = icon,
             contentDescription = contentDescription,
             tint = resolvedContentColor,
             modifier = Modifier.size(iconSize)
@@ -191,27 +192,19 @@ internal fun SeriesButtonContent(
         verticalAlignment = Alignment.CenterVertically
     ) {
         if (icon != null) {
-            SeriesIcon(
-                icon = icon,
+            AppIcon(
+                imageVector = icon,
                 contentDescription = contentDescription,
                 tint = contentColor,
                 modifier = Modifier.size(iconSize)
             )
         }
         if (text != null) {
-            if (ThemeResolver.isMiuixEngine(composeEngine)) {
-                MiuixText(
-                    text = text,
-                    style = textStyle,
-                    color = contentColor
-                )
-            } else {
-                AppText(
-                    text = text,
-                    style = textStyle,
-                    color = contentColor
-                )
-            }
+            AppText(
+                text = text,
+                style = textStyle,
+                color = contentColor
+            )
         }
     }
 }
@@ -237,60 +230,26 @@ internal fun SeriesAnimatedButtonContent(
         ),
         verticalAlignment = Alignment.CenterVertically
     ) {
-        SeriesIcon(
-            icon = icon,
+        AppIcon(
+            imageVector = icon,
             contentDescription = contentDescription,
             tint = contentColor,
             modifier = Modifier.size(iconSize)
         )
         AnimatedVisibility(visible = showText && text != null) {
             if (text != null) {
-                if (ThemeResolver.isMiuixEngine(composeEngine)) {
-                    MiuixText(
-                        text = text,
-                        style = textStyle,
-                        color = contentColor,
-                        maxLines = 1,
-                        softWrap = false
-                    )
-                } else {
-                    AppText(
-                        text = text,
-                        style = textStyle,
-                        color = contentColor,
-                        maxLines = 1,
-                        softWrap = false
-                    )
-                }
+                AppText(
+                    text = text,
+                    style = textStyle,
+                    color = contentColor,
+                    maxLines = 1,
+                    softWrap = false
+                )
             }
         }
     }
 }
 
-@Composable
-private fun SeriesIcon(
-    icon: ImageVector,
-    contentDescription: String?,
-    tint: Color,
-    modifier: Modifier
-) {
-    if (ThemeResolver.isMiuixEngine(composeEngine)) {
-        MiuixIcon(
-            imageVector = icon,
-            contentDescription = contentDescription,
-            tint = tint,
-            modifier = modifier
-        )
-    } else {
-        Icon(
-            imageVector = icon,
-            contentDescription = contentDescription,
-            tint = tint,
-            modifier = modifier
-        )
-    }
-}
-
 internal fun squareSize(size: Dp) = DpSize(size, size)
 
 @Composable

+ 1 - 1
app/src/main/java/io/legado/app/ui/widget/components/button/series/SmallOutlinedButton.kt

@@ -37,7 +37,7 @@ fun SmallOutlinedButton(
                 iconSize = smallIconSize,
                 textStyle = LegadoTheme.typography.labelMedium,
                 contentColor = contentColor,
-                padding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
+                padding = PaddingValues(horizontal = 8.dp, vertical = 6.dp),
                 spacing = 4.dp
             )
         }

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

@@ -1974,6 +1974,9 @@
     <string name="ai_rewrite_temporary_instruction">临时要求(仅本次)</string>
     <string name="ai_rewrite_temporary_instruction_hint">例如:这一版更口语化,保留原段落长度</string>
     <string name="ai_rewrite_reference_used">已参考 %1$d 段已缓存或本地章节摘录。</string>
+    <string name="ai_rewrite_history">历史</string>
+    <string name="ai_rewrite_history_empty">这段原文暂无改写结果。</string>
+    <string name="ai_rewrite_use_history">使用</string>
     <string name="ai_rewrite_empty_result">AI 返回了空文本。</string>
     <string name="ai_rewrite_preset_empty">名称和要求不能为空。</string>
     <string name="ai_rewrite_preset_name">名称</string>
@@ -2467,4 +2470,5 @@
     <string name="collapsed_menu">折叠菜单</string>
     <string name="hidden_items">已隐藏项目</string>
     <string name="merged_in_multiline">多行模式下将合并展示</string>
+    <string name="apply">应用</string>
 </resources>

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

@@ -1904,6 +1904,9 @@
     <string name="ai_rewrite_temporary_instruction">Temporary instruction (this time only)</string>
     <string name="ai_rewrite_temporary_instruction_hint">Example: make this version more conversational while keeping the original length</string>
     <string name="ai_rewrite_reference_used">Referenced %1$d related excerpt(s) from cached or local chapters.</string>
+    <string name="ai_rewrite_history">History</string>
+    <string name="ai_rewrite_history_empty">No rewritten results for this text.</string>
+    <string name="ai_rewrite_use_history">Use</string>
     <string name="ai_rewrite_empty_result">AI returned empty text.</string>
     <string name="ai_rewrite_preset_empty">Name and requirement cannot be empty.</string>
     <string name="ai_rewrite_preset_name">Name</string>
@@ -2472,4 +2475,5 @@
     <string name="collapsed_menu">Collapsed Menu</string>
     <string name="hidden_items">Hidden Items</string>
     <string name="merged_in_multiline">Merged in multiline mode</string>
+    <string name="apply">Apply</string>
 </resources>