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

Merge pull request #2029 from lalala-12138/feat/ai-auto-group-incremental

feat: AI 自动分组支持增量模式
Kudomaga 2 месяцев назад
Родитель
Сommit
ffc2cf37d7

+ 18 - 4
app/src/main/java/io/legado/app/data/repository/BookshelfAutoGroupRepository.kt

@@ -9,6 +9,7 @@ import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupBook
 import io.legado.app.domain.model.BookshelfAutoGroupBook
 import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
 import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
 import io.legado.app.domain.model.BookshelfAutoGroupException
 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.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.help.book.isNotShelf
 import io.legado.app.help.book.isNotShelf
@@ -51,6 +52,7 @@ class BookshelfAutoGroupRepository(
 
 
     override suspend fun applyPlan(
     override suspend fun applyPlan(
         plan: BookshelfAutoGroupPlan,
         plan: BookshelfAutoGroupPlan,
+        options: BookshelfAutoGroupOptions,
     ): BookshelfAutoGroupApplyResult = withContext(Dispatchers.IO) {
     ): BookshelfAutoGroupApplyResult = withContext(Dispatchers.IO) {
         database.withTransaction {
         database.withTransaction {
             val groupDao = database.bookGroupDao
             val groupDao = database.bookGroupDao
@@ -60,19 +62,31 @@ class BookshelfAutoGroupRepository(
                 .asSequence()
                 .asSequence()
                 .filter { it.isUserGroup() && it.isPrivate }
                 .filter { it.isUserGroup() && it.isPrivate }
                 .fold(0L) { mask, group -> mask or group.groupId }
                 .fold(0L) { mask, group -> mask or group.groupId }
+            val publicGroupMask = allGroups
+                .asSequence()
+                .filter { it.isUserGroup() && !it.isPrivate }
+                .fold(0L) { mask, group -> mask or group.groupId }
             val existingGroups = allGroups
             val existingGroups = allGroups
                 .filter { it.isUserGroup() && !it.isPrivate }
                 .filter { it.isUserGroup() && !it.isPrivate }
                 .associateBy(BookGroup::groupName)
                 .associateBy(BookGroup::groupName)
                 .toMutableMap()
                 .toMutableMap()
             var createdGroupCount = 0
             var createdGroupCount = 0
             var reusedGroupCount = 0
             var reusedGroupCount = 0
-            var missingBookCount = 0
+            var skippedBookCount = 0
 
 
             // Resolve current rows before creating groups so stale plans cannot create empty groups.
             // Resolve current rows before creating groups so stale plans cannot create empty groups.
             val applicableGroups = plan.groups.mapNotNull { group ->
             val applicableGroups = plan.groups.mapNotNull { group ->
                 val books = group.books.mapNotNull { plannedBook ->
                 val books = group.books.mapNotNull { plannedBook ->
-                    bookDao.getBook(plannedBook.bookUrl).also { entity ->
-                        if (entity == null) missingBookCount++
+                    val entity = bookDao.getBook(plannedBook.bookUrl)
+                    // Re-check membership because the user may have grouped the book after analysis.
+                    val shouldSkip = entity == null || (
+                        options.incrementalOnly && entity.group and publicGroupMask != 0L
+                    )
+                    if (shouldSkip) {
+                        skippedBookCount++
+                        null
+                    } else {
+                        entity
                     }
                     }
                 }
                 }
                 group.takeIf { books.isNotEmpty() }?.let { it to books }
                 group.takeIf { books.isNotEmpty() }?.let { it to books }
@@ -140,7 +154,7 @@ class BookshelfAutoGroupRepository(
                 createdGroupCount = createdGroupCount,
                 createdGroupCount = createdGroupCount,
                 reusedGroupCount = reusedGroupCount,
                 reusedGroupCount = reusedGroupCount,
                 updatedBookCount = updates.size,
                 updatedBookCount = updates.size,
-                ignoredBookCount = plan.ignoredBooks.size + missingBookCount,
+                ignoredBookCount = plan.ignoredBooks.size + skippedBookCount,
             )
             )
         }
         }
     }
     }

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

@@ -1,6 +1,7 @@
 package io.legado.app.domain.gateway
 package io.legado.app.domain.gateway
 
 
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 
 
@@ -9,5 +10,8 @@ interface BookshelfAutoGroupGateway {
     suspend fun loadSource(): BookshelfAutoGroupSource
     suspend fun loadSource(): BookshelfAutoGroupSource
 
 
     /** Applies the complete plan atomically. */
     /** Applies the complete plan atomically. */
-    suspend fun applyPlan(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult
+    suspend fun applyPlan(
+        plan: BookshelfAutoGroupPlan,
+        options: BookshelfAutoGroupOptions,
+    ): BookshelfAutoGroupApplyResult
 }
 }

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

@@ -18,11 +18,13 @@ data class BookshelfAutoGroupSource(
 }
 }
 
 
 data class BookshelfAutoGroupPreflight(
 data class BookshelfAutoGroupPreflight(
+    val analyzedBookCount: Int,
     val effectiveInputCharLimit: Int,
     val effectiveInputCharLimit: Int,
     val estimatedRequestCount: Int,
     val estimatedRequestCount: Int,
 )
 )
 
 
 data class BookshelfAutoGroupOptions(
 data class BookshelfAutoGroupOptions(
+    val incrementalOnly: Boolean = true,
     val includeBookIntro: Boolean = false,
     val includeBookIntro: Boolean = false,
     val enableDeepThinking: Boolean = false,
     val enableDeepThinking: Boolean = false,
 )
 )

+ 6 - 2
app/src/main/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCase.kt

@@ -2,6 +2,7 @@ package io.legado.app.domain.usecase
 
 
 import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
 import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 
 
@@ -9,10 +10,13 @@ class ApplyBookshelfAutoGroupPlanUseCase(
     private val gateway: BookshelfAutoGroupGateway,
     private val gateway: BookshelfAutoGroupGateway,
 ) {
 ) {
 
 
-    suspend fun execute(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult {
+    suspend fun execute(
+        plan: BookshelfAutoGroupPlan,
+        options: BookshelfAutoGroupOptions,
+    ): BookshelfAutoGroupApplyResult {
         val normalizedPlan = normalize(plan)
         val normalizedPlan = normalize(plan)
         require(normalizedPlan.groups.isNotEmpty()) { "No applicable grouping plan" }
         require(normalizedPlan.groups.isNotEmpty()) { "No applicable grouping plan" }
-        return gateway.applyPlan(normalizedPlan)
+        return gateway.applyPlan(normalizedPlan, options)
     }
     }
 
 
     internal fun normalize(
     internal fun normalize(

+ 35 - 14
app/src/main/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCase.kt

@@ -42,14 +42,23 @@ class GenerateBookshelfAutoGroupPlanUseCase(
         options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
         options: BookshelfAutoGroupOptions = BookshelfAutoGroupOptions(),
     ): BookshelfAutoGroupPreflight {
     ): BookshelfAutoGroupPreflight {
         validateSource(source)
         validateSource(source)
+        val analysisSource = source.forAnalysis(options)
+        if (analysisSource.books.isEmpty()) {
+            return BookshelfAutoGroupPreflight(
+                analyzedBookCount = 0,
+                effectiveInputCharLimit = 0,
+                estimatedRequestCount = 0,
+            )
+        }
         val promptText = promptGateway.getPromptText()
         val promptText = promptGateway.getPromptText()
         val preset = resolvePreset()
         val preset = resolvePreset()
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val inputBudget = effectiveInputBudget(preset)
         val inputBudget = effectiveInputBudget(preset)
-        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
-            buildGeneratePrompt(source, books, groupingInstruction, emptyList(), options, promptText)
+        val batches = createBatches(analysisSource, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildGeneratePrompt(analysisSource, books, groupingInstruction, emptyList(), options, promptText)
         }
         }
         return BookshelfAutoGroupPreflight(
         return BookshelfAutoGroupPreflight(
+            analyzedBookCount = analysisSource.bookCount,
             effectiveInputCharLimit = inputBudget.effectiveCharLimit,
             effectiveInputCharLimit = inputBudget.effectiveCharLimit,
             estimatedRequestCount = batches.size,
             estimatedRequestCount = batches.size,
         )
         )
@@ -62,12 +71,14 @@ class GenerateBookshelfAutoGroupPlanUseCase(
         onProgress: (BookshelfAutoGroupProgress) -> Unit = {},
         onProgress: (BookshelfAutoGroupProgress) -> Unit = {},
     ): BookshelfAutoGroupPlan {
     ): BookshelfAutoGroupPlan {
         validateSource(source)
         validateSource(source)
+        val analysisSource = source.forAnalysis(options)
+        if (analysisSource.books.isEmpty()) return BookshelfAutoGroupPlan(emptyList())
         val promptText = promptGateway.getPromptText()
         val promptText = promptGateway.getPromptText()
         val preset = resolvePreset()
         val preset = resolvePreset()
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val inputBudget = effectiveInputBudget(preset)
         val inputBudget = effectiveInputBudget(preset)
-        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
-            buildGeneratePrompt(source, books, groupingInstruction, emptyList(), options, promptText)
+        val batches = createBatches(analysisSource, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildGeneratePrompt(analysisSource, books, groupingInstruction, emptyList(), options, promptText)
         }
         }
         val plans = mutableListOf<BookshelfAutoGroupPlan>()
         val plans = mutableListOf<BookshelfAutoGroupPlan>()
         val proposedGroupNames = linkedSetOf<String>()
         val proposedGroupNames = linkedSetOf<String>()
@@ -78,13 +89,13 @@ class GenerateBookshelfAutoGroupPlanUseCase(
                 inputBudget = inputBudget,
                 inputBudget = inputBudget,
                 systemPrompt = systemPrompt,
                 systemPrompt = systemPrompt,
             ) { names ->
             ) { names ->
-                buildGeneratePrompt(source, batch, groupingInstruction, names, options, promptText)
+                buildGeneratePrompt(analysisSource, batch, groupingInstruction, names, options, promptText)
             }
             }
             val plan = generateBatch(
             val plan = generateBatch(
                 preset = preset,
                 preset = preset,
                 systemPrompt = systemPrompt,
                 systemPrompt = systemPrompt,
                 prompt = buildGeneratePrompt(
                 prompt = buildGeneratePrompt(
-                    source,
+                    analysisSource,
                     batch,
                     batch,
                     groupingInstruction,
                     groupingInstruction,
                     sharedGroupNames,
                     sharedGroupNames,
@@ -92,13 +103,13 @@ class GenerateBookshelfAutoGroupPlanUseCase(
                     promptText,
                     promptText,
                 ),
                 ),
                 books = batch,
                 books = batch,
-                existingGroupNames = source.existingGroupNames.toSet(),
+                existingGroupNames = analysisSource.existingGroupNames.toSet(),
                 options = options,
                 options = options,
             )
             )
             plans += plan
             plans += plan
             plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
             plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
         }
         }
-        return mergePlans(plans, source)
+        return mergePlans(plans, analysisSource)
     }
     }
 
 
     suspend fun revise(
     suspend fun revise(
@@ -110,12 +121,14 @@ class GenerateBookshelfAutoGroupPlanUseCase(
     ): BookshelfAutoGroupPlan {
     ): BookshelfAutoGroupPlan {
         require(instruction.isNotBlank()) { "Revision instruction is required" }
         require(instruction.isNotBlank()) { "Revision instruction is required" }
         validateSource(source)
         validateSource(source)
+        val analysisSource = source.forAnalysis(options)
+        if (analysisSource.books.isEmpty()) return BookshelfAutoGroupPlan(emptyList())
         val promptText = promptGateway.getPromptText()
         val promptText = promptGateway.getPromptText()
         val preset = resolvePreset()
         val preset = resolvePreset()
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val systemPrompt = resolveSystemPrompt(preset, promptText)
         val inputBudget = effectiveInputBudget(preset)
         val inputBudget = effectiveInputBudget(preset)
-        val batches = createBatches(source, batchInputBudget(inputBudget), systemPrompt) { books ->
-            buildRevisePrompt(source, currentPlan, books, instruction, emptyList(), options, promptText)
+        val batches = createBatches(analysisSource, batchInputBudget(inputBudget), systemPrompt) { books ->
+            buildRevisePrompt(analysisSource, currentPlan, books, instruction, emptyList(), options, promptText)
         }
         }
         val plans = mutableListOf<BookshelfAutoGroupPlan>()
         val plans = mutableListOf<BookshelfAutoGroupPlan>()
         val proposedGroupNames = currentPlan.groups
         val proposedGroupNames = currentPlan.groups
@@ -127,13 +140,13 @@ class GenerateBookshelfAutoGroupPlanUseCase(
                 inputBudget = inputBudget,
                 inputBudget = inputBudget,
                 systemPrompt = systemPrompt,
                 systemPrompt = systemPrompt,
             ) { names ->
             ) { names ->
-                buildRevisePrompt(source, currentPlan, batch, instruction, names, options, promptText)
+                buildRevisePrompt(analysisSource, currentPlan, batch, instruction, names, options, promptText)
             }
             }
             val plan = generateBatch(
             val plan = generateBatch(
                 preset = preset,
                 preset = preset,
                 systemPrompt = systemPrompt,
                 systemPrompt = systemPrompt,
                 prompt = buildRevisePrompt(
                 prompt = buildRevisePrompt(
-                    source,
+                    analysisSource,
                     currentPlan,
                     currentPlan,
                     batch,
                     batch,
                     instruction,
                     instruction,
@@ -142,13 +155,13 @@ class GenerateBookshelfAutoGroupPlanUseCase(
                     promptText,
                     promptText,
                 ),
                 ),
                 books = batch,
                 books = batch,
-                existingGroupNames = source.existingGroupNames.toSet(),
+                existingGroupNames = analysisSource.existingGroupNames.toSet(),
                 options = options,
                 options = options,
             )
             )
             plans += plan
             plans += plan
             plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
             plan.groups.mapTo(proposedGroupNames, BookshelfAutoGroupPlanGroup::name)
         }
         }
-        return mergePlans(plans, source)
+        return mergePlans(plans, analysisSource)
     }
     }
 
 
     private suspend fun generateBatch(
     private suspend fun generateBatch(
@@ -205,6 +218,14 @@ class GenerateBookshelfAutoGroupPlanUseCase(
         }
         }
     }
     }
 
 
+    private fun BookshelfAutoGroupSource.forAnalysis(
+        options: BookshelfAutoGroupOptions,
+    ): BookshelfAutoGroupSource {
+        if (!options.incrementalOnly) return this
+        // Keep existing group names for reuse while removing grouped book metadata from AI input.
+        return copy(books = books.filter { it.currentGroupNames.isEmpty() })
+    }
+
     private fun effectiveInputBudget(preset: AiTaskPresetConfig): InputBudget {
     private fun effectiveInputBudget(preset: AiTaskPresetConfig): InputBudget {
         val configuredLimit = preset.runtimeOptions.maxInputChars
         val configuredLimit = preset.runtimeOptions.maxInputChars
             .takeIf { it > 0 }
             .takeIf { it > 0 }

+ 2 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupContract.kt

@@ -26,6 +26,7 @@ data class AiAutoGroupUiState(
     val currentBatch: Int = 0,
     val currentBatch: Int = 0,
     val totalBatches: Int = 0,
     val totalBatches: Int = 0,
     val groupingInstruction: String = "",
     val groupingInstruction: String = "",
+    val incrementalOnly: Boolean = true,
     val includeBookIntro: Boolean = false,
     val includeBookIntro: Boolean = false,
     val enableDeepThinking: Boolean = false,
     val enableDeepThinking: Boolean = false,
     val groups: ImmutableList<AiAutoGroupGroupUi> = persistentListOf(),
     val groups: ImmutableList<AiAutoGroupGroupUi> = persistentListOf(),
@@ -104,6 +105,7 @@ sealed interface AiAutoGroupIntent {
     data class IgnoreBook(val bookUrl: String) : AiAutoGroupIntent
     data class IgnoreBook(val bookUrl: String) : AiAutoGroupIntent
     data class AddGroup(val name: String) : AiAutoGroupIntent
     data class AddGroup(val name: String) : AiAutoGroupIntent
     data class UpdateGroupingInstruction(val instruction: String) : AiAutoGroupIntent
     data class UpdateGroupingInstruction(val instruction: String) : AiAutoGroupIntent
+    data class SetIncrementalOnly(val enabled: Boolean) : AiAutoGroupIntent
     data class SetIncludeBookIntro(val enabled: Boolean) : AiAutoGroupIntent
     data class SetIncludeBookIntro(val enabled: Boolean) : AiAutoGroupIntent
     data class SetDeepThinkingEnabled(val enabled: Boolean) : AiAutoGroupIntent
     data class SetDeepThinkingEnabled(val enabled: Boolean) : AiAutoGroupIntent
     data class UpdateRevisionInstruction(val instruction: String) : AiAutoGroupIntent
     data class UpdateRevisionInstruction(val instruction: String) : AiAutoGroupIntent

+ 40 - 3
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupSheet.kt

@@ -7,10 +7,13 @@ import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.imePadding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.Check
 import androidx.compose.material.icons.filled.Check
@@ -123,6 +126,9 @@ fun AiAutoGroupSheet(
                 onGroupingInstructionChange = {
                 onGroupingInstructionChange = {
                     viewModel.onIntent(AiAutoGroupIntent.UpdateGroupingInstruction(it))
                     viewModel.onIntent(AiAutoGroupIntent.UpdateGroupingInstruction(it))
                 },
                 },
+                onIncrementalOnlyChange = {
+                    viewModel.onIntent(AiAutoGroupIntent.SetIncrementalOnly(it))
+                },
                 onIncludeBookIntroChange = {
                 onIncludeBookIntroChange = {
                     viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(it))
                     viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(it))
                 },
                 },
@@ -171,7 +177,11 @@ fun AiAutoGroupSheet(
         onDismissRequest = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
         onDismissRequest = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
         title = stringResource(R.string.ai_auto_group_confirm_title),
         title = stringResource(R.string.ai_auto_group_confirm_title),
         text = stringResource(
         text = stringResource(
-            R.string.ai_auto_group_confirm_message,
+            if (state.incrementalOnly) {
+                R.string.ai_auto_group_confirm_message_incremental
+            } else {
+                R.string.ai_auto_group_confirm_message
+            },
             state.newGroupCount,
             state.newGroupCount,
             state.assignedBookCount,
             state.assignedBookCount,
             state.ignoredBooks.size,
             state.ignoredBooks.size,
@@ -189,10 +199,17 @@ private fun AutoGroupPreflightContent(
     onDismiss: () -> Unit,
     onDismiss: () -> Unit,
     onAnalyze: () -> Unit,
     onAnalyze: () -> Unit,
     onGroupingInstructionChange: (String) -> Unit,
     onGroupingInstructionChange: (String) -> Unit,
+    onIncrementalOnlyChange: (Boolean) -> Unit,
     onIncludeBookIntroChange: (Boolean) -> Unit,
     onIncludeBookIntroChange: (Boolean) -> Unit,
     onDeepThinkingChange: (Boolean) -> Unit,
     onDeepThinkingChange: (Boolean) -> Unit,
 ) {
 ) {
-    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .imePadding()
+            .verticalScroll(rememberScrollState()),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
         GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
         GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
             Column(
             Column(
                 modifier = Modifier
                 modifier = Modifier
@@ -206,7 +223,13 @@ private fun AutoGroupPreflightContent(
                     fontWeight = FontWeight.SemiBold,
                     fontWeight = FontWeight.SemiBold,
                 )
                 )
                 Text(
                 Text(
-                    text = stringResource(R.string.ai_auto_group_preflight_description),
+                    text = stringResource(
+                        if (state.incrementalOnly) {
+                            R.string.ai_auto_group_preflight_description_incremental
+                        } else {
+                            R.string.ai_auto_group_preflight_description
+                        }
+                    ),
                     style = LegadoTheme.typography.bodyMedium,
                     style = LegadoTheme.typography.bodyMedium,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                     color = LegadoTheme.colorScheme.onSurfaceVariant,
                 )
                 )
@@ -233,6 +256,12 @@ private fun AutoGroupPreflightContent(
         }
         }
 
 
         SplicedColumnGroup(modifier = Modifier.fillMaxWidth()) {
         SplicedColumnGroup(modifier = Modifier.fillMaxWidth()) {
+            SwitchSettingItem(
+                title = stringResource(R.string.ai_auto_group_incremental),
+                description = stringResource(R.string.ai_auto_group_incremental_desc),
+                checked = state.incrementalOnly,
+                onCheckedChange = onIncrementalOnlyChange,
+            )
             SwitchSettingItem(
             SwitchSettingItem(
                 title = stringResource(R.string.ai_auto_group_include_intro),
                 title = stringResource(R.string.ai_auto_group_include_intro),
                 description = stringResource(R.string.ai_auto_group_include_intro_desc),
                 description = stringResource(R.string.ai_auto_group_include_intro_desc),
@@ -272,6 +301,14 @@ private fun AutoGroupPreflightContent(
             )
             )
         }
         }
 
 
+        if (state.incrementalOnly && state.bookCount == 0 && state.error == null) {
+            Text(
+                text = stringResource(R.string.ai_auto_group_no_ungrouped_books),
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.primary,
+            )
+        }
+
         ConfirmDismissButtonsRow(
         ConfirmDismissButtonsRow(
             onDismiss = onDismiss,
             onDismiss = onDismiss,
             onConfirm = onAnalyze,
             onConfirm = onAnalyze,

+ 17 - 5
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt

@@ -60,6 +60,7 @@ class AiAutoGroupViewModel(
             is AiAutoGroupIntent.IgnoreBook -> ignoreBook(intent.bookUrl)
             is AiAutoGroupIntent.IgnoreBook -> ignoreBook(intent.bookUrl)
             is AiAutoGroupIntent.AddGroup -> addGroup(intent.name)
             is AiAutoGroupIntent.AddGroup -> addGroup(intent.name)
             is AiAutoGroupIntent.UpdateGroupingInstruction -> updateGroupingInstruction(intent.instruction)
             is AiAutoGroupIntent.UpdateGroupingInstruction -> updateGroupingInstruction(intent.instruction)
+            is AiAutoGroupIntent.SetIncrementalOnly -> updateIncrementalOption(intent.enabled)
             is AiAutoGroupIntent.SetIncludeBookIntro -> updateBookIntroOption(intent.enabled)
             is AiAutoGroupIntent.SetIncludeBookIntro -> updateBookIntroOption(intent.enabled)
             is AiAutoGroupIntent.SetDeepThinkingEnabled -> updateDeepThinkingOption(intent.enabled)
             is AiAutoGroupIntent.SetDeepThinkingEnabled -> updateDeepThinkingOption(intent.enabled)
             is AiAutoGroupIntent.UpdateRevisionInstruction -> {
             is AiAutoGroupIntent.UpdateRevisionInstruction -> {
@@ -107,6 +108,7 @@ class AiAutoGroupViewModel(
         _uiState.value = AiAutoGroupUiState(
         _uiState.value = AiAutoGroupUiState(
             phase = AiAutoGroupPhase.LoadingSource,
             phase = AiAutoGroupPhase.LoadingSource,
             groupingInstruction = groupingInstruction,
             groupingInstruction = groupingInstruction,
+            incrementalOnly = options.incrementalOnly,
             includeBookIntro = options.includeBookIntro,
             includeBookIntro = options.includeBookIntro,
             enableDeepThinking = options.enableDeepThinking,
             enableDeepThinking = options.enableDeepThinking,
         )
         )
@@ -128,7 +130,7 @@ class AiAutoGroupViewModel(
                 _uiState.update {
                 _uiState.update {
                     it.copy(
                     it.copy(
                         phase = AiAutoGroupPhase.Preflight,
                         phase = AiAutoGroupPhase.Preflight,
-                        bookCount = loaded.bookCount,
+                        bookCount = preflight.analyzedBookCount,
                         groupedBookCount = loaded.groupedBookCount,
                         groupedBookCount = loaded.groupedBookCount,
                         existingGroupCount = loaded.existingGroupNames.size,
                         existingGroupCount = loaded.existingGroupNames.size,
                         effectiveInputCharLimit = preflight.effectiveInputCharLimit,
                         effectiveInputCharLimit = preflight.effectiveInputCharLimit,
@@ -151,16 +153,22 @@ class AiAutoGroupViewModel(
         schedulePreflight()
         schedulePreflight()
     }
     }
 
 
+    private fun updateIncrementalOption(enabled: Boolean) {
+        _uiState.update { it.copy(incrementalOnly = enabled) }
+        // Mode changes affect both the visible book count and request estimate, so refresh immediately.
+        schedulePreflight(debounce = false)
+    }
+
     private fun updateDeepThinkingOption(enabled: Boolean) {
     private fun updateDeepThinkingOption(enabled: Boolean) {
         _uiState.update { it.copy(enableDeepThinking = enabled) }
         _uiState.update { it.copy(enableDeepThinking = enabled) }
     }
     }
 
 
-    private fun schedulePreflight() {
+    private fun schedulePreflight(debounce: Boolean = true) {
         val loaded = source ?: return
         val loaded = source ?: return
         val state = _uiState.value
         val state = _uiState.value
         preflightJob?.cancel()
         preflightJob?.cancel()
         preflightJob = viewModelScope.launch {
         preflightJob = viewModelScope.launch {
-            delay(PREFLIGHT_DEBOUNCE_MILLIS)
+            if (debounce) delay(PREFLIGHT_DEBOUNCE_MILLIS)
             runCatching {
             runCatching {
                 generatePlanUseCase.preflight(
                 generatePlanUseCase.preflight(
                     source = loaded,
                     source = loaded,
@@ -179,6 +187,7 @@ class AiAutoGroupViewModel(
     private fun updatePreflight(preflight: BookshelfAutoGroupPreflight) {
     private fun updatePreflight(preflight: BookshelfAutoGroupPreflight) {
         _uiState.update {
         _uiState.update {
             it.copy(
             it.copy(
+                bookCount = preflight.analyzedBookCount,
                 effectiveInputCharLimit = preflight.effectiveInputCharLimit,
                 effectiveInputCharLimit = preflight.effectiveInputCharLimit,
                 estimatedRequestCount = preflight.estimatedRequestCount,
                 estimatedRequestCount = preflight.estimatedRequestCount,
                 error = null,
                 error = null,
@@ -294,13 +303,15 @@ class AiAutoGroupViewModel(
     }
     }
 
 
     private fun apply() {
     private fun apply() {
-        val plan = _uiState.value.toDomainPlan()
+        val state = _uiState.value
+        val plan = state.toDomainPlan()
+        val options = state.toDomainOptions()
         runningJob?.cancel()
         runningJob?.cancel()
         runningJob = viewModelScope.launch {
         runningJob = viewModelScope.launch {
             _uiState.update {
             _uiState.update {
                 it.copy(phase = AiAutoGroupPhase.Applying, showApplyConfirm = false, error = null)
                 it.copy(phase = AiAutoGroupPhase.Applying, showApplyConfirm = false, error = null)
             }
             }
-            runCatching { applyPlanUseCase.execute(plan) }
+            runCatching { applyPlanUseCase.execute(plan, options) }
                 .onSuccess { result ->
                 .onSuccess { result ->
                     _uiState.update {
                     _uiState.update {
                         it.copy(phase = AiAutoGroupPhase.Result, applyResult = result.toUi())
                         it.copy(phase = AiAutoGroupPhase.Result, applyResult = result.toUi())
@@ -473,6 +484,7 @@ class AiAutoGroupViewModel(
     )
     )
 
 
     private fun AiAutoGroupUiState.toDomainOptions() = BookshelfAutoGroupOptions(
     private fun AiAutoGroupUiState.toDomainOptions() = BookshelfAutoGroupOptions(
+        incrementalOnly = incrementalOnly,
         includeBookIntro = includeBookIntro,
         includeBookIntro = includeBookIntro,
         enableDeepThinking = enableDeepThinking,
         enableDeepThinking = enableDeepThinking,
     )
     )

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

@@ -3012,11 +3012,15 @@
     <string name="ai_auto_group_applying">正在应用分组</string>
     <string name="ai_auto_group_applying">正在应用分组</string>
     <string name="ai_auto_group_confirm_title">确认执行分组方案</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_confirm_message">将创建 %1$d 个分组,调整 %2$d 本书,跳过 %3$d 本书。现有公开分组归属会被替换,私密分组保持不变。</string>
+    <string name="ai_auto_group_confirm_message_incremental">将创建 %1$d 个分组,为 %2$d 本未分组书籍设置分组,跳过 %3$d 本书。已分组书籍和私密分组保持不变。</string>
     <string name="ai_auto_group_execute">执行</string>
     <string name="ai_auto_group_execute">执行</string>
     <string name="ai_auto_group_will_analyze">将分析 %1$d 本书</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_preflight_description">会将书名、作者、分类和当前公开分组发送给已配置的 AI;简介仅在开启下方选项时发送,私密分组中的书籍不会发送。AI 只生成方案,确认前不会修改书架。</string>
+    <string name="ai_auto_group_preflight_description_incremental">仅将未分组书籍的书名、作者和分类发送给已配置的 AI;现有公开分组名称会用于复用,但不会发送已分组或私密分组书籍的数据。简介仅在开启下方选项时发送。</string>
     <string name="ai_auto_group_existing_summary">已有 %1$d 个用户分组,%2$d 本书已有分组。</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_batch_estimate">预计将分为 %1$d 次 AI 请求顺序处理。</string>
+    <string name="ai_auto_group_incremental">增量分组</string>
+    <string name="ai_auto_group_incremental_desc">仅分析尚未加入公开分组的书籍,并保持现有分组归属不变。</string>
     <string name="ai_auto_group_include_intro">参考书籍简介</string>
     <string name="ai_auto_group_include_intro">参考书籍简介</string>
     <string name="ai_auto_group_include_intro_desc">开启后会将书籍简介作为分组参考,并显著增加 Token 消耗。</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">启用深度思考</string>
@@ -3047,6 +3051,7 @@
     <string name="ai_auto_group_message_no_plan">没有可执行的分组方案</string>
     <string name="ai_auto_group_message_no_plan">没有可执行的分组方案</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_group_name_required">分组名称不能为空</string>
     <string name="ai_auto_group_message_group_name_required">分组名称不能为空</string>
+    <string name="ai_auto_group_no_ungrouped_books">没有未分组书籍可分析,可关闭增量分组以分析整个书架。</string>
     <string name="ai_auto_group_error_empty_shelf">书架没有可分析的书籍。</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_missing_model">请先配置默认 AI 模型。</string>
     <string name="ai_auto_group_error_capacity">当前模型或任务的输入容量不足,无法处理该书架。</string>
     <string name="ai_auto_group_error_capacity">当前模型或任务的输入容量不足,无法处理该书架。</string>

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

@@ -2771,11 +2771,15 @@
     <string name="ai_auto_group_applying">正在套用分組</string>
     <string name="ai_auto_group_applying">正在套用分組</string>
     <string name="ai_auto_group_confirm_title">確認執行分組方案</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_confirm_message">將建立 %1$d 個分組,調整 %2$d 本書,略過 %3$d 本書。現有公開分組歸屬會被取代,私密分組保持不變。</string>
+    <string name="ai_auto_group_confirm_message_incremental">將建立 %1$d 個分組,為 %2$d 本未分組書籍設定分組,略過 %3$d 本書。已分組書籍和私密分組保持不變。</string>
     <string name="ai_auto_group_execute">執行</string>
     <string name="ai_auto_group_execute">執行</string>
     <string name="ai_auto_group_will_analyze">將分析 %1$d 本書</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_preflight_description">會將書名、作者、分類和目前公開分組傳送給已設定的 AI;簡介只會在開啟下方選項時傳送,私密分組中的書籍不會傳送。AI 只會產生方案,確認前不會修改書架。</string>
+    <string name="ai_auto_group_preflight_description_incremental">只會將未分組書籍的書名、作者和分類傳送給已設定的 AI;現有公開分組名稱會用於沿用,但不會傳送已分組或私密分組書籍的資料。簡介只會在開啟下方選項時傳送。</string>
     <string name="ai_auto_group_existing_summary">已有 %1$d 個用戶分組,%2$d 本書已有分組。</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_batch_estimate">預計分為 %1$d 次 AI 請求依序處理。</string>
+    <string name="ai_auto_group_incremental">增量分組</string>
+    <string name="ai_auto_group_incremental_desc">只分析尚未加入公開分組的書籍,並保持現有分組歸屬不變。</string>
     <string name="ai_auto_group_include_intro">參考書籍簡介</string>
     <string name="ai_auto_group_include_intro">參考書籍簡介</string>
     <string name="ai_auto_group_include_intro_desc">開啟後會將書籍簡介作為分組參考,並顯著增加 Token 消耗。</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">啟用深度思考</string>
@@ -2806,6 +2810,7 @@
     <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
     <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
     <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
+    <string name="ai_auto_group_no_ungrouped_books">沒有未分組書籍可供分析,可關閉增量分組以分析整個書架。</string>
     <string name="ai_auto_group_error_empty_shelf">書架沒有可分析的書籍。</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_missing_model">請先設定預設 AI 模型。</string>
     <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>
     <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>

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

@@ -2776,11 +2776,15 @@
     <string name="ai_auto_group_applying">正在套用分組</string>
     <string name="ai_auto_group_applying">正在套用分組</string>
     <string name="ai_auto_group_confirm_title">確認執行分組方案</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_confirm_message">將建立 %1$d 個分組,調整 %2$d 本書,略過 %3$d 本書。現有公開分組歸屬會被取代,私密分組保持不變。</string>
+    <string name="ai_auto_group_confirm_message_incremental">將建立 %1$d 個分組,為 %2$d 本未分組書籍設定分組,略過 %3$d 本書。已分組書籍和私密分組保持不變。</string>
     <string name="ai_auto_group_execute">執行</string>
     <string name="ai_auto_group_execute">執行</string>
     <string name="ai_auto_group_will_analyze">將分析 %1$d 本書</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_preflight_description">會將書名、作者、分類和目前公開分組傳送給已設定的 AI;簡介只會在開啟下方選項時傳送,私密分組中的書籍不會傳送。AI 只會產生方案,確認前不會修改書架。</string>
+    <string name="ai_auto_group_preflight_description_incremental">只會將未分組書籍的書名、作者和分類傳送給已設定的 AI;現有公開分組名稱會用於沿用,但不會傳送已分組或私密分組書籍的資料。簡介只會在開啟下方選項時傳送。</string>
     <string name="ai_auto_group_existing_summary">已有 %1$d 個使用者分組,%2$d 本書已有分組。</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_batch_estimate">預計分為 %1$d 次 AI 請求依序處理。</string>
+    <string name="ai_auto_group_incremental">增量分組</string>
+    <string name="ai_auto_group_incremental_desc">只分析尚未加入公開分組的書籍,並保持現有分組歸屬不變。</string>
     <string name="ai_auto_group_include_intro">參考書籍簡介</string>
     <string name="ai_auto_group_include_intro">參考書籍簡介</string>
     <string name="ai_auto_group_include_intro_desc">開啟後會將書籍簡介作為分組參考,並顯著增加 Token 消耗。</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">啟用深度思考</string>
@@ -2811,6 +2815,7 @@
     <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
     <string name="ai_auto_group_message_no_plan">沒有可執行的分組方案</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_cancelled">已取消</string>
     <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
     <string name="ai_auto_group_message_group_name_required">分組名稱不能為空</string>
+    <string name="ai_auto_group_no_ungrouped_books">沒有未分組書籍可供分析,可關閉增量分組以分析整個書架。</string>
     <string name="ai_auto_group_error_empty_shelf">書架沒有可分析的書籍。</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_missing_model">請先設定預設 AI 模型。</string>
     <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>
     <string name="ai_auto_group_error_capacity">目前模型或任務的輸入容量不足,無法處理此書架。</string>

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

@@ -3017,11 +3017,15 @@
     <string name="ai_auto_group_applying">Applying groups</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_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_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_confirm_message_incremental">This will create %1$d groups, group %2$d previously ungrouped books, and skip %3$d books. Already grouped books and private groups remain unchanged.</string>
     <string name="ai_auto_group_execute">Apply</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_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_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_preflight_description_incremental">Only titles, authors, and categories of ungrouped books will be sent to the configured AI. Existing public group names are included for reuse, but data from already grouped or private books is excluded. Introductions are sent only when enabled below.</string>
     <string name="ai_auto_group_existing_summary">%1$d existing groups; %2$d books are already grouped.</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_batch_estimate">The bookshelf will be processed in approximately %1$d sequential AI requests.</string>
+    <string name="ai_auto_group_incremental">Incremental grouping</string>
+    <string name="ai_auto_group_incremental_desc">Analyze only books that are not yet in a public group, leaving existing assignments unchanged.</string>
     <string name="ai_auto_group_include_intro">Reference book introductions</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_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">Enable deep thinking</string>
@@ -3052,6 +3056,7 @@
     <string name="ai_auto_group_message_no_plan">There is no grouping plan to apply</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_cancelled">Cancelled</string>
     <string name="ai_auto_group_message_group_name_required">Group name cannot be empty</string>
     <string name="ai_auto_group_message_group_name_required">Group name cannot be empty</string>
+    <string name="ai_auto_group_no_ungrouped_books">There are no ungrouped books to analyze. Turn off incremental grouping to analyze the full bookshelf.</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_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_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_capacity">The selected model or task input capacity is too small for this bookshelf.</string>

+ 29 - 5
app/src/test/java/io/legado/app/data/repository/BookshelfAutoGroupRepositoryTest.kt

@@ -11,6 +11,7 @@ import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
 import io.legado.app.domain.model.BookshelfAutoGroupErrorReason
 import io.legado.app.domain.model.BookshelfAutoGroupException
 import io.legado.app.domain.model.BookshelfAutoGroupException
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import kotlinx.coroutines.runBlocking
 import kotlinx.coroutines.runBlocking
 import org.junit.After
 import org.junit.After
 import org.junit.Assert.assertEquals
 import org.junit.Assert.assertEquals
@@ -99,7 +100,7 @@ class BookshelfAutoGroupRepositoryTest {
             )
             )
         )
         )
 
 
-        val result = repository.applyPlan(plan)
+        val result = repository.applyPlan(plan, fullOptions)
 
 
         assertEquals(1, result.createdGroupCount)
         assertEquals(1, result.createdGroupCount)
         assertEquals(1, result.reusedGroupCount)
         assertEquals(1, result.reusedGroupCount)
@@ -116,7 +117,7 @@ class BookshelfAutoGroupRepositoryTest {
             groups = listOf(group("missing", "Missing", listOf(planBook("deleted"))))
             groups = listOf(group("missing", "Missing", listOf(planBook("deleted"))))
         )
         )
 
 
-        val result = repository.applyPlan(plan)
+        val result = repository.applyPlan(plan, fullOptions)
 
 
         assertEquals(0, result.createdGroupCount)
         assertEquals(0, result.createdGroupCount)
         assertEquals(1, result.ignoredBookCount)
         assertEquals(1, result.ignoredBookCount)
@@ -135,7 +136,8 @@ class BookshelfAutoGroupRepositoryTest {
         repository.applyPlan(
         repository.applyPlan(
             BookshelfAutoGroupPlan(
             BookshelfAutoGroupPlan(
                 groups = listOf(group("new", "New public", listOf(planBook("book-1"))))
                 groups = listOf(group("new", "New public", listOf(planBook("book-1"))))
-            )
+            ),
+            fullOptions,
         )
         )
 
 
         assertEquals(6L, database.bookDao.getBook("book-1")?.group)
         assertEquals(6L, database.bookDao.getBook("book-1")?.group)
@@ -157,7 +159,7 @@ class BookshelfAutoGroupRepositoryTest {
             groups = listOf(group("new", "New", listOf(planBook("book-1"))))
             groups = listOf(group("new", "New", listOf(planBook("book-1"))))
         )
         )
 
 
-        val result = runCatching { repository.applyPlan(plan) }
+        val result = runCatching { repository.applyPlan(plan, fullOptions) }
 
 
         assertTrue(result.isFailure)
         assertTrue(result.isFailure)
         assertTrue(database.bookGroupDao.all.isEmpty())
         assertTrue(database.bookGroupDao.all.isEmpty())
@@ -176,7 +178,8 @@ class BookshelfAutoGroupRepositoryTest {
             repository.applyPlan(
             repository.applyPlan(
                 BookshelfAutoGroupPlan(
                 BookshelfAutoGroupPlan(
                     groups = listOf(group("new", "New", listOf(planBook("book-1"))))
                     groups = listOf(group("new", "New", listOf(planBook("book-1"))))
-                )
+                ),
+                fullOptions,
             )
             )
         }.exceptionOrNull()
         }.exceptionOrNull()
 
 
@@ -189,10 +192,31 @@ class BookshelfAutoGroupRepositoryTest {
         assertEquals(1L, database.bookDao.getBook("book-1")?.group)
         assertEquals(1L, database.bookDao.getBook("book-1")?.group)
     }
     }
 
 
+    @Test
+    fun `incremental apply skips a book grouped after analysis`() = runBlocking {
+        database.bookGroupDao.insert(BookGroup(groupId = 1L, groupName = "Manually grouped"))
+        database.bookDao.insert(book("book-1").copy(group = 1L))
+
+        val result = repository.applyPlan(
+            BookshelfAutoGroupPlan(
+                groups = listOf(group("new", "New", listOf(planBook("book-1"))))
+            ),
+            BookshelfAutoGroupOptions(incrementalOnly = true),
+        )
+
+        assertEquals(0, result.createdGroupCount)
+        assertEquals(0, result.updatedBookCount)
+        assertEquals(1, result.ignoredBookCount)
+        assertEquals(1L, database.bookDao.getBook("book-1")?.group)
+        assertTrue(database.bookGroupDao.all.none { it.groupName == "New" })
+    }
+
     private fun book(url: String) = Book(bookUrl = url, name = url, author = "Author")
     private fun book(url: String) = Book(bookUrl = url, name = url, author = "Author")
 
 
     private fun planBook(url: String) = BookshelfAutoGroupPlanBook(url, url, "", emptyList(), "")
     private fun planBook(url: String) = BookshelfAutoGroupPlanBook(url, url, "", emptyList(), "")
 
 
     private fun group(key: String, name: String, books: List<BookshelfAutoGroupPlanBook>) =
     private fun group(key: String, name: String, books: List<BookshelfAutoGroupPlanBook>) =
         BookshelfAutoGroupPlanGroup(key, name, "", false, books)
         BookshelfAutoGroupPlanGroup(key, name, "", false, books)
+
+    private val fullOptions = BookshelfAutoGroupOptions(incrementalOnly = false)
 }
 }

+ 10 - 2
app/src/test/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCaseTest.kt

@@ -3,6 +3,7 @@ package io.legado.app.domain.usecase
 import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
 import io.legado.app.domain.gateway.BookshelfAutoGroupGateway
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupIgnoredBook
 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.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanBook
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
 import io.legado.app.domain.model.BookshelfAutoGroupPlanGroup
@@ -32,7 +33,8 @@ class ApplyBookshelfAutoGroupPlanUseCaseTest {
                     ignored("url-3"),
                     ignored("url-3"),
                     ignored("url-3"),
                     ignored("url-3"),
                 ),
                 ),
-            )
+            ),
+            BookshelfAutoGroupOptions(incrementalOnly = true),
         )
         )
 
 
         val applied = gateway.appliedPlan!!
         val applied = gateway.appliedPlan!!
@@ -40,15 +42,21 @@ class ApplyBookshelfAutoGroupPlanUseCaseTest {
         assertEquals("Fantasy", applied.groups.single().name)
         assertEquals("Fantasy", applied.groups.single().name)
         assertEquals(listOf("url-1", "url-2"), applied.groups.single().books.map { it.bookUrl })
         assertEquals(listOf("url-1", "url-2"), applied.groups.single().books.map { it.bookUrl })
         assertEquals(listOf("url-3"), applied.ignoredBooks.map { it.bookUrl })
         assertEquals(listOf("url-3"), applied.ignoredBooks.map { it.bookUrl })
+        assertEquals(true, gateway.appliedOptions?.incrementalOnly)
     }
     }
 
 
     private class RecordingGateway : BookshelfAutoGroupGateway {
     private class RecordingGateway : BookshelfAutoGroupGateway {
         var appliedPlan: BookshelfAutoGroupPlan? = null
         var appliedPlan: BookshelfAutoGroupPlan? = null
+        var appliedOptions: BookshelfAutoGroupOptions? = null
 
 
         override suspend fun loadSource() = BookshelfAutoGroupSource(emptyList(), emptyList())
         override suspend fun loadSource() = BookshelfAutoGroupSource(emptyList(), emptyList())
 
 
-        override suspend fun applyPlan(plan: BookshelfAutoGroupPlan): BookshelfAutoGroupApplyResult {
+        override suspend fun applyPlan(
+            plan: BookshelfAutoGroupPlan,
+            options: BookshelfAutoGroupOptions,
+        ): BookshelfAutoGroupApplyResult {
             appliedPlan = plan
             appliedPlan = plan
+            appliedOptions = options
             return BookshelfAutoGroupApplyResult(0, 0, 0, plan.ignoredBooks.size)
             return BookshelfAutoGroupApplyResult(0, 0, 0, plan.ignoredBooks.size)
         }
         }
     }
     }

+ 70 - 1
app/src/test/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCaseTest.kt

@@ -210,6 +210,72 @@ class GenerateBookshelfAutoGroupPlanUseCaseTest {
         assertEquals("Unexpected", plan.groups.single().books.single().reason)
         assertEquals("Unexpected", plan.groups.single().books.single().reason)
     }
     }
 
 
+    @Test
+    fun `incremental mode excludes grouped books but keeps existing group names`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(
+                book(1, name = "Ungrouped book"),
+                book(2, name = "Already grouped").copy(currentGroupNames = listOf("Existing")),
+            ),
+            existingGroupNames = listOf("Existing"),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(responseForIds(listOf("b1")))
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+
+        val preflight = useCase.preflight(source)
+        useCase.generate(source, "")
+
+        val prompt = aiGateway.requests.single().messages.last().content
+        assertEquals(1, preflight.analyzedBookCount)
+        assertTrue(prompt.contains("Ungrouped book"))
+        assertFalse(prompt.contains("Already grouped"))
+        assertTrue(prompt.contains("Existing groups: Existing"))
+    }
+
+    @Test
+    fun `full mode includes books that already have public groups`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(
+                book(1, name = "Ungrouped book"),
+                book(2, name = "Already grouped").copy(currentGroupNames = listOf("Existing")),
+            ),
+            existingGroupNames = listOf("Existing"),
+        )
+        val aiGateway = RecordingAiTextGateway(
+            mutableListOf(responseForIds(listOf("b1", "b2")))
+        )
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+        val options = BookshelfAutoGroupOptions(incrementalOnly = false)
+
+        val preflight = useCase.preflight(source, options = options)
+        useCase.generate(source, "", options)
+
+        val prompt = aiGateway.requests.single().messages.last().content
+        assertEquals(2, preflight.analyzedBookCount)
+        assertTrue(prompt.contains("Already grouped"))
+        assertTrue(prompt.contains("\"currentGroups\":[\"Existing\"]"))
+    }
+
+    @Test
+    fun `incremental preflight returns zero when every book is already grouped`() = runBlocking {
+        val source = BookshelfAutoGroupSource(
+            books = listOf(
+                book(1, name = "Already grouped").copy(currentGroupNames = listOf("Existing"))
+            ),
+            existingGroupNames = listOf("Existing"),
+        )
+        val aiGateway = RecordingAiTextGateway(mutableListOf())
+        val useCase = useCase(source, aiGateway, maxInputChars = 10_000)
+
+        val preflight = useCase.preflight(source)
+
+        assertEquals(0, preflight.analyzedBookCount)
+        assertEquals(0, preflight.estimatedRequestCount)
+        assertTrue(aiGateway.requests.isEmpty())
+    }
+
     @Test
     @Test
     fun `localized prompt is used end to end and raw kind remains unchanged`() = runBlocking {
     fun `localized prompt is used end to end and raw kind remains unchanged`() = runBlocking {
         val rawKind = "0.0分,都市,连载中,15小时前"
         val rawKind = "0.0分,都市,连载中,15小时前"
@@ -248,7 +314,10 @@ class GenerateBookshelfAutoGroupPlanUseCaseTest {
     ) = GenerateBookshelfAutoGroupPlanUseCase(
     ) = GenerateBookshelfAutoGroupPlanUseCase(
         gateway = object : BookshelfAutoGroupGateway {
         gateway = object : BookshelfAutoGroupGateway {
             override suspend fun loadSource() = source
             override suspend fun loadSource() = source
-            override suspend fun applyPlan(plan: BookshelfAutoGroupPlan) =
+            override suspend fun applyPlan(
+                plan: BookshelfAutoGroupPlan,
+                options: BookshelfAutoGroupOptions,
+            ) =
                 BookshelfAutoGroupApplyResult(0, 0, 0, 0)
                 BookshelfAutoGroupApplyResult(0, 0, 0, 0)
         },
         },
         promptGateway = object : BookshelfAutoGroupPromptGateway {
         promptGateway = object : BookshelfAutoGroupPromptGateway {

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

@@ -19,6 +19,7 @@ import io.legado.app.domain.model.AiTaskRuntimeOptions
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupApplyResult
 import io.legado.app.domain.model.BookshelfAutoGroupBook
 import io.legado.app.domain.model.BookshelfAutoGroupBook
+import io.legado.app.domain.model.BookshelfAutoGroupOptions
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPlan
 import io.legado.app.domain.model.BookshelfAutoGroupPromptText
 import io.legado.app.domain.model.BookshelfAutoGroupPromptText
 import io.legado.app.domain.model.BookshelfAutoGroupSource
 import io.legado.app.domain.model.BookshelfAutoGroupSource
@@ -53,6 +54,46 @@ class AiAutoGroupViewModelTest {
         assertEquals(2, viewModel.uiState.value.estimatedRequestCount)
         assertEquals(2, viewModel.uiState.value.estimatedRequestCount)
     }
     }
 
 
+    @Test
+    fun `incremental mode updates the preflight count and can switch to full mode`() {
+        val viewModel = viewModel(source(count = 2, groupedCount = 1), mutableListOf())
+
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertTrue(viewModel.uiState.value.incrementalOnly)
+        assertEquals(1, viewModel.uiState.value.bookCount)
+
+        viewModel.onIntent(AiAutoGroupIntent.SetIncrementalOnly(false))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertFalse(viewModel.uiState.value.incrementalOnly)
+        assertEquals(2, viewModel.uiState.value.bookCount)
+    }
+
+    @Test
+    fun `all grouped bookshelf remains in preflight with zero incremental candidates`() {
+        val viewModel = viewModel(source(count = 2, groupedCount = 2), mutableListOf())
+
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertEquals(AiAutoGroupPhase.Preflight, viewModel.uiState.value.phase)
+        assertEquals(0, viewModel.uiState.value.bookCount)
+        assertEquals(null, viewModel.uiState.value.error)
+    }
+
+    @Test
+    fun `empty bookshelf still reports the terminal empty source error`() {
+        val viewModel = viewModel(source(count = 0), mutableListOf())
+
+        viewModel.onIntent(AiAutoGroupIntent.StartSession(1L))
+        Shadows.shadowOf(Looper.getMainLooper()).idle()
+
+        assertEquals(AiAutoGroupPhase.Error, viewModel.uiState.value.phase)
+        assertEquals(AiAutoGroupErrorUi.EmptyBookshelf, viewModel.uiState.value.error)
+    }
+
     @Test
     @Test
     fun `analysis options default off and survive restart within the session`() {
     fun `analysis options default off and survive restart within the session`() {
         val viewModel = viewModel(source(1), mutableListOf())
         val viewModel = viewModel(source(1), mutableListOf())
@@ -61,7 +102,9 @@ class AiAutoGroupViewModelTest {
 
 
         assertFalse(viewModel.uiState.value.includeBookIntro)
         assertFalse(viewModel.uiState.value.includeBookIntro)
         assertFalse(viewModel.uiState.value.enableDeepThinking)
         assertFalse(viewModel.uiState.value.enableDeepThinking)
+        assertTrue(viewModel.uiState.value.incrementalOnly)
 
 
+        viewModel.onIntent(AiAutoGroupIntent.SetIncrementalOnly(false))
         viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(true))
         viewModel.onIntent(AiAutoGroupIntent.SetIncludeBookIntro(true))
         viewModel.onIntent(AiAutoGroupIntent.SetDeepThinkingEnabled(true))
         viewModel.onIntent(AiAutoGroupIntent.SetDeepThinkingEnabled(true))
         viewModel.onIntent(AiAutoGroupIntent.Restart)
         viewModel.onIntent(AiAutoGroupIntent.Restart)
@@ -69,10 +112,12 @@ class AiAutoGroupViewModelTest {
 
 
         assertTrue(viewModel.uiState.value.includeBookIntro)
         assertTrue(viewModel.uiState.value.includeBookIntro)
         assertTrue(viewModel.uiState.value.enableDeepThinking)
         assertTrue(viewModel.uiState.value.enableDeepThinking)
+        assertFalse(viewModel.uiState.value.incrementalOnly)
 
 
         viewModel.onIntent(AiAutoGroupIntent.CloseSession)
         viewModel.onIntent(AiAutoGroupIntent.CloseSession)
         assertFalse(viewModel.uiState.value.includeBookIntro)
         assertFalse(viewModel.uiState.value.includeBookIntro)
         assertFalse(viewModel.uiState.value.enableDeepThinking)
         assertFalse(viewModel.uiState.value.enableDeepThinking)
+        assertTrue(viewModel.uiState.value.incrementalOnly)
     }
     }
 
 
     @Test
     @Test
@@ -133,7 +178,7 @@ class AiAutoGroupViewModelTest {
         outputSchema = "{\"groups\":[],\"ignoredBooks\":[]}",
         outputSchema = "{\"groups\":[],\"ignoredBooks\":[]}",
     )
     )
 
 
-    private fun source(count: Int) = BookshelfAutoGroupSource(
+    private fun source(count: Int, groupedCount: Int = 0) = BookshelfAutoGroupSource(
         books = (1..count).map { index ->
         books = (1..count).map { index ->
             BookshelfAutoGroupBook(
             BookshelfAutoGroupBook(
                 bookUrl = "url-$index",
                 bookUrl = "url-$index",
@@ -141,10 +186,10 @@ class AiAutoGroupViewModelTest {
                 author = "Author",
                 author = "Author",
                 intro = "Intro",
                 intro = "Intro",
                 kind = "Kind",
                 kind = "Kind",
-                currentGroupNames = emptyList(),
+                currentGroupNames = if (index <= groupedCount) listOf("Existing") else emptyList(),
             )
             )
         },
         },
-        existingGroupNames = emptyList(),
+        existingGroupNames = if (groupedCount > 0) listOf("Existing") else emptyList(),
     )
     )
 
 
     private fun preset(): AiTaskPresetConfig {
     private fun preset(): AiTaskPresetConfig {
@@ -189,7 +234,10 @@ class AiAutoGroupViewModelTest {
         private val source: BookshelfAutoGroupSource,
         private val source: BookshelfAutoGroupSource,
     ) : BookshelfAutoGroupGateway {
     ) : BookshelfAutoGroupGateway {
         override suspend fun loadSource() = source
         override suspend fun loadSource() = source
-        override suspend fun applyPlan(plan: BookshelfAutoGroupPlan) =
+        override suspend fun applyPlan(
+            plan: BookshelfAutoGroupPlan,
+            options: BookshelfAutoGroupOptions,
+        ) =
             BookshelfAutoGroupApplyResult(0, 0, 0, 0)
             BookshelfAutoGroupApplyResult(0, 0, 0, 0)
     }
     }