Sfoglia il codice sorgente

feat: 在书架的分组管理中添加“AI自动分组”入口

lyc 3 mesi fa
parent
commit
dd67e49ea4

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

@@ -156,6 +156,7 @@ import io.legado.app.domain.usecase.AiTaskManager
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
 import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
+import io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
@@ -170,6 +171,7 @@ import io.legado.app.domain.usecase.DeleteBooksUseCase
 import io.legado.app.domain.usecase.ExploreBooksUseCase
 import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
+import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
 import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
@@ -275,6 +277,7 @@ import io.legado.app.ui.login.SourceLoginViewModel
 import io.legado.app.ui.main.MainRouteSearchContent
 import io.legado.app.ui.main.MainViewModel
 import io.legado.app.ui.main.bookshelf.BookshelfViewModel
+import io.legado.app.ui.main.bookshelf.autoGroup.AiAutoGroupViewModel
 import io.legado.app.ui.main.explore.ExploreViewModel
 import io.legado.app.ui.main.home.HomeViewModel
 import io.legado.app.ui.main.homepage.HomepageViewModel
@@ -464,6 +467,8 @@ val appModule = module {
     singleOf(::IdentifyBookCharactersUseCase)
     singleOf(::GenerateChapterSummaryUseCase)
     singleOf(::AiTextFactoryUseCase)
+    singleOf(::GenerateBookshelfAutoGroupPlanUseCase)
+    singleOf(::ApplyBookshelfAutoGroupPlanUseCase)
     singleOf(::CleanSelectedTextUseCase)
     singleOf(::SaveBookContentProcessUseCase)
     singleOf(::ReplaceRuleRepository)
@@ -513,6 +518,7 @@ val appModule = module {
     viewModelOf(::ExploreShowViewModel)
     viewModelOf(::MyViewModel)
     viewModelOf(::BookshelfViewModel)
+    viewModelOf(::AiAutoGroupViewModel)
     viewModelOf(::MainViewModel)
     viewModelOf(::HomeViewModel)
     viewModelOf(::HomepageViewModel)

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

@@ -27,6 +27,7 @@ object AiTaskType {
     const val REWRITE_TEXT = "rewrite_text"
     const val ANALYZE_SPEECH = "analyze_speech"
     const val IDENTIFY_CHARACTERS = "identify_characters"
+    const val BOOKSHELF_AUTO_GROUP = "bookshelf_auto_group"
 }
 
 object AiPromptTemplate {

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

@@ -0,0 +1,56 @@
+package io.legado.app.domain.model
+
+data class BookshelfAutoGroupBook(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val intro: String,
+    val kind: String,
+    val currentGroupNames: List<String>,
+)
+
+data class BookshelfAutoGroupSource(
+    val books: List<BookshelfAutoGroupBook>,
+    val existingGroupNames: List<String>,
+) {
+    val bookCount: Int get() = books.size
+    val groupedBookCount: Int get() = books.count { it.currentGroupNames.isNotEmpty() }
+}
+
+data class BookshelfAutoGroupPlan(
+    val groups: List<BookshelfAutoGroupPlanGroup>,
+    val ignoredBooks: List<BookshelfAutoGroupIgnoredBook> = emptyList(),
+) {
+    val assignedBookCount: Int get() = groups.sumOf { it.books.size }
+    val newGroupCount: Int get() = groups.count { !it.reuseExisting }
+}
+
+data class BookshelfAutoGroupPlanGroup(
+    val key: String,
+    val name: String,
+    val description: String,
+    val reuseExisting: Boolean,
+    val books: List<BookshelfAutoGroupPlanBook>,
+)
+
+data class BookshelfAutoGroupPlanBook(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val currentGroupNames: List<String>,
+    val reason: String,
+)
+
+data class BookshelfAutoGroupIgnoredBook(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val reason: String,
+)
+
+data class BookshelfAutoGroupApplyResult(
+    val createdGroupCount: Int,
+    val reusedGroupCount: Int,
+    val updatedBookCount: Int,
+    val ignoredBookCount: Int,
+)

+ 75 - 0
app/src/main/java/io/legado/app/domain/usecase/ApplyBookshelfAutoGroupPlanUseCase.kt

@@ -0,0 +1,75 @@
+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.model.BookshelfAutoGroupApplyResult
+import io.legado.app.domain.model.BookshelfAutoGroupPlan
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ApplyBookshelfAutoGroupPlanUseCase(
+    private val database: AppDatabase,
+) {
+
+    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,
+                )
+            }
+        }
+}

+ 324 - 0
app/src/main/java/io/legado/app/domain/usecase/GenerateBookshelfAutoGroupPlanUseCase.kt

@@ -0,0 +1,324 @@
+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 io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.BookshelfAutoGroupBook
+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 io.legado.app.help.book.isNotShelf
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.util.UUID
+
+class GenerateBookshelfAutoGroupPlanUseCase(
+    private val database: AppDatabase,
+    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,
+        )
+    }
+
+    suspend fun generate(
+        source: BookshelfAutoGroupSource,
+        groupingInstruction: String,
+    ): 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)),
+                ),
+                params = preset.params,
+            )
+        ).getOrThrow().text
+        return parseAndSanitizePlan(response, source)
+    }
+
+    suspend fun revise(
+        source: BookshelfAutoGroupSource,
+        currentPlan: BookshelfAutoGroupPlan,
+        instruction: String,
+    ): 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)),
+                ),
+                params = preset.params,
+            )
+        ).getOrThrow().text
+        return parseAndSanitizePlan(response, source)
+    }
+
+    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)
+    }
+
+    private fun buildSystemPrompt(): String {
+        return """
+            你是阅读 App 的书架整理助手。
+            你的任务是根据书名、作者、分类、简介和当前分组,生成可由用户确认的书架分组方案。
+            只输出 JSON,不要 Markdown,不要代码块,不要解释。
+            不要虚构 bookUrl,只能使用用户输入中存在的 bookUrl。
+            分组名称使用简短中文,避免“其他”“综合”等过泛名称,除非确实无法判断。
+            可以复用已有分组名称;也可以提出新分组。
+            每本书最多放入一个建议分组。无法判断的书放入 ignoredBooks。
+        """.trimIndent()
+    }
+
+    private fun buildGeneratePrompt(
+        source: BookshelfAutoGroupSource,
+        groupingInstruction: String,
+    ): String {
+        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() }))
+        }
+    }
+
+    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 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,
+                )
+            }
+        }
+
+        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
+            }
+        }
+
+        val missingIgnored = source.books
+            .filterNot { it.bookUrl in assignedBookUrls }
+            .map {
+                BookshelfAutoGroupIgnoredBook(
+                    bookUrl = it.bookUrl,
+                    name = it.name,
+                    author = it.author,
+                    reason = "AI 未给出明确分组",
+                )
+            }
+
+        return BookshelfAutoGroupPlan(
+            groups = groups.mergeSameName(existingNames),
+            ignoredBooks = ignored + missingIgnored,
+        )
+    }
+
+    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 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 Book.groupingIntro(): String {
+        return (customIntro ?: intro ?: listIntro).orEmpty()
+            .replace(Regex("\\s+"), " ")
+            .take(MAX_INTRO_CHARS)
+    }
+
+    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 BookshelfAutoGroupPlan.toPromptJson(): String {
+        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,
+                            )
+                        },
+                    )
+                },
+                "ignoredBooks" to ignoredBooks.map { book ->
+                    mapOf(
+                        "bookUrl" to book.bookUrl,
+                        "reason" to book.reason,
+                    )
+                },
+            )
+        )
+    }
+
+    private fun JsonObject.string(name: String): String? {
+        return get(name)?.takeIf { !it.isJsonNull }?.asString
+    }
+
+    private fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
+        get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
+    }.getOrNull()
+
+    private fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? {
+        return takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
+    }
+
+    private companion object {
+        const val MAX_INTRO_CHARS = 320
+
+        val OUTPUT_SCHEMA = """
+            {
+              "groups": [
+                {
+                  "name": "分组名称",
+                  "description": "分组依据",
+                  "books": [
+                    {"bookUrl": "输入中的 bookUrl", "reason": "为什么放入该分组"}
+                  ]
+                }
+              ],
+              "ignoredBooks": [
+                {"bookUrl": "输入中的 bookUrl", "reason": "无法判断原因"}
+              ]
+            }
+        """.trimIndent()
+    }
+}

+ 19 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/GroupManageSheet.kt

@@ -23,6 +23,7 @@ import androidx.compose.runtime.collectAsState
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.runtime.setValue
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.platform.LocalContext
@@ -34,6 +35,7 @@ import io.legado.app.ui.book.group.GroupDeleteAction
 import io.legado.app.ui.book.group.GroupEditContent
 import io.legado.app.ui.book.group.GroupResetCoverAction
 import io.legado.app.ui.book.group.GroupViewModel
+import io.legado.app.ui.main.bookshelf.autoGroup.AiAutoGroupSheet
 import io.legado.app.ui.tagGroupRule.TagGroupRuleEditSheet
 import io.legado.app.ui.tagGroupRule.TagGroupRuleIntent
 import io.legado.app.ui.tagGroupRule.TagGroupRuleViewModel
@@ -65,6 +67,8 @@ fun GroupManageSheet(
 
     var editingTagRule by remember { mutableStateOf<io.legado.app.data.entities.TagGroupRule?>(null) }
     var showTagRuleEdit by remember { mutableStateOf(false) }
+    var showAiAutoGroup by remember { mutableStateOf(false) }
+    var aiAutoGroupSessionKey by rememberSaveable { mutableStateOf(0L) }
 
     var editingGroupTagRule by remember { mutableStateOf<io.legado.app.data.entities.TagGroupRule?>(null) }
 
@@ -135,6 +139,15 @@ fun GroupManageSheet(
                                 isEditing = true
                             }
                         )
+                        RoundDropdownMenuItem(
+                            text = "AI自动分组",
+                            leadingIcon = { Icon(Icons.Default.AutoAwesome, null) },
+                            onClick = {
+                                showMenu = false
+                                aiAutoGroupSessionKey += 1L
+                                showAiAutoGroup = true
+                            }
+                        )
                         RoundDropdownMenuItem(
                             text = stringResource(R.string.tag_group_add_rule),
                             leadingIcon = { Icon(Icons.Default.Add, null) },
@@ -240,5 +253,11 @@ fun GroupManageSheet(
             },
             onPaste = { tagGroupRuleViewModel.pasteRule() }
         )
+
+        AiAutoGroupSheet(
+            show = showAiAutoGroup,
+            sessionKey = aiAutoGroupSessionKey,
+            onDismissRequest = { showAiAutoGroup = false }
+        )
     }
 }

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

@@ -0,0 +1,84 @@
+package io.legado.app.ui.main.bookshelf.autoGroup
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+enum class AiAutoGroupPhase {
+    LoadingSource,
+    Preflight,
+    Analyzing,
+    Reviewing,
+    Revising,
+    Applying,
+    Result,
+    Error,
+}
+
+@Stable
+data class AiAutoGroupUiState(
+    val phase: AiAutoGroupPhase = AiAutoGroupPhase.Preflight,
+    val bookCount: Int = 0,
+    val groupedBookCount: Int = 0,
+    val existingGroupCount: Int = 0,
+    val groupingInstruction: String = "",
+    val groups: ImmutableList<AiAutoGroupGroupUi> = persistentListOf(),
+    val ignoredBooks: ImmutableList<AiAutoGroupIgnoredBookUi> = persistentListOf(),
+    val revisionInstruction: String = "",
+    val resultText: String? = null,
+    val errorMessage: String? = 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 AiAutoGroupGroupUi(
+    val key: String,
+    val name: String,
+    val description: String,
+    val reuseExisting: Boolean,
+    val books: ImmutableList<AiAutoGroupBookUi> = persistentListOf(),
+)
+
+@Stable
+data class AiAutoGroupBookUi(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val currentGroupNames: ImmutableList<String> = persistentListOf(),
+    val reason: String,
+)
+
+@Stable
+data class AiAutoGroupIgnoredBookUi(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val reason: String,
+)
+
+sealed interface AiAutoGroupIntent {
+    data class StartSession(val sessionKey: Long) : AiAutoGroupIntent
+    data object CloseSession : AiAutoGroupIntent
+    data object Analyze : AiAutoGroupIntent
+    data object DismissApplyConfirm : AiAutoGroupIntent
+    data object RequestApply : AiAutoGroupIntent
+    data object ConfirmApply : AiAutoGroupIntent
+    data object Restart : AiAutoGroupIntent
+    data object CancelRunning : AiAutoGroupIntent
+    data class RenameGroup(val groupKey: String, val name: String) : AiAutoGroupIntent
+    data class RemoveGroup(val groupKey: String) : AiAutoGroupIntent
+    data class MoveBook(val bookUrl: String, val targetGroupKey: String) : AiAutoGroupIntent
+    data class IgnoreBook(val bookUrl: String) : AiAutoGroupIntent
+    data class AddGroup(val name: String) : AiAutoGroupIntent
+    data class UpdateGroupingInstruction(val instruction: String) : AiAutoGroupIntent
+    data class UpdateRevisionInstruction(val instruction: String) : AiAutoGroupIntent
+    data object Revise : AiAutoGroupIntent
+}
+
+sealed interface AiAutoGroupEffect {
+    data class ShowMessage(val message: String) : AiAutoGroupEffect
+    data object Applied : AiAutoGroupEffect
+}

+ 562 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupSheet.kt

@@ -0,0 +1,562 @@
+package io.legado.app.ui.main.bookshelf.autoGroup
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.PlayArrow
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.CircularProgressIndicator
+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.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppTextField
+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
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+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.utils.toastOnUi
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun AiAutoGroupSheet(
+    show: Boolean,
+    sessionKey: Long,
+    onDismissRequest: () -> Unit,
+    viewModel: AiAutoGroupViewModel = koinViewModel(),
+) {
+    val state by viewModel.uiState.collectAsState()
+    val context = androidx.compose.ui.platform.LocalContext.current
+    val closeSheet = {
+        if (state.phase == AiAutoGroupPhase.Applying) {
+            context.toastOnUi("正在应用分组,请稍候")
+        } else {
+            viewModel.onIntent(AiAutoGroupIntent.CloseSession)
+            onDismissRequest()
+        }
+    }
+
+    LaunchedEffect(show, sessionKey) {
+        if (show) {
+            viewModel.onIntent(AiAutoGroupIntent.StartSession(sessionKey))
+        }
+    }
+
+    LaunchedEffect(Unit) {
+        viewModel.effects.collect { effect ->
+            when (effect) {
+                is AiAutoGroupEffect.ShowMessage -> context.toastOnUi(effect.message)
+                AiAutoGroupEffect.Applied -> context.toastOnUi("AI 自动分组已完成")
+            }
+        }
+    }
+
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = closeSheet,
+        title = "AI 自动分组",
+        endAction = {
+            when (state.phase) {
+                AiAutoGroupPhase.Reviewing -> MediumTonalButton(
+                    onClick = { viewModel.onIntent(AiAutoGroupIntent.RequestApply) },
+                    icon = Icons.Default.Check,
+                    contentDescription = "确认执行",
+                    enabled = state.assignedBookCount > 0,
+                )
+
+                AiAutoGroupPhase.Result -> MediumPlainButton(
+                    onClick = closeSheet,
+                    icon = Icons.Default.Close,
+                    contentDescription = "关闭",
+                )
+
+                else -> Unit
+            }
+        }
+    ) {
+        when (state.phase) {
+            AiAutoGroupPhase.LoadingSource -> AutoGroupProgressContent("正在读取书架")
+            AiAutoGroupPhase.Preflight -> AutoGroupPreflightContent(
+                state = state,
+                onDismiss = closeSheet,
+                onAnalyze = { viewModel.onIntent(AiAutoGroupIntent.Analyze) },
+                onGroupingInstructionChange = {
+                    viewModel.onIntent(AiAutoGroupIntent.UpdateGroupingInstruction(it))
+                },
+            )
+
+            AiAutoGroupPhase.Analyzing -> AutoGroupProgressContent(
+                text = "正在分析书架",
+                onCancel = { viewModel.onIntent(AiAutoGroupIntent.CancelRunning) },
+            )
+            AiAutoGroupPhase.Revising -> AutoGroupProgressContent(
+                text = "正在调整方案",
+                onCancel = { viewModel.onIntent(AiAutoGroupIntent.CancelRunning) },
+            )
+            AiAutoGroupPhase.Applying -> AutoGroupProgressContent("正在应用分组")
+            AiAutoGroupPhase.Reviewing -> AutoGroupReviewContent(
+                state = state,
+                onIntent = viewModel::onIntent,
+            )
+
+            AiAutoGroupPhase.Result -> AutoGroupResultContent(
+                resultText = state.resultText.orEmpty(),
+                onDone = closeSheet,
+                onReset = { viewModel.onIntent(AiAutoGroupIntent.Restart) },
+            )
+
+            AiAutoGroupPhase.Error -> AutoGroupErrorContent(
+                message = state.errorMessage.orEmpty(),
+                onRetry = { viewModel.onIntent(AiAutoGroupIntent.Restart) },
+                onDismiss = closeSheet,
+            )
+        }
+    }
+
+    AppAlertDialog(
+        show = state.showApplyConfirm,
+        onDismissRequest = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
+        title = "确认执行分组方案",
+        text = "将创建 ${state.newGroupCount} 个分组,调整 ${state.assignedBookCount} 本书,跳过 ${state.ignoredBooks.size} 本书。执行后会直接修改书架分组。",
+        confirmText = "执行",
+        onConfirm = { viewModel.onIntent(AiAutoGroupIntent.ConfirmApply) },
+        dismissText = "取消",
+        onDismiss = { viewModel.onIntent(AiAutoGroupIntent.DismissApplyConfirm) },
+    )
+}
+
+@Composable
+private fun AutoGroupPreflightContent(
+    state: AiAutoGroupUiState,
+    onDismiss: () -> Unit,
+    onAnalyze: () -> Unit,
+    onGroupingInstructionChange: (String) -> Unit,
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+        GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(16.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                Text(
+                    text = "将分析 ${state.bookCount} 本书",
+                    style = LegadoTheme.typography.titleMedium,
+                    fontWeight = FontWeight.SemiBold,
+                )
+                Text(
+                    text = "会使用书名、作者、分类、简介和当前分组生成建议。AI 只生成方案,不会直接修改书架。",
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+                Text(
+                    text = "已有 ${state.existingGroupCount} 个用户分组,${state.groupedBookCount} 本书已有分组。",
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+
+        AppTextField(
+            value = state.groupingInstruction,
+            onValueChange = onGroupingInstructionChange,
+            label = "补充分组要求",
+            minLines = 3,
+            maxLines = 5,
+            backgroundColor = LegadoTheme.colorScheme.onSheetContent,
+            modifier = Modifier.fillMaxWidth(),
+            placeholder = {
+                Text(
+                    text = "例如:按题材分组;玄幻和仙侠合并;不要处理本地书;分组数量控制在 8 个以内",
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            },
+        )
+
+        ConfirmDismissButtonsRow(
+            onDismiss = onDismiss,
+            onConfirm = onAnalyze,
+            dismissText = "取消",
+            confirmText = "开始分析",
+            confirmEnabled = state.bookCount > 0,
+        )
+    }
+}
+
+@Composable
+private fun AutoGroupProgressContent(
+    text: String,
+    onCancel: (() -> Unit)? = null,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .heightIn(min = 180.dp),
+        horizontalAlignment = Alignment.CenterHorizontally,
+        verticalArrangement = Arrangement.Center,
+    ) {
+        CircularProgressIndicator()
+        Spacer(modifier = Modifier.height(16.dp))
+        Text(text = text, style = LegadoTheme.typography.bodyMedium)
+        if (onCancel != null) {
+            Spacer(modifier = Modifier.height(16.dp))
+            MediumPlainButton(
+                onClick = onCancel,
+                icon = Icons.Default.Close,
+                text = "取消",
+            )
+        }
+    }
+}
+
+@Composable
+private fun AutoGroupReviewContent(
+    state: AiAutoGroupUiState,
+    onIntent: (AiAutoGroupIntent) -> Unit,
+) {
+    var newGroupName by remember { mutableStateOf("") }
+
+    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+        AutoGroupSummaryCard(state = state)
+
+        GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+            Column(
+                modifier = Modifier.padding(12.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                AppTextField(
+                    value = state.revisionInstruction,
+                    onValueChange = { onIntent(AiAutoGroupIntent.UpdateRevisionInstruction(it)) },
+                    label = "让 AI 调整方案",
+                    minLines = 2,
+                    backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
+                    modifier = Modifier.fillMaxWidth(),
+                )
+                Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                    MediumTonalButton(
+                        onClick = { onIntent(AiAutoGroupIntent.Revise) },
+                        icon = Icons.Default.Refresh,
+                        text = "重新调整",
+                        enabled = state.revisionInstruction.isNotBlank(),
+                    )
+                    MediumPlainButton(
+                        onClick = { onIntent(AiAutoGroupIntent.RequestApply) },
+                        icon = Icons.Default.PlayArrow,
+                        text = "确认执行",
+                        enabled = state.assignedBookCount > 0,
+                    )
+                }
+            }
+        }
+
+        Row(
+            modifier = Modifier.fillMaxWidth(),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppTextField(
+                value = newGroupName,
+                onValueChange = { newGroupName = it },
+                label = "新增分组",
+                singleLine = true,
+                backgroundColor = LegadoTheme.colorScheme.onSheetContent,
+                modifier = Modifier.weight(1f),
+            )
+            MediumTonalButton(
+                onClick = {
+                    onIntent(AiAutoGroupIntent.AddGroup(newGroupName))
+                    newGroupName = ""
+                },
+                icon = Icons.Default.Add,
+                contentDescription = "新增分组",
+            )
+        }
+
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .heightIn(max = 560.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(state.groups, key = { it.key }) { group ->
+                AutoGroupCard(
+                    group = group,
+                    allGroups = state.groups,
+                    onIntent = onIntent,
+                )
+            }
+
+            if (state.ignoredBooks.isNotEmpty()) {
+                item(key = "ignored") {
+                    IgnoredBooksCard(books = state.ignoredBooks)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun AutoGroupSummaryCard(state: AiAutoGroupUiState) {
+    GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .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())
+        }
+    }
+}
+
+@Composable
+private fun SummaryMetric(label: String, value: String) {
+    Column(horizontalAlignment = Alignment.CenterHorizontally) {
+        Text(
+            text = value,
+            style = LegadoTheme.typography.titleMedium,
+            fontWeight = FontWeight.SemiBold,
+        )
+        Text(
+            text = label,
+            style = LegadoTheme.typography.bodySmall,
+            color = LegadoTheme.colorScheme.onSurfaceVariant,
+        )
+    }
+}
+
+@Composable
+private fun AutoGroupCard(
+    group: AiAutoGroupGroupUi,
+    allGroups: List<AiAutoGroupGroupUi>,
+    onIntent: (AiAutoGroupIntent) -> Unit,
+) {
+    GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                AppTextField(
+                    value = group.name,
+                    onValueChange = { onIntent(AiAutoGroupIntent.RenameGroup(group.key, it)) },
+                    singleLine = true,
+                    label = if (group.reuseExisting) "复用分组" else "新建分组",
+                    backgroundColor = LegadoTheme.colorScheme.surfaceContainer,
+                    modifier = Modifier.weight(1f),
+                )
+                MediumPlainButton(
+                    onClick = { onIntent(AiAutoGroupIntent.RemoveGroup(group.key)) },
+                    icon = Icons.Default.Delete,
+                    contentDescription = "删除分组",
+                )
+            }
+
+            if (group.description.isNotBlank()) {
+                Text(
+                    text = group.description,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+
+            group.books.forEach { book ->
+                AutoGroupBookItem(
+                    book = book,
+                    currentGroupKey = group.key,
+                    allGroups = allGroups,
+                    onIntent = onIntent,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun AutoGroupBookItem(
+    book: AiAutoGroupBookUi,
+    currentGroupKey: String,
+    allGroups: List<AiAutoGroupGroupUi>,
+    onIntent: (AiAutoGroupIntent) -> Unit,
+) {
+    SelectionItemCard(
+        title = book.name,
+        subtitle = buildString {
+            if (book.author.isNotBlank()) append(book.author)
+            if (book.currentGroupNames.isNotEmpty()) {
+                if (isNotBlank()) append(" · ")
+                append("原分组:")
+                append(book.currentGroupNames.joinToString("、"))
+            }
+        }.ifBlank { null },
+        supportingContent = {
+            book.reason.takeIf { it.isNotBlank() }?.let {
+                Text(
+                    text = it,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+        },
+        onToggleSelection = {},
+        trailingAction = {
+            SmallPlainButton(
+                onClick = { onIntent(AiAutoGroupIntent.IgnoreBook(book.bookUrl)) },
+                icon = Icons.Default.Close,
+                contentDescription = "不处理",
+            )
+        },
+        dropdownContent = { onDismiss ->
+            allGroups.filterNot { it.key == currentGroupKey }.forEach { target ->
+                RoundDropdownMenuItem(
+                    text = "移到 ${target.name}",
+                    onClick = {
+                        onIntent(AiAutoGroupIntent.MoveBook(book.bookUrl, target.key))
+                        onDismiss()
+                    },
+                )
+            }
+        },
+        containerColor = LegadoTheme.colorScheme.surfaceContainer,
+    )
+}
+
+@Composable
+private fun IgnoredBooksCard(books: List<AiAutoGroupIgnoredBookUi>) {
+    GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(6.dp),
+        ) {
+            Text(
+                text = "不处理 ${books.size} 本",
+                style = LegadoTheme.typography.titleSmall,
+                fontWeight = FontWeight.SemiBold,
+            )
+            books.take(12).forEach { book ->
+                Text(
+                    text = buildString {
+                        append(book.name)
+                        if (book.reason.isNotBlank()) {
+                            append(" · ")
+                            append(book.reason)
+                        }
+                    },
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+            if (books.size > 12) {
+                Text(
+                    text = "还有 ${books.size - 12} 本未显示",
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun AutoGroupResultContent(
+    resultText: String,
+    onDone: () -> Unit,
+    onReset: () -> Unit,
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+        GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(16.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Icon(Icons.Default.Check, contentDescription = null)
+                Spacer(modifier = Modifier.width(12.dp))
+                Text(text = resultText, style = LegadoTheme.typography.bodyMedium)
+            }
+        }
+        ConfirmDismissButtonsRow(
+            onDismiss = onReset,
+            onConfirm = onDone,
+            dismissText = "重新分析",
+            confirmText = "完成",
+        )
+    }
+}
+
+@Composable
+private fun AutoGroupErrorContent(
+    message: String,
+    onRetry: () -> Unit,
+    onDismiss: () -> Unit,
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+        GlassCard(containerColor = LegadoTheme.colorScheme.onSheetContent) {
+            Column(
+                modifier = Modifier.padding(16.dp),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                Text(
+                    text = "自动分组失败",
+                    style = LegadoTheme.typography.titleMedium,
+                    fontWeight = FontWeight.SemiBold,
+                )
+                Text(
+                    text = message.ifBlank { "未知错误" },
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        }
+        ConfirmDismissButtonsRow(
+            onDismiss = onDismiss,
+            onConfirm = onRetry,
+            dismissText = "关闭",
+            confirmText = "返回",
+        )
+    }
+}

+ 408 - 0
app/src/main/java/io/legado/app/ui/main/bookshelf/autoGroup/AiAutoGroupViewModel.kt

@@ -0,0 +1,408 @@
+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.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 io.legado.app.domain.usecase.ApplyBookshelfAutoGroupPlanUseCase
+import io.legado.app.domain.usecase.GenerateBookshelfAutoGroupPlanUseCase
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+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,
+    private val applyPlanUseCase: ApplyBookshelfAutoGroupPlanUseCase,
+) : ViewModel() {
+
+    private var source: BookshelfAutoGroupSource? = null
+    private var runningJob: Job? = null
+    private var activeSessionKey: Long? = null
+
+    private val _uiState = MutableStateFlow(AiAutoGroupUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<AiAutoGroupEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    fun onIntent(intent: AiAutoGroupIntent) {
+        when (intent) {
+            is AiAutoGroupIntent.StartSession -> startSession(intent.sessionKey)
+            AiAutoGroupIntent.CloseSession -> closeSession()
+            AiAutoGroupIntent.Analyze -> analyze()
+            AiAutoGroupIntent.DismissApplyConfirm -> {
+                _uiState.update { it.copy(showApplyConfirm = false) }
+            }
+            AiAutoGroupIntent.RequestApply -> requestApply()
+            AiAutoGroupIntent.ConfirmApply -> apply()
+            AiAutoGroupIntent.Restart -> restartSession()
+            AiAutoGroupIntent.CancelRunning -> cancelRunning()
+            is AiAutoGroupIntent.RenameGroup -> renameGroup(intent.groupKey, intent.name)
+            is AiAutoGroupIntent.RemoveGroup -> removeGroup(intent.groupKey)
+            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.UpdateRevisionInstruction -> {
+                _uiState.update { it.copy(revisionInstruction = intent.instruction) }
+            }
+            AiAutoGroupIntent.Revise -> revise()
+        }
+    }
+
+    private fun startSession(sessionKey: Long) {
+        if (activeSessionKey == sessionKey) return
+        beginCleanSession(
+            sessionKey = sessionKey,
+            groupingInstruction = "",
+        )
+    }
+
+    private fun closeSession() {
+        if (_uiState.value.phase != AiAutoGroupPhase.Applying) {
+            runningJob?.cancel()
+        }
+        runningJob = null
+        activeSessionKey = null
+        source = null
+        _uiState.value = AiAutoGroupUiState()
+    }
+
+    private fun restartSession() {
+        val sessionKey = activeSessionKey ?: System.nanoTime()
+        val groupingInstruction = _uiState.value.groupingInstruction
+        beginCleanSession(
+            sessionKey = sessionKey,
+            groupingInstruction = groupingInstruction,
+        )
+    }
+
+    private fun beginCleanSession(
+        sessionKey: Long,
+        groupingInstruction: String,
+    ) {
+        activeSessionKey = sessionKey
+        source = null
+        runningJob?.cancel()
+        _uiState.value = AiAutoGroupUiState(
+            phase = AiAutoGroupPhase.LoadingSource,
+            groupingInstruction = groupingInstruction,
+        )
+        loadSource()
+    }
+
+    private fun loadSource() {
+        runningJob = viewModelScope.launch {
+            runCatching {
+                generatePlanUseCase.loadSource()
+            }.onSuccess { loaded ->
+                source = loaded
+                _uiState.update {
+                    it.copy(
+                        phase = AiAutoGroupPhase.Preflight,
+                        bookCount = loaded.bookCount,
+                        groupedBookCount = loaded.groupedBookCount,
+                        existingGroupCount = loaded.existingGroupNames.size,
+                        errorMessage = null,
+                    )
+                }
+            }.onFailure { error ->
+                if (error is CancellationException) throw error
+                showError(error)
+            }
+        }
+    }
+
+    private fun analyze() {
+        val loadedSource = source ?: return loadSource()
+        runningJob?.cancel()
+        runningJob = viewModelScope.launch {
+            _uiState.update { it.copy(phase = AiAutoGroupPhase.Analyzing, errorMessage = null) }
+            runCatching {
+                generatePlanUseCase.generate(
+                    source = loadedSource,
+                    groupingInstruction = _uiState.value.groupingInstruction,
+                )
+            }.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)
+            }
+        }
+    }
+
+    private fun revise() {
+        val loadedSource = source ?: return
+        val instruction = _uiState.value.revisionInstruction.trim()
+        if (instruction.isBlank()) {
+            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("请输入调整要求"))
+            return
+        }
+        val currentPlan = _uiState.value.toDomainPlan()
+        runningJob?.cancel()
+        runningJob = viewModelScope.launch {
+            _uiState.update { it.copy(phase = AiAutoGroupPhase.Revising, errorMessage = null) }
+            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 = "",
+                    )
+                }
+            }.onFailure { error ->
+                if (error is CancellationException) throw error
+                showError(error)
+            }
+        }
+    }
+
+    private fun requestApply() {
+        val state = _uiState.value
+        if (state.assignedBookCount == 0) {
+            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("没有可执行的分组方案"))
+            return
+        }
+        _uiState.update { it.copy(showApplyConfirm = true) }
+    }
+
+    private fun cancelRunning() {
+        runningJob?.cancel()
+        _uiState.update { state ->
+            state.copy(
+                phase = if (state.groups.isEmpty()) {
+                    AiAutoGroupPhase.Preflight
+                } else {
+                    AiAutoGroupPhase.Reviewing
+                },
+                errorMessage = null,
+            )
+        }
+        _effects.tryEmit(AiAutoGroupEffect.ShowMessage("已取消"))
+    }
+
+    private fun apply() {
+        val plan = _uiState.value.toDomainPlan()
+        runningJob?.cancel()
+        runningJob = viewModelScope.launch {
+            _uiState.update {
+                it.copy(
+                    phase = AiAutoGroupPhase.Applying,
+                    showApplyConfirm = false,
+                    errorMessage = null,
+                )
+            }
+            runCatching {
+                applyPlanUseCase.execute(plan)
+            }.onSuccess { result ->
+                _uiState.update {
+                    it.copy(
+                        phase = AiAutoGroupPhase.Result,
+                        resultText = result.toResultText(),
+                    )
+                }
+                _effects.tryEmit(AiAutoGroupEffect.Applied)
+            }.onFailure { error ->
+                if (error is CancellationException) throw error
+                showError(error)
+            }
+        }
+    }
+
+    private fun renameGroup(groupKey: String, name: String) {
+        val existingNames = source?.existingGroupNames.orEmpty().toSet()
+        _uiState.update { state ->
+            state.copy(
+                groups = state.groups.map { group ->
+                    if (group.key == groupKey) {
+                        group.copy(
+                            name = name.take(24),
+                            reuseExisting = name.trim() in existingNames,
+                        )
+                    } else {
+                        group
+                    }
+                }.toImmutableList()
+            )
+        }
+    }
+
+    private fun removeGroup(groupKey: String) {
+        _uiState.update { state ->
+            val removed = state.groups.firstOrNull { it.key == groupKey } ?: return@update state
+            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 = "已从建议分组移除",
+                    )
+                }).toImmutableList(),
+            )
+        }
+    }
+
+    private fun moveBook(bookUrl: String, targetGroupKey: String) {
+        _uiState.update { state ->
+            val currentBook = state.groups.asSequence()
+                .flatMap { it.books.asSequence() }
+                .firstOrNull { it.bookUrl == bookUrl }
+                ?: state.ignoredBooks.firstOrNull { it.bookUrl == bookUrl }?.toBookUi()
+                ?: return@update state
+            state.copy(
+                groups = state.groups.map { group ->
+                    val withoutBook = group.books.filterNot { it.bookUrl == bookUrl }
+                    if (group.key == targetGroupKey) {
+                        group.copy(books = (withoutBook + currentBook).toImmutableList())
+                    } else {
+                        group.copy(books = withoutBook.toImmutableList())
+                    }
+                }.filter { it.books.isNotEmpty() || it.key == targetGroupKey }
+                    .toImmutableList(),
+                ignoredBooks = state.ignoredBooks.filterNot { it.bookUrl == bookUrl }.toImmutableList(),
+            )
+        }
+    }
+
+    private fun ignoreBook(bookUrl: String) {
+        _uiState.update { state ->
+            val book = state.groups.asSequence()
+                .flatMap { it.books.asSequence() }
+                .firstOrNull { it.bookUrl == bookUrl }
+                ?: return@update state
+            state.copy(
+                groups = state.groups.map { group ->
+                    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 = "用户选择不处理",
+                )).toImmutableList(),
+            )
+        }
+    }
+
+    private fun addGroup(name: String) {
+        val finalName = name.trim().take(24)
+        if (finalName.isBlank()) {
+            _effects.tryEmit(AiAutoGroupEffect.ShowMessage("分组名称不能为空"))
+            return
+        }
+        val existingNames = source?.existingGroupNames.orEmpty().toSet()
+        _uiState.update { state ->
+            state.copy(
+                groups = (state.groups + AiAutoGroupGroupUi(
+                    key = UUID.randomUUID().toString(),
+                    name = finalName,
+                    description = "用户新增",
+                    reuseExisting = finalName in existingNames,
+                )).toImmutableList()
+            )
+        }
+    }
+
+    private fun showError(error: Throwable) {
+        _uiState.update {
+            it.copy(
+                phase = AiAutoGroupPhase.Error,
+                errorMessage = error.localizedMessage ?: "AI 自动分组失败",
+            )
+        }
+    }
+
+    private fun BookshelfAutoGroupPlanGroup.toUi() = AiAutoGroupGroupUi(
+        key = key,
+        name = name,
+        description = description,
+        reuseExisting = reuseExisting,
+        books = books.map { it.toUi() }.toImmutableList(),
+    )
+
+    private fun BookshelfAutoGroupPlanBook.toUi() = AiAutoGroupBookUi(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        currentGroupNames = currentGroupNames.toImmutableList(),
+        reason = reason,
+    )
+
+    private fun BookshelfAutoGroupIgnoredBook.toUi() = AiAutoGroupIgnoredBookUi(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        reason = reason,
+    )
+
+    private fun AiAutoGroupIgnoredBookUi.toBookUi() = AiAutoGroupBookUi(
+        bookUrl = bookUrl,
+        name = name,
+        author = author,
+        reason = reason,
+    )
+
+    private fun AiAutoGroupUiState.toDomainPlan() = BookshelfAutoGroupPlan(
+        groups = groups.mapNotNull { group ->
+            val name = group.name.trim().takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            if (group.books.isEmpty()) return@mapNotNull null
+            BookshelfAutoGroupPlanGroup(
+                key = group.key,
+                name = name,
+                description = group.description,
+                reuseExisting = group.reuseExisting,
+                books = group.books.map {
+                    BookshelfAutoGroupPlanBook(
+                        bookUrl = it.bookUrl,
+                        name = it.name,
+                        author = it.author,
+                        currentGroupNames = it.currentGroupNames,
+                        reason = it.reason,
+                    )
+                },
+            )
+        },
+        ignoredBooks = ignoredBooks.map {
+            BookshelfAutoGroupIgnoredBook(
+                bookUrl = it.bookUrl,
+                name = it.name,
+                author = it.author,
+                reason = it.reason,
+            )
+        },
+    )
+
+    private fun BookshelfAutoGroupApplyResult.toResultText(): String {
+        return "已创建 ${createdGroupCount} 个分组,复用 ${reusedGroupCount} 个分组,更新 ${updatedBookCount} 本书,跳过 ${ignoredBookCount} 本书。"
+    }
+}