فهرست منبع

feat: 优化自动分组功能

- 增加了参考书籍简介和启用深度思考两个开关,兼顾速度、质量和token消耗
- 修复了图标遗漏的 import 问题,并将文本改为字符串资源
- 其他相应优化
lyc 3 ماه پیش
والد
کامیت
7824ce33e7
26فایلهای تغییر یافته به همراه2614 افزوده شده و 497 حذف شده
  1. 30 0
      app/src/main/java/io/legado/app/data/repository/BookshelfAutoGroupPromptRepository.kt
  2. 161 0
      app/src/main/java/io/legado/app/data/repository/BookshelfAutoGroupRepository.kt
  3. 29 10
      app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt
  4. 6 0
      app/src/main/java/io/legado/app/di/appModule.kt
  5. 13 0
      app/src/main/java/io/legado/app/domain/gateway/BookshelfAutoGroupGateway.kt
  6. 8 0
      app/src/main/java/io/legado/app/domain/gateway/BookshelfAutoGroupPromptGateway.kt
  7. 28 0
      app/src/main/java/io/legado/app/domain/model/BookshelfAutoGroup.kt
  8. 22 0
      app/src/main/java/io/legado/app/domain/model/BookshelfAutoGroupPromptText.kt
  9. 35 64
      app/src/main/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCase.kt
  10. 176 0
      app/src/main/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParser.kt
  11. 373 231
      app/src/main/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCase.kt
  12. 6 0
      app/src/main/java/io/legado/app/ui/config/ai/prompt/AiPromptConfigViewModel.kt
  13. 2 1
      app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt
  14. 36 3
      app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupContract.kt
  15. 190 54
      app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupSheet.kt
  16. 204 134
      app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt
  17. 68 0
      app/src/main/res/values-zh-rCN/strings.xml
  18. 68 0
      app/src/main/res/values-zh-rHK/strings.xml
  19. 68 0
      app/src/main/res/values-zh-rTW/strings.xml
  20. 68 0
      app/src/main/res/values/strings.xml
  21. 198 0
      app/src/test/java/io/legado/app/data/repository/BookshelfAutoGroupRepositoryTest.kt
  22. 54 0
      app/src/test/java/io/legado/app/data/repository/ai/OpenAiChatReasoningOptionsTest.kt
  23. 62 0
      app/src/test/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCaseTest.kt
  24. 133 0
      app/src/test/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParserTest.kt
  25. 379 0
      app/src/test/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCaseTest.kt
  26. 197 0
      app/src/test/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModelTest.kt

+ 30 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfAutoGroupPromptRepository.kt

@@ -0,0 +1,30 @@
+package io.legado.app.data.repository
+
+import android.content.Context
+import io.legado.app.R
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
+import io.legado.app.domain.model.BookshelfAutoGroupPromptText
+
+class BookshelfAutoGroupPromptRepository(
+    private val context: Context,
+) : BookshelfAutoGroupPromptGateway {
+
+    override fun getPromptText() = BookshelfAutoGroupPromptText(
+        defaultSystemPrompt = context.getString(R.string.ai_prompt_default_bookshelf_auto_group),
+        mandatoryRules = context.getString(R.string.ai_auto_group_prompt_mandatory_rules),
+        generateTask = context.getString(R.string.ai_auto_group_prompt_generate_task),
+        reviseTask = context.getString(R.string.ai_auto_group_prompt_revise_task),
+        existingGroups = context.getString(R.string.ai_auto_group_prompt_existing_groups),
+        noExistingGroups = context.getString(R.string.ai_auto_group_prompt_no_existing_groups),
+        userRequirements = context.getString(R.string.ai_auto_group_prompt_user_requirements),
+        previouslyProposedGroups = context.getString(
+            R.string.ai_auto_group_prompt_previously_proposed_groups
+        ),
+        reuseGroupNamesRule = context.getString(R.string.ai_auto_group_prompt_reuse_group_names_rule),
+        reasonRule = context.getString(R.string.ai_auto_group_prompt_reason_rule),
+        currentPlan = context.getString(R.string.ai_auto_group_prompt_current_plan),
+        books = context.getString(R.string.ai_auto_group_prompt_books),
+        outputSchemaLabel = context.getString(R.string.ai_auto_group_prompt_output_schema_label),
+        outputSchema = context.getString(R.string.ai_auto_group_prompt_output_schema),
+    )
+}

+ 161 - 0
app/src/main/java/io/legado/app/data/repository/BookshelfAutoGroupRepository.kt

@@ -0,0 +1,161 @@
+package io.legado.app.data.repository
+
+import androidx.room.withTransaction
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupSource
+import io.legado.app.help.book.isNotShelf
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class BookshelfAutoGroupRepository(
+    private val database: AppDatabase,
+) : BookshelfAutoGroupGateway {
+
+    override suspend fun loadSource(): BookshelfAutoGroupSource = withContext(Dispatchers.IO) {
+        val groupDao = database.bookGroupDao
+        val allGroups = groupDao.all
+        val publicGroups = allGroups.filter { it.isUserGroup() && !it.isPrivate }
+        val privateGroupMask = allGroups
+            .asSequence()
+            .filter { it.isUserGroup() && it.isPrivate }
+            .fold(0L) { mask, group -> mask or group.groupId }
+        val books = database.bookDao.getAll()
+            .filterNot { it.isNotShelf }
+            // Private shelf metadata must not be sent to an external AI provider.
+            .filter { book -> book.group and privateGroupMask == 0L }
+            .sortedWith(compareBy<Book> { it.name }.thenBy { it.author })
+        BookshelfAutoGroupSource(
+            books = books.map { book ->
+                BookshelfAutoGroupBook(
+                    bookUrl = book.bookUrl,
+                    name = book.name,
+                    author = book.author,
+                    intro = book.groupingIntro(),
+                    kind = book.customTag ?: book.kind.orEmpty(),
+                    currentGroupNames = groupDao.getGroupNames(book.group),
+                )
+            },
+            existingGroupNames = publicGroups
+                .map(BookGroup::groupName)
+                .filter(String::isNotBlank),
+        )
+    }
+
+    override suspend fun applyPlan(
+        plan: BookshelfAutoGroupPlan,
+    ): BookshelfAutoGroupApplyResult = withContext(Dispatchers.IO) {
+        database.withTransaction {
+            val groupDao = database.bookGroupDao
+            val bookDao = database.bookDao
+            val allGroups = groupDao.all
+            val privateGroupMask = allGroups
+                .asSequence()
+                .filter { it.isUserGroup() && it.isPrivate }
+                .fold(0L) { mask, group -> mask or group.groupId }
+            val existingGroups = allGroups
+                .filter { it.isUserGroup() && !it.isPrivate }
+                .associateBy(BookGroup::groupName)
+                .toMutableMap()
+            var createdGroupCount = 0
+            var reusedGroupCount = 0
+            var missingBookCount = 0
+
+            // Resolve current rows before creating groups so stale plans cannot create empty groups.
+            val applicableGroups = plan.groups.mapNotNull { group ->
+                val books = group.books.mapNotNull { plannedBook ->
+                    bookDao.getBook(plannedBook.bookUrl).also { entity ->
+                        if (entity == null) missingBookCount++
+                    }
+                }
+                group.takeIf { books.isNotEmpty() }?.let { it to books }
+            }
+
+            val newGroupCount = applicableGroups
+                .asSequence()
+                .map { (group, _) -> group.name }
+                .distinct()
+                .count { it !in existingGroups }
+            val occupiedGroupBits = allGroups.count { it.isUserGroup() }
+            val availableGroupBits = (MAX_USER_GROUPS - occupiedGroupBits).coerceAtLeast(0)
+            if (newGroupCount > availableGroupBits) {
+                throw BookshelfAutoGroupException(
+                    BookshelfAutoGroupErrorReason.GroupCapacityExceeded
+                )
+            }
+
+            val targetGroupIds = applicableGroups.associate { (group, _) ->
+                val existing = existingGroups[group.name]
+                val groupId = if (existing != null) {
+                    reusedGroupCount++
+                    existing.groupId
+                } else {
+                    val newGroupId = groupDao.getUnusedId()
+                    // A zero id means all 64 group bits are already occupied.
+                    if (newGroupId == 0L) {
+                        throw BookshelfAutoGroupException(
+                            BookshelfAutoGroupErrorReason.GroupCapacityExceeded
+                        )
+                    }
+                    val newGroup = BookGroup(
+                        groupId = newGroupId,
+                        groupName = group.name,
+                        order = groupDao.maxOrder.plus(1),
+                        enableRefresh = true,
+                        show = true,
+                        bookSort = -1,
+                        isPrivate = false,
+                    )
+                    // Clear any stale bit before reusing a historical group id.
+                    bookDao.removeGroup(newGroupId)
+                    groupDao.insert(newGroup)
+                    existingGroups[group.name] = newGroup
+                    createdGroupCount++
+                    newGroupId
+                }
+                group.key to groupId
+            }
+
+            val updates = applicableGroups.flatMap { (group, books) ->
+                val targetGroupId = targetGroupIds[group.key] ?: return@flatMap emptyList()
+                books.mapNotNull { entity ->
+                    // Auto-grouping replaces public memberships but never removes private ones.
+                    val updatedGroup = (entity.group and privateGroupMask) or targetGroupId
+                    entity.takeIf { it.group != updatedGroup }?.copy(group = updatedGroup)
+                }
+            }
+            if (updates.isNotEmpty()) {
+                bookDao.update(*updates.toTypedArray())
+            }
+
+            BookshelfAutoGroupApplyResult(
+                createdGroupCount = createdGroupCount,
+                reusedGroupCount = reusedGroupCount,
+                updatedBookCount = updates.size,
+                ignoredBookCount = plan.ignoredBooks.size + missingBookCount,
+            )
+        }
+    }
+
+    private fun Book.groupingIntro(): String {
+        return (customIntro ?: intro ?: listIntro).orEmpty()
+            .replace(Regex("\\s+"), " ")
+            .take(MAX_INTRO_CHARS)
+    }
+
+    private fun BookGroup.isUserGroup(): Boolean {
+        return groupId > 0 || groupId == Long.MIN_VALUE
+    }
+
+    private companion object {
+        const val MAX_INTRO_CHARS = 320
+        const val MAX_USER_GROUPS = Long.SIZE_BITS
+    }
+}

+ 29 - 10
app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt

@@ -9,6 +9,7 @@ import io.legado.app.domain.model.AiMessage
 import io.legado.app.domain.model.AiMessageRole
 import io.legado.app.domain.model.AiProtocol
 import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.domain.model.AiToolDefinition
 import io.legado.app.help.http.addHeaders
 import io.legado.app.help.http.newCallResponse
@@ -55,11 +56,7 @@ class OpenAiChatHandler : AiProtocolHandler {
         params.temperature?.let { body["temperature"] = it }
         params.maxOutputTokens?.let { body["max_tokens"] = it }
         params.topP?.let { body["top_p"] = it }
-        if (hasReasoningCapability(request.model.capabilities)) {
-            params.reasoningLevel.effortFor(provider)?.let {
-                body["reasoning_effort"] = it
-            }
-        }
+        body.applyOpenAiChatReasoningOptions(request)
 
         return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
             val response = aiOkHttpClient.newCallStrResponse {
@@ -104,11 +101,7 @@ class OpenAiChatHandler : AiProtocolHandler {
         params.temperature?.let { body["temperature"] = it }
         params.maxOutputTokens?.let { body["max_tokens"] = it }
         params.topP?.let { body["top_p"] = it }
-        if (hasReasoningCapability(request.model.capabilities)) {
-            params.reasoningLevel.effortFor(provider)?.let {
-                body["reasoning_effort"] = it
-            }
-        }
+        body.applyOpenAiChatReasoningOptions(request)
 
         // For streaming, we retry before establishing the SSE connection.
         // Once streaming starts, errors are not retried (partial output would be confusing).
@@ -188,8 +181,34 @@ class OpenAiChatHandler : AiProtocolHandler {
             json?.data.toAvailableModels()
         }
     }
+
+}
+
+internal fun MutableMap<String, Any?>.applyOpenAiChatReasoningOptions(
+    request: AiGenerateRequest,
+) {
+    val params = request.params
+    val provider = request.model.provider
+    if (request.model.modelId.startsWith(DEEPSEEK_V4_MODEL_PREFIX, ignoreCase = true)) {
+        val thinkingEnabled = params.reasoningLevel != AiReasoningLevel.OFF
+        put(
+            "thinking",
+            mapOf("type" to if (thinkingEnabled) "enabled" else "disabled"),
+        )
+        if (thinkingEnabled) {
+            params.reasoningLevel.effortFor(provider)?.let {
+                put("reasoning_effort", it)
+            }
+        }
+    } else if (hasReasoningCapability(request.model.capabilities)) {
+        params.reasoningLevel.effortFor(provider)?.let {
+            put("reasoning_effort", it)
+        }
+    }
 }
 
+private const val DEEPSEEK_V4_MODEL_PREFIX = "deepseek-v4-"
+
 // ---- Message & tool format converters ----
 
 internal fun List<AiMessage>.toOpenAiChatMessages(): List<Map<String, Any?>> {

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

@@ -26,6 +26,8 @@ import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookExportSettingsRepository
 import io.legado.app.data.repository.BookGroupMutationRepository
 import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookshelfAutoGroupRepository
+import io.legado.app.data.repository.BookshelfAutoGroupPromptRepository
 import io.legado.app.data.repository.BookImportRepository
 import io.legado.app.data.repository.BookKnowledgeRepository
 import io.legado.app.data.repository.BookRepository
@@ -112,6 +114,8 @@ import io.legado.app.domain.gateway.BookContentProcessGateway
 import io.legado.app.domain.gateway.BookExportSettingsGateway
 import io.legado.app.domain.gateway.BookGroupMutationGateway
 import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.BookSourceCheckGateway
@@ -323,6 +327,8 @@ val appModule = module {
     singleOf(::BookCacheManageRepository)
     singleOf(::TagGroupRuleApplier)
     single<BookGroupMutationGateway> { BookGroupMutationRepository(get(), get()) }
+    single<BookshelfAutoGroupGateway> { BookshelfAutoGroupRepository(get()) }
+    single<BookshelfAutoGroupPromptGateway> { BookshelfAutoGroupPromptRepository(get()) }
     singleOf(::BookSourceRepository)
     singleOf(::BookshelfRepository)
     singleOf(::DictRuleRepository)

+ 13 - 0
app/src/main/java/io/legado/app/domain/gateway/BookshelfAutoGroupGateway.kt

@@ -0,0 +1,13 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupSource
+
+interface BookshelfAutoGroupGateway {
+
+    suspend fun loadSource(): BookshelfAutoGroupSource
+
+    /** Applies the complete plan atomically. */
+    suspend fun applyPlan(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult
+}

+ 8 - 0
app/src/main/java/io/legado/app/domain/gateway/BookshelfAutoGroupPromptGateway.kt

@@ -0,0 +1,8 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.BookshelfAutoGroupPromptText
+
+interface BookshelfAutoGroupPromptGateway {
+    /** Resolves prompt text on demand so it follows the current application locale. */
+    fun getPromptText(): BookshelfAutoGroupPromptText
+}

+ 28 - 0
app/src/main/java/io/legado/app/domain/model/BookshelfAutoGroup.kt

@@ -17,6 +17,34 @@ data class BookshelfAutoGroupSource(
     val groupedBookCount: Int get() = books.count { it.currentGroupNames.isNotEmpty() }
 }
 
+data class BookshelfAutoGroupPreflight(
+    val effectiveInputCharLimit: Int,
+    val estimatedRequestCount: Int,
+)
+
+data class BookshelfAutoGroupOptions(
+    val includeBookIntro: Boolean = false,
+    val enableDeepThinking: Boolean = false,
+)
+
+data class BookshelfAutoGroupProgress(
+    val currentBatch: Int,
+    val totalBatches: Int,
+)
+
+enum class BookshelfAutoGroupErrorReason {
+    EmptyBookshelf,
+    MissingModel,
+    CapacityTooSmall,
+    GroupCapacityExceeded,
+    InvalidResponse,
+}
+
+class BookshelfAutoGroupException(
+    val reason: BookshelfAutoGroupErrorReason,
+    cause: Throwable? = null,
+) : IllegalStateException(cause?.message, cause)
+
 data class BookshelfAutoGroupPlan(
     val groups: List<BookshelfAutoGroupPlanGroup>,
     val ignoredBooks: List<BookshelfAutoGroupIgnoredBook> = emptyList(),

+ 22 - 0
app/src/main/java/io/legado/app/domain/model/BookshelfAutoGroupPromptText.kt

@@ -0,0 +1,22 @@
+package io.legado.app.domain.model
+
+/**
+ * Localized text used to assemble bookshelf auto-group prompts.
+ * JSON field names remain protocol constants and are therefore not localized.
+ */
+data class BookshelfAutoGroupPromptText(
+    val defaultSystemPrompt: String,
+    val mandatoryRules: String,
+    val generateTask: String,
+    val reviseTask: String,
+    val existingGroups: String,
+    val noExistingGroups: String,
+    val userRequirements: String,
+    val previouslyProposedGroups: String,
+    val reuseGroupNamesRule: String,
+    val reasonRule: String,
+    val currentPlan: String,
+    val books: String,
+    val outputSchemaLabel: String,
+    val outputSchema: String,
+)

+ 35 - 64
app/src/main/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCase.kt

@@ -1,75 +1,46 @@
 package io.legado.app.domain.usecase
 
-import androidx.room.withTransaction
-import io.legado.app.data.AppDatabase
-import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
+import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 
 class ApplyBookshelfAutoGroupPlanUseCase(
-    private val database: AppDatabase,
+    private val gateway: BookshelfAutoGroupGateway,
 ) {
 
-    suspend fun execute(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult =
-        withContext(Dispatchers.IO) {
-            require(plan.groups.isNotEmpty()) { "没有可执行的分组方案" }
-
-            database.withTransaction {
-                val groupDao = database.bookGroupDao
-                val bookDao = database.bookDao
-                val existingGroups = groupDao.all
-                    .filter { it.groupId > 0 }
-                    .associateBy { it.groupName }
-                    .toMutableMap()
-                var createdGroupCount = 0
-                var reusedGroupCount = 0
-
-                val targetGroupIds = plan.groups.associate { group ->
-                    val existing = existingGroups[group.name]
-                    val groupId = if (existing != null) {
-                        reusedGroupCount++
-                        existing.groupId
-                    } else {
-                        val newGroupId = groupDao.getUnusedId()
-                        val newGroup = BookGroup(
-                            groupId = newGroupId,
-                            groupName = group.name,
-                            order = groupDao.maxOrder.plus(1),
-                            enableRefresh = true,
-                            show = true,
-                            bookSort = -1,
-                            isPrivate = false,
-                        )
-                        bookDao.removeGroup(newGroupId)
-                        groupDao.insert(newGroup)
-                        existingGroups[group.name] = newGroup
-                        createdGroupCount++
-                        newGroupId
-                    }
-                    group.key to groupId
-                }
-
-                val updates = plan.groups.flatMap { group ->
-                    val targetGroupId = targetGroupIds[group.key] ?: return@flatMap emptyList()
-                    group.books.mapNotNull { book ->
-                        bookDao.getBook(book.bookUrl)
-                            ?.takeIf { it.group != targetGroupId }
-                            ?.copy(group = targetGroupId)
-                    }
-                }
-
-                if (updates.isNotEmpty()) {
-                    bookDao.update(*updates.toTypedArray())
-                }
-
-                BookshelfAutoGroupApplyResult(
-                    createdGroupCount = createdGroupCount,
-                    reusedGroupCount = reusedGroupCount,
-                    updatedBookCount = updates.size,
-                    ignoredBookCount = plan.ignoredBooks.size,
-                )
+    suspend fun execute(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult {
+        val normalizedPlan = normalize(plan)
+        require(normalizedPlan.groups.isNotEmpty()) { "No applicable grouping plan" }
+        return gateway.applyPlan(normalizedPlan)
+    }
+
+    internal fun normalize(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupPlan {
+        val assignedBookUrls = linkedSetOf<String>()
+        val groupsByName = linkedMapOf<String, BookshelfAutoGroupPlanGroup>()
+        plan.groups.forEach { group ->
+            val name = group.name.trim().take(MAX_GROUP_NAME_CHARS)
+            if (name.isBlank()) return@forEach
+            val uniqueBooks = group.books.filter { assignedBookUrls.add(it.bookUrl) }
+            if (uniqueBooks.isEmpty()) return@forEach
+            val existing = groupsByName[name]
+            groupsByName[name] = if (existing == null) {
+                group.copy(name = name, books = uniqueBooks)
+            } else {
+                existing.copy(books = existing.books + uniqueBooks)
             }
         }
+        val ignoredBookUrls = linkedSetOf<String>()
+        val ignoredBooks = plan.ignoredBooks.filter { book ->
+            book.bookUrl !in assignedBookUrls && ignoredBookUrls.add(book.bookUrl)
+        }
+        return BookshelfAutoGroupPlan(
+            groups = groupsByName.values.toList(),
+            ignoredBooks = ignoredBooks,
+        )
+    }
+
+    private companion object {
+        const val MAX_GROUP_NAME_CHARS = 24
+    }
 }

+ 176 - 0
app/src/main/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParser.kt

@@ -0,0 +1,176 @@
+package io.legado.app.domain.usecase
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
+import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
+import java.util.UUID
+
+internal class BookshelfAutoGroupPlanParser {
+
+    fun parse(
+        response: String,
+        booksByPromptId: Map<String, BookshelfAutoGroupBook>,
+        existingGroupNames: Set<String>,
+    ): BookshelfAutoGroupPlan {
+        val root = extractFirstPlanObject(response)
+        val assignedIds = linkedSetOf<String>()
+        val groups = mutableListOf<BookshelfAutoGroupPlanGroup>()
+
+        root.arrayOrNull("groups")?.forEach { groupElement ->
+            val groupObject = groupElement.objectOrNull() ?: return@forEach
+            val name = groupObject.stringOrNull("name")
+                ?.trim()
+                ?.take(MAX_GROUP_NAME_CHARS)
+                ?.takeIf(String::isNotBlank)
+                ?: return@forEach
+            val books = groupObject.arrayOrNull("books")
+                ?.mapNotNull { bookElement ->
+                    val bookObject = bookElement.objectOrNull() ?: return@mapNotNull null
+                    val promptId = bookObject.stringOrNull("id") ?: return@mapNotNull null
+                    val book = booksByPromptId[promptId] ?: return@mapNotNull null
+                    if (!assignedIds.add(promptId)) return@mapNotNull null
+                    BookshelfAutoGroupPlanBook(
+                        bookUrl = book.bookUrl,
+                        name = book.name,
+                        author = book.author,
+                        currentGroupNames = book.currentGroupNames,
+                        reason = bookObject.stringOrNull("reason").toShortReason(),
+                    )
+                }
+                .orEmpty()
+            if (books.isNotEmpty()) {
+                groups += BookshelfAutoGroupPlanGroup(
+                    key = UUID.randomUUID().toString(),
+                    name = name,
+                    description = groupObject.stringOrNull("description")?.trim().orEmpty()
+                        .take(MAX_DESCRIPTION_CHARS),
+                    reuseExisting = name in existingGroupNames,
+                    books = books,
+                )
+            }
+        }
+
+        val ignored = mutableListOf<BookshelfAutoGroupIgnoredBook>()
+        root.arrayOrNull("ignoredBooks")?.forEach { ignoredElement ->
+            val ignoredObject = ignoredElement.objectOrNull() ?: return@forEach
+            val promptId = ignoredObject.stringOrNull("id") ?: return@forEach
+            val book = booksByPromptId[promptId] ?: return@forEach
+            if (assignedIds.add(promptId)) {
+                ignored += BookshelfAutoGroupIgnoredBook(
+                    bookUrl = book.bookUrl,
+                    name = book.name,
+                    author = book.author,
+                    reason = ignoredObject.stringOrNull("reason").toShortReason(),
+                )
+            }
+        }
+
+        booksByPromptId.forEach { (promptId, book) ->
+            if (assignedIds.add(promptId)) {
+                ignored += BookshelfAutoGroupIgnoredBook(
+                    bookUrl = book.bookUrl,
+                    name = book.name,
+                    author = book.author,
+                    reason = "",
+                )
+            }
+        }
+        return BookshelfAutoGroupPlan(
+            groups = mergeGroups(groups, existingGroupNames),
+            ignoredBooks = ignored,
+        )
+    }
+
+    private fun extractFirstPlanObject(text: String): JsonObject {
+        text.indices.filter { text[it] == '{' }.forEach { start ->
+            var depth = 0
+            var inString = false
+            var escaped = false
+            for (index in start until text.length) {
+                val char = text[index]
+                if (inString) {
+                    when {
+                        escaped -> escaped = false
+                        char == '\\' -> escaped = true
+                        char == '"' -> inString = false
+                    }
+                    continue
+                }
+                when (char) {
+                    '"' -> inString = true
+                    '{' -> depth++
+                    '}' -> {
+                        depth--
+                        if (depth == 0) {
+                            val candidate = text.substring(start, index + 1)
+                            val parsed = runCatching { JsonParser.parseString(candidate) }.getOrNull()
+                            if (parsed?.isJsonObject == true) {
+                                val root = parsed.asJsonObject
+                                if (root.hasPlanShape()) return root
+                            }
+                            break
+                        }
+                    }
+                }
+            }
+        }
+        throw BookshelfAutoGroupException(BookshelfAutoGroupErrorReason.InvalidResponse)
+    }
+
+    private fun JsonObject.hasPlanShape(): Boolean {
+        val groups = get("groups")
+        val ignoredBooks = get("ignoredBooks")
+        val hasKnownField = groups != null || ignoredBooks != null
+        return hasKnownField &&
+            (groups == null || groups.isJsonArray) &&
+            (ignoredBooks == null || ignoredBooks.isJsonArray)
+    }
+
+    private fun mergeGroups(
+        groups: List<BookshelfAutoGroupPlanGroup>,
+        existingNames: Set<String>,
+    ): List<BookshelfAutoGroupPlanGroup> {
+        val groupsByName = linkedMapOf<String, BookshelfAutoGroupPlanGroup>()
+        groups.forEach { group ->
+            val existing = groupsByName[group.name]
+            groupsByName[group.name] = if (existing == null) {
+                group.copy(reuseExisting = group.name in existingNames)
+            } else {
+                existing.copy(books = existing.books + group.books)
+            }
+        }
+        return groupsByName.values.toList()
+    }
+
+    private fun JsonObject.arrayOrNull(name: String) = get(name)
+        ?.takeIf { it.isJsonArray }
+        ?.asJsonArray
+
+    private fun JsonElement.objectOrNull() = takeIf { it.isJsonObject }?.asJsonObject
+
+    private fun JsonObject.stringOrNull(name: String): String? {
+        val element = get(name) ?: return null
+        return element.takeIf { it.isJsonPrimitive && it.asJsonPrimitive.isString }?.asString
+    }
+
+    private fun String?.toShortReason(): String {
+        val reason = this?.trim().orEmpty()
+        val sentenceEnd = reason.indexOfAny(SENTENCE_ENDINGS)
+        return reason.take(if (sentenceEnd >= 0) sentenceEnd + 1 else MAX_REASON_CHARS)
+            .take(MAX_REASON_CHARS)
+    }
+
+    private companion object {
+        const val MAX_GROUP_NAME_CHARS = 24
+        const val MAX_DESCRIPTION_CHARS = 120
+        const val MAX_REASON_CHARS = 60
+        val SENTENCE_ENDINGS = charArrayOf('。', '!', '?', '.', '!', '?')
+    }
+}

+ 373 - 231
app/src/main/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCase.kt

@@ -1,324 +1,466 @@
 package io.legado.app.domain.usecase
 
-import com.google.gson.JsonObject
-import com.google.gson.JsonParser
-import io.legado.app.data.AppDatabase
-import io.legado.app.data.entities.Book
+import com.google.gson.Gson
 import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
 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.AiReasoningLevel
 import io.legado.app.domain.model.AiTaskPresetConfig
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
 import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
-import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
+import io.legado.app.domain.model.BookshelfAutoGroupPreflight
+import io.legado.app.domain.model.BookshelfAutoGroupProgress
+import io.legado.app.domain.model.BookshelfAutoGroupPromptText
 import io.legado.app.domain.model.BookshelfAutoGroupSource
-import io.legado.app.help.book.isNotShelf
-import io.legado.app.utils.GSON
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.util.UUID
+import kotlin.text.Charsets.UTF_8
 
 class GenerateBookshelfAutoGroupPlanUseCase(
-    private val database: AppDatabase,
+    private val gateway: BookshelfAutoGroupGateway,
+    private val promptGateway: BookshelfAutoGroupPromptGateway,
     private val aiProfileGateway: AiProfileGateway,
     private val aiTextGateway: AiTextGateway,
 ) {
 
-    suspend fun loadSource(): BookshelfAutoGroupSource = withContext(Dispatchers.IO) {
-        val books = database.bookDao.getAll()
-            .filterNot { it.isNotShelf }
-            .sortedWith(compareBy<Book> { it.name }.thenBy { it.author })
-        val existingGroups = database.bookGroupDao.all
-            .filter { it.groupId > 0 }
-            .map { it.groupName }
-            .filter { it.isNotBlank() }
-        BookshelfAutoGroupSource(
-            books = books.map { book ->
-                BookshelfAutoGroupBook(
-                    bookUrl = book.bookUrl,
-                    name = book.name,
-                    author = book.author,
-                    intro = book.groupingIntro(),
-                    kind = book.customTag ?: book.kind.orEmpty(),
-                    currentGroupNames = database.bookGroupDao.getGroupNames(book.group),
-                )
-            },
-            existingGroupNames = existingGroups,
+    private val gson = Gson()
+    private val parser = BookshelfAutoGroupPlanParser()
+
+    suspend fun loadSource(): BookshelfAutoGroupSource = gateway.loadSource()
+
+    suspend fun preflight(
+        source: BookshelfAutoGroupSource,
+        groupingInstruction: String = "",
+        options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
+    ): BookshelfAutoGroupPreflight {
+        validateSource(source)
+        val promptText = promptGateway.getPromptText()
+        val preset = resolvePreset()
+        val systemPrompt = resolveSystemPrompt(preset, promptText)
+        val inputBudget = effectiveInputBudget(preset)
+        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildGeneratePrompt(source, books, groupingInstruction, emptyList(), options, promptText)
+        }
+        return BookshelfAutoGroupPreflight(
+            effectiveInputCharLimit = inputBudget.effectiveCharLimit,
+            estimatedRequestCount = batches.size,
         )
     }
 
     suspend fun generate(
         source: BookshelfAutoGroupSource,
         groupingInstruction: String,
+        options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
+        onProgress: (BookshelfAutoGroupProgress) -> Unit = {},
     ): BookshelfAutoGroupPlan {
-        require(source.books.isNotEmpty()) { "书架没有可分析的书籍" }
-        val preset = resolvePreset() ?: error("请先配置默认 AI 模型")
-        val response = aiTextGateway.generate(
-            AiGenerateRequest(
-                model = preset.model,
-                messages = listOf(
-                    AiMessage(AiMessageRole.SYSTEM, buildSystemPrompt()),
-                    AiMessage(AiMessageRole.USER, buildGeneratePrompt(source, groupingInstruction)),
+        validateSource(source)
+        val promptText = promptGateway.getPromptText()
+        val preset = resolvePreset()
+        val systemPrompt = resolveSystemPrompt(preset, promptText)
+        val inputBudget = effectiveInputBudget(preset)
+        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildGeneratePrompt(source, books, groupingInstruction, emptyList(), options, promptText)
+        }
+        val plans = mutableListOf<BookshelfAutoGroupPlan>()
+        val proposedGroupNames = linkedSetOf<String>()
+        batches.forEachIndexed { index, batch ->
+            onProgress(BookshelfAutoGroupProgress(index + 1, batches.size))
+            val sharedGroupNames = fitSharedGroupNames(
+                candidates = proposedGroupNames,
+                inputBudget = inputBudget,
+                systemPrompt = systemPrompt,
+            ) { names ->
+                buildGeneratePrompt(source, batch, groupingInstruction, names, options, promptText)
+            }
+            val plan = generateBatch(
+                preset = preset,
+                systemPrompt = systemPrompt,
+                prompt = buildGeneratePrompt(
+                    source,
+                    batch,
+                    groupingInstruction,
+                    sharedGroupNames,
+                    options,
+                    promptText,
                 ),
-                params = preset.params,
+                books = batch,
+                existingGroupNames = source.existingGroupNames.toSet(),
+                options = options,
             )
-        ).getOrThrow().text
-        return parseAndSanitizePlan(response, source)
+            plans += plan
+            plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
+        }
+        return mergePlans(plans, source)
     }
 
     suspend fun revise(
         source: BookshelfAutoGroupSource,
         currentPlan: BookshelfAutoGroupPlan,
         instruction: String,
+        options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
+        onProgress: (BookshelfAutoGroupProgress) -> Unit = {},
+    ): BookshelfAutoGroupPlan {
+        require(instruction.isNotBlank()) { "Revision instruction is required" }
+        validateSource(source)
+        val promptText = promptGateway.getPromptText()
+        val preset = resolvePreset()
+        val systemPrompt = resolveSystemPrompt(preset, promptText)
+        val inputBudget = effectiveInputBudget(preset)
+        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildRevisePrompt(source, currentPlan, books, instruction, emptyList(), options, promptText)
+        }
+        val plans = mutableListOf<BookshelfAutoGroupPlan>()
+        val proposedGroupNames = currentPlan.groups
+            .mapTo(linkedSetOf(), BookshelfAutoGroupPlanGroup::name)
+        batches.forEachIndexed { index, batch ->
+            onProgress(BookshelfAutoGroupProgress(index + 1, batches.size))
+            val sharedGroupNames = fitSharedGroupNames(
+                candidates = proposedGroupNames,
+                inputBudget = inputBudget,
+                systemPrompt = systemPrompt,
+            ) { names ->
+                buildRevisePrompt(source, currentPlan, batch, instruction, names, options, promptText)
+            }
+            val plan = generateBatch(
+                preset = preset,
+                systemPrompt = systemPrompt,
+                prompt = buildRevisePrompt(
+                    source,
+                    currentPlan,
+                    batch,
+                    instruction,
+                    sharedGroupNames,
+                    options,
+                    promptText,
+                ),
+                books = batch,
+                existingGroupNames = source.existingGroupNames.toSet(),
+                options = options,
+            )
+            plans += plan
+            plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
+        }
+        return mergePlans(plans, source)
+    }
+
+    private suspend fun generateBatch(
+        preset: AiTaskPresetConfig,
+        systemPrompt: String,
+        prompt: String,
+        books: List<PromptBook>,
+        existingGroupNames: Set<String>,
+        options: BookshelfAutoGroupOptions,
     ): BookshelfAutoGroupPlan {
-        require(instruction.isNotBlank()) { "调整要求不能为空" }
-        val preset = resolvePreset() ?: error("请先配置默认 AI 模型")
         val response = aiTextGateway.generate(
             AiGenerateRequest(
                 model = preset.model,
                 messages = listOf(
-                    AiMessage(AiMessageRole.SYSTEM, buildSystemPrompt()),
-                    AiMessage(AiMessageRole.USER, buildRevisePrompt(source, currentPlan, instruction)),
+                    AiMessage(AiMessageRole.SYSTEM, systemPrompt),
+                    AiMessage(AiMessageRole.USER, prompt),
                 ),
-                params = preset.params,
+                params = autoGroupParams(preset, options),
             )
         ).getOrThrow().text
-        return parseAndSanitizePlan(response, source)
+        return parser.parse(
+            response = response,
+            booksByPromptId = books.associate { it.id to it.book },
+            existingGroupNames = existingGroupNames,
+        )
     }
 
-    private suspend fun resolvePreset(): AiTaskPresetConfig? {
+    private suspend fun resolvePreset(): AiTaskPresetConfig {
         return aiProfileGateway.getTaskPreset(AiTaskType.BOOKSHELF_AUTO_GROUP)
             ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
             ?: aiProfileGateway.getTaskPreset(AiTaskType.TEXT_FACTORY)
             ?: aiProfileGateway.getTaskPreset(AiTaskType.SUMMARIZE_BOOK)
             ?: aiProfileGateway.getTaskPreset(AiTaskType.SUMMARIZE_CHAPTER)
+            ?: throw BookshelfAutoGroupException(BookshelfAutoGroupErrorReason.MissingModel)
     }
 
-    private fun buildSystemPrompt(): String {
-        return """
-            你是阅读 App 的书架整理助手。
-            你的任务是根据书名、作者、分类、简介和当前分组,生成可由用户确认的书架分组方案。
-            只输出 JSON,不要 Markdown,不要代码块,不要解释。
-            不要虚构 bookUrl,只能使用用户输入中存在的 bookUrl。
-            分组名称使用简短中文,避免“其他”“综合”等过泛名称,除非确实无法判断。
-            可以复用已有分组名称;也可以提出新分组。
-            每本书最多放入一个建议分组。无法判断的书放入 ignoredBooks。
-        """.trimIndent()
-    }
-
-    private fun buildGeneratePrompt(
-        source: BookshelfAutoGroupSource,
-        groupingInstruction: String,
+    private fun resolveSystemPrompt(
+        preset: AiTaskPresetConfig,
+        promptText: BookshelfAutoGroupPromptText,
     ): String {
+        val taskPrompt = preset.promptTemplate.takeIf {
+            preset.taskType == AiTaskType.BOOKSHELF_AUTO_GROUP && it.isNotBlank()
+        }
         return buildString {
-            append("请为以下全部书籍生成分组方案。\n")
-            append("已有用户分组:")
-            append(source.existingGroupNames.joinToString("、").ifBlank { "无" })
-            groupingInstruction.trim().takeIf { it.isNotBlank() }?.let { instruction ->
-                append("\n\n用户补充分组要求:\n")
-                append(instruction)
-            }
-            append("\n\n返回 JSON 格式:\n")
-            append(OUTPUT_SCHEMA)
-            append("\n\n书籍数据:\n")
-            append(GSON.toJson(source.books.map { it.toPromptMap() }))
+            append(taskPrompt ?: promptText.defaultSystemPrompt)
+            append("\n\n")
+            append(promptText.mandatoryRules)
         }
     }
 
-    private fun buildRevisePrompt(
-        source: BookshelfAutoGroupSource,
-        currentPlan: BookshelfAutoGroupPlan,
-        instruction: String,
-    ): String {
-        return buildString {
-            append("请根据用户调整要求,重新输出完整分组方案。\n")
-            append("用户调整要求:")
-            append(instruction)
-            append("\n\n已有用户分组:")
-            append(source.existingGroupNames.joinToString("、").ifBlank { "无" })
-            append("\n\n当前方案:\n")
-            append(currentPlan.toPromptJson())
-            append("\n\n书籍数据:\n")
-            append(GSON.toJson(source.books.map { it.toPromptMap() }))
-            append("\n\n返回 JSON 格式:\n")
-            append(OUTPUT_SCHEMA)
+    private fun validateSource(source: BookshelfAutoGroupSource) {
+        if (source.books.isEmpty()) {
+            throw BookshelfAutoGroupException(BookshelfAutoGroupErrorReason.EmptyBookshelf)
         }
     }
 
-    private fun parseAndSanitizePlan(
-        response: String,
-        source: BookshelfAutoGroupSource,
-    ): BookshelfAutoGroupPlan {
-        val root = extractJsonObject(response)
-        val booksByUrl = source.books.associateBy { it.bookUrl }
-        val existingNames = source.existingGroupNames.toSet()
-        val assignedBookUrls = linkedSetOf<String>()
-        val groups = mutableListOf<BookshelfAutoGroupPlanGroup>()
-
-        root.getAsJsonArrayOrNull("groups")?.forEach { groupElement ->
-            val groupObject = groupElement.asJsonObjectOrNull() ?: return@forEach
-            val name = groupObject.string("name")
-                ?.trim()
-                ?.take(24)
-                ?.takeIf { it.isNotBlank() }
-                ?: return@forEach
-            val description = groupObject.string("description")?.trim().orEmpty().take(120)
-            val books = groupObject.getAsJsonArrayOrNull("books")
-                ?.mapNotNull { bookElement ->
-                    val bookObject = bookElement.asJsonObjectOrNull() ?: return@mapNotNull null
-                    val bookUrl = bookObject.string("bookUrl") ?: return@mapNotNull null
-                    val book = booksByUrl[bookUrl] ?: return@mapNotNull null
-                    if (!assignedBookUrls.add(bookUrl)) return@mapNotNull null
-                    BookshelfAutoGroupPlanBook(
-                        bookUrl = book.bookUrl,
-                        name = book.name,
-                        author = book.author,
-                        currentGroupNames = book.currentGroupNames,
-                        reason = bookObject.string("reason")?.trim().orEmpty().take(120),
-                    )
-                }
-                .orEmpty()
-            if (books.isNotEmpty()) {
-                groups += BookshelfAutoGroupPlanGroup(
-                    key = UUID.randomUUID().toString(),
-                    name = name,
-                    description = description,
-                    reuseExisting = name in existingNames,
-                    books = books,
-                )
-            }
+    private fun effectiveInputBudget(preset: AiTaskPresetConfig): InputBudget {
+        val configuredLimit = preset.runtimeOptions.maxInputChars
+            .takeIf { it > 0 }
+            ?: DEFAULT_MAX_INPUT_CHARS
+        val outputReserve = preset.params.maxOutputTokens
+            ?.takeIf { it > 0 }
+            ?: preset.model.maxOutputTokens.takeIf { it > 0 }?.coerceAtMost(DEFAULT_OUTPUT_RESERVE)
+            ?: DEFAULT_OUTPUT_RESERVE
+        val contextTokenLimit = preset.model.contextWindow
+            .takeIf { it > 0 }
+            ?.minus(outputReserve)
+            ?: Int.MAX_VALUE
+        if (contextTokenLimit < MIN_INPUT_CHARS) {
+            throw BookshelfAutoGroupException(BookshelfAutoGroupErrorReason.CapacityTooSmall)
         }
+        return InputBudget(
+            maxChars = configuredLimit,
+            // UTF-8 bytes are a conservative upper bound for byte-level tokenizer input tokens.
+            maxUtf8Bytes = contextTokenLimit,
+        )
+    }
 
-        val ignored = mutableListOf<BookshelfAutoGroupIgnoredBook>()
-        root.getAsJsonArrayOrNull("ignoredBooks")?.forEach { ignoredElement ->
-            val ignoredObject = ignoredElement.asJsonObjectOrNull() ?: return@forEach
-            val bookUrl = ignoredObject.string("bookUrl") ?: return@forEach
-            val book = booksByUrl[bookUrl] ?: return@forEach
-            if (book.bookUrl !in assignedBookUrls) {
-                ignored += BookshelfAutoGroupIgnoredBook(
-                    bookUrl = book.bookUrl,
-                    name = book.name,
-                    author = book.author,
-                    reason = ignoredObject.string("reason")?.trim().orEmpty().take(120),
-                )
-                assignedBookUrls += book.bookUrl
-            }
-        }
+    private fun autoGroupParams(
+        preset: AiTaskPresetConfig,
+        options: BookshelfAutoGroupOptions,
+    ) = preset.params.copy(
+        temperature = AUTO_GROUP_TEMPERATURE.takeUnless { options.enableDeepThinking },
+        reasoningLevel = if (options.enableDeepThinking) {
+            AiReasoningLevel.HIGH
+        } else {
+            AiReasoningLevel.OFF
+        },
+    )
 
-        val missingIgnored = source.books
-            .filterNot { it.bookUrl in assignedBookUrls }
-            .map {
-                BookshelfAutoGroupIgnoredBook(
-                    bookUrl = it.bookUrl,
-                    name = it.name,
-                    author = it.author,
-                    reason = "AI 未给出明确分组",
-                )
+    private fun createBatches(
+        source: BookshelfAutoGroupSource,
+        inputBudget: InputBudget,
+        systemPrompt: String,
+        promptBuilder: (List<PromptBook>) -> String,
+    ): List<List<PromptBook>> {
+        val promptBooks = source.books.mapIndexed { index, book -> PromptBook("b${index + 1}", book) }
+        val batches = mutableListOf<List<PromptBook>>()
+        var current = mutableListOf<PromptBook>()
+        promptBooks.forEach { book ->
+            val candidate = current + book
+            val fits = candidate.size <= MAX_BOOKS_PER_BATCH &&
+                inputBudget.fits(systemPrompt, promptBuilder(candidate))
+            if (fits) {
+                current += book
+            } else {
+                if (current.isNotEmpty()) batches += current.toList()
+                if (!inputBudget.fits(systemPrompt, promptBuilder(listOf(book)))) {
+                    throw BookshelfAutoGroupException(BookshelfAutoGroupErrorReason.CapacityTooSmall)
+                }
+                current = mutableListOf(book)
             }
+        }
+        if (current.isNotEmpty()) batches += current.toList()
+        return batches
+    }
 
-        return BookshelfAutoGroupPlan(
-            groups = groups.mergeSameName(existingNames),
-            ignoredBooks = ignored + missingIgnored,
+    private fun batchInputBudget(inputBudget: InputBudget): InputBudget {
+        return InputBudget(
+            maxChars = (inputBudget.maxChars - SHARED_GROUP_NAMES_RESERVE_CHARS)
+                .coerceAtLeast(minOf(MIN_INPUT_CHARS, inputBudget.maxChars)),
+            maxUtf8Bytes = (inputBudget.maxUtf8Bytes - SHARED_GROUP_NAMES_RESERVE_UTF8_BYTES)
+                .coerceAtLeast(minOf(MIN_INPUT_CHARS, inputBudget.maxUtf8Bytes)),
         )
     }
 
-    private fun extractJsonObject(text: String): JsonObject {
-        val start = text.indexOf('{')
-        val end = text.lastIndexOf('}')
-        require(start >= 0 && end > start) { "AI 未返回可解析的分组方案" }
-        return JsonParser.parseString(text.substring(start, end + 1)).asJsonObject
+    private fun fitSharedGroupNames(
+        candidates: Collection<String>,
+        inputBudget: InputBudget,
+        systemPrompt: String,
+        promptBuilder: (List<String>) -> String,
+    ): List<String> {
+        val included = mutableListOf<String>()
+        candidates.forEach { name ->
+            val candidate = included + name
+            if (inputBudget.fits(systemPrompt, promptBuilder(candidate))) {
+                included += name
+            }
+        }
+        return included
     }
 
-    private fun List<BookshelfAutoGroupPlanGroup>.mergeSameName(
-        existingNames: Set<String>,
-    ): List<BookshelfAutoGroupPlanGroup> {
-        return groupBy { it.name }.map { (name, groups) ->
-            val first = groups.first()
-            first.copy(
-                name = name,
-                reuseExisting = name in existingNames,
-                books = groups.flatMap { it.books },
-            )
+    private fun buildGeneratePrompt(
+        source: BookshelfAutoGroupSource,
+        books: List<PromptBook>,
+        groupingInstruction: String,
+        proposedGroupNames: List<String>,
+        options: BookshelfAutoGroupOptions,
+        promptText: BookshelfAutoGroupPromptText,
+    ): String = buildString {
+        append(promptText.generateTask)
+        append("\n")
+        append(promptText.existingGroups)
+        append(": ")
+        append(source.existingGroupNames.joinToString(", ").ifBlank { promptText.noExistingGroups })
+        groupingInstruction.trim().takeIf(String::isNotBlank)?.let { instruction ->
+            append("\n")
+            append(promptText.userRequirements)
+            append(":\n")
+            append(instruction)
         }
+        appendSharedGroupNames(proposedGroupNames, promptText)
+        appendReasonRequirement(promptText)
+        append("\n")
+        append(promptText.outputSchemaLabel)
+        append(":\n")
+        append(promptText.outputSchema)
+        append("\n")
+        append(promptText.books)
+        append(":\n")
+        append(gson.toJson(books.map { it.toPromptMap(options.includeBookIntro) }))
+    }
+
+    private fun buildRevisePrompt(
+        source: BookshelfAutoGroupSource,
+        currentPlan: BookshelfAutoGroupPlan,
+        books: List<PromptBook>,
+        instruction: String,
+        proposedGroupNames: List<String>,
+        options: BookshelfAutoGroupOptions,
+        promptText: BookshelfAutoGroupPromptText,
+    ): String = buildString {
+        append(promptText.reviseTask)
+        append("\n")
+        append(promptText.userRequirements)
+        append(": ")
+        append(instruction.trim())
+        append("\n")
+        append(promptText.existingGroups)
+        append(": ")
+        append(source.existingGroupNames.joinToString(", ").ifBlank { promptText.noExistingGroups })
+        appendSharedGroupNames(proposedGroupNames, promptText)
+        appendReasonRequirement(promptText)
+        append("\n")
+        append(promptText.currentPlan)
+        append(":\n")
+        append(currentPlan.toPromptJson(books))
+        append("\n")
+        append(promptText.books)
+        append(":\n")
+        append(gson.toJson(books.map { it.toPromptMap(options.includeBookIntro) }))
+        append("\n")
+        append(promptText.outputSchemaLabel)
+        append(":\n")
+        append(promptText.outputSchema)
     }
 
-    private fun Book.groupingIntro(): String {
-        return (customIntro ?: intro ?: listIntro).orEmpty()
-            .replace(Regex("\\s+"), " ")
-            .take(MAX_INTRO_CHARS)
+    private fun StringBuilder.appendSharedGroupNames(
+        names: List<String>,
+        promptText: BookshelfAutoGroupPromptText,
+    ) {
+        if (names.isEmpty()) return
+        append("\n")
+        append(promptText.previouslyProposedGroups)
+        append(": ")
+        append(gson.toJson(names))
+        append("\n")
+        append(promptText.reuseGroupNamesRule)
     }
 
-    private fun BookshelfAutoGroupBook.toPromptMap(): Map<String, Any?> {
-        return mapOf(
-            "bookUrl" to bookUrl,
-            "name" to name,
-            "author" to author,
-            "kind" to kind,
-            "intro" to intro,
-            "currentGroups" to currentGroupNames,
-        )
+    private fun StringBuilder.appendReasonRequirement(promptText: BookshelfAutoGroupPromptText) {
+        append("\n")
+        append(promptText.reasonRule)
     }
 
-    private fun BookshelfAutoGroupPlan.toPromptJson(): String {
-        return GSON.toJson(
+    private fun BookshelfAutoGroupPlan.toPromptJson(books: List<PromptBook>): String {
+        val idsByUrl = books.associate { it.book.bookUrl to it.id }
+        return gson.toJson(
             mapOf(
-                "groups" to groups.map { group ->
-                    mapOf(
-                        "name" to group.name,
-                        "description" to group.description,
-                        "books" to group.books.map { book ->
-                            mapOf(
-                                "bookUrl" to book.bookUrl,
-                                "reason" to book.reason,
-                            )
-                        },
-                    )
+                "groups" to groups.mapNotNull { group ->
+                    val promptBooks = group.books.mapNotNull { book ->
+                        idsByUrl[book.bookUrl]?.let { id -> mapOf("id" to id, "reason" to book.reason) }
+                    }
+                    group.takeIf { promptBooks.isNotEmpty() }?.let {
+                        mapOf("name" to group.name, "description" to group.description, "books" to promptBooks)
+                    }
                 },
-                "ignoredBooks" to ignoredBooks.map { book ->
-                    mapOf(
-                        "bookUrl" to book.bookUrl,
-                        "reason" to book.reason,
-                    )
+                "ignoredBooks" to ignoredBooks.mapNotNull { book ->
+                    idsByUrl[book.bookUrl]?.let { id -> mapOf("id" to id, "reason" to book.reason) }
                 },
             )
         )
     }
 
-    private fun JsonObject.string(name: String): String? {
-        return get(name)?.takeIf { !it.isJsonNull }?.asString
+    private fun PromptBook.toPromptMap(includeBookIntro: Boolean): Map<String, Any> = buildMap {
+        put("id", id)
+        put("name", book.name)
+        book.author.takeIf(String::isNotBlank)?.let { put("author", it) }
+        book.kind.takeIf(String::isNotBlank)?.let { put("kind", it) }
+        if (includeBookIntro) {
+            book.intro.takeIf(String::isNotBlank)?.let { put("intro", it) }
+        }
+        book.currentGroupNames.takeIf { it.isNotEmpty() }?.let { put("currentGroups", it) }
     }
 
-    private fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
-        get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
-    }.getOrNull()
+    private fun mergePlans(
+        plans: List<BookshelfAutoGroupPlan>,
+        source: BookshelfAutoGroupSource,
+    ): BookshelfAutoGroupPlan {
+        val existingNames = source.existingGroupNames.toSet()
+        val assignedUrls = linkedSetOf<String>()
+        val groupsByName = linkedMapOf<String, BookshelfAutoGroupPlanGroup>()
+        plans.flatMap(BookshelfAutoGroupPlan::groups).forEach { group ->
+            val books = group.books.filter { assignedUrls.add(it.bookUrl) }
+            if (books.isEmpty()) return@forEach
+            val existing = groupsByName[group.name]
+            groupsByName[group.name] = if (existing == null) {
+                group.copy(reuseExisting = group.name in existingNames, books = books)
+            } else {
+                existing.copy(books = existing.books + books)
+            }
+        }
+        val ignoredUrls = linkedSetOf<String>()
+        val ignored = plans.flatMap(BookshelfAutoGroupPlan::ignoredBooks)
+            .filter { it.bookUrl !in assignedUrls && ignoredUrls.add(it.bookUrl) }
+            .toMutableList()
+        source.books.forEach { book ->
+            if (book.bookUrl !in assignedUrls && ignoredUrls.add(book.bookUrl)) {
+                ignored += BookshelfAutoGroupIgnoredBook(book.bookUrl, book.name, book.author, "")
+            }
+        }
+        return BookshelfAutoGroupPlan(groupsByName.values.toList(), ignored)
+    }
+
+    private data class PromptBook(
+        val id: String,
+        val book: BookshelfAutoGroupBook,
+    )
 
-    private fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? {
-        return takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
+    private data class InputBudget(
+        val maxChars: Int,
+        val maxUtf8Bytes: Int,
+    ) {
+        val effectiveCharLimit: Int get() = minOf(maxChars, maxUtf8Bytes)
+
+        fun fits(systemPrompt: String, userPrompt: String): Boolean {
+            val charCount = systemPrompt.length + userPrompt.length
+            if (charCount > maxChars) return false
+            val utf8Bytes = systemPrompt.toByteArray(UTF_8).size +
+                userPrompt.toByteArray(UTF_8).size
+            return utf8Bytes <= maxUtf8Bytes
+        }
     }
 
     private companion object {
-        const val MAX_INTRO_CHARS = 320
+        const val DEFAULT_MAX_INPUT_CHARS = 10_000
+        const val DEFAULT_OUTPUT_RESERVE = 4_096
+        const val MIN_INPUT_CHARS = 512
+        const val MAX_BOOKS_PER_BATCH = 30
+        const val SHARED_GROUP_NAMES_RESERVE_CHARS = 640
+        const val SHARED_GROUP_NAMES_RESERVE_UTF8_BYTES = 640
+        const val AUTO_GROUP_TEMPERATURE = 0.3f
 
-        val OUTPUT_SCHEMA = """
-            {
-              "groups": [
-                {
-                  "name": "分组名称",
-                  "description": "分组依据",
-                  "books": [
-                    {"bookUrl": "输入中的 bookUrl", "reason": "为什么放入该分组"}
-                  ]
-                }
-              ],
-              "ignoredBooks": [
-                {"bookUrl": "输入中的 bookUrl", "reason": "无法判断原因"}
-              ]
-            }
-        """.trimIndent()
     }
 }

+ 6 - 0
app/src/main/java/io/legado/app/ui/config/ai/prompt/AiPromptConfigViewModel.kt

@@ -75,6 +75,12 @@ class AiPromptConfigViewModel(
             nameResId = R.string.ai_prompt_task_identify_characters,
             descResId = R.string.ai_prompt_task_identify_characters_desc,
             defaultPromptResId = R.string.ai_prompt_default_identify_characters
+        ),
+        TaskPromptMeta(
+            taskType = AiTaskType.BOOKSHELF_AUTO_GROUP,
+            nameResId = R.string.ai_prompt_task_bookshelf_auto_group,
+            descResId = R.string.ai_prompt_task_bookshelf_auto_group_desc,
+            defaultPromptResId = R.string.ai_prompt_default_bookshelf_auto_group
         )
     )
 

+ 2 - 1
app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt

@@ -13,6 +13,7 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.rememberLazyListState
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.AutoAwesome
 import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.Sync
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -140,7 +141,7 @@ fun GroupManageSheet(
                             }
                         )
                         RoundDropdownMenuItem(
-                            text = "AI自动分组",
+                            text = stringResource(R.string.ai_auto_group),
                             leadingIcon = { Icon(Icons.Default.AutoAwesome, null) },
                             onClick = {
                                 showMenu = false

+ 36 - 3
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupContract.kt

@@ -21,18 +21,48 @@ data class AiAutoGroupUiState(
     val bookCount: Int = 0,
     val groupedBookCount: Int = 0,
     val existingGroupCount: Int = 0,
+    val effectiveInputCharLimit: Int = 0,
+    val estimatedRequestCount: Int = 0,
+    val currentBatch: Int = 0,
+    val totalBatches: Int = 0,
     val groupingInstruction: String = "",
+    val includeBookIntro: Boolean = false,
+    val enableDeepThinking: Boolean = false,
     val groups: ImmutableList<AiAutoGroupGroupUi> = persistentListOf(),
     val ignoredBooks: ImmutableList<AiAutoGroupIgnoredBookUi> = persistentListOf(),
     val revisionInstruction: String = "",
-    val resultText: String? = null,
-    val errorMessage: String? = null,
+    val applyResult: AiAutoGroupApplyResultUi? = null,
+    val error: AiAutoGroupErrorUi? = null,
     val showApplyConfirm: Boolean = false,
 ) {
     val assignedBookCount: Int get() = groups.sumOf { it.books.size }
     val newGroupCount: Int get() = groups.count { !it.reuseExisting }
 }
 
+@Stable
+data class AiAutoGroupApplyResultUi(
+    val createdGroupCount: Int,
+    val reusedGroupCount: Int,
+    val updatedBookCount: Int,
+    val ignoredBookCount: Int,
+)
+
+sealed interface AiAutoGroupErrorUi {
+    data object EmptyBookshelf : AiAutoGroupErrorUi
+    data object MissingModel : AiAutoGroupErrorUi
+    data object CapacityTooSmall : AiAutoGroupErrorUi
+    data object GroupCapacityExceeded : AiAutoGroupErrorUi
+    data object InvalidResponse : AiAutoGroupErrorUi
+    data class Unexpected(val detail: String?) : AiAutoGroupErrorUi
+}
+
+enum class AiAutoGroupMessage {
+    EnterRevisionInstruction,
+    NoApplicablePlan,
+    Cancelled,
+    GroupNameRequired,
+}
+
 @Stable
 data class AiAutoGroupGroupUi(
     val key: String,
@@ -74,11 +104,14 @@ sealed interface AiAutoGroupIntent {
     data class IgnoreBook(val bookUrl: String) : AiAutoGroupIntent
     data class AddGroup(val name: String) : AiAutoGroupIntent
     data class UpdateGroupingInstruction(val instruction: String) : AiAutoGroupIntent
+    data class SetIncludeBookIntro(val enabled: Boolean) : AiAutoGroupIntent
+    data class SetDeepThinkingEnabled(val enabled: Boolean) : AiAutoGroupIntent
     data class UpdateRevisionInstruction(val instruction: String) : AiAutoGroupIntent
     data object Revise : AiAutoGroupIntent
 }
 
 sealed interface AiAutoGroupEffect {
-    data class ShowMessage(val message: String) : AiAutoGroupEffect
+    data class ShowMessage(val message: AiAutoGroupMessage) : AiAutoGroupEffect
+    data class ShowError(val error: AiAutoGroupErrorUi) : AiAutoGroupEffect
     data object Applied : AiAutoGroupEffect
 }

+ 190 - 54
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupSheet.kt

@@ -23,18 +23,22 @@ import androidx.compose.material3.Icon
 import androidx.compose.material3.Text
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.SplicedColumnGroup
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.ConfirmDismissButtonsRow
 import io.legado.app.ui.widget.components.button.series.MediumPlainButton
@@ -44,6 +48,7 @@ import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.SelectionItemCard
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.SwitchSettingItem
 import io.legado.app.utils.toastOnUi
 import org.koin.androidx.compose.koinViewModel
 
@@ -54,11 +59,11 @@ fun AiAutoGroupSheet(
     onDismissRequest: () -> Unit,
     viewModel: AiAutoGroupViewModel = koinViewModel(),
 ) {
-    val state by viewModel.uiState.collectAsState()
-    val context = androidx.compose.ui.platform.LocalContext.current
+    val state by viewModel.uiState.collectAsStateWithLifecycle()
+    val context = LocalContext.current
     val closeSheet = {
         if (state.phase == AiAutoGroupPhase.Applying) {
-            context.toastOnUi("正在应用分组,请稍候")
+            context.toastOnUi(context.getString(R.string.ai_auto_group_applying_wait))
         } else {
             viewModel.onIntent(AiAutoGroupIntent.CloseSession)
             onDismissRequest()
@@ -74,8 +79,13 @@ fun AiAutoGroupSheet(
     LaunchedEffect(Unit) {
         viewModel.effects.collect { effect ->
             when (effect) {
-                is AiAutoGroupEffect.ShowMessage -> context.toastOnUi(effect.message)
-                AiAutoGroupEffect.Applied -> context.toastOnUi("AI 自动分组已完成")
+                is AiAutoGroupEffect.ShowMessage -> context.toastOnUi(
+                    context.getString(effect.message.stringRes)
+                )
+                is AiAutoGroupEffect.ShowError -> context.toastOnUi(effect.error.localizedText(context))
+                AiAutoGroupEffect.Applied -> context.toastOnUi(
+                    context.getString(R.string.ai_auto_group_completed)
+                )
             }
         }
     }
@@ -83,20 +93,20 @@ fun AiAutoGroupSheet(
     AppModalBottomSheet(
         show = show,
         onDismissRequest = closeSheet,
-        title = "AI 自动分组",
+        title = stringResource(R.string.ai_auto_group),
         endAction = {
             when (state.phase) {
                 AiAutoGroupPhase.Reviewing -> MediumTonalButton(
                     onClick = { viewModel.onIntent(AiAutoGroupIntent.RequestApply) },
                     icon = Icons.Default.Check,
-                    contentDescription = "确认执行",
+                    contentDescription = stringResource(R.string.ai_auto_group_confirm_execute),
                     enabled = state.assignedBookCount > 0,
                 )
 
                 AiAutoGroupPhase.Result -> MediumPlainButton(
                     onClick = closeSheet,
                     icon = Icons.Default.Close,
-                    contentDescription = "关闭",
+                    contentDescription = stringResource(R.string.close),
                 )
 
                 else -> Unit
@@ -104,7 +114,9 @@ fun AiAutoGroupSheet(
         }
     ) {
         when (state.phase) {
-            AiAutoGroupPhase.LoadingSource -> AutoGroupProgressContent("正在读取书架")
+            AiAutoGroupPhase.LoadingSource -> AutoGroupProgressContent(
+                text = stringResource(R.string.ai_auto_group_loading_shelf),
+            )
             AiAutoGroupPhase.Preflight -> AutoGroupPreflightContent(
                 state = state,
                 onDismiss = closeSheet,
@@ -112,30 +124,43 @@ fun AiAutoGroupSheet(
                 onGroupingInstructionChange = {
                     viewModel.onIntent(AiAutoGroupIntent.UpdateGroupingInstruction(it))
                 },
+                onIncludeBookIntroChange = {
+                    viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(it))
+                },
+                onDeepThinkingChange = {
+                    viewModel.onIntent(AiAutoGroupIntent.SetDeepThinkingEnabled(it))
+                },
             )
 
             AiAutoGroupPhase.Analyzing -> AutoGroupProgressContent(
-                text = "正在分析书架",
+                text = stringResource(R.string.ai_auto_group_analyzing),
+                currentBatch = state.currentBatch,
+                totalBatches = state.totalBatches,
                 onCancel = { viewModel.onIntent(AiAutoGroupIntent.CancelRunning) },
             )
             AiAutoGroupPhase.Revising -> AutoGroupProgressContent(
-                text = "正在调整方案",
+                text = stringResource(R.string.ai_auto_group_revising),
+                currentBatch = state.currentBatch,
+                totalBatches = state.totalBatches,
                 onCancel = { viewModel.onIntent(AiAutoGroupIntent.CancelRunning) },
             )
-            AiAutoGroupPhase.Applying -> AutoGroupProgressContent("正在应用分组")
+            AiAutoGroupPhase.Applying -> AutoGroupProgressContent(
+                text = stringResource(R.string.ai_auto_group_applying),
+            )
             AiAutoGroupPhase.Reviewing -> AutoGroupReviewContent(
                 state = state,
                 onIntent = viewModel::onIntent,
             )
 
             AiAutoGroupPhase.Result -> AutoGroupResultContent(
-                resultText = state.resultText.orEmpty(),
+                result = state.applyResult,
                 onDone = closeSheet,
                 onReset = { viewModel.onIntent(AiAutoGroupIntent.Restart) },
             )
 
             AiAutoGroupPhase.Error -> AutoGroupErrorContent(
-                message = state.errorMessage.orEmpty(),
+                message = state.error?.localizedText(context)
+                    ?: stringResource(R.string.ai_auto_group_unknown_error),
                 onRetry = { viewModel.onIntent(AiAutoGroupIntent.Restart) },
                 onDismiss = closeSheet,
             )
@@ -145,11 +170,16 @@ fun AiAutoGroupSheet(
     AppAlertDialog(
         show = state.showApplyConfirm,
         onDismissRequest = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
-        title = "确认执行分组方案",
-        text = "将创建 ${state.newGroupCount} 个分组,调整 ${state.assignedBookCount} 本书,跳过 ${state.ignoredBooks.size} 本书。执行后会直接修改书架分组。",
-        confirmText = "执行",
+        title = stringResource(R.string.ai_auto_group_confirm_title),
+        text = stringResource(
+            R.string.ai_auto_group_confirm_message,
+            state.newGroupCount,
+            state.assignedBookCount,
+            state.ignoredBooks.size,
+        ),
+        confirmText = stringResource(R.string.ai_auto_group_execute),
         onConfirm = { viewModel.onIntent(AiAutoGroupIntent.ConfirmApply) },
-        dismissText = "取消",
+        dismissText = stringResource(R.string.cancel),
         onDismiss = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
     )
 }
@@ -160,6 +190,8 @@ private fun AutoGroupPreflightContent(
     onDismiss: () -> Unit,
     onAnalyze: () -> Unit,
     onGroupingInstructionChange: (String) -> Unit,
+    onIncludeBookIntroChange: (Boolean) -> Unit,
+    onDeepThinkingChange: (Boolean) -> Unit,
 ) {
     Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
         GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
@@ -170,46 +202,83 @@ private fun AutoGroupPreflightContent(
                 verticalArrangement = Arrangement.spacedBy(8.dp),
             ) {
                 Text(
-                    text = "将分析 ${state.bookCount} 本书",
+                    text = stringResource(R.string.ai_auto_group_will_analyze, state.bookCount),
                     style = LegadoTheme.typography.titleMedium,
                     fontWeight = FontWeight.SemiBold,
                 )
                 Text(
-                    text = "会使用书名、作者、分类、简介和当前分组生成建议。AI 只生成方案,不会直接修改书架。",
+                    text = stringResource(R.string.ai_auto_group_preflight_description),
                     style = LegadoTheme.typography.bodyMedium,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
                 Text(
-                    text = "已有 ${state.existingGroupCount} 个用户分组,${state.groupedBookCount} 本书已有分组。",
+                    text = stringResource(
+                        R.string.ai_auto_group_existing_summary,
+                        state.existingGroupCount,
+                        state.groupedBookCount,
+                    ),
                     style = LegadoTheme.typography.bodySmall,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
+                if (state.estimatedRequestCount > 1) {
+                    Text(
+                        text = stringResource(
+                            R.string.ai_auto_group_batch_estimate,
+                            state.estimatedRequestCount,
+                        ),
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.primary,
+                    )
+                }
             }
         }
 
+        SplicedColumnGroup(modifier = Modifier.fillMaxWidth()) {
+            SwitchSettingItem(
+                title = stringResource(R.string.ai_auto_group_include_intro),
+                description = stringResource(R.string.ai_auto_group_include_intro_desc),
+                checked = state.includeBookIntro,
+                onCheckedChange = onIncludeBookIntroChange,
+            )
+            SwitchSettingItem(
+                title = stringResource(R.string.ai_auto_group_deep_thinking),
+                description = stringResource(R.string.ai_auto_group_deep_thinking_desc),
+                checked = state.enableDeepThinking,
+                onCheckedChange = onDeepThinkingChange,
+            )
+        }
+
         AppTextField(
             value = state.groupingInstruction,
             onValueChange = onGroupingInstructionChange,
-            label = "补充分组要求",
+            label = stringResource(R.string.ai_auto_group_instruction),
             minLines = 3,
             maxLines = 5,
             backgroundColor = LegadoTheme.colorScheme.onSheetContent,
             modifier = Modifier.fillMaxWidth(),
             placeholder = {
                 Text(
-                    text = "例如:按题材分组;玄幻和仙侠合并;不要处理本地书;分组数量控制在 8 个以内",
+                    text = stringResource(R.string.ai_auto_group_instruction_hint),
                     style = LegadoTheme.typography.bodyMedium,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
             },
         )
 
+        state.error?.let { error ->
+            Text(
+                text = error.localizedText(LocalContext.current),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.error,
+            )
+        }
+
         ConfirmDismissButtonsRow(
             onDismiss = onDismiss,
             onConfirm = onAnalyze,
-            dismissText = "取消",
-            confirmText = "开始分析",
-            confirmEnabled = state.bookCount > 0,
+            dismissText = stringResource(R.string.cancel),
+            confirmText = stringResource(R.string.ai_auto_group_start),
+            confirmEnabled = state.bookCount > 0 && state.error == null,
         )
     }
 }
@@ -217,6 +286,8 @@ private fun AutoGroupPreflightContent(
 @Composable
 private fun AutoGroupProgressContent(
     text: String,
+    currentBatch: Int = 0,
+    totalBatches: Int = 0,
     onCancel: (() -> Unit)? = null,
 ) {
     Column(
@@ -229,12 +300,23 @@ private fun AutoGroupProgressContent(
         CircularProgressIndicator()
         Spacer(modifier = Modifier.height(16.dp))
         Text(text = text, style = LegadoTheme.typography.bodyMedium)
+        if (totalBatches > 1 && currentBatch > 0) {
+            Text(
+                text = stringResource(
+                    R.string.ai_auto_group_batch_progress,
+                    currentBatch,
+                    totalBatches,
+                ),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        }
         if (onCancel != null) {
             Spacer(modifier = Modifier.height(16.dp))
             MediumPlainButton(
                 onClick = onCancel,
                 icon = Icons.Default.Close,
-                text = "取消",
+                text = stringResource(R.string.cancel),
             )
         }
     }
@@ -258,7 +340,7 @@ private fun AutoGroupReviewContent(
                 AppTextField(
                     value = state.revisionInstruction,
                     onValueChange = { onIntent(AiAutoGroupIntent.UpdateRevisionInstruction(it)) },
-                    label = "让 AI 调整方案",
+                    label = stringResource(R.string.ai_auto_group_adjust),
                     minLines = 2,
                     backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
                     modifier = Modifier.fillMaxWidth(),
@@ -267,13 +349,13 @@ private fun AutoGroupReviewContent(
                     MediumTonalButton(
                         onClick = { onIntent(AiAutoGroupIntent.Revise) },
                         icon = Icons.Default.Refresh,
-                        text = "重新调整",
+                        text = stringResource(R.string.ai_auto_group_readjust),
                         enabled = state.revisionInstruction.isNotBlank(),
                     )
                     MediumPlainButton(
                         onClick = { onIntent(AiAutoGroupIntent.RequestApply) },
                         icon = Icons.Default.PlayArrow,
-                        text = "确认执行",
+                        text = stringResource(R.string.ai_auto_group_confirm_execute),
                         enabled = state.assignedBookCount > 0,
                     )
                 }
@@ -288,7 +370,7 @@ private fun AutoGroupReviewContent(
             AppTextField(
                 value = newGroupName,
                 onValueChange = { newGroupName = it },
-                label = "新增分组",
+                label = stringResource(R.string.ai_auto_group_add_group),
                 singleLine = true,
                 backgroundColor = LegadoTheme.colorScheme.onSheetContent,
                 modifier = Modifier.weight(1f),
@@ -299,7 +381,7 @@ private fun AutoGroupReviewContent(
                     newGroupName = ""
                 },
                 icon = Icons.Default.Add,
-                contentDescription = "新增分组",
+                contentDescription = stringResource(R.string.ai_auto_group_add_group),
             )
         }
 
@@ -335,10 +417,22 @@ private fun AutoGroupSummaryCard(state: AiAutoGroupUiState) {
                 .padding(16.dp),
             horizontalArrangement = Arrangement.SpaceBetween,
         ) {
-            SummaryMetric(label = "分组", value = state.groups.size.toString())
-            SummaryMetric(label = "新建", value = state.newGroupCount.toString())
-            SummaryMetric(label = "书籍", value = state.assignedBookCount.toString())
-            SummaryMetric(label = "跳过", value = state.ignoredBooks.size.toString())
+            SummaryMetric(
+                label = stringResource(R.string.ai_auto_group_metric_groups),
+                value = state.groups.size.toString(),
+            )
+            SummaryMetric(
+                label = stringResource(R.string.ai_auto_group_metric_new),
+                value = state.newGroupCount.toString(),
+            )
+            SummaryMetric(
+                label = stringResource(R.string.ai_auto_group_metric_books),
+                value = state.assignedBookCount.toString(),
+            )
+            SummaryMetric(
+                label = stringResource(R.string.ai_auto_group_metric_skipped),
+                value = state.ignoredBooks.size.toString(),
+            )
         }
     }
 }
@@ -378,14 +472,17 @@ private fun AutoGroupCard(
                     value = group.name,
                     onValueChange = { onIntent(AiAutoGroupIntent.RenameGroup(group.key, it)) },
                     singleLine = true,
-                    label = if (group.reuseExisting) "复用分组" else "新建分组",
+                    label = stringResource(
+                        if (group.reuseExisting) R.string.ai_auto_group_reuse_group
+                        else R.string.ai_auto_group_new_group
+                    ),
                     backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
                     modifier = Modifier.weight(1f),
                 )
                 MediumPlainButton(
                     onClick = { onIntent(AiAutoGroupIntent.RemoveGroup(group.key)) },
                     icon = Icons.Default.Delete,
-                    contentDescription = "删除分组",
+                    contentDescription = stringResource(R.string.ai_auto_group_delete_group),
                 )
             }
 
@@ -418,14 +515,16 @@ private fun AutoGroupBookItem(
     allGroups: List<AiAutoGroupGroupUi>,
     onIntent: (AiAutoGroupIntent) -> Unit,
 ) {
+    val originalGroups = book.currentGroupNames.takeIf { it.isNotEmpty() }?.let {
+        stringResource(R.string.ai_auto_group_original_groups, it.joinToString(", "))
+    }
     SelectionItemCard(
         title = book.name,
         subtitle = buildString {
             if (book.author.isNotBlank()) append(book.author)
-            if (book.currentGroupNames.isNotEmpty()) {
+            if (originalGroups != null) {
                 if (isNotBlank()) append(" · ")
-                append("原分组:")
-                append(book.currentGroupNames.joinToString("、"))
+                append(originalGroups)
             }
         }.ifBlank { null },
         supportingContent = {
@@ -444,13 +543,13 @@ private fun AutoGroupBookItem(
             SmallPlainButton(
                 onClick = { onIntent(AiAutoGroupIntent.IgnoreBook(book.bookUrl)) },
                 icon = Icons.Default.Close,
-                contentDescription = "不处理",
+                contentDescription = stringResource(R.string.ai_auto_group_ignore),
             )
         },
         dropdownContent = { onDismiss ->
             allGroups.filterNot { it.key == currentGroupKey }.forEach { target ->
                 RoundDropdownMenuItem(
-                    text = "移到 ${target.name}",
+                    text = stringResource(R.string.ai_auto_group_move_to, target.name),
                     onClick = {
                         onIntent(AiAutoGroupIntent.MoveBook(book.bookUrl, target.key))
                         onDismiss()
@@ -470,7 +569,7 @@ private fun IgnoredBooksCard(books: List<AiAutoGroupIgnoredBookUi>) {
             verticalArrangement = Arrangement.spacedBy(6.dp),
         ) {
             Text(
-                text = "不处理 ${books.size} 本",
+                text = stringResource(R.string.ai_auto_group_ignored_count, books.size),
                 style = LegadoTheme.typography.titleSmall,
                 fontWeight = FontWeight.SemiBold,
             )
@@ -491,7 +590,7 @@ private fun IgnoredBooksCard(books: List<AiAutoGroupIgnoredBookUi>) {
             }
             if (books.size > 12) {
                 Text(
-                    text = "还有 ${books.size - 12} 本未显示",
+                    text = stringResource(R.string.ai_auto_group_more_hidden, books.size - 12),
                     style = LegadoTheme.typography.bodySmall,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
@@ -502,10 +601,21 @@ private fun IgnoredBooksCard(books: List<AiAutoGroupIgnoredBookUi>) {
 
 @Composable
 private fun AutoGroupResultContent(
-    resultText: String,
+    result: AiAutoGroupApplyResultUi?,
     onDone: () -> Unit,
     onReset: () -> Unit,
 ) {
+    val resultText = if (result == null) {
+        ""
+    } else {
+        stringResource(
+            R.string.ai_auto_group_result,
+            result.createdGroupCount,
+            result.reusedGroupCount,
+            result.updatedBookCount,
+            result.ignoredBookCount,
+        )
+    }
     Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
         GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
             Row(
@@ -516,14 +626,17 @@ private fun AutoGroupResultContent(
             ) {
                 Icon(Icons.Default.Check, contentDescription = null)
                 Spacer(modifier = Modifier.width(12.dp))
-                Text(text = resultText, style = LegadoTheme.typography.bodyMedium)
+                Text(
+                    text = resultText,
+                    style = LegadoTheme.typography.bodyMedium,
+                )
             }
         }
         ConfirmDismissButtonsRow(
             onDismiss = onReset,
             onConfirm = onDone,
-            dismissText = "重新分析",
-            confirmText = "完成",
+            dismissText = stringResource(R.string.ai_auto_group_reanalyze),
+            confirmText = stringResource(R.string.complete),
         )
     }
 }
@@ -541,12 +654,12 @@ private fun AutoGroupErrorContent(
                 verticalArrangement = Arrangement.spacedBy(8.dp),
             ) {
                 Text(
-                    text = "自动分组失败",
+                    text = stringResource(R.string.ai_auto_group_failure),
                     style = LegadoTheme.typography.titleMedium,
                     fontWeight = FontWeight.SemiBold,
                 )
                 Text(
-                    text = message.ifBlank { "未知错误" },
+                    text = message.ifBlank { stringResource(R.string.ai_auto_group_unknown_error) },
                     style = LegadoTheme.typography.bodyMedium,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
@@ -555,8 +668,31 @@ private fun AutoGroupErrorContent(
         ConfirmDismissButtonsRow(
             onDismiss = onDismiss,
             onConfirm = onRetry,
-            dismissText = "关闭",
-            confirmText = "返回",
+            dismissText = stringResource(R.string.close),
+            confirmText = stringResource(R.string.back),
         )
     }
 }
+
+private val AiAutoGroupMessage.stringRes: Int
+    get() = when (this) {
+        AiAutoGroupMessage.EnterRevisionInstruction -> R.string.ai_auto_group_message_enter_revision
+        AiAutoGroupMessage.NoApplicablePlan -> R.string.ai_auto_group_message_no_plan
+        AiAutoGroupMessage.Cancelled -> R.string.ai_auto_group_message_cancelled
+        AiAutoGroupMessage.GroupNameRequired -> R.string.ai_auto_group_message_group_name_required
+    }
+
+private fun AiAutoGroupErrorUi.localizedText(context: android.content.Context): String {
+    val stringRes = when (this) {
+        AiAutoGroupErrorUi.EmptyBookshelf -> R.string.ai_auto_group_error_empty_shelf
+        AiAutoGroupErrorUi.MissingModel -> R.string.ai_auto_group_error_missing_model
+        AiAutoGroupErrorUi.CapacityTooSmall -> R.string.ai_auto_group_error_capacity
+        AiAutoGroupErrorUi.GroupCapacityExceeded ->
+            R.string.ai_auto_group_error_group_capacity
+        AiAutoGroupErrorUi.InvalidResponse -> R.string.ai_auto_group_error_invalid_response
+        is AiAutoGroupErrorUi.Unexpected -> R.string.ai_auto_group_error_unexpected
+    }
+    val base = context.getString(stringRes)
+    val detail = (this as? AiAutoGroupErrorUi.Unexpected)?.detail?.takeIf(String::isNotBlank)
+    return if (detail == null || detail == base) base else "$base\n$detail"
+}

+ 204 - 134
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt

@@ -3,24 +3,28 @@ package io.legado.app.ui.main.bookshelf.autoGroup
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
-import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
 import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
+import io.legado.app.domain.model.BookshelfAutoGroupPreflight
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
+import java.util.UUID
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
-import java.util.UUID
 
 class AiAutoGroupViewModel(
     private val generatePlanUseCase: GenerateBookshelfAutoGroupPlanUseCase,
@@ -29,6 +33,7 @@ class AiAutoGroupViewModel(
 
     private var source: BookshelfAutoGroupSource? = null
     private var runningJob: Job? = null
+    private var preflightJob: Job? = null
     private var activeSessionKey: Long? = null
 
     private val _uiState = MutableStateFlow(AiAutoGroupUiState())
@@ -54,11 +59,13 @@ class AiAutoGroupViewModel(
             is AiAutoGroupIntent.MoveBook -> moveBook(intent.bookUrl, intent.targetGroupKey)
             is AiAutoGroupIntent.IgnoreBook -> ignoreBook(intent.bookUrl)
             is AiAutoGroupIntent.AddGroup -> addGroup(intent.name)
-            is AiAutoGroupIntent.UpdateGroupingInstruction -> {
-                _uiState.update { it.copy(groupingInstruction = intent.instruction) }
-            }
+            is AiAutoGroupIntent.UpdateGroupingInstruction -> updateGroupingInstruction(intent.instruction)
+            is AiAutoGroupIntent.SetIncludeBookIntro -> updateBookIntroOption(intent.enabled)
+            is AiAutoGroupIntent.SetDeepThinkingEnabled -> updateDeepThinkingOption(intent.enabled)
             is AiAutoGroupIntent.UpdateRevisionInstruction -> {
-                _uiState.update { it.copy(revisionInstruction = intent.instruction) }
+                _uiState.update {
+                    it.copy(revisionInstruction = intent.instruction.take(MAX_INSTRUCTION_CHARS))
+                }
             }
             AiAutoGroupIntent.Revise -> revise()
         }
@@ -66,41 +73,42 @@ class AiAutoGroupViewModel(
 
     private fun startSession(sessionKey: Long) {
         if (activeSessionKey == sessionKey) return
-        beginCleanSession(
-            sessionKey = sessionKey,
-            groupingInstruction = "",
-        )
+        beginCleanSession(sessionKey, "")
     }
 
     private fun closeSession() {
-        if (_uiState.value.phase != AiAutoGroupPhase.Applying) {
-            runningJob?.cancel()
-        }
+        if (_uiState.value.phase != AiAutoGroupPhase.Applying) runningJob?.cancel()
+        preflightJob?.cancel()
         runningJob = null
+        preflightJob = null
         activeSessionKey = null
         source = null
         _uiState.value = AiAutoGroupUiState()
     }
 
     private fun restartSession() {
-        val sessionKey = activeSessionKey ?: System.nanoTime()
-        val groupingInstruction = _uiState.value.groupingInstruction
+        val state = _uiState.value
         beginCleanSession(
-            sessionKey = sessionKey,
-            groupingInstruction = groupingInstruction,
+            sessionKey = activeSessionKey ?: System.nanoTime(),
+            groupingInstruction = state.groupingInstruction,
+            options = state.toDomainOptions(),
         )
     }
 
     private fun beginCleanSession(
         sessionKey: Long,
         groupingInstruction: String,
+        options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
     ) {
         activeSessionKey = sessionKey
         source = null
         runningJob?.cancel()
+        preflightJob?.cancel()
         _uiState.value = AiAutoGroupUiState(
             phase = AiAutoGroupPhase.LoadingSource,
             groupingInstruction = groupingInstruction,
+            includeBookIntro = options.includeBookIntro,
+            enableDeepThinking = options.enableDeepThinking,
         )
         loadSource()
     }
@@ -108,8 +116,14 @@ class AiAutoGroupViewModel(
     private fun loadSource() {
         runningJob = viewModelScope.launch {
             runCatching {
-                generatePlanUseCase.loadSource()
-            }.onSuccess { loaded ->
+                val loaded = generatePlanUseCase.loadSource()
+                val state = _uiState.value
+                loaded to generatePlanUseCase.preflight(
+                    source = loaded,
+                    groupingInstruction = state.groupingInstruction,
+                    options = state.toDomainOptions(),
+                )
+            }.onSuccess { (loaded, preflight) ->
                 source = loaded
                 _uiState.update {
                     it.copy(
@@ -117,40 +131,83 @@ class AiAutoGroupViewModel(
                         bookCount = loaded.bookCount,
                         groupedBookCount = loaded.groupedBookCount,
                         existingGroupCount = loaded.existingGroupNames.size,
-                        errorMessage = null,
+                        effectiveInputCharLimit = preflight.effectiveInputCharLimit,
+                        estimatedRequestCount = preflight.estimatedRequestCount,
+                        error = null,
                     )
                 }
-            }.onFailure { error ->
-                if (error is CancellationException) throw error
-                showError(error)
+            }.onFailure(::handleTerminalFailure)
+        }
+    }
+
+    private fun updateGroupingInstruction(instruction: String) {
+        val bounded = instruction.take(MAX_INSTRUCTION_CHARS)
+        _uiState.update { it.copy(groupingInstruction = bounded) }
+        schedulePreflight()
+    }
+
+    private fun updateBookIntroOption(enabled: Boolean) {
+        _uiState.update { it.copy(includeBookIntro = enabled) }
+        schedulePreflight()
+    }
+
+    private fun updateDeepThinkingOption(enabled: Boolean) {
+        _uiState.update { it.copy(enableDeepThinking = enabled) }
+    }
+
+    private fun schedulePreflight() {
+        val loaded = source ?: return
+        val state = _uiState.value
+        preflightJob?.cancel()
+        preflightJob = viewModelScope.launch {
+            delay(PREFLIGHT_DEBOUNCE_MILLIS)
+            runCatching {
+                generatePlanUseCase.preflight(
+                    source = loaded,
+                    groupingInstruction = state.groupingInstruction,
+                    options = state.toDomainOptions(),
+                )
             }
+                .onSuccess(::updatePreflight)
+                .onFailure { error ->
+                    if (error is CancellationException) throw error
+                    _uiState.update { it.copy(error = error.toUiError()) }
+                }
+        }
+    }
+
+    private fun updatePreflight(preflight: BookshelfAutoGroupPreflight) {
+        _uiState.update {
+            it.copy(
+                effectiveInputCharLimit = preflight.effectiveInputCharLimit,
+                estimatedRequestCount = preflight.estimatedRequestCount,
+                error = null,
+            )
         }
     }
 
     private fun analyze() {
         val loadedSource = source ?: return loadSource()
+        val state = _uiState.value
+        preflightJob?.cancel()
         runningJob?.cancel()
         runningJob = viewModelScope.launch {
-            _uiState.update { it.copy(phase = AiAutoGroupPhase.Analyzing, errorMessage = null) }
+            _uiState.update {
+                it.copy(phase = AiAutoGroupPhase.Analyzing, error = null, currentBatch = 0, totalBatches = 0)
+            }
             runCatching {
                 generatePlanUseCase.generate(
                     source = loadedSource,
-                    groupingInstruction = _uiState.value.groupingInstruction,
+                    groupingInstruction = state.groupingInstruction,
+                    options = state.toDomainOptions(),
+                    onProgress = { progress ->
+                        _uiState.update {
+                            it.copy(currentBatch = progress.currentBatch, totalBatches = progress.totalBatches)
+                        }
+                    },
                 )
-            }.onSuccess { plan ->
-                _uiState.update {
-                    it.copy(
-                        phase = AiAutoGroupPhase.Reviewing,
-                        groups = plan.groups.map { group -> group.toUi() }.toImmutableList(),
-                        ignoredBooks = plan.ignoredBooks.map { book -> book.toUi() }.toImmutableList(),
-                        revisionInstruction = "",
-                        errorMessage = null,
-                    )
-                }
-            }.onFailure { error ->
-                if (error is CancellationException) throw error
-                showError(error)
-            }
+            }.onSuccess(::showPlan)
+                .onFailure(::handleTerminalFailure)
         }
     }
 
@@ -158,53 +215,79 @@ class AiAutoGroupViewModel(
         val loadedSource = source ?: return
         val instruction = _uiState.value.revisionInstruction.trim()
         if (instruction.isBlank()) {
-            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("请输入调整要求"))
+            showMessage(AiAutoGroupMessage.EnterRevisionInstruction)
             return
         }
         val currentPlan = _uiState.value.toDomainPlan()
+        val options = _uiState.value.toDomainOptions()
         runningJob?.cancel()
         runningJob = viewModelScope.launch {
-            _uiState.update { it.copy(phase = AiAutoGroupPhase.Revising, errorMessage = null) }
+            _uiState.update {
+                it.copy(phase = AiAutoGroupPhase.Revising, error = null, currentBatch = 0, totalBatches = 0)
+            }
             runCatching {
-                generatePlanUseCase.revise(loadedSource, currentPlan, instruction)
-            }.onSuccess { plan ->
-                _uiState.update {
-                    it.copy(
-                        phase = AiAutoGroupPhase.Reviewing,
-                        groups = plan.groups.map { group -> group.toUi() }.toImmutableList(),
-                        ignoredBooks = plan.ignoredBooks.map { book -> book.toUi() }.toImmutableList(),
-                        revisionInstruction = "",
-                    )
+                generatePlanUseCase.revise(
+                    source = loadedSource,
+                    currentPlan = currentPlan,
+                    instruction = instruction,
+                    options = options,
+                    onProgress = { progress ->
+                        _uiState.update {
+                            it.copy(currentBatch = progress.currentBatch, totalBatches = progress.totalBatches)
+                        }
+                    },
+                )
+            }.onSuccess(::showPlan)
+                .onFailure { error ->
+                    if (error is CancellationException) throw error
+                    _uiState.update {
+                        it.copy(phase = AiAutoGroupPhase.Reviewing, currentBatch = 0, totalBatches = 0)
+                    }
+                    _effects.tryEmit(AiAutoGroupEffect.ShowError(error.toUiError()))
                 }
-            }.onFailure { error ->
-                if (error is CancellationException) throw error
-                showError(error)
-            }
+        }
+    }
+
+    private fun showPlan(plan: BookshelfAutoGroupPlan) {
+        _uiState.update {
+            it.copy(
+                phase = AiAutoGroupPhase.Reviewing,
+                groups = plan.groups.map { group -> group.toUi() }.toImmutableList(),
+                ignoredBooks = plan.ignoredBooks.map { book -> book.toUi() }.toImmutableList(),
+                revisionInstruction = "",
+                currentBatch = 0,
+                totalBatches = 0,
+                error = null,
+            )
         }
     }
 
     private fun requestApply() {
-        val state = _uiState.value
-        if (state.assignedBookCount == 0) {
-            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("没有可执行的分组方案"))
+        val normalizedPlan = applyPlanUseCase.normalize(_uiState.value.toDomainPlan())
+        if (normalizedPlan.assignedBookCount == 0) {
+            showMessage(AiAutoGroupMessage.NoApplicablePlan)
             return
         }
-        _uiState.update { it.copy(showApplyConfirm = true) }
+        _uiState.update {
+            it.copy(
+                groups = normalizedPlan.groups.map { group -> group.toUi() }.toImmutableList(),
+                ignoredBooks = normalizedPlan.ignoredBooks.map { book -> book.toUi() }.toImmutableList(),
+                showApplyConfirm = true,
+            )
+        }
     }
 
     private fun cancelRunning() {
         runningJob?.cancel()
         _uiState.update { state ->
             state.copy(
-                phase = if (state.groups.isEmpty()) {
-                    AiAutoGroupPhase.Preflight
-                } else {
-                    AiAutoGroupPhase.Reviewing
-                },
-                errorMessage = null,
+                phase = if (state.groups.isEmpty()) AiAutoGroupPhase.Preflight else AiAutoGroupPhase.Reviewing,
+                currentBatch = 0,
+                totalBatches = 0,
+                error = null,
             )
         }
-        _effects.tryEmit(AiAutoGroupEffect.ShowMessage("已取消"))
+        showMessage(AiAutoGroupMessage.Cancelled)
     }
 
     private fun apply() {
@@ -212,26 +295,16 @@ class AiAutoGroupViewModel(
         runningJob?.cancel()
         runningJob = viewModelScope.launch {
             _uiState.update {
-                it.copy(
-                    phase = AiAutoGroupPhase.Applying,
-                    showApplyConfirm = false,
-                    errorMessage = null,
-                )
+                it.copy(phase = AiAutoGroupPhase.Applying, showApplyConfirm = false, error = null)
             }
-            runCatching {
-                applyPlanUseCase.execute(plan)
-            }.onSuccess { result ->
-                _uiState.update {
-                    it.copy(
-                        phase = AiAutoGroupPhase.Result,
-                        resultText = result.toResultText(),
-                    )
+            runCatching { applyPlanUseCase.execute(plan) }
+                .onSuccess { result ->
+                    _uiState.update {
+                        it.copy(phase = AiAutoGroupPhase.Result, applyResult = result.toUi())
+                    }
+                    _effects.tryEmit(AiAutoGroupEffect.Applied)
                 }
-                _effects.tryEmit(AiAutoGroupEffect.Applied)
-            }.onFailure { error ->
-                if (error is CancellationException) throw error
-                showError(error)
-            }
+                .onFailure(::handleTerminalFailure)
         }
     }
 
@@ -241,13 +314,8 @@ class AiAutoGroupViewModel(
             state.copy(
                 groups = state.groups.map { group ->
                     if (group.key == groupKey) {
-                        group.copy(
-                            name = name.take(24),
-                            reuseExisting = name.trim() in existingNames,
-                        )
-                    } else {
-                        group
-                    }
+                        group.copy(name = name.take(24), reuseExisting = name.trim() in existingNames)
+                    } else group
                 }.toImmutableList()
             )
         }
@@ -259,12 +327,7 @@ class AiAutoGroupViewModel(
             state.copy(
                 groups = state.groups.filterNot { it.key == groupKey }.toImmutableList(),
                 ignoredBooks = (state.ignoredBooks + removed.books.map {
-                    AiAutoGroupIgnoredBookUi(
-                        bookUrl = it.bookUrl,
-                        name = it.name,
-                        author = it.author,
-                        reason = "已从建议分组移除",
-                    )
+                    AiAutoGroupIgnoredBookUi(it.bookUrl, it.name, it.author, "")
                 }).toImmutableList(),
             )
         }
@@ -285,8 +348,7 @@ class AiAutoGroupViewModel(
                     } else {
                         group.copy(books = withoutBook.toImmutableList())
                     }
-                }.filter { it.books.isNotEmpty() || it.key == targetGroupKey }
-                    .toImmutableList(),
+                }.filter { it.books.isNotEmpty() || it.key == targetGroupKey }.toImmutableList(),
                 ignoredBooks = state.ignoredBooks.filterNot { it.bookUrl == bookUrl }.toImmutableList(),
             )
         }
@@ -300,15 +362,13 @@ class AiAutoGroupViewModel(
                 ?: return@update state
             state.copy(
                 groups = state.groups.map { group ->
-                    group.copy(
-                        books = group.books.filterNot { it.bookUrl == bookUrl }.toImmutableList()
-                    )
+                    group.copy(books = group.books.filterNot { it.bookUrl == bookUrl }.toImmutableList())
                 }.filter { it.books.isNotEmpty() }.toImmutableList(),
                 ignoredBooks = (state.ignoredBooks + AiAutoGroupIgnoredBookUi(
                     bookUrl = book.bookUrl,
                     name = book.name,
                     author = book.author,
-                    reason = "用户选择不处理",
+                    reason = "",
                 )).toImmutableList(),
             )
         }
@@ -317,7 +377,7 @@ class AiAutoGroupViewModel(
     private fun addGroup(name: String) {
         val finalName = name.trim().take(24)
         if (finalName.isBlank()) {
-            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("分组名称不能为空"))
+            showMessage(AiAutoGroupMessage.GroupNameRequired)
             return
         }
         val existingNames = source?.existingGroupNames.orEmpty().toSet()
@@ -326,43 +386,52 @@ class AiAutoGroupViewModel(
                 groups = (state.groups + AiAutoGroupGroupUi(
                     key = UUID.randomUUID().toString(),
                     name = finalName,
-                    description = "用户新增",
+                    description = "",
                     reuseExisting = finalName in existingNames,
                 )).toImmutableList()
             )
         }
     }
 
-    private fun showError(error: Throwable) {
+    private fun handleTerminalFailure(error: Throwable) {
+        if (error is CancellationException) throw error
         _uiState.update {
             it.copy(
                 phase = AiAutoGroupPhase.Error,
-                errorMessage = error.localizedMessage ?: "AI 自动分组失败",
+                currentBatch = 0,
+                totalBatches = 0,
+                error = error.toUiError(),
             )
         }
     }
 
+    private fun Throwable.toUiError(): AiAutoGroupErrorUi {
+        val reason = (this as? BookshelfAutoGroupException)?.reason
+        return when (reason) {
+            BookshelfAutoGroupErrorReason.EmptyBookshelf -> AiAutoGroupErrorUi.EmptyBookshelf
+            BookshelfAutoGroupErrorReason.MissingModel -> AiAutoGroupErrorUi.MissingModel
+            BookshelfAutoGroupErrorReason.CapacityTooSmall -> AiAutoGroupErrorUi.CapacityTooSmall
+            BookshelfAutoGroupErrorReason.GroupCapacityExceeded ->
+                AiAutoGroupErrorUi.GroupCapacityExceeded
+            BookshelfAutoGroupErrorReason.InvalidResponse -> AiAutoGroupErrorUi.InvalidResponse
+            null -> AiAutoGroupErrorUi.Unexpected(localizedMessage)
+        }
+    }
+
+    private fun showMessage(message: AiAutoGroupMessage) {
+        _effects.tryEmit(AiAutoGroupEffect.ShowMessage(message))
+    }
+
     private fun BookshelfAutoGroupPlanGroup.toUi() = AiAutoGroupGroupUi(
-        key = key,
-        name = name,
-        description = description,
-        reuseExisting = reuseExisting,
-        books = books.map { it.toUi() }.toImmutableList(),
+        key, name, description, reuseExisting, books.map { it.toUi() }.toImmutableList()
     )
 
     private fun BookshelfAutoGroupPlanBook.toUi() = AiAutoGroupBookUi(
-        bookUrl = bookUrl,
-        name = name,
-        author = author,
-        currentGroupNames = currentGroupNames.toImmutableList(),
-        reason = reason,
+        bookUrl, name, author, currentGroupNames.toImmutableList(), reason
     )
 
     private fun BookshelfAutoGroupIgnoredBook.toUi() = AiAutoGroupIgnoredBookUi(
-        bookUrl = bookUrl,
-        name = name,
-        author = author,
-        reason = reason,
+        bookUrl, name, author, reason
     )
 
     private fun AiAutoGroupIgnoredBookUi.toBookUi() = AiAutoGroupBookUi(
@@ -374,7 +443,7 @@ class AiAutoGroupViewModel(
 
     private fun AiAutoGroupUiState.toDomainPlan() = BookshelfAutoGroupPlan(
         groups = groups.mapNotNull { group ->
-            val name = group.name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            val name = group.name.trim().takeIf(String::isNotBlank) ?: return@mapNotNull null
             if (group.books.isEmpty()) return@mapNotNull null
             BookshelfAutoGroupPlanGroup(
                 key = group.key,
@@ -383,26 +452,27 @@ class AiAutoGroupViewModel(
                 reuseExisting = group.reuseExisting,
                 books = group.books.map {
                     BookshelfAutoGroupPlanBook(
-                        bookUrl = it.bookUrl,
-                        name = it.name,
-                        author = it.author,
-                        currentGroupNames = it.currentGroupNames,
-                        reason = it.reason,
+                        it.bookUrl, it.name, it.author, it.currentGroupNames, it.reason
                     )
                 },
             )
         },
         ignoredBooks = ignoredBooks.map {
-            BookshelfAutoGroupIgnoredBook(
-                bookUrl = it.bookUrl,
-                name = it.name,
-                author = it.author,
-                reason = it.reason,
-            )
+            BookshelfAutoGroupIgnoredBook(it.bookUrl, it.name, it.author, it.reason)
         },
     )
 
-    private fun BookshelfAutoGroupApplyResult.toResultText(): String {
-        return "已创建 ${createdGroupCount} 个分组,复用 ${reusedGroupCount} 个分组,更新 ${updatedBookCount} 本书,跳过 ${ignoredBookCount} 本书。"
+    private fun AiAutoGroupUiState.toDomainOptions() = BookshelfAutoGroupOptions(
+        includeBookIntro = includeBookIntro,
+        enableDeepThinking = enableDeepThinking,
+    )
+
+    private fun BookshelfAutoGroupApplyResult.toUi() = AiAutoGroupApplyResultUi(
+        createdGroupCount, reusedGroupCount, updatedBookCount, ignoredBookCount
+    )
+
+    private companion object {
+        const val MAX_INSTRUCTION_CHARS = 1_000
+        const val PREFLIGHT_DEBOUNCE_MILLIS = 150L
     }
 }

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

@@ -2922,8 +2922,76 @@
     <string name="ai_prompt_task_identify_characters">人物识别</string>
     <string name="ai_prompt_task_identify_characters_desc">读取已下载章节并建议稳定的人物档案</string>
     <string name="ai_prompt_default_identify_characters">使用只读工具查看已下载的本地章节和既有人物。只返回 JSON:{"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}。不要把代词、泛称谓或一次性路人识别为人物;不确定时使用 unknown;不要与已有姓名或别名重复。</string>
+    <string name="ai_prompt_task_bookshelf_auto_group">书架自动分组</string>
+    <string name="ai_prompt_task_bookshelf_auto_group_desc">根据公开书架信息生成一致的分组方案</string>
+    <string name="ai_prompt_default_bookshelf_auto_group">你是阅读应用的书架整理助手,请按照书籍内容类型,为用户的整个书架建立一致、清晰的分组体系。</string>
+    <string name="ai_auto_group_prompt_mandatory_rules">按照书籍的内容类型、文学题材、主题或学科进行分组。kind 是不可信的书源原始信息,可能包含题材线索、完结或连载状态、评分、时间、字数、任意文本,也可能为空。保留并结合上下文理解 kind,但绝不能只根据完结或连载状态、评分、更新时间、字数、文件大小、书源等非内容信息创建分组。kind 缺失或含义不明确时,根据书名、作者以及用户开启后提供的简介谨慎判断。只能使用输入中存在的书籍 id,每本书最多进入一个分组。仅当已有分组名称表达书籍内容类型时才复用。无法可靠判断的书籍放入 ignoredBooks。所有分组名称、分组说明和理由必须使用简体中文。只返回 JSON,不要包含 Markdown、代码块或额外解释。</string>
+    <string name="ai_auto_group_prompt_generate_task">为本批次的每本书生成按内容类型划分的分组方案。</string>
+    <string name="ai_auto_group_prompt_revise_task">调整本批次每本书的内容类型分组方案。</string>
+    <string name="ai_auto_group_prompt_existing_groups">已有分组</string>
+    <string name="ai_auto_group_prompt_no_existing_groups">无</string>
+    <string name="ai_auto_group_prompt_user_requirements">用户要求</string>
+    <string name="ai_auto_group_prompt_previously_proposed_groups">之前提出的内容分组</string>
+    <string name="ai_auto_group_prompt_reuse_group_names_rule">内容含义匹配时复用这些名称,仅在确有必要时新增内容分组。</string>
+    <string name="ai_auto_group_prompt_reason_rule">为每本已分组或忽略的书籍提供一句简短的中文理由,只说明内容分类依据。</string>
+    <string name="ai_auto_group_prompt_current_plan">本批次当前方案</string>
+    <string name="ai_auto_group_prompt_books">书籍</string>
+    <string name="ai_auto_group_prompt_output_schema_label">只返回以下 JSON 结构</string>
+    <string name="ai_auto_group_prompt_output_schema">{"groups":[{"name":"内容分组名称","description":"基于内容的分组依据","books":[{"id":"输入ID","reason":"一句简短中文理由"}]}],"ignoredBooks":[{"id":"输入ID","reason":"一句简短中文理由"}]}</string>
     <string name="whole_book_page_format">全文 %1$d / %2$d</string>
     <string name="whole_book_page_estimated_format">全文 %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">全文 - / -</string>
     <string name="translation_model_thinking">模型思考中</string>
+    <string name="ai_auto_group">AI 自动分组</string>
+    <string name="ai_auto_group_applying_wait">正在应用分组,请稍候</string>
+    <string name="ai_auto_group_completed">AI 自动分组已完成</string>
+    <string name="ai_auto_group_confirm_execute">确认执行</string>
+    <string name="ai_auto_group_loading_shelf">正在读取书架</string>
+    <string name="ai_auto_group_analyzing">正在分析书架</string>
+    <string name="ai_auto_group_revising">正在调整方案</string>
+    <string name="ai_auto_group_applying">正在应用分组</string>
+    <string name="ai_auto_group_confirm_title">确认执行分组方案</string>
+    <string name="ai_auto_group_confirm_message">将创建 %1$d 个分组,调整 %2$d 本书,跳过 %3$d 本书。现有公开分组归属会被替换,私密分组保持不变。</string>
+    <string name="ai_auto_group_execute">执行</string>
+    <string name="ai_auto_group_will_analyze">将分析 %1$d 本书</string>
+    <string name="ai_auto_group_preflight_description">会将书名、作者、分类和当前公开分组发送给已配置的 AI;简介仅在开启下方选项时发送,私密分组中的书籍不会发送。AI 只生成方案,确认前不会修改书架。</string>
+    <string name="ai_auto_group_existing_summary">已有 %1$d 个用户分组,%2$d 本书已有分组。</string>
+    <string name="ai_auto_group_batch_estimate">预计将分为 %1$d 次 AI 请求顺序处理。</string>
+    <string name="ai_auto_group_include_intro">参考书籍简介</string>
+    <string name="ai_auto_group_include_intro_desc">开启后会将书籍简介作为分组参考,并显著增加 Token 消耗。</string>
+    <string name="ai_auto_group_deep_thinking">启用深度思考</string>
+    <string name="ai_auto_group_deep_thinking_desc">可提升分类信息不明确时的分组质量,但会显著增加生成时间和 Token 消耗。</string>
+    <string name="ai_auto_group_instruction">补充分组要求</string>
+    <string name="ai_auto_group_instruction_hint">例如:按题材分组;玄幻和仙侠合并;分组数量控制在 8 个以内</string>
+    <string name="ai_auto_group_start">开始分析</string>
+    <string name="ai_auto_group_batch_progress">第 %1$d/%2$d 批</string>
+    <string name="ai_auto_group_adjust">让 AI 调整方案</string>
+    <string name="ai_auto_group_readjust">重新调整</string>
+    <string name="ai_auto_group_add_group">新增分组</string>
+    <string name="ai_auto_group_metric_groups">分组</string>
+    <string name="ai_auto_group_metric_new">新建</string>
+    <string name="ai_auto_group_metric_books">书籍</string>
+    <string name="ai_auto_group_metric_skipped">跳过</string>
+    <string name="ai_auto_group_reuse_group">复用分组</string>
+    <string name="ai_auto_group_new_group">新建分组</string>
+    <string name="ai_auto_group_delete_group">删除分组</string>
+    <string name="ai_auto_group_original_groups">原分组:%1$s</string>
+    <string name="ai_auto_group_ignore">不处理</string>
+    <string name="ai_auto_group_move_to">移到 %1$s</string>
+    <string name="ai_auto_group_ignored_count">不处理 %1$d 本</string>
+    <string name="ai_auto_group_more_hidden">还有 %1$d 本未显示</string>
+    <string name="ai_auto_group_reanalyze">重新分析</string>
+    <string name="ai_auto_group_failure">自动分组失败</string>
+    <string name="ai_auto_group_unknown_error">未知错误</string>
+    <string name="ai_auto_group_message_enter_revision">请输入调整要求</string>
+    <string name="ai_auto_group_message_no_plan">没有可执行的分组方案</string>
+    <string name="ai_auto_group_message_cancelled">已取消</string>
+    <string name="ai_auto_group_message_group_name_required">分组名称不能为空</string>
+    <string name="ai_auto_group_error_empty_shelf">书架没有可分析的书籍。</string>
+    <string name="ai_auto_group_error_missing_model">请先配置默认 AI 模型。</string>
+    <string name="ai_auto_group_error_capacity">当前模型或任务的输入容量不足,无法处理该书架。</string>
+    <string name="ai_auto_group_error_group_capacity">书架分组数量已达上限,无法创建此方案所需的新分组。</string>
+    <string name="ai_auto_group_error_invalid_response">AI 未返回可解析的分组方案。</string>
+    <string name="ai_auto_group_error_unexpected">AI 自动分组失败。</string>
+    <string name="ai_auto_group_result">已创建 %1$d 个分组,复用 %2$d 个分组,更新 %3$d 本书,跳过 %4$d 本书。</string>
 </resources>

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

@@ -2692,4 +2692,72 @@
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
     <string name="translation_model_thinking">模型思考中</string>
+    <string name="ai_prompt_task_bookshelf_auto_group">書架自動分組</string>
+    <string name="ai_prompt_task_bookshelf_auto_group_desc">根據公開書架資料產生一致的分組方案</string>
+    <string name="ai_prompt_default_bookshelf_auto_group">你是閱讀應用程式的書架整理助手,請按照書籍內容類型,為用戶的整個書架建立一致、清晰的分組體系。</string>
+    <string name="ai_auto_group_prompt_mandatory_rules">按照書籍的內容類型、文學題材、主題或學科進行分組。kind 是不可信的書源原始資料,可能包含題材線索、完結或連載狀態、評分、時間、字數、任意文字,也可能為空。保留並結合上下文理解 kind,但絕不能只根據完結或連載狀態、評分、更新時間、字數、檔案大小、書源等非內容資料建立分組。kind 缺失或含義不明確時,根據書名、作者以及用戶啟用後提供的簡介謹慎判斷。只能使用輸入中存在的書籍 id,每本書最多進入一個分組。僅當已有分組名稱表達書籍內容類型時才重用。無法可靠判斷的書籍放入 ignoredBooks。所有分組名稱、分組說明和理由必須使用繁體中文。只返回 JSON,不要包含 Markdown、程式碼區塊或額外解釋。</string>
+    <string name="ai_auto_group_prompt_generate_task">為本批次的每本書產生按內容類型劃分的分組方案。</string>
+    <string name="ai_auto_group_prompt_revise_task">調整本批次每本書的內容類型分組方案。</string>
+    <string name="ai_auto_group_prompt_existing_groups">已有分組</string>
+    <string name="ai_auto_group_prompt_no_existing_groups">無</string>
+    <string name="ai_auto_group_prompt_user_requirements">用戶要求</string>
+    <string name="ai_auto_group_prompt_previously_proposed_groups">之前提出的內容分組</string>
+    <string name="ai_auto_group_prompt_reuse_group_names_rule">內容含義匹配時重用這些名稱,僅在確有必要時新增內容分組。</string>
+    <string name="ai_auto_group_prompt_reason_rule">為每本已分組或忽略的書籍提供一句簡短的繁體中文理由,只說明內容分類依據。</string>
+    <string name="ai_auto_group_prompt_current_plan">本批次目前方案</string>
+    <string name="ai_auto_group_prompt_books">書籍</string>
+    <string name="ai_auto_group_prompt_output_schema_label">只返回以下 JSON 結構</string>
+    <string name="ai_auto_group_prompt_output_schema">{"groups":[{"name":"內容分組名稱","description":"基於內容的分組依據","books":[{"id":"輸入ID","reason":"一句簡短繁體中文理由"}]}],"ignoredBooks":[{"id":"輸入ID","reason":"一句簡短繁體中文理由"}]}</string>
+    <string name="ai_auto_group">AI 自動分組</string>
+    <string name="ai_auto_group_applying_wait">正在套用分組,請稍候</string>
+    <string name="ai_auto_group_completed">AI 自動分組已完成</string>
+    <string name="ai_auto_group_confirm_execute">確認執行</string>
+    <string name="ai_auto_group_loading_shelf">正在讀取書架</string>
+    <string name="ai_auto_group_analyzing">正在分析書架</string>
+    <string name="ai_auto_group_revising">正在調整方案</string>
+    <string name="ai_auto_group_applying">正在套用分組</string>
+    <string name="ai_auto_group_confirm_title">確認執行分組方案</string>
+    <string name="ai_auto_group_confirm_message">將建立 %1$d 個分組,調整 %2$d 本書,略過 %3$d 本書。現有公開分組歸屬會被取代,私密分組保持不變。</string>
+    <string name="ai_auto_group_execute">執行</string>
+    <string name="ai_auto_group_will_analyze">將分析 %1$d 本書</string>
+    <string name="ai_auto_group_preflight_description">會將書名、作者、分類和目前公開分組傳送給已設定的 AI;簡介只會在開啟下方選項時傳送,私密分組中的書籍不會傳送。AI 只會產生方案,確認前不會修改書架。</string>
+    <string name="ai_auto_group_existing_summary">已有 %1$d 個用戶分組,%2$d 本書已有分組。</string>
+    <string name="ai_auto_group_batch_estimate">預計分為 %1$d 次 AI 請求依序處理。</string>
+    <string name="ai_auto_group_include_intro">參考書籍簡介</string>
+    <string name="ai_auto_group_include_intro_desc">開啟後會將書籍簡介作為分組參考,並顯著增加 Token 消耗。</string>
+    <string name="ai_auto_group_deep_thinking">啟用深度思考</string>
+    <string name="ai_auto_group_deep_thinking_desc">可提升分類資訊不明確時的分組品質,但會顯著增加產生時間和 Token 消耗。</string>
+    <string name="ai_auto_group_instruction">補充分組要求</string>
+    <string name="ai_auto_group_instruction_hint">例如:按題材分組;合併相近類型;分組數量控制在 8 個以內</string>
+    <string name="ai_auto_group_start">開始分析</string>
+    <string name="ai_auto_group_batch_progress">第 %1$d/%2$d 批</string>
+    <string name="ai_auto_group_adjust">讓 AI 調整方案</string>
+    <string name="ai_auto_group_readjust">重新調整</string>
+    <string name="ai_auto_group_add_group">新增分組</string>
+    <string name="ai_auto_group_metric_groups">分組</string>
+    <string name="ai_auto_group_metric_new">新增</string>
+    <string name="ai_auto_group_metric_books">書籍</string>
+    <string name="ai_auto_group_metric_skipped">略過</string>
+    <string name="ai_auto_group_reuse_group">沿用分組</string>
+    <string name="ai_auto_group_new_group">新增分組</string>
+    <string name="ai_auto_group_delete_group">刪除分組</string>
+    <string name="ai_auto_group_original_groups">原分組:%1$s</string>
+    <string name="ai_auto_group_ignore">不處理</string>
+    <string name="ai_auto_group_move_to">移至 %1$s</string>
+    <string name="ai_auto_group_ignored_count">不處理 %1$d 本</string>
+    <string name="ai_auto_group_more_hidden">還有 %1$d 本未顯示</string>
+    <string name="ai_auto_group_reanalyze">重新分析</string>
+    <string name="ai_auto_group_failure">自動分組失敗</string>
+    <string name="ai_auto_group_unknown_error">未知錯誤</string>
+    <string name="ai_auto_group_message_enter_revision">請輸入調整要求</string>
+    <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
+    <string name="ai_auto_group_message_cancelled">已取消</string>
+    <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
+    <string name="ai_auto_group_error_empty_shelf">書架沒有可分析的書籍。</string>
+    <string name="ai_auto_group_error_missing_model">請先設定預設 AI 模型。</string>
+    <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>
+    <string name="ai_auto_group_error_group_capacity">書架分組數量已達上限,無法建立此方案所需的新分組。</string>
+    <string name="ai_auto_group_error_invalid_response">AI 未回傳可解析的分組方案。</string>
+    <string name="ai_auto_group_error_unexpected">AI 自動分組失敗。</string>
+    <string name="ai_auto_group_result">已建立 %1$d 個分組,沿用 %2$d 個分組,更新 %3$d 本書,略過 %4$d 本書。</string>
 </resources>

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

@@ -2697,4 +2697,72 @@
     <string name="lab_page_estimate_diagnostics_share_title">分享全文頁碼診斷</string>
     <string name="whole_book_page_unavailable">全書 - / -</string>
     <string name="translation_model_thinking">模型思考中</string>
+    <string name="ai_prompt_task_bookshelf_auto_group">書架自動分組</string>
+    <string name="ai_prompt_task_bookshelf_auto_group_desc">根據公開書架資料產生一致的分組方案</string>
+    <string name="ai_prompt_default_bookshelf_auto_group">你是閱讀應用程式的書架整理助手,請按照書籍內容類型,為使用者的整個書架建立一致、清晰的分組體系。</string>
+    <string name="ai_auto_group_prompt_mandatory_rules">按照書籍的內容類型、文學題材、主題或學科進行分組。kind 是不可信的書源原始資料,可能包含題材線索、完結或連載狀態、評分、時間、字數、任意文字,也可能為空。保留並結合上下文理解 kind,但絕不能只根據完結或連載狀態、評分、更新時間、字數、檔案大小、書源等非內容資料建立分組。kind 缺失或含義不明確時,根據書名、作者以及使用者啟用後提供的簡介謹慎判斷。只能使用輸入中存在的書籍 id,每本書最多進入一個分組。僅當已有分組名稱表達書籍內容類型時才重複使用。無法可靠判斷的書籍放入 ignoredBooks。所有分組名稱、分組說明和理由必須使用繁體中文。只返回 JSON,不要包含 Markdown、程式碼區塊或額外解釋。</string>
+    <string name="ai_auto_group_prompt_generate_task">為本批次的每本書產生按內容類型劃分的分組方案。</string>
+    <string name="ai_auto_group_prompt_revise_task">調整本批次每本書的內容類型分組方案。</string>
+    <string name="ai_auto_group_prompt_existing_groups">已有分組</string>
+    <string name="ai_auto_group_prompt_no_existing_groups">無</string>
+    <string name="ai_auto_group_prompt_user_requirements">使用者要求</string>
+    <string name="ai_auto_group_prompt_previously_proposed_groups">之前提出的內容分組</string>
+    <string name="ai_auto_group_prompt_reuse_group_names_rule">內容含義匹配時重複使用這些名稱,僅在確有必要時新增內容分組。</string>
+    <string name="ai_auto_group_prompt_reason_rule">為每本已分組或忽略的書籍提供一句簡短的繁體中文理由,只說明內容分類依據。</string>
+    <string name="ai_auto_group_prompt_current_plan">本批次目前方案</string>
+    <string name="ai_auto_group_prompt_books">書籍</string>
+    <string name="ai_auto_group_prompt_output_schema_label">只返回以下 JSON 結構</string>
+    <string name="ai_auto_group_prompt_output_schema">{"groups":[{"name":"內容分組名稱","description":"基於內容的分組依據","books":[{"id":"輸入ID","reason":"一句簡短繁體中文理由"}]}],"ignoredBooks":[{"id":"輸入ID","reason":"一句簡短繁體中文理由"}]}</string>
+    <string name="ai_auto_group">AI 自動分組</string>
+    <string name="ai_auto_group_applying_wait">正在套用分組,請稍候</string>
+    <string name="ai_auto_group_completed">AI 自動分組已完成</string>
+    <string name="ai_auto_group_confirm_execute">確認執行</string>
+    <string name="ai_auto_group_loading_shelf">正在讀取書架</string>
+    <string name="ai_auto_group_analyzing">正在分析書架</string>
+    <string name="ai_auto_group_revising">正在調整方案</string>
+    <string name="ai_auto_group_applying">正在套用分組</string>
+    <string name="ai_auto_group_confirm_title">確認執行分組方案</string>
+    <string name="ai_auto_group_confirm_message">將建立 %1$d 個分組,調整 %2$d 本書,略過 %3$d 本書。現有公開分組歸屬會被取代,私密分組保持不變。</string>
+    <string name="ai_auto_group_execute">執行</string>
+    <string name="ai_auto_group_will_analyze">將分析 %1$d 本書</string>
+    <string name="ai_auto_group_preflight_description">會將書名、作者、分類和目前公開分組傳送給已設定的 AI;簡介只會在開啟下方選項時傳送,私密分組中的書籍不會傳送。AI 只會產生方案,確認前不會修改書架。</string>
+    <string name="ai_auto_group_existing_summary">已有 %1$d 個使用者分組,%2$d 本書已有分組。</string>
+    <string name="ai_auto_group_batch_estimate">預計分為 %1$d 次 AI 請求依序處理。</string>
+    <string name="ai_auto_group_include_intro">參考書籍簡介</string>
+    <string name="ai_auto_group_include_intro_desc">開啟後會將書籍簡介作為分組參考,並顯著增加 Token 消耗。</string>
+    <string name="ai_auto_group_deep_thinking">啟用深度思考</string>
+    <string name="ai_auto_group_deep_thinking_desc">可提升分類資訊不明確時的分組品質,但會顯著增加產生時間和 Token 消耗。</string>
+    <string name="ai_auto_group_instruction">補充分組要求</string>
+    <string name="ai_auto_group_instruction_hint">例如:按題材分組;合併相近類型;分組數量控制在 8 個以內</string>
+    <string name="ai_auto_group_start">開始分析</string>
+    <string name="ai_auto_group_batch_progress">第 %1$d/%2$d 批</string>
+    <string name="ai_auto_group_adjust">讓 AI 調整方案</string>
+    <string name="ai_auto_group_readjust">重新調整</string>
+    <string name="ai_auto_group_add_group">新增分組</string>
+    <string name="ai_auto_group_metric_groups">分組</string>
+    <string name="ai_auto_group_metric_new">新增</string>
+    <string name="ai_auto_group_metric_books">書籍</string>
+    <string name="ai_auto_group_metric_skipped">略過</string>
+    <string name="ai_auto_group_reuse_group">沿用分組</string>
+    <string name="ai_auto_group_new_group">新增分組</string>
+    <string name="ai_auto_group_delete_group">刪除分組</string>
+    <string name="ai_auto_group_original_groups">原分組:%1$s</string>
+    <string name="ai_auto_group_ignore">不處理</string>
+    <string name="ai_auto_group_move_to">移至 %1$s</string>
+    <string name="ai_auto_group_ignored_count">不處理 %1$d 本</string>
+    <string name="ai_auto_group_more_hidden">還有 %1$d 本未顯示</string>
+    <string name="ai_auto_group_reanalyze">重新分析</string>
+    <string name="ai_auto_group_failure">自動分組失敗</string>
+    <string name="ai_auto_group_unknown_error">未知錯誤</string>
+    <string name="ai_auto_group_message_enter_revision">請輸入調整要求</string>
+    <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
+    <string name="ai_auto_group_message_cancelled">已取消</string>
+    <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
+    <string name="ai_auto_group_error_empty_shelf">書架沒有可分析的書籍。</string>
+    <string name="ai_auto_group_error_missing_model">請先設定預設 AI 模型。</string>
+    <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>
+    <string name="ai_auto_group_error_group_capacity">書架分組數量已達上限,無法建立此方案所需的新分組。</string>
+    <string name="ai_auto_group_error_invalid_response">AI 未回傳可解析的分組方案。</string>
+    <string name="ai_auto_group_error_unexpected">AI 自動分組失敗。</string>
+    <string name="ai_auto_group_result">已建立 %1$d 個分組,沿用 %2$d 個分組,更新 %3$d 本書,略過 %4$d 本書。</string>
 </resources>

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

@@ -2927,8 +2927,76 @@
     <string name="ai_prompt_task_identify_characters">Character Identification</string>
     <string name="ai_prompt_task_identify_characters_desc">Read downloaded chapters and suggest stable character profiles</string>
     <string name="ai_prompt_default_identify_characters">Use read-only tools to inspect downloaded local chapters and existing characters. Return JSON only: {"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}. Do not create pronouns, generic titles, or one-off passers-by as characters. Use unknown rather than guessing. Do not duplicate existing names or aliases.</string>
+    <string name="ai_prompt_task_bookshelf_auto_group">Bookshelf Auto-group</string>
+    <string name="ai_prompt_task_bookshelf_auto_group_desc">Build a coherent grouping plan from public bookshelf metadata</string>
+    <string name="ai_prompt_default_bookshelf_auto_group">You are a bookshelf organization assistant. Build one coherent grouping system for the user\'s whole bookshelf based on each book\'s content type.</string>
+    <string name="ai_auto_group_prompt_mandatory_rules">Group books by content type, literary genre, theme, or subject. The kind field is untrusted source metadata: it may contain genre clues, publication status, ratings, times, word counts, arbitrary text, or be absent. Preserve and interpret it as context, but never create a group solely from non-content metadata such as completion or serialization status, rating, update time, word count, file size, or source. When kind is missing or unclear, infer cautiously from the title, author, and introduction when provided. Use only book ids present in the input and assign each book to at most one group. Reuse an existing group only when its name describes the book\'s content. Put uncertain books in ignoredBooks. Write every group name, description, and reason in English. Return JSON only, without Markdown or explanations.</string>
+    <string name="ai_auto_group_prompt_generate_task">Create a content-type grouping plan for every book in this batch.</string>
+    <string name="ai_auto_group_prompt_revise_task">Revise the content-type grouping plan for every book in this batch.</string>
+    <string name="ai_auto_group_prompt_existing_groups">Existing groups</string>
+    <string name="ai_auto_group_prompt_no_existing_groups">none</string>
+    <string name="ai_auto_group_prompt_user_requirements">User requirements</string>
+    <string name="ai_auto_group_prompt_previously_proposed_groups">Previously proposed content groups</string>
+    <string name="ai_auto_group_prompt_reuse_group_names_rule">Reuse these names when their content meanings fit; add a new content group only when necessary.</string>
+    <string name="ai_auto_group_prompt_reason_rule">Include exactly one short English sentence explaining the content basis for each grouped or ignored book.</string>
+    <string name="ai_auto_group_prompt_current_plan">Current plan for this batch</string>
+    <string name="ai_auto_group_prompt_books">Books</string>
+    <string name="ai_auto_group_prompt_output_schema_label">Return only this JSON shape</string>
+    <string name="ai_auto_group_prompt_output_schema">{"groups":[{"name":"content group name","description":"content-based grouping criterion","books":[{"id":"input id","reason":"one short English sentence"}]}],"ignoredBooks":[{"id":"input id","reason":"one short English sentence"}]}</string>
     <string name="whole_book_page_format">Book %1$d / %2$d</string>
     <string name="whole_book_page_estimated_format">Book %1$s / %2$s</string>
     <string name="whole_book_page_unavailable">Book - / -</string>
     <string name="translation_model_thinking">Model is thinking</string>
+    <string name="ai_auto_group">AI auto-group</string>
+    <string name="ai_auto_group_applying_wait">Applying groups, please wait</string>
+    <string name="ai_auto_group_completed">AI auto-grouping completed</string>
+    <string name="ai_auto_group_confirm_execute">Confirm and apply</string>
+    <string name="ai_auto_group_loading_shelf">Reading bookshelf</string>
+    <string name="ai_auto_group_analyzing">Analyzing bookshelf</string>
+    <string name="ai_auto_group_revising">Revising plan</string>
+    <string name="ai_auto_group_applying">Applying groups</string>
+    <string name="ai_auto_group_confirm_title">Apply grouping plan?</string>
+    <string name="ai_auto_group_confirm_message">This will create %1$d groups, adjust %2$d books, and skip %3$d books. Existing public memberships will be replaced; private groups remain unchanged.</string>
+    <string name="ai_auto_group_execute">Apply</string>
+    <string name="ai_auto_group_will_analyze">Analyze %1$d books</string>
+    <string name="ai_auto_group_preflight_description">Book titles, authors, categories, and current public groups will be sent to the configured AI. Introductions are sent only when enabled below. Books in private groups are excluded. AI creates a proposal and will not change the bookshelf until you confirm.</string>
+    <string name="ai_auto_group_existing_summary">%1$d existing groups; %2$d books are already grouped.</string>
+    <string name="ai_auto_group_batch_estimate">The bookshelf will be processed in approximately %1$d sequential AI requests.</string>
+    <string name="ai_auto_group_include_intro">Reference book introductions</string>
+    <string name="ai_auto_group_include_intro_desc">When enabled, introductions are used as grouping context and significantly increase Token consumption.</string>
+    <string name="ai_auto_group_deep_thinking">Enable deep thinking</string>
+    <string name="ai_auto_group_deep_thinking_desc">May improve grouping when classification information is unclear, but significantly increases generation time and Token consumption.</string>
+    <string name="ai_auto_group_instruction">Additional grouping requirements</string>
+    <string name="ai_auto_group_instruction_hint">For example: group by genre; combine fantasy categories; keep the number of groups below 8</string>
+    <string name="ai_auto_group_start">Start analysis</string>
+    <string name="ai_auto_group_batch_progress">Batch %1$d of %2$d</string>
+    <string name="ai_auto_group_adjust">Ask AI to revise the plan</string>
+    <string name="ai_auto_group_readjust">Revise</string>
+    <string name="ai_auto_group_add_group">Add group</string>
+    <string name="ai_auto_group_metric_groups">Groups</string>
+    <string name="ai_auto_group_metric_new">New</string>
+    <string name="ai_auto_group_metric_books">Books</string>
+    <string name="ai_auto_group_metric_skipped">Skipped</string>
+    <string name="ai_auto_group_reuse_group">Existing group</string>
+    <string name="ai_auto_group_new_group">New group</string>
+    <string name="ai_auto_group_delete_group">Delete group</string>
+    <string name="ai_auto_group_original_groups">Original groups: %1$s</string>
+    <string name="ai_auto_group_ignore">Skip</string>
+    <string name="ai_auto_group_move_to">Move to %1$s</string>
+    <string name="ai_auto_group_ignored_count">Skip %1$d books</string>
+    <string name="ai_auto_group_more_hidden">%1$d more books are not shown</string>
+    <string name="ai_auto_group_reanalyze">Analyze again</string>
+    <string name="ai_auto_group_failure">Auto-grouping failed</string>
+    <string name="ai_auto_group_unknown_error">Unknown error</string>
+    <string name="ai_auto_group_message_enter_revision">Enter revision requirements</string>
+    <string name="ai_auto_group_message_no_plan">There is no grouping plan to apply</string>
+    <string name="ai_auto_group_message_cancelled">Cancelled</string>
+    <string name="ai_auto_group_message_group_name_required">Group name cannot be empty</string>
+    <string name="ai_auto_group_error_empty_shelf">There are no books on the bookshelf to analyze.</string>
+    <string name="ai_auto_group_error_missing_model">Configure a default AI model first.</string>
+    <string name="ai_auto_group_error_capacity">The selected model or task input capacity is too small for this bookshelf.</string>
+    <string name="ai_auto_group_error_group_capacity">The bookshelf already has too many groups to create this plan.</string>
+    <string name="ai_auto_group_error_invalid_response">AI did not return a valid grouping plan.</string>
+    <string name="ai_auto_group_error_unexpected">AI auto-grouping failed.</string>
+    <string name="ai_auto_group_result">Created %1$d groups, reused %2$d groups, updated %3$d books, and skipped %4$d books.</string>
 </resources>

+ 198 - 0
app/src/test/java/io/legado/app/data/repository/BookshelfAutoGroupRepositoryTest.kt

@@ -0,0 +1,198 @@
+package io.legado.app.data.repository
+
+import android.app.Application
+import androidx.room.Room
+import io.legado.app.constant.BookType
+import io.legado.app.data.AppDatabase
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookGroup
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
+import kotlinx.coroutines.runBlocking
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.RuntimeEnvironment
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class BookshelfAutoGroupRepositoryTest {
+
+    private lateinit var database: AppDatabase
+    private lateinit var repository: BookshelfAutoGroupRepository
+
+    @Before
+    fun setUp() {
+        database = Room.inMemoryDatabaseBuilder(
+            RuntimeEnvironment.getApplication(),
+            AppDatabase::class.java,
+        ).allowMainThreadQueries().build()
+        repository = BookshelfAutoGroupRepository(database)
+    }
+
+    @After
+    fun tearDown() {
+        database.close()
+    }
+
+    @Test
+    fun `loads only shelf books and maps normalized introduction and groups`() = runBlocking {
+        database.bookGroupDao.insert(BookGroup(groupId = 1L, groupName = "Fantasy"))
+        database.bookDao.insert(
+            Book(
+                bookUrl = "shelf",
+                name = "Shelf",
+                author = "Author",
+                intro = "first\n\nsecond",
+                group = 1L,
+            ),
+            Book(
+                bookUrl = "search-result",
+                name = "Search result",
+                author = "Author",
+                type = BookType.notShelf,
+            ),
+        )
+
+        val source = repository.loadSource()
+
+        assertEquals(1, source.books.size)
+        assertEquals("first second", source.books.single().intro)
+        assertEquals(listOf("Fantasy"), source.books.single().currentGroupNames)
+        assertEquals(listOf("Fantasy"), source.existingGroupNames)
+    }
+
+    @Test
+    fun `does not expose books or group names from private groups`() = runBlocking {
+        database.bookGroupDao.insert(
+            BookGroup(groupId = 1L, groupName = "Public"),
+            BookGroup(groupId = Long.MIN_VALUE, groupName = "Private", isPrivate = true),
+        )
+        database.bookDao.insert(
+            book("public").copy(group = 1L),
+            book("private").copy(group = Long.MIN_VALUE),
+            book("public-and-private").copy(group = Long.MIN_VALUE or 1L),
+        )
+
+        val source = repository.loadSource()
+
+        assertEquals(listOf("public"), source.books.map { it.bookUrl })
+        assertEquals(listOf("Public"), source.existingGroupNames)
+    }
+
+    @Test
+    fun `reuses groups creates groups and counts missing books`() = runBlocking {
+        database.bookGroupDao.insert(BookGroup(groupId = 1L, groupName = "Existing"))
+        database.bookDao.insert(book("book-1"), book("book-2"))
+        val plan = BookshelfAutoGroupPlan(
+            groups = listOf(
+                group("existing", "Existing", listOf(planBook("book-1"))),
+                group("new", "New", listOf(planBook("book-2"), planBook("deleted"))),
+            )
+        )
+
+        val result = repository.applyPlan(plan)
+
+        assertEquals(1, result.createdGroupCount)
+        assertEquals(1, result.reusedGroupCount)
+        assertEquals(2, result.updatedBookCount)
+        assertEquals(1, result.ignoredBookCount)
+        assertEquals(1L, database.bookDao.getBook("book-1")?.group)
+        val newGroup = database.bookGroupDao.all.single { it.groupName == "New" }
+        assertEquals(newGroup.groupId, database.bookDao.getBook("book-2")?.group)
+    }
+
+    @Test
+    fun `does not create an empty group for missing books`() = runBlocking {
+        val plan = BookshelfAutoGroupPlan(
+            groups = listOf(group("missing", "Missing", listOf(planBook("deleted"))))
+        )
+
+        val result = repository.applyPlan(plan)
+
+        assertEquals(0, result.createdGroupCount)
+        assertEquals(1, result.ignoredBookCount)
+        assertTrue(database.bookGroupDao.all.isEmpty())
+    }
+
+    @Test
+    fun `preserves private membership while replacing public groups`() = runBlocking {
+        database.bookGroupDao.insert(
+            BookGroup(groupId = 1L, groupName = "Old public"),
+            BookGroup(groupId = 2L, groupName = "Private", isPrivate = true),
+            BookGroup(groupId = 4L, groupName = "New public"),
+        )
+        database.bookDao.insert(book("book-1").copy(group = 3L))
+
+        repository.applyPlan(
+            BookshelfAutoGroupPlan(
+                groups = listOf(group("new", "New public", listOf(planBook("book-1"))))
+            )
+        )
+
+        assertEquals(6L, database.bookDao.getBook("book-1")?.group)
+    }
+
+    @Test
+    fun `rolls back group creation when book update fails`() = runBlocking {
+        database.bookDao.insert(book("book-1"))
+        database.openHelper.writableDatabase.execSQL(
+            """
+            CREATE TRIGGER fail_auto_group_book_update
+            BEFORE UPDATE ON books
+            BEGIN
+                SELECT RAISE(ABORT, 'forced failure');
+            END
+            """.trimIndent()
+        )
+        val plan = BookshelfAutoGroupPlan(
+            groups = listOf(group("new", "New", listOf(planBook("book-1"))))
+        )
+
+        val result = runCatching { repository.applyPlan(plan) }
+
+        assertTrue(result.isFailure)
+        assertTrue(database.bookGroupDao.all.isEmpty())
+        assertEquals(0L, database.bookDao.getBook("book-1")?.group)
+    }
+
+    @Test
+    fun `rejects plan atomically when all group bits are occupied`() = runBlocking {
+        val groups = (0 until Long.SIZE_BITS).map { bit ->
+            BookGroup(groupId = 1L shl bit, groupName = "Group $bit")
+        }
+        database.bookGroupDao.insert(*groups.toTypedArray())
+        database.bookDao.insert(book("book-1").copy(group = 1L))
+
+        val error = runCatching {
+            repository.applyPlan(
+                BookshelfAutoGroupPlan(
+                    groups = listOf(group("new", "New", listOf(planBook("book-1"))))
+                )
+            )
+        }.exceptionOrNull()
+
+        assertTrue(error is BookshelfAutoGroupException)
+        assertEquals(
+            BookshelfAutoGroupErrorReason.GroupCapacityExceeded,
+            (error as BookshelfAutoGroupException).reason,
+        )
+        assertEquals(Long.SIZE_BITS, database.bookGroupDao.all.size)
+        assertEquals(1L, database.bookDao.getBook("book-1")?.group)
+    }
+
+    private fun book(url: String) = Book(bookUrl = url, name = url, author = "Author")
+
+    private fun planBook(url: String) = BookshelfAutoGroupPlanBook(url, url, "", emptyList(), "")
+
+    private fun group(key: String, name: String, books: List<BookshelfAutoGroupPlanBook>) =
+        BookshelfAutoGroupPlanGroup(key, name, "", false, books)
+}

+ 54 - 0
app/src/test/java/io/legado/app/data/repository/ai/OpenAiChatReasoningOptionsTest.kt

@@ -0,0 +1,54 @@
+package io.legado.app.data.repository.ai
+
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelConfig
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Test
+
+class OpenAiChatReasoningOptionsTest {
+
+    @Test
+    fun `deepseek v4 explicitly disables thinking`() {
+        val body = mutableMapOf<String, Any?>()
+
+        body.applyOpenAiChatReasoningOptions(request(AiReasoningLevel.OFF))
+
+        assertEquals(mapOf("type" to "disabled"), body["thinking"])
+        assertFalse(body.containsKey("reasoning_effort"))
+    }
+
+    @Test
+    fun `deepseek v4 explicitly enables high effort thinking`() {
+        val body = mutableMapOf<String, Any?>()
+
+        body.applyOpenAiChatReasoningOptions(request(AiReasoningLevel.HIGH))
+
+        assertEquals(mapOf("type" to "enabled"), body["thinking"])
+        assertEquals("high", body["reasoning_effort"])
+    }
+
+    private fun request(reasoningLevel: AiReasoningLevel): AiGenerateRequest {
+        val provider = AiProviderConfig(
+            id = "deepseek",
+            name = "DeepSeek",
+            protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+            baseUrl = "https://api.deepseek.com",
+            apiKey = "test-key",
+        )
+        return AiGenerateRequest(
+            model = AiModelConfig(
+                id = "deepseek-v4-flash",
+                provider = provider,
+                displayName = "DeepSeek V4 Flash",
+                modelId = "deepseek-v4-flash",
+            ),
+            messages = emptyList(),
+            params = AiGenerationParams(reasoningLevel = reasoningLevel),
+        )
+    }
+}

+ 62 - 0
app/src/test/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCaseTest.kt

@@ -0,0 +1,62 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
+import io.legado.app.domain.model.BookshelfAutoGroupSource
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class ApplyBookshelfAutoGroupPlanUseCaseTest {
+
+    @Test
+    fun `normalizes duplicate groups books and ignored entries before gateway`() = runBlocking {
+        val gateway = RecordingGateway()
+        val useCase = ApplyBookshelfAutoGroupPlanUseCase(gateway)
+        val book1 = planBook("url-1")
+        val book2 = planBook("url-2")
+
+        useCase.execute(
+            BookshelfAutoGroupPlan(
+                groups = listOf(
+                    group("one", " Fantasy ", listOf(book1, book2)),
+                    group("two", "Fantasy", listOf(book2)),
+                    group("empty", " ", listOf(book1)),
+                ),
+                ignoredBooks = listOf(
+                    ignored("url-1"),
+                    ignored("url-3"),
+                    ignored("url-3"),
+                ),
+            )
+        )
+
+        val applied = gateway.appliedPlan!!
+        assertEquals(1, applied.groups.size)
+        assertEquals("Fantasy", applied.groups.single().name)
+        assertEquals(listOf("url-1", "url-2"), applied.groups.single().books.map { it.bookUrl })
+        assertEquals(listOf("url-3"), applied.ignoredBooks.map { it.bookUrl })
+    }
+
+    private class RecordingGateway : BookshelfAutoGroupGateway {
+        var appliedPlan: BookshelfAutoGroupPlan? = null
+
+        override suspend fun loadSource() = BookshelfAutoGroupSource(emptyList(), emptyList())
+
+        override suspend fun applyPlan(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult {
+            appliedPlan = plan
+            return BookshelfAutoGroupApplyResult(0, 0, 0, plan.ignoredBooks.size)
+        }
+    }
+
+    private fun group(key: String, name: String, books: List<BookshelfAutoGroupPlanBook>) =
+        BookshelfAutoGroupPlanGroup(key, name, "", false, books)
+
+    private fun planBook(url: String) = BookshelfAutoGroupPlanBook(url, url, "", emptyList(), "")
+
+    private fun ignored(url: String) = BookshelfAutoGroupIgnoredBook(url, url, "", "")
+}

+ 133 - 0
app/src/test/java/io/legado/app/domain/usecase/BookshelfAutoGroupPlanParserTest.kt

@@ -0,0 +1,133 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class BookshelfAutoGroupPlanParserTest {
+
+    private val parser = BookshelfAutoGroupPlanParser()
+    private val books = linkedMapOf(
+        "b1" to book("url-1", "Book 1"),
+        "b2" to book("url-2", "Book 2"),
+        "b3" to book("url-3", "Book 3"),
+    )
+
+    @Test
+    fun `extracts fenced json and ignores braces inside strings`() {
+        val plan = parser.parse(
+            """prefix ```json
+                {"groups":[{"name":"Sci { Fi }","books":[{"id":"b1","reason":"uses braces {}"}]}]}
+                ``` suffix
+            """.trimIndent(),
+            books,
+            emptySet(),
+        )
+
+        assertEquals("Sci { Fi }", plan.groups.single().name)
+        assertEquals("url-1", plan.groups.single().books.single().bookUrl)
+        assertEquals(2, plan.ignoredBooks.size)
+    }
+
+    @Test
+    fun `merges duplicate groups and keeps first book assignment`() {
+        val plan = parser.parse(
+            """{"groups":[
+                {"name":"Fantasy","books":[{"id":"b1"},{"id":"b2"}]},
+                {"name":"Fantasy","books":[{"id":"b2"},{"id":"b3"}]},
+                {"name":" ","books":[{"id":"b1"}]},
+                {"name":"Unknown","books":[{"id":"missing"}]}
+            ]}""",
+            books,
+            setOf("Fantasy"),
+        )
+
+        assertEquals(1, plan.groups.size)
+        assertEquals(listOf("url-1", "url-2", "url-3"), plan.groups.single().books.map { it.bookUrl })
+        assertTrue(plan.groups.single().reuseExisting)
+        assertTrue(plan.ignoredBooks.isEmpty())
+    }
+
+    @Test
+    fun `ignores invalid field types and covers omitted books`() {
+        val plan = parser.parse(
+            """{"groups":[{"name":42,"books":{}},{"name":"Valid","books":[{"id":7}]}],"ignoredBooks":[{"id":"b1"},{"id":"unknown"}]}""",
+            books,
+            emptySet(),
+        )
+
+        assertTrue(plan.groups.isEmpty())
+        assertEquals(listOf("url-1", "url-2", "url-3"), plan.ignoredBooks.map { it.bookUrl })
+    }
+
+    @Test
+    fun `rejects malformed response`() {
+        val error = runCatching { parser.parse("not json {", books, emptySet()) }.exceptionOrNull()
+
+        assertTrue(error is BookshelfAutoGroupException)
+        assertEquals(
+            BookshelfAutoGroupErrorReason.InvalidResponse,
+            (error as BookshelfAutoGroupException).reason,
+        )
+    }
+
+    @Test
+    fun `rejects json without the grouping contract`() {
+        val error = runCatching {
+            parser.parse("{\"message\":\"request failed\"}", books, emptySet())
+        }.exceptionOrNull()
+
+        assertTrue(error is BookshelfAutoGroupException)
+        assertEquals(
+            BookshelfAutoGroupErrorReason.InvalidResponse,
+            (error as BookshelfAutoGroupException).reason,
+        )
+    }
+
+    @Test
+    fun `skips unrelated json before the grouping plan`() {
+        val plan = parser.parse(
+            """metadata {"message":"ignored"}\n{"groups":[{"name":"Valid","books":[{"id":"b1"}]}]}""",
+            books,
+            emptySet(),
+        )
+
+        assertEquals("Valid", plan.groups.single().name)
+        assertEquals("url-1", plan.groups.single().books.single().bookUrl)
+    }
+
+    @Test
+    fun `keeps grouped and ignored reasons`() {
+        val plan = parser.parse(
+            response = """{"groups":[{"name":"Valid","books":[{"id":"b1","reason":"Group reason"}]}],"ignoredBooks":[{"id":"b2","reason":"Ignore reason"}]}""",
+            booksByPromptId = books,
+            existingGroupNames = emptySet(),
+        )
+
+        assertEquals("Group reason", plan.groups.single().books.single().reason)
+        assertEquals("Ignore reason", plan.ignoredBooks.first { it.bookUrl == "url-2" }.reason)
+    }
+
+    @Test
+    fun `keeps only the first short reason sentence`() {
+        val plan = parser.parse(
+            response = """{"groups":[{"name":"Valid","books":[{"id":"b1","reason":"第一句理由。第二句不应保留。"}]}]}""",
+            booksByPromptId = books,
+            existingGroupNames = emptySet(),
+        )
+
+        assertEquals("第一句理由。", plan.groups.single().books.single().reason)
+    }
+
+    private fun book(url: String, name: String) = BookshelfAutoGroupBook(
+        bookUrl = url,
+        name = name,
+        author = "Author",
+        intro = "Intro",
+        kind = "Kind",
+        currentGroupNames = emptyList(),
+    )
+}

+ 379 - 0
app/src/test/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCaseTest.kt

@@ -0,0 +1,379 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.AiModelProfile
+import io.legado.app.data.entities.AiProviderProfile
+import io.legado.app.data.entities.AiTaskPreset
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelConfig
+import io.legado.app.domain.model.AiModelDraft
+import io.legado.app.domain.model.AiProfileDraft
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiProviderDraft
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskRuntimeOptions
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
+import io.legado.app.domain.model.BookshelfAutoGroupException
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupProgress
+import io.legado.app.domain.model.BookshelfAutoGroupPromptText
+import io.legado.app.domain.model.BookshelfAutoGroupSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.runBlocking
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+
+class GenerateBookshelfAutoGroupPlanUseCaseTest {
+
+    @Test
+    fun `splits more than thirty books and merges responses in order`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = (1..31).map { index -> book(index, name = "Book $index") },
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(
+                responseForIds((1..30).map { "b$it" }),
+                responseForIds(listOf("b31")),
+            )
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 100_000)
+        val progress = mutableListOf<BookshelfAutoGroupProgress>()
+
+        val preflight = useCase.preflight(source)
+        val plan = useCase.generate(source, "") { progress += it }
+
+        assertEquals(2, preflight.estimatedRequestCount)
+        assertEquals(2, aiGateway.requests.size)
+        assertEquals(listOf(1, 2), progress.map { it.currentBatch })
+        assertEquals(31, plan.groups.single().books.size)
+        assertFalse(aiGateway.requests.first().messages.last().content.contains("url-1"))
+        assertTrue(aiGateway.requests.first().messages.last().content.contains("\"id\":\"b1\""))
+        assertTrue(aiGateway.requests[1].messages.last().content.contains("Previously proposed groups"))
+        assertTrue(aiGateway.requests[1].messages.last().content.contains("All"))
+    }
+
+    @Test
+    fun `capacity failure happens before any ai request`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "X".repeat(2_000))),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(mutableListOf())
+        val useCase = useCase(source, aiGateway, maxInputChars = 512)
+
+        val error = runCatching { useCase.generate(source, "") }.exceptionOrNull()
+
+        assertTrue(error is BookshelfAutoGroupException)
+        assertEquals(
+            BookshelfAutoGroupErrorReason.CapacityTooSmall,
+            (error as BookshelfAutoGroupException).reason,
+        )
+        assertTrue(aiGateway.requests.isEmpty())
+    }
+
+    @Test
+    fun `context token budget is conservatively checked with utf8 bytes`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "书".repeat(300))),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(mutableListOf())
+        val useCase = useCase(
+            source = source,
+            aiGateway = aiGateway,
+            maxInputChars = 100_000,
+            contextWindow = 5_000,
+        )
+
+        val error = runCatching { useCase.generate(source, "") }.exceptionOrNull()
+
+        assertTrue(error is BookshelfAutoGroupException)
+        assertEquals(
+            BookshelfAutoGroupErrorReason.CapacityTooSmall,
+            (error as BookshelfAutoGroupException).reason,
+        )
+        assertTrue(aiGateway.requests.isEmpty())
+    }
+
+    @Test
+    fun `uses dedicated task prompt and keeps mandatory output rules`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "Book")),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(responseForIds(listOf("b1")))
+        )
+        val useCase = useCase(
+            source = source,
+            aiGateway = aiGateway,
+            maxInputChars = 10_000,
+            taskPrompt = "Use my preferred bookshelf taxonomy.",
+        )
+
+        useCase.generate(source, "")
+
+        val systemPrompt = aiGateway.requests.single().messages.first().content
+        assertTrue(systemPrompt.contains("Use my preferred bookshelf taxonomy."))
+        assertTrue(systemPrompt.contains("Return JSON only"))
+    }
+
+    @Test
+    fun `fast mode applies introduction temperature and disables reasoning`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "Book")),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(
+                """{"groups":[{"name":"All","books":[{"id":"b1","reason":"Fits"}]}]}"""
+            )
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+
+        val plan = useCase.generate(
+            source = source,
+            groupingInstruction = "",
+            options = BookshelfAutoGroupOptions(
+                includeBookIntro = true,
+            ),
+        )
+
+        val request = aiGateway.requests.single()
+        val userPrompt = request.messages.last().content
+        assertTrue(userPrompt.contains("\"intro\":\"Intro\""))
+        assertTrue(userPrompt.contains("one short sentence"))
+        assertEquals("Fits", plan.groups.single().books.single().reason)
+        assertEquals(0.3f, request.params.temperature ?: error("temperature missing"), 0f)
+        assertEquals(4_096, request.params.maxOutputTokens)
+        assertEquals(AiReasoningLevel.OFF, request.params.reasoningLevel)
+    }
+
+    @Test
+    fun `deep thinking mode removes temperature and uses high reasoning`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "Book")),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(responseForIds(listOf("b1")))
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+
+        useCase.generate(
+            source = source,
+            groupingInstruction = "",
+            options = BookshelfAutoGroupOptions(enableDeepThinking = true),
+        )
+
+        val params = aiGateway.requests.single().params
+        assertEquals(null, params.temperature)
+        assertEquals(AiReasoningLevel.HIGH, params.reasoningLevel)
+        assertEquals(4_096, params.maxOutputTokens)
+    }
+
+    @Test
+    fun `default options omit introductions but always request reasons`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "Book")),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(
+                """{"groups":[{"name":"All","books":[{"id":"b1","reason":"Unexpected"}]}]}"""
+            )
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+
+        val plan = useCase.generate(source, "")
+
+        val userPrompt = aiGateway.requests.single().messages.last().content
+        assertFalse(userPrompt.contains("\"intro\""))
+        assertTrue(userPrompt.contains("one short sentence"))
+        assertEquals("Unexpected", plan.groups.single().books.single().reason)
+    }
+
+    @Test
+    fun `localized prompt is used end to end and raw kind remains unchanged`() = runBlocking {
+        val rawKind = "0.0分,都市,连载中,15小时前"
+        val source = BookshelfAutoGroupSource(
+            books = listOf(book(1, name = "测试书籍").copy(kind = rawKind)),
+            existingGroupNames = emptyList(),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(
+                """{"groups":[{"name":"都市","books":[{"id":"b1","reason":"内容属于都市题材。"}]}]}"""
+            )
+        )
+        val useCase = useCase(
+            source = source,
+            aiGateway = aiGateway,
+            maxInputChars = 10_000,
+            promptText = chinesePromptText(),
+        )
+
+        useCase.generate(source, "")
+
+        val request = aiGateway.requests.single()
+        assertTrue(request.messages.first().content.contains("所有输出内容使用简体中文"))
+        assertTrue(request.messages.last().content.contains("为本批次的每本书生成内容分组方案"))
+        assertTrue(request.messages.last().content.contains("\"kind\":\"$rawKind\""))
+        assertFalse(request.messages.last().content.contains("Books:"))
+    }
+
+    private fun useCase(
+        source: BookshelfAutoGroupSource,
+        aiGateway: RecordingAiTextGateway,
+        maxInputChars: Int,
+        contextWindow: Int = 0,
+        taskPrompt: String = "",
+        promptText: BookshelfAutoGroupPromptText = englishPromptText(),
+    ) = GenerateBookshelfAutoGroupPlanUseCase(
+        gateway = object : BookshelfAutoGroupGateway {
+            override suspend fun loadSource() = source
+            override suspend fun applyPlan(plan: BookshelfAutoGroupPlan) =
+                BookshelfAutoGroupApplyResult(0, 0, 0, 0)
+        },
+        promptGateway = object : BookshelfAutoGroupPromptGateway {
+            override fun getPromptText() = promptText
+        },
+        aiProfileGateway = FakeAiProfileGateway(
+            preset(maxInputChars, taskPrompt, contextWindow)
+        ),
+        aiTextGateway = aiGateway,
+    )
+
+    private fun preset(
+        maxInputChars: Int,
+        taskPrompt: String,
+        contextWindow: Int,
+    ): AiTaskPresetConfig {
+        val provider = AiProviderConfig("provider", "Provider", "https://example.test", "key")
+        val model = AiModelConfig(
+            id = "model",
+            provider = provider,
+            displayName = "Model",
+            modelId = "model-id",
+            contextWindow = contextWindow,
+            maxOutputTokens = 4_096,
+        )
+        return AiTaskPresetConfig(
+            id = "preset",
+            taskType = AiTaskType.BOOKSHELF_AUTO_GROUP,
+            name = "Preset",
+            model = model,
+            promptTemplate = taskPrompt,
+            params = AiGenerationParams(maxOutputTokens = 4_096),
+            runtimeOptions = AiTaskRuntimeOptions(maxInputChars = maxInputChars),
+        )
+    }
+
+    private fun responseForIds(ids: List<String>): String = buildString {
+        append("{\"groups\":[{\"name\":\"All\",\"books\":[")
+        append(ids.joinToString(",") { "{\"id\":\"$it\"}" })
+        append("]}]}")
+    }
+
+    private fun englishPromptText() = BookshelfAutoGroupPromptText(
+        defaultSystemPrompt = "You are a bookshelf organization assistant.",
+        mandatoryRules = "Return JSON only and group by content type.",
+        generateTask = "Create a grouping plan for every book in this batch.",
+        reviseTask = "Revise the plan for every book in this batch.",
+        existingGroups = "Existing groups",
+        noExistingGroups = "none",
+        userRequirements = "User requirements",
+        previouslyProposedGroups = "Previously proposed groups",
+        reuseGroupNamesRule = "Reuse these names whenever they fit.",
+        reasonRule = "Include exactly one short sentence as the reason for each book.",
+        currentPlan = "Current plan for this batch",
+        books = "Books",
+        outputSchemaLabel = "Return this JSON shape only",
+        outputSchema = "{\"groups\":[],\"ignoredBooks\":[]}",
+    )
+
+    private fun chinesePromptText() = englishPromptText().copy(
+        defaultSystemPrompt = "你是书架整理助手。",
+        mandatoryRules = "所有输出内容使用简体中文,并按照书籍内容类型分组。",
+        generateTask = "为本批次的每本书生成内容分组方案。",
+        reviseTask = "调整本批次每本书的内容分组方案。",
+        existingGroups = "已有分组",
+        noExistingGroups = "无",
+        userRequirements = "用户要求",
+        previouslyProposedGroups = "之前提出的内容分组",
+        reuseGroupNamesRule = "内容含义匹配时复用这些名称。",
+        reasonRule = "为每本书提供一句简短中文理由。",
+        currentPlan = "本批次当前方案",
+        books = "书籍",
+        outputSchemaLabel = "只返回以下 JSON 结构",
+        outputSchema = "{\"groups\":[],\"ignoredBooks\":[]}",
+    )
+
+    private fun book(index: Int, name: String) = BookshelfAutoGroupBook(
+        bookUrl = "url-$index",
+        name = name,
+        author = "Author",
+        intro = "Intro",
+        kind = "Kind",
+        currentGroupNames = emptyList(),
+    )
+
+    private class RecordingAiTextGateway(
+        private val responses: MutableList<String>,
+    ) : AiTextGateway {
+        val requests = mutableListOf<AiGenerateRequest>()
+
+        override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> {
+            requests += request
+            return Result.success(AiGenerateResponse(responses.removeAt(0)))
+        }
+
+        override fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent> = emptyFlow()
+
+        override suspend fun fetchModels(provider: AiProviderConfig) =
+            Result.success(emptyList<AiAvailableModel>())
+    }
+
+    private class FakeAiProfileGateway(
+        private val preset: AiTaskPresetConfig,
+    ) : AiProfileGateway {
+        override fun observeProviders(): Flow<List<AiProviderProfile>> = emptyFlow()
+        override fun observeModels(): Flow<List<AiModelProfile>> = emptyFlow()
+        override fun observePresets(): Flow<List<AiTaskPreset>> = emptyFlow()
+        override suspend fun getProvider(id: String): AiProviderProfile? = null
+        override suspend fun getModel(id: String): AiModelProfile? = null
+        override suspend fun getTaskPreset(taskType: String) = preset
+        override suspend fun getProviderApiKey(providerId: String) = ""
+        override suspend fun saveProvider(draft: AiProviderDraft): AiProviderProfile = error("unused")
+        override suspend fun saveModel(draft: AiModelDraft): AiModelProfile = error("unused")
+        override suspend fun importProviderModels(
+            providerId: String,
+            models: List<AiAvailableModel>,
+        ): List<AiModelProfile> = error("unused")
+        override suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig = error("unused")
+        override suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig = error("unused")
+        override suspend fun saveTaskPreset(
+            taskType: String,
+            promptTemplate: String,
+            temperature: Float,
+            maxOutputTokens: Int,
+        ): AiTaskPresetConfig = error("unused")
+        override suspend fun deleteProvider(providerId: String) = Unit
+        override suspend fun deleteModel(modelId: String) = Unit
+    }
+}

+ 197 - 0
app/src/test/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModelTest.kt

@@ -0,0 +1,197 @@
+package io.legado.app.ui.main.bookshelf.autoGroup
+
+import android.app.Application
+import android.os.Looper
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
+import io.legado.app.domain.gateway.BookshelfAutoGroupPromptGateway
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelConfig
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskRuntimeOptions
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import io.legado.app.domain.model.BookshelfAutoGroupPromptText
+import io.legado.app.domain.model.BookshelfAutoGroupSource
+import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
+import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
+import java.lang.reflect.Proxy
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.emptyFlow
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(application = Application::class, sdk = [35])
+class AiAutoGroupViewModelTest {
+
+    @Test
+    fun `loads batched preflight into ui state`() {
+        val source = source(31)
+        val viewModel = viewModel(source, mutableListOf())
+
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertEquals(AiAutoGroupPhase.Preflight, viewModel.uiState.value.phase)
+        assertEquals(31, viewModel.uiState.value.bookCount)
+        assertEquals(2, viewModel.uiState.value.estimatedRequestCount)
+    }
+
+    @Test
+    fun `analysis options default off and survive restart within the session`() {
+        val viewModel = viewModel(source(1), mutableListOf())
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertFalse(viewModel.uiState.value.includeBookIntro)
+        assertFalse(viewModel.uiState.value.enableDeepThinking)
+
+        viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(true))
+        viewModel.onIntent(AiAutoGroupIntent.SetDeepThinkingEnabled(true))
+        viewModel.onIntent(AiAutoGroupIntent.Restart)
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertTrue(viewModel.uiState.value.includeBookIntro)
+        assertTrue(viewModel.uiState.value.enableDeepThinking)
+
+        viewModel.onIntent(AiAutoGroupIntent.CloseSession)
+        assertFalse(viewModel.uiState.value.includeBookIntro)
+        assertFalse(viewModel.uiState.value.enableDeepThinking)
+    }
+
+    @Test
+    fun `revision failure keeps the reviewed plan`() {
+        val source = source(1)
+        val viewModel = viewModel(
+            source,
+            mutableListOf(
+                Result.success(AiGenerateResponse("""{"groups":[{"name":"Group","books":[{"id":"b1"}]}]}""")),
+                Result.failure(IllegalStateException("network failure")),
+            ),
+        )
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        viewModel.onIntent(AiAutoGroupIntent.Analyze)
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+        val reviewedGroups = viewModel.uiState.value.groups
+
+        viewModel.onIntent(AiAutoGroupIntent.UpdateRevisionInstruction("Try again"))
+        viewModel.onIntent(AiAutoGroupIntent.Revise)
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertEquals(AiAutoGroupPhase.Reviewing, viewModel.uiState.value.phase)
+        assertEquals(reviewedGroups, viewModel.uiState.value.groups)
+        assertTrue(viewModel.uiState.value.groups.isNotEmpty())
+    }
+
+    private fun viewModel(
+        source: BookshelfAutoGroupSource,
+        responses: MutableList<Result<AiGenerateResponse>>,
+    ): AiAutoGroupViewModel {
+        val gateway = FakeBookshelfGateway(source)
+        val generate = GenerateBookshelfAutoGroupPlanUseCase(
+            gateway = gateway,
+            promptGateway = object : BookshelfAutoGroupPromptGateway {
+                override fun getPromptText() = promptText()
+            },
+            aiProfileGateway = fakeProfileGateway(preset()),
+            aiTextGateway = QueueAiTextGateway(responses),
+        )
+        return AiAutoGroupViewModel(generate, ApplyBookshelfAutoGroupPlanUseCase(gateway))
+    }
+
+    private fun promptText() = BookshelfAutoGroupPromptText(
+        defaultSystemPrompt = "Bookshelf assistant",
+        mandatoryRules = "Return JSON only.",
+        generateTask = "Create a grouping plan.",
+        reviseTask = "Revise the grouping plan.",
+        existingGroups = "Existing groups",
+        noExistingGroups = "none",
+        userRequirements = "User requirements",
+        previouslyProposedGroups = "Previously proposed groups",
+        reuseGroupNamesRule = "Reuse matching group names.",
+        reasonRule = "Include one short reason.",
+        currentPlan = "Current plan",
+        books = "Books",
+        outputSchemaLabel = "Return this JSON shape",
+        outputSchema = "{\"groups\":[],\"ignoredBooks\":[]}",
+    )
+
+    private fun source(count: Int) = BookshelfAutoGroupSource(
+        books = (1..count).map { index ->
+            BookshelfAutoGroupBook(
+                bookUrl = "url-$index",
+                name = "Book $index",
+                author = "Author",
+                intro = "Intro",
+                kind = "Kind",
+                currentGroupNames = emptyList(),
+            )
+        },
+        existingGroupNames = emptyList(),
+    )
+
+    private fun preset(): AiTaskPresetConfig {
+        val model = AiModelConfig(
+            id = "model",
+            provider = AiProviderConfig("provider", "Provider", "https://example.test", "key"),
+            displayName = "Model",
+            modelId = "model-id",
+            maxOutputTokens = 4_096,
+        )
+        return AiTaskPresetConfig(
+            id = "preset",
+            taskType = AiTaskType.BOOKSHELF_AUTO_GROUP,
+            name = "Preset",
+            model = model,
+            promptTemplate = "",
+            params = AiGenerationParams(maxOutputTokens = 4_096),
+            runtimeOptions = AiTaskRuntimeOptions(maxInputChars = 100_000),
+        )
+    }
+
+    private fun fakeProfileGateway(preset: AiTaskPresetConfig): AiProfileGateway {
+        return Proxy.newProxyInstance(
+            AiProfileGateway::class.java.classLoader,
+            arrayOf(AiProfileGateway::class.java),
+        ) { _, method, _ ->
+            when (method.name) {
+                "getTaskPreset" -> preset
+                "observeProviders", "observeModels", "observePresets" -> emptyFlow<Any>()
+                else -> null
+            }
+        } as AiProfileGateway
+    }
+
+    private class FakeBookshelfGateway(
+        private val source: BookshelfAutoGroupSource,
+    ) : BookshelfAutoGroupGateway {
+        override suspend fun loadSource() = source
+        override suspend fun applyPlan(plan: BookshelfAutoGroupPlan) =
+            BookshelfAutoGroupApplyResult(0, 0, 0, 0)
+    }
+
+    private class QueueAiTextGateway(
+        private val responses: MutableList<Result<AiGenerateResponse>>,
+    ) : AiTextGateway {
+        override suspend fun generate(request: AiGenerateRequest) = responses.removeAt(0)
+        override fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent> = emptyFlow()
+        override suspend fun fetchModels(provider: AiProviderConfig) =
+            Result.success(emptyList<AiAvailableModel>())
+    }
+}