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

feat: 人物详情页增加了AI添加人物的功能,人物添加青年等标识,优化听书功能喵喵喵

HapeLee 3 месяцев назад
Родитель
Сommit
1daa858c95
67 измененных файлов с 7470 добавлено и 516 удалено
  1. 4539 0
      app/schemas/io.legado.app.data.AppDatabase/97.json
  2. 3 2
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  3. 15 0
      app/src/main/java/io/legado/app/data/dao/BookKnowledgeDao.kt
  4. 21 0
      app/src/main/java/io/legado/app/data/entities/BookKnowledge.kt
  5. 4 1
      app/src/main/java/io/legado/app/data/entities/HttpTTS.kt
  6. 6 0
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  7. 19 0
      app/src/main/java/io/legado/app/data/repository/BookKnowledgeRepository.kt
  8. 1 0
      app/src/main/java/io/legado/app/data/repository/HttpTtsEngineRepository.kt
  9. 10 1
      app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt
  10. 5 0
      app/src/main/java/io/legado/app/di/appModule.kt
  11. 9 0
      app/src/main/java/io/legado/app/domain/gateway/BookKnowledgeGateway.kt
  12. 1 0
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  13. 11 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSpeechModels.kt
  14. 2 0
      app/src/main/java/io/legado/app/domain/model/readaloud/SpeechIdentity.kt
  15. 1 0
      app/src/main/java/io/legado/app/domain/model/readaloud/TtsCatalogModels.kt
  16. 145 0
      app/src/main/java/io/legado/app/domain/usecase/AiTaskManager.kt
  17. 40 0
      app/src/main/java/io/legado/app/domain/usecase/AiTextFactoryUseCase.kt
  18. 4 1
      app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt
  19. 25 18
      app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt
  20. 61 0
      app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt
  21. 46 1
      app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt
  22. 216 0
      app/src/main/java/io/legado/app/domain/usecase/IdentifyBookCharactersUseCase.kt
  23. 3 1
      app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt
  24. 5 1
      app/src/main/java/io/legado/app/domain/usecase/ResolveLocalSpeakersUseCase.kt
  25. 12 1
      app/src/main/java/io/legado/app/help/readaloud/playback/CharacterPerformanceInstructionBuilder.kt
  26. 19 3
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  27. 1 0
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  28. 40 0
      app/src/main/java/io/legado/app/ui/ai/AiTaskResultSheet.kt
  29. 139 7
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt
  30. 35 2
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt
  31. 25 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListContract.kt
  32. 135 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt
  33. 164 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListViewModel.kt
  34. 22 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkScreen.kt
  35. 27 2
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt
  36. 10 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeContract.kt
  37. 22 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailScreen.kt
  38. 19 1
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt
  39. 2 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListContract.kt
  40. 1 1
      app/src/main/java/io/legado/app/ui/book/read/ReadBookContract.kt
  41. 33 2
      app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt
  42. 0 30
      app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt
  43. 139 186
      app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt
  44. 25 10
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt
  45. 52 50
      app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudScreen.kt
  46. 0 4
      app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingContract.kt
  47. 0 4
      app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingScreen.kt
  48. 0 5
      app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingViewModel.kt
  49. 53 3
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsContract.kt
  50. 553 98
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt
  51. 449 12
      app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt
  52. 1 1
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt
  53. 28 23
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt
  54. 4 12
      app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt
  55. 6 1
      app/src/main/java/io/legado/app/ui/config/ai/prompt/AiPromptConfigViewModel.kt
  56. 1 1
      app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt
  57. 36 3
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  58. 1 1
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  59. 2 2
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  60. 22 10
      app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt
  61. 45 3
      app/src/main/res/values-zh-rCN/strings.xml
  62. 37 3
      app/src/main/res/values-zh-rHK/strings.xml
  63. 37 3
      app/src/main/res/values-zh-rTW/strings.xml
  64. 45 3
      app/src/main/res/values/strings.xml
  65. 26 0
      app/src/test/java/io/legado/app/data/entities/HttpTTSJsonImportTest.kt
  66. 9 3
      app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt
  67. 1 0
      app/src/test/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCaseTest.kt

+ 4539 - 0
app/schemas/io.legado.app.data.AppDatabase/97.json

@@ -0,0 +1,4539 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 97,
+    "identityHash": "7eb5b4729e665212e9cddaf3dfac2101",
+    "entities": [
+      {
+        "tableName": "books",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL DEFAULT '', `tocUrl` TEXT NOT NULL DEFAULT '', `origin` TEXT NOT NULL DEFAULT 'loc_book', `originName` TEXT NOT NULL DEFAULT '', `name` TEXT NOT NULL DEFAULT '', `author` TEXT NOT NULL DEFAULT '', `kind` TEXT, `customTag` TEXT, `coverUrl` TEXT, `customCoverUrl` TEXT, `intro` TEXT, `customIntro` TEXT, `remark` TEXT, `charset` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `group` INTEGER NOT NULL DEFAULT 0, `latestChapterTitle` TEXT, `latestChapterTime` INTEGER NOT NULL DEFAULT 0, `lastCheckTime` INTEGER NOT NULL DEFAULT 0, `lastCheckCount` INTEGER NOT NULL DEFAULT 0, `totalChapterNum` INTEGER NOT NULL DEFAULT 0, `durChapterTitle` TEXT, `durChapterIndex` INTEGER NOT NULL DEFAULT 0, `durChapterPos` INTEGER NOT NULL DEFAULT 0, `durChapterTime` INTEGER NOT NULL DEFAULT 0, `wordCount` TEXT, `canUpdate` INTEGER NOT NULL DEFAULT 1, `order` INTEGER NOT NULL DEFAULT 0, `originOrder` INTEGER NOT NULL DEFAULT 0, `variable` TEXT, `readConfig` TEXT, `syncTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`bookUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'loc_book'"
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customTag",
+            "columnName": "customTag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customCoverUrl",
+            "columnName": "customCoverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customIntro",
+            "columnName": "customIntro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "remark",
+            "columnName": "remark",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "charset",
+            "columnName": "charset",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTime",
+            "columnName": "latestChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckTime",
+            "columnName": "lastCheckTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastCheckCount",
+            "columnName": "lastCheckCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "totalChapterNum",
+            "columnName": "totalChapterNum",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTitle",
+            "columnName": "durChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "durChapterIndex",
+            "columnName": "durChapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterPos",
+            "columnName": "durChapterPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durChapterTime",
+            "columnName": "durChapterTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "canUpdate",
+            "columnName": "canUpdate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readConfig",
+            "columnName": "readConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncTime",
+            "columnName": "syncTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_books_name_author",
+            "unique": false,
+            "columnNames": [
+              "name",
+              "author"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_name_author` ON `${TABLE_NAME}` (`name`, `author`)"
+          },
+          {
+            "name": "index_books_durChapterTime",
+            "unique": false,
+            "columnNames": [
+              "durChapterTime"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_books_durChapterTime` ON `${TABLE_NAME}` (`durChapterTime`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_groups",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`groupId` INTEGER NOT NULL, `groupName` TEXT NOT NULL, `cover` TEXT, `order` INTEGER NOT NULL, `enableRefresh` INTEGER NOT NULL DEFAULT 1, `show` INTEGER NOT NULL DEFAULT 1, `bookSort` INTEGER NOT NULL DEFAULT -1, `isPrivate` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`groupId`))",
+        "fields": [
+          {
+            "fieldPath": "groupId",
+            "columnName": "groupId",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cover",
+            "columnName": "cover",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enableRefresh",
+            "columnName": "enableRefresh",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "show",
+            "columnName": "show",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "bookSort",
+            "columnName": "bookSort",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "isPrivate",
+            "columnName": "isPrivate",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "groupId"
+          ]
+        }
+      },
+      {
+        "tableName": "book_sources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookSourceUrl` TEXT NOT NULL, `bookSourceName` TEXT NOT NULL, `bookSourceGroup` TEXT, `bookSourceType` INTEGER NOT NULL, `bookUrlPattern` TEXT, `customOrder` INTEGER NOT NULL DEFAULT 0, `enabled` INTEGER NOT NULL DEFAULT 1, `enabledExplore` INTEGER NOT NULL DEFAULT 1, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `bookSourceComment` TEXT, `variableComment` TEXT, `lastUpdateTime` INTEGER NOT NULL, `respondTime` INTEGER NOT NULL, `weight` INTEGER NOT NULL, `exploreUrl` TEXT, `exploreScreen` TEXT, `ruleExplore` TEXT, `searchUrl` TEXT, `ruleSearch` TEXT, `ruleBookInfo` TEXT, `ruleToc` TEXT, `ruleContent` TEXT, `ruleReview` TEXT, `eventListener` INTEGER NOT NULL DEFAULT 0, `customButton` INTEGER NOT NULL DEFAULT 0, `homepageModules` TEXT, PRIMARY KEY(`bookSourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "bookSourceUrl",
+            "columnName": "bookSourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceName",
+            "columnName": "bookSourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookSourceGroup",
+            "columnName": "bookSourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceType",
+            "columnName": "bookSourceType",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrlPattern",
+            "columnName": "bookUrlPattern",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "enabledExplore",
+            "columnName": "enabledExplore",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bookSourceComment",
+            "columnName": "bookSourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "weight",
+            "columnName": "weight",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "exploreUrl",
+            "columnName": "exploreUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "exploreScreen",
+            "columnName": "exploreScreen",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleExplore",
+            "columnName": "ruleExplore",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleSearch",
+            "columnName": "ruleSearch",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleBookInfo",
+            "columnName": "ruleBookInfo",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleToc",
+            "columnName": "ruleToc",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleReview",
+            "columnName": "ruleReview",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "eventListener",
+            "columnName": "eventListener",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customButton",
+            "columnName": "customButton",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "homepageModules",
+            "columnName": "homepageModules",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookSourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_sources_bookSourceUrl",
+            "unique": false,
+            "columnNames": [
+              "bookSourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_sources_bookSourceUrl` ON `${TABLE_NAME}` (`bookSourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapters",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `title` TEXT NOT NULL, `isVolume` INTEGER NOT NULL, `tocLevel` INTEGER NOT NULL DEFAULT 0, `baseUrl` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `index` INTEGER NOT NULL, `isVip` INTEGER NOT NULL, `isPay` INTEGER NOT NULL, `resourceUrl` TEXT, `tag` TEXT, `wordCount` TEXT, `start` INTEGER, `end` INTEGER, `startFragmentId` TEXT, `endFragmentId` TEXT, `variable` TEXT, `reviewImg` TEXT, PRIMARY KEY(`url`, `bookUrl`), FOREIGN KEY(`bookUrl`) REFERENCES `books`(`bookUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVolume",
+            "columnName": "isVolume",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "tocLevel",
+            "columnName": "tocLevel",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "index",
+            "columnName": "index",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isVip",
+            "columnName": "isVip",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isPay",
+            "columnName": "isPay",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resourceUrl",
+            "columnName": "resourceUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tag",
+            "columnName": "tag",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "startFragmentId",
+            "columnName": "startFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "endFragmentId",
+            "columnName": "endFragmentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "reviewImg",
+            "columnName": "reviewImg",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url",
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapters_bookUrl",
+            "unique": false,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapters_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_chapters_bookUrl_index",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "index"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapters_bookUrl_index` ON `${TABLE_NAME}` (`bookUrl`, `index`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "books",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "bookUrl"
+            ],
+            "referencedColumns": [
+              "bookUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "replace_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL DEFAULT '', `group` TEXT, `pattern` TEXT NOT NULL DEFAULT '', `replacement` TEXT NOT NULL DEFAULT '', `scope` TEXT, `scopeTitle` INTEGER NOT NULL DEFAULT 0, `scopeContent` INTEGER NOT NULL DEFAULT 1, `excludeScope` TEXT, `isEnabled` INTEGER NOT NULL DEFAULT 1, `isRegex` INTEGER NOT NULL DEFAULT 1, `timeoutMillisecond` INTEGER NOT NULL DEFAULT 3000, `sortOrder` INTEGER NOT NULL DEFAULT 0)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "replacement",
+            "columnName": "replacement",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "scope",
+            "columnName": "scope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "scopeTitle",
+            "columnName": "scopeTitle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "scopeContent",
+            "columnName": "scopeContent",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "excludeScope",
+            "columnName": "excludeScope",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "isRegex",
+            "columnName": "isRegex",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "timeoutMillisecond",
+            "columnName": "timeoutMillisecond",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "3000"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_replace_rules_id",
+            "unique": false,
+            "columnNames": [
+              "id"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_replace_rules_id` ON `${TABLE_NAME}` (`id`)"
+          }
+        ]
+      },
+      {
+        "tableName": "searchBooks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `origin` TEXT NOT NULL, `originName` TEXT NOT NULL, `type` INTEGER NOT NULL, `name` TEXT NOT NULL, `author` TEXT NOT NULL, `kind` TEXT, `coverUrl` TEXT, `intro` TEXT, `wordCount` TEXT, `latestChapterTitle` TEXT, `tocUrl` TEXT NOT NULL, `time` INTEGER NOT NULL, `variable` TEXT, `originOrder` INTEGER NOT NULL, `chapterWordCountText` TEXT, `chapterWordCount` INTEGER NOT NULL DEFAULT -1, `respondTime` INTEGER NOT NULL DEFAULT -1, PRIMARY KEY(`bookUrl`), FOREIGN KEY(`origin`) REFERENCES `book_sources`(`bookSourceUrl`) ON UPDATE NO ACTION ON DELETE CASCADE )",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "originName",
+            "columnName": "originName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "author",
+            "columnName": "author",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverUrl",
+            "columnName": "coverUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "intro",
+            "columnName": "intro",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "wordCount",
+            "columnName": "wordCount",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "latestChapterTitle",
+            "columnName": "latestChapterTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tocUrl",
+            "columnName": "tocUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "originOrder",
+            "columnName": "originOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterWordCountText",
+            "columnName": "chapterWordCountText",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chapterWordCount",
+            "columnName": "chapterWordCount",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          },
+          {
+            "fieldPath": "respondTime",
+            "columnName": "respondTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "-1"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_searchBooks_bookUrl",
+            "unique": true,
+            "columnNames": [
+              "bookUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_searchBooks_bookUrl` ON `${TABLE_NAME}` (`bookUrl`)"
+          },
+          {
+            "name": "index_searchBooks_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_searchBooks_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ],
+        "foreignKeys": [
+          {
+            "table": "book_sources",
+            "onDelete": "CASCADE",
+            "onUpdate": "NO ACTION",
+            "columns": [
+              "origin"
+            ],
+            "referencedColumns": [
+              "bookSourceUrl"
+            ]
+          }
+        ]
+      },
+      {
+        "tableName": "search_keywords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`word` TEXT NOT NULL, `usage` INTEGER NOT NULL, `lastUseTime` INTEGER NOT NULL, PRIMARY KEY(`word`))",
+        "fields": [
+          {
+            "fieldPath": "word",
+            "columnName": "word",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "usage",
+            "columnName": "usage",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "lastUseTime",
+            "columnName": "lastUseTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "word"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_keywords_word",
+            "unique": true,
+            "columnNames": [
+              "word"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_keywords_word` ON `${TABLE_NAME}` (`word`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cookies",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`url` TEXT NOT NULL, `cookie` TEXT NOT NULL, PRIMARY KEY(`url`))",
+        "fields": [
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "cookie",
+            "columnName": "cookie",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "url"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cookies_url",
+            "unique": true,
+            "columnNames": [
+              "url"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_cookies_url` ON `${TABLE_NAME}` (`url`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssSources",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`sourceUrl` TEXT NOT NULL, `sourceName` TEXT NOT NULL, `sourceIcon` TEXT NOT NULL, `sourceGroup` TEXT, `sourceComment` TEXT, `enabled` INTEGER NOT NULL, `variableComment` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `concurrentRate` TEXT, `header` TEXT, `loginUrl` TEXT, `loginUi` TEXT, `loginCheckJs` TEXT, `coverDecodeJs` TEXT, `sortUrl` TEXT, `singleUrl` INTEGER NOT NULL, `articleStyle` INTEGER NOT NULL DEFAULT 0, `ruleArticles` TEXT, `ruleNextPage` TEXT, `ruleTitle` TEXT, `rulePubDate` TEXT, `ruleDescription` TEXT, `ruleImage` TEXT, `ruleLink` TEXT, `ruleContent` TEXT, `contentWhitelist` TEXT, `contentBlacklist` TEXT, `shouldOverrideUrlLoading` TEXT, `style` TEXT, `enableJs` INTEGER NOT NULL DEFAULT 1, `loadWithBaseUrl` INTEGER NOT NULL DEFAULT 1, `injectJs` TEXT, `preloadJs` TEXT, `startHtml` TEXT, `startStyle` TEXT, `startJs` TEXT, `showWebLog` INTEGER NOT NULL DEFAULT 0, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, `customOrder` INTEGER NOT NULL DEFAULT 0, `type` INTEGER NOT NULL DEFAULT 0, `preload` INTEGER NOT NULL DEFAULT 0, `cacheFirst` INTEGER NOT NULL DEFAULT 0, `searchUrl` TEXT, `redirectPolicy` TEXT NOT NULL DEFAULT 'ASK_CROSS_ORIGIN', PRIMARY KEY(`sourceUrl`))",
+        "fields": [
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceName",
+            "columnName": "sourceName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceIcon",
+            "columnName": "sourceIcon",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceGroup",
+            "columnName": "sourceGroup",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceComment",
+            "columnName": "sourceComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variableComment",
+            "columnName": "variableComment",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "coverDecodeJs",
+            "columnName": "coverDecodeJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortUrl",
+            "columnName": "sortUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "singleUrl",
+            "columnName": "singleUrl",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "articleStyle",
+            "columnName": "articleStyle",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "ruleArticles",
+            "columnName": "ruleArticles",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleNextPage",
+            "columnName": "ruleNextPage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleTitle",
+            "columnName": "ruleTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "rulePubDate",
+            "columnName": "rulePubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleDescription",
+            "columnName": "ruleDescription",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleImage",
+            "columnName": "ruleImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleLink",
+            "columnName": "ruleLink",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "ruleContent",
+            "columnName": "ruleContent",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentWhitelist",
+            "columnName": "contentWhitelist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "contentBlacklist",
+            "columnName": "contentBlacklist",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "shouldOverrideUrlLoading",
+            "columnName": "shouldOverrideUrlLoading",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "style",
+            "columnName": "style",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enableJs",
+            "columnName": "enableJs",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "loadWithBaseUrl",
+            "columnName": "loadWithBaseUrl",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "injectJs",
+            "columnName": "injectJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "preloadJs",
+            "columnName": "preloadJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startHtml",
+            "columnName": "startHtml",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startStyle",
+            "columnName": "startStyle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "startJs",
+            "columnName": "startJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "showWebLog",
+            "columnName": "showWebLog",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "preload",
+            "columnName": "preload",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "cacheFirst",
+            "columnName": "cacheFirst",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "searchUrl",
+            "columnName": "searchUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "redirectPolicy",
+            "columnName": "redirectPolicy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'ASK_CROSS_ORIGIN'"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "sourceUrl"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssSources_sourceUrl",
+            "unique": false,
+            "columnNames": [
+              "sourceUrl"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssSources_sourceUrl` ON `${TABLE_NAME}` (`sourceUrl`)"
+          }
+        ]
+      },
+      {
+        "tableName": "bookmarks",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`time` INTEGER NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `chapterIndex` INTEGER NOT NULL, `chapterPos` INTEGER NOT NULL, `chapterName` TEXT NOT NULL, `bookText` TEXT NOT NULL, `content` TEXT NOT NULL, PRIMARY KEY(`time`))",
+        "fields": [
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPos",
+            "columnName": "chapterPos",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterName",
+            "columnName": "chapterName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookText",
+            "columnName": "bookText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "time"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_bookmarks_bookName_bookAuthor",
+            "unique": false,
+            "columnNames": [
+              "bookName",
+              "bookAuthor"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_bookmarks_bookName_bookAuthor` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`)"
+          }
+        ]
+      },
+      {
+        "tableName": "rssArticles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `order` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `read` INTEGER NOT NULL, `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`, `sort`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link",
+            "sort"
+          ]
+        }
+      },
+      {
+        "tableName": "rssReadRecords",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`record` TEXT NOT NULL, `title` TEXT, `readTime` INTEGER, `read` INTEGER NOT NULL, `origin` TEXT NOT NULL DEFAULT '', `sort` TEXT NOT NULL DEFAULT '', `image` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, `pubDate` TEXT, PRIMARY KEY(`record`))",
+        "fields": [
+          {
+            "fieldPath": "record",
+            "columnName": "record",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "read",
+            "columnName": "read",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "record"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_rssReadRecords_origin",
+            "unique": false,
+            "columnNames": [
+              "origin"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_rssReadRecords_origin` ON `${TABLE_NAME}` (`origin`)"
+          }
+        ]
+      },
+      {
+        "tableName": "readRecordDetail",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `date` TEXT NOT NULL, `readTime` INTEGER NOT NULL DEFAULT 0, `readWords` INTEGER NOT NULL DEFAULT 0, `firstReadTime` INTEGER NOT NULL DEFAULT 0, `lastReadTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`, `date`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "date",
+            "columnName": "date",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "readWords",
+            "columnName": "readWords",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "firstReadTime",
+            "columnName": "firstReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastReadTime",
+            "columnName": "lastReadTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor",
+            "date"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecordSession",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `startTime` INTEGER NOT NULL, `endTime` INTEGER NOT NULL, `words` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "startTime",
+            "columnName": "startTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "endTime",
+            "columnName": "endTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "words",
+            "columnName": "words",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "rssStars",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`origin` TEXT NOT NULL, `sort` TEXT NOT NULL, `title` TEXT NOT NULL, `starTime` INTEGER NOT NULL, `link` TEXT NOT NULL, `pubDate` TEXT, `description` TEXT, `content` TEXT, `image` TEXT, `group` TEXT NOT NULL DEFAULT '默认分组', `variable` TEXT, `type` INTEGER NOT NULL DEFAULT 0, `durPos` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`origin`, `link`))",
+        "fields": [
+          {
+            "fieldPath": "origin",
+            "columnName": "origin",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sort",
+            "columnName": "sort",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "starTime",
+            "columnName": "starTime",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "link",
+            "columnName": "link",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pubDate",
+            "columnName": "pubDate",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "description",
+            "columnName": "description",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "image",
+            "columnName": "image",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "group",
+            "columnName": "group",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'默认分组'"
+          },
+          {
+            "fieldPath": "variable",
+            "columnName": "variable",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "durPos",
+            "columnName": "durPos",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "origin",
+            "link"
+          ]
+        }
+      },
+      {
+        "tableName": "txtTocRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `rule` TEXT NOT NULL, `example` TEXT, `serialNumber` INTEGER NOT NULL, `enable` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "rule",
+            "columnName": "rule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "example",
+            "columnName": "example",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "serialNumber",
+            "columnName": "serialNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enable",
+            "columnName": "enable",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "readRecord",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`deviceId` TEXT NOT NULL, `bookName` TEXT NOT NULL, `bookAuthor` TEXT NOT NULL DEFAULT '', `readTime` INTEGER NOT NULL DEFAULT 0, `lastRead` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`deviceId`, `bookName`, `bookAuthor`))",
+        "fields": [
+          {
+            "fieldPath": "deviceId",
+            "columnName": "deviceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "readTime",
+            "columnName": "readTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "lastRead",
+            "columnName": "lastRead",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "deviceId",
+            "bookName",
+            "bookAuthor"
+          ]
+        }
+      },
+      {
+        "tableName": "httpTTS",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `contentType` TEXT, `concurrentRate` TEXT DEFAULT '0', `loginUrl` TEXT, `loginUi` TEXT, `header` TEXT, `jsLib` TEXT, `enabledCookieJar` INTEGER DEFAULT 0, `loginCheckJs` TEXT, `lastUpdateTime` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentType",
+            "columnName": "contentType",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "concurrentRate",
+            "columnName": "concurrentRate",
+            "affinity": "TEXT",
+            "defaultValue": "'0'"
+          },
+          {
+            "fieldPath": "loginUrl",
+            "columnName": "loginUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "loginUi",
+            "columnName": "loginUi",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "header",
+            "columnName": "header",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "jsLib",
+            "columnName": "jsLib",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabledCookieJar",
+            "columnName": "enabledCookieJar",
+            "affinity": "INTEGER",
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "loginCheckJs",
+            "columnName": "loginCheckJs",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "lastUpdateTime",
+            "columnName": "lastUpdateTime",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "caches",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`key` TEXT NOT NULL, `value` TEXT, `deadline` INTEGER NOT NULL, PRIMARY KEY(`key`))",
+        "fields": [
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "deadline",
+            "columnName": "deadline",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_caches_key",
+            "unique": true,
+            "columnNames": [
+              "key"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_caches_key` ON `${TABLE_NAME}` (`key`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ruleSubs",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `url` TEXT NOT NULL, `type` INTEGER NOT NULL, `customOrder` INTEGER NOT NULL, `autoUpdate` INTEGER NOT NULL, `update` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customOrder",
+            "columnName": "customOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "autoUpdate",
+            "columnName": "autoUpdate",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "update",
+            "columnName": "update",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "dictRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`name` TEXT NOT NULL, `urlRule` TEXT NOT NULL, `showRule` TEXT NOT NULL, `enabled` INTEGER NOT NULL DEFAULT 1, `sortNumber` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`name`))",
+        "fields": [
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "urlRule",
+            "columnName": "urlRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "showRule",
+            "columnName": "showRule",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "name"
+          ]
+        }
+      },
+      {
+        "tableName": "keyboardAssists",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`type` INTEGER NOT NULL DEFAULT 0, `key` TEXT NOT NULL DEFAULT '', `value` TEXT NOT NULL DEFAULT '', `serialNo` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`type`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "serialNo",
+            "columnName": "serialNo",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "type",
+            "key"
+          ]
+        }
+      },
+      {
+        "tableName": "servers",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `name` TEXT NOT NULL, `type` TEXT NOT NULL, `config` TEXT, `sortNumber` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "config",
+            "columnName": "config",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "search_content_history",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bookName` TEXT DEFAULT '', `bookAuthor` TEXT DEFAULT '', `query` TEXT NOT NULL, `time` INTEGER NOT NULL)",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookName",
+            "columnName": "bookName",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "bookAuthor",
+            "columnName": "bookAuthor",
+            "affinity": "TEXT",
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "query",
+            "columnName": "query",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "time",
+            "columnName": "time",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": true,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_search_content_history_bookName_bookAuthor_query",
+            "unique": true,
+            "columnNames": [
+              "bookName",
+              "bookAuthor",
+              "query"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_content_history_bookName_bookAuthor_query` ON `${TABLE_NAME}` (`bookName`, `bookAuthor`, `query`)"
+          }
+        ]
+      },
+      {
+        "tableName": "homepage_modules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `sourceUrl` TEXT NOT NULL, `moduleKey` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `args` TEXT, `layoutConfig` TEXT, `url` TEXT, `isEnabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `customSetId` TEXT, `isUserCreated` INTEGER NOT NULL, `customTitle` TEXT, `customSetTitle` TEXT, `sourceJsonHash` TEXT, `syncedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceUrl",
+            "columnName": "sourceUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "moduleKey",
+            "columnName": "moduleKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "args",
+            "columnName": "args",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "layoutConfig",
+            "columnName": "layoutConfig",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "url",
+            "columnName": "url",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isEnabled",
+            "columnName": "isEnabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customSetId",
+            "columnName": "customSetId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "isUserCreated",
+            "columnName": "isUserCreated",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "customTitle",
+            "columnName": "customTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customSetTitle",
+            "columnName": "customSetTitle",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceJsonHash",
+            "columnName": "sourceJsonHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "syncedAt",
+            "columnName": "syncedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "homepage_custom_sets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `sortOrder` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "highlightRules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `pattern` TEXT NOT NULL, `sampleText` TEXT NOT NULL, `targetScope` INTEGER NOT NULL, `enabled` INTEGER NOT NULL, `position` INTEGER NOT NULL, `textColor` INTEGER, `bgColor` INTEGER, `underlineMode` INTEGER NOT NULL, `underlineColor` INTEGER, `underlineWidth` REAL NOT NULL, `underlineOffset` REAL NOT NULL, `underlineSvgPath` TEXT, `bgImage` TEXT, `bgImageFit` INTEGER NOT NULL, `bgImageScale` REAL NOT NULL, `configName` TEXT, `fontPath` TEXT, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sampleText",
+            "columnName": "sampleText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "targetScope",
+            "columnName": "targetScope",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "position",
+            "columnName": "position",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "textColor",
+            "columnName": "textColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "bgColor",
+            "columnName": "bgColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineMode",
+            "columnName": "underlineMode",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineColor",
+            "columnName": "underlineColor",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "underlineWidth",
+            "columnName": "underlineWidth",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineOffset",
+            "columnName": "underlineOffset",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "underlineSvgPath",
+            "columnName": "underlineSvgPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImage",
+            "columnName": "bgImage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "bgImageFit",
+            "columnName": "bgImageFit",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bgImageScale",
+            "columnName": "bgImageScale",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "configName",
+            "columnName": "configName",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "fontPath",
+            "columnName": "fontPath",
+            "affinity": "TEXT"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_provider_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `protocol` TEXT NOT NULL, `baseUrl` TEXT NOT NULL, `modelsUrl` TEXT, `apiKey` TEXT NOT NULL, `authType` TEXT NOT NULL, `secretRef` TEXT, `headersJson` TEXT, `chatPath` TEXT, `responsesPath` TEXT, `messagesPath` TEXT, `modelsPath` TEXT, `customHeadersJson` TEXT, `enabled` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "protocol",
+            "columnName": "protocol",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelsUrl",
+            "columnName": "modelsUrl",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "authType",
+            "columnName": "authType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "secretRef",
+            "columnName": "secretRef",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "headersJson",
+            "columnName": "headersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chatPath",
+            "columnName": "chatPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "responsesPath",
+            "columnName": "responsesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "messagesPath",
+            "columnName": "messagesPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "modelsPath",
+            "columnName": "modelsPath",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "customHeadersJson",
+            "columnName": "customHeadersJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_model_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `providerId` TEXT NOT NULL, `displayName` TEXT NOT NULL, `modelId` TEXT NOT NULL, `contextWindow` INTEGER NOT NULL, `maxOutputTokens` INTEGER NOT NULL, `capabilities` TEXT NOT NULL, `defaultParamsJson` TEXT, `enabled` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "providerId",
+            "columnName": "providerId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelId",
+            "columnName": "modelId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contextWindow",
+            "columnName": "contextWindow",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "maxOutputTokens",
+            "columnName": "maxOutputTokens",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "capabilities",
+            "columnName": "capabilities",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "defaultParamsJson",
+            "columnName": "defaultParamsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_model_profiles_providerId",
+            "unique": false,
+            "columnNames": [
+              "providerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_model_profiles_providerId` ON `${TABLE_NAME}` (`providerId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_task_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `promptTemplate` TEXT NOT NULL, `paramsJson` TEXT, `chunkPolicyJson` TEXT, `enabled` INTEGER NOT NULL, `isDefault` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptTemplate",
+            "columnName": "promptTemplate",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paramsJson",
+            "columnName": "paramsJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "chunkPolicyJson",
+            "columnName": "chunkPolicyJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isDefault",
+            "columnName": "isDefault",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_task_presets_taskType",
+            "unique": false,
+            "columnNames": [
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_taskType` ON `${TABLE_NAME}` (`taskType`)"
+          },
+          {
+            "name": "index_ai_task_presets_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_task_presets_modelProfileId` ON `${TABLE_NAME}` (`modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_artifacts",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `contentHash` TEXT NOT NULL, `promptHash` TEXT NOT NULL, `modelProfileId` TEXT NOT NULL, `status` INTEGER NOT NULL, `output` TEXT, `errorMessage` TEXT, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "promptHash",
+            "columnName": "promptHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "output",
+            "columnName": "output",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "errorMessage",
+            "columnName": "errorMessage",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_artifacts_bookUrl_chapterIndex_taskType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "taskType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_bookUrl_chapterIndex_taskType` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `taskType`)"
+          },
+          {
+            "name": "index_ai_artifacts_contentHash_promptHash_modelProfileId",
+            "unique": false,
+            "columnNames": [
+              "contentHash",
+              "promptHash",
+              "modelProfileId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_artifacts_contentHash_promptHash_modelProfileId` ON `${TABLE_NAME}` (`contentHash`, `promptHash`, `modelProfileId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_chat_conversations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `reasoningLevel` TEXT NOT NULL, `modelProfileId` TEXT, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "reasoningLevel",
+            "columnName": "reasoningLevel",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "modelProfileId",
+            "columnName": "modelProfileId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "ai_chat_messages",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `conversationId` TEXT NOT NULL, `role` TEXT NOT NULL, `partsJson` TEXT NOT NULL, `createdAt` INTEGER NOT NULL, `branchIndex` INTEGER NOT NULL, `isSelected` INTEGER NOT NULL, `parentMessageId` TEXT, `thinkingDuration` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "partsJson",
+            "columnName": "partsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "branchIndex",
+            "columnName": "branchIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "isSelected",
+            "columnName": "isSelected",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentMessageId",
+            "columnName": "parentMessageId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "thinkingDuration",
+            "columnName": "thinkingDuration",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_chat_messages_conversationId_createdAt",
+            "unique": false,
+            "columnNames": [
+              "conversationId",
+              "createdAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_chat_messages_conversationId_createdAt` ON `${TABLE_NAME}` (`conversationId`, `createdAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_memory",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`conversationId` TEXT NOT NULL, `key` TEXT NOT NULL, `value` TEXT NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`conversationId`, `key`))",
+        "fields": [
+          {
+            "fieldPath": "conversationId",
+            "columnName": "conversationId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "key",
+            "columnName": "key",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "value",
+            "columnName": "value",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "conversationId",
+            "key"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_memory_conversationId",
+            "unique": false,
+            "columnNames": [
+              "conversationId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_memory_conversationId` ON `${TABLE_NAME}` (`conversationId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "highlight_tag_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `title` TEXT NOT NULL, `pattern` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "tag_group_rules",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `pattern` TEXT NOT NULL, `groupName` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "pattern",
+            "columnName": "pattern",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "groupName",
+            "columnName": "groupName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        }
+      },
+      {
+        "tableName": "book_content_processes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER, `kind` TEXT NOT NULL, `stage` TEXT NOT NULL, `target` TEXT NOT NULL, `anchorJson` TEXT NOT NULL, `actionJson` TEXT NOT NULL, `styleJson` TEXT, `source` TEXT NOT NULL, `aiArtifactId` TEXT, `sourceContentHash` TEXT, `enabled` INTEGER NOT NULL, `sortOrder` INTEGER NOT NULL, `status` INTEGER NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "kind",
+            "columnName": "kind",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "stage",
+            "columnName": "stage",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "target",
+            "columnName": "target",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "anchorJson",
+            "columnName": "anchorJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "actionJson",
+            "columnName": "actionJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "styleJson",
+            "columnName": "styleJson",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aiArtifactId",
+            "columnName": "aiArtifactId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "sourceContentHash",
+            "columnName": "sourceContentHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortOrder",
+            "columnName": "sortOrder",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "enabled",
+              "sortOrder"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_chapterIndex_enabled_sortOrder` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `enabled`, `sortOrder`)"
+          },
+          {
+            "name": "index_book_content_processes_bookUrl_kind",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "kind"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_bookUrl_kind` ON `${TABLE_NAME}` (`bookUrl`, `kind`)"
+          },
+          {
+            "name": "index_book_content_processes_aiArtifactId",
+            "unique": false,
+            "columnNames": [
+              "aiArtifactId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_content_processes_aiArtifactId` ON `${TABLE_NAME}` (`aiArtifactId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "ai_prompt_presets",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `taskType` TEXT NOT NULL, `name` TEXT NOT NULL, `instruction` TEXT NOT NULL, `enabled` INTEGER NOT NULL, `builtIn` INTEGER NOT NULL, `sortNumber` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "taskType",
+            "columnName": "taskType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "instruction",
+            "columnName": "instruction",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "builtIn",
+            "columnName": "builtIn",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sortNumber",
+            "columnName": "sortNumber",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_ai_prompt_presets_taskType_enabled_sortNumber",
+            "unique": false,
+            "columnNames": [
+              "taskType",
+              "enabled",
+              "sortNumber"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_ai_prompt_presets_taskType_enabled_sortNumber` ON `${TABLE_NAME}` (`taskType`, `enabled`, `sortNumber`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_profiles",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `name` TEXT NOT NULL, `aliasesJson` TEXT NOT NULL, `avatarUri` TEXT, `tagsJson` TEXT NOT NULL, `role` TEXT NOT NULL, `voiceGender` TEXT NOT NULL DEFAULT 'unknown', `voiceAgeBand` TEXT NOT NULL DEFAULT 'unknown', `personality` TEXT NOT NULL, `summary` TEXT NOT NULL, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "aliasesJson",
+            "columnName": "aliasesJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "avatarUri",
+            "columnName": "avatarUri",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "tagsJson",
+            "columnName": "tagsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "role",
+            "columnName": "role",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceGender",
+            "columnName": "voiceGender",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "voiceAgeBand",
+            "columnName": "voiceAgeBand",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'unknown'"
+          },
+          {
+            "fieldPath": "personality",
+            "columnName": "personality",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_profiles_bookUrl_name",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "name"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_name` ON `${TABLE_NAME}` (`bookUrl`, `name`)"
+          },
+          {
+            "name": "index_book_character_profiles_bookUrl_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_profiles_bookUrl_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_events",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `characterId` TEXT NOT NULL, `chapterIndex` INTEGER, `chapterTitle` TEXT NOT NULL, `eventTimeText` TEXT NOT NULL, `content` TEXT NOT NULL, `importance` INTEGER NOT NULL, `sourceTextHash` TEXT, `evidenceJson` TEXT NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "chapterTitle",
+            "columnName": "chapterTitle",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "eventTimeText",
+            "columnName": "eventTimeText",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "importance",
+            "columnName": "importance",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "sourceTextHash",
+            "columnName": "sourceTextHash",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_events_bookUrl_characterId_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "characterId",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_characterId_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `characterId`, `chapterIndex`)"
+          },
+          {
+            "name": "index_book_character_events_bookUrl_chapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_events_bookUrl_chapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_character_relations",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `fromCharacterId` TEXT NOT NULL, `toCharacterId` TEXT NOT NULL, `relationType` TEXT NOT NULL, `summary` TEXT NOT NULL, `attitude` TEXT NOT NULL, `evidenceJson` TEXT NOT NULL, `chapterIndex` INTEGER, `status` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "fromCharacterId",
+            "columnName": "fromCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "toCharacterId",
+            "columnName": "toCharacterId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "relationType",
+            "columnName": "relationType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "attitude",
+            "columnName": "attitude",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId_toCharacterId",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`, `toCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_fromCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "fromCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_fromCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `fromCharacterId`)"
+          },
+          {
+            "name": "index_book_character_relations_bookUrl_toCharacterId",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "toCharacterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_character_relations_bookUrl_toCharacterId` ON `${TABLE_NAME}` (`bookUrl`, `toCharacterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_knowledge_entries",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `type` TEXT NOT NULL, `title` TEXT NOT NULL, `keywordsJson` TEXT NOT NULL, `content` TEXT NOT NULL, `scopeStartChapter` INTEGER, `scopeEndChapter` INTEGER, `priority` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `evidenceJson` TEXT NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "type",
+            "columnName": "type",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "keywordsJson",
+            "columnName": "keywordsJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "content",
+            "columnName": "content",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "scopeStartChapter",
+            "columnName": "scopeStartChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "scopeEndChapter",
+            "columnName": "scopeEndChapter",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "priority",
+            "columnName": "priority",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "evidenceJson",
+            "columnName": "evidenceJson",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_knowledge_entries_bookUrl_type_priority",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "type",
+              "priority"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_type_priority` ON `${TABLE_NAME}` (`bookUrl`, `type`, `priority`)"
+          },
+          {
+            "name": "index_book_knowledge_entries_bookUrl_title",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "title"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_knowledge_entries_bookUrl_title` ON `${TABLE_NAME}` (`bookUrl`, `title`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_outline_nodes",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `parentId` TEXT, `nodeType` TEXT NOT NULL, `title` TEXT NOT NULL, `summary` TEXT NOT NULL, `startChapterIndex` INTEGER, `endChapterIndex` INTEGER, `order` INTEGER NOT NULL, `source` TEXT NOT NULL, `confidence` REAL NOT NULL, `schemaVersion` INTEGER NOT NULL, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "parentId",
+            "columnName": "parentId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "nodeType",
+            "columnName": "nodeType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "title",
+            "columnName": "title",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "summary",
+            "columnName": "summary",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "startChapterIndex",
+            "columnName": "startChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "endChapterIndex",
+            "columnName": "endChapterIndex",
+            "affinity": "INTEGER"
+          },
+          {
+            "fieldPath": "order",
+            "columnName": "order",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true
+          },
+          {
+            "fieldPath": "schemaVersion",
+            "columnName": "schemaVersion",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "nodeType",
+              "startChapterIndex",
+              "endChapterIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_nodeType_startChapterIndex_endChapterIndex` ON `${TABLE_NAME}` (`bookUrl`, `nodeType`, `startChapterIndex`, `endChapterIndex`)"
+          },
+          {
+            "name": "index_book_outline_nodes_bookUrl_parentId_order",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "parentId",
+              "order"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_outline_nodes_bookUrl_parentId_order` ON `${TABLE_NAME}` (`bookUrl`, `parentId`, `order`)"
+          }
+        ]
+      },
+      {
+        "tableName": "read_aloud_voices",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `engineType` TEXT NOT NULL, `engineId` TEXT NOT NULL DEFAULT '', `speakerId` TEXT NOT NULL DEFAULT '', `displayName` TEXT NOT NULL, `traitsJson` TEXT NOT NULL DEFAULT '[]', `emotionCatalogJson` TEXT NOT NULL DEFAULT '[]', `managedBy` TEXT NOT NULL DEFAULT 'user', `enabled` INTEGER NOT NULL DEFAULT 1, `available` INTEGER NOT NULL DEFAULT 1, `revision` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineType",
+            "columnName": "engineType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "engineId",
+            "columnName": "engineId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "speakerId",
+            "columnName": "speakerId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "displayName",
+            "columnName": "displayName",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "traitsJson",
+            "columnName": "traitsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "emotionCatalogJson",
+            "columnName": "emotionCatalogJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'[]'"
+          },
+          {
+            "fieldPath": "managedBy",
+            "columnName": "managedBy",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "available",
+            "columnName": "available",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "revision",
+            "columnName": "revision",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_read_aloud_voices_engineType_engineId_speakerId",
+            "unique": true,
+            "columnNames": [
+              "engineType",
+              "engineId",
+              "speakerId"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_read_aloud_voices_engineType_engineId_speakerId` ON `${TABLE_NAME}` (`engineType`, `engineId`, `speakerId`)"
+          },
+          {
+            "name": "index_read_aloud_voices_enabled_displayName",
+            "unique": false,
+            "columnNames": [
+              "enabled",
+              "displayName"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_read_aloud_voices_enabled_displayName` ON `${TABLE_NAME}` (`enabled`, `displayName`)"
+          }
+        ]
+      },
+      {
+        "tableName": "book_voice_bindings",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`bookUrl` TEXT NOT NULL, `subjectType` TEXT NOT NULL, `subjectId` TEXT NOT NULL, `voiceId` TEXT NOT NULL, `locked` INTEGER NOT NULL DEFAULT 0, `source` TEXT NOT NULL DEFAULT 'user', `confidence` REAL NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`bookUrl`, `subjectType`, `subjectId`))",
+        "fields": [
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectType",
+            "columnName": "subjectType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "subjectId",
+            "columnName": "subjectId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "voiceId",
+            "columnName": "voiceId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "locked",
+            "columnName": "locked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'user'"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "bookUrl",
+            "subjectType",
+            "subjectId"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_book_voice_bindings_bookUrl_subjectType",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "subjectType"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_bookUrl_subjectType` ON `${TABLE_NAME}` (`bookUrl`, `subjectType`)"
+          },
+          {
+            "name": "index_book_voice_bindings_voiceId",
+            "unique": false,
+            "columnNames": [
+              "voiceId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_book_voice_bindings_voiceId` ON `${TABLE_NAME}` (`voiceId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_analysis",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `contentHash` TEXT NOT NULL, `resolverVersion` TEXT NOT NULL, `characterRevision` TEXT NOT NULL DEFAULT '', `status` TEXT NOT NULL DEFAULT 'pending', `error` TEXT NOT NULL DEFAULT '', `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "contentHash",
+            "columnName": "contentHash",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "resolverVersion",
+            "columnName": "resolverVersion",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterRevision",
+            "columnName": "characterRevision",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "status",
+            "columnName": "status",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'pending'"
+          },
+          {
+            "fieldPath": "error",
+            "columnName": "error",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion",
+            "unique": true,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "contentHash",
+              "resolverVersion"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_contentHash_resolverVersion` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `contentHash`, `resolverVersion`)"
+          },
+          {
+            "name": "index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "updatedAt"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_analysis_bookUrl_chapterIndex_updatedAt` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `updatedAt`)"
+          }
+        ]
+      },
+      {
+        "tableName": "chapter_speech_segments",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `analysisId` TEXT NOT NULL, `bookUrl` TEXT NOT NULL, `chapterIndex` INTEGER NOT NULL, `paragraphIndex` INTEGER NOT NULL, `start` INTEGER NOT NULL, `end` INTEGER NOT NULL, `chapterPosition` INTEGER NOT NULL, `text` TEXT NOT NULL, `roleType` TEXT NOT NULL, `characterId` TEXT, `characterName` TEXT NOT NULL DEFAULT '', `emotion` TEXT NOT NULL DEFAULT '', `confidence` REAL NOT NULL DEFAULT 0, `source` TEXT NOT NULL, `userLocked` INTEGER NOT NULL DEFAULT 0, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "analysisId",
+            "columnName": "analysisId",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "bookUrl",
+            "columnName": "bookUrl",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterIndex",
+            "columnName": "chapterIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "paragraphIndex",
+            "columnName": "paragraphIndex",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "start",
+            "columnName": "start",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "end",
+            "columnName": "end",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "chapterPosition",
+            "columnName": "chapterPosition",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "text",
+            "columnName": "text",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "roleType",
+            "columnName": "roleType",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "characterId",
+            "columnName": "characterId",
+            "affinity": "TEXT"
+          },
+          {
+            "fieldPath": "characterName",
+            "columnName": "characterName",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "emotion",
+            "columnName": "emotion",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "confidence",
+            "columnName": "confidence",
+            "affinity": "REAL",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "source",
+            "columnName": "source",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "userLocked",
+            "columnName": "userLocked",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "0"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_chapter_speech_segments_analysisId_paragraphIndex_start_end",
+            "unique": true,
+            "columnNames": [
+              "analysisId",
+              "paragraphIndex",
+              "start",
+              "end"
+            ],
+            "orders": [],
+            "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_chapter_speech_segments_analysisId_paragraphIndex_start_end` ON `${TABLE_NAME}` (`analysisId`, `paragraphIndex`, `start`, `end`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex",
+            "unique": false,
+            "columnNames": [
+              "bookUrl",
+              "chapterIndex",
+              "paragraphIndex"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_bookUrl_chapterIndex_paragraphIndex` ON `${TABLE_NAME}` (`bookUrl`, `chapterIndex`, `paragraphIndex`)"
+          },
+          {
+            "name": "index_chapter_speech_segments_characterId",
+            "unique": false,
+            "columnNames": [
+              "characterId"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_chapter_speech_segments_characterId` ON `${TABLE_NAME}` (`characterId`)"
+          }
+        ]
+      },
+      {
+        "tableName": "cloud_tts_engines",
+        "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `provider` TEXT NOT NULL, `baseUrl` TEXT NOT NULL DEFAULT '', `apiKey` TEXT NOT NULL DEFAULT '', `secretKey` TEXT NOT NULL DEFAULT '', `region` TEXT NOT NULL DEFAULT '', `appId` TEXT NOT NULL DEFAULT '', `model` TEXT NOT NULL DEFAULT '', `optionsJson` TEXT NOT NULL DEFAULT '{}', `enabled` INTEGER NOT NULL DEFAULT 1, `createdAt` INTEGER NOT NULL, `updatedAt` INTEGER NOT NULL, PRIMARY KEY(`id`))",
+        "fields": [
+          {
+            "fieldPath": "id",
+            "columnName": "id",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "name",
+            "columnName": "name",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "provider",
+            "columnName": "provider",
+            "affinity": "TEXT",
+            "notNull": true
+          },
+          {
+            "fieldPath": "baseUrl",
+            "columnName": "baseUrl",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "apiKey",
+            "columnName": "apiKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "secretKey",
+            "columnName": "secretKey",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "region",
+            "columnName": "region",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "appId",
+            "columnName": "appId",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "model",
+            "columnName": "model",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "''"
+          },
+          {
+            "fieldPath": "optionsJson",
+            "columnName": "optionsJson",
+            "affinity": "TEXT",
+            "notNull": true,
+            "defaultValue": "'{}'"
+          },
+          {
+            "fieldPath": "enabled",
+            "columnName": "enabled",
+            "affinity": "INTEGER",
+            "notNull": true,
+            "defaultValue": "1"
+          },
+          {
+            "fieldPath": "createdAt",
+            "columnName": "createdAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          },
+          {
+            "fieldPath": "updatedAt",
+            "columnName": "updatedAt",
+            "affinity": "INTEGER",
+            "notNull": true
+          }
+        ],
+        "primaryKey": {
+          "autoGenerate": false,
+          "columnNames": [
+            "id"
+          ]
+        },
+        "indices": [
+          {
+            "name": "index_cloud_tts_engines_provider_enabled",
+            "unique": false,
+            "columnNames": [
+              "provider",
+              "enabled"
+            ],
+            "orders": [],
+            "createSql": "CREATE INDEX IF NOT EXISTS `index_cloud_tts_engines_provider_enabled` ON `${TABLE_NAME}` (`provider`, `enabled`)"
+          }
+        ]
+      }
+    ],
+    "views": [
+      {
+        "viewName": "book_sources_part",
+        "createSql": "CREATE VIEW `${VIEW_NAME}` AS select bookSourceUrl, bookSourceName, bookSourceGroup, customOrder, enabled, enabledExplore, \n    (loginUrl is not null and trim(loginUrl) <> '') hasLoginUrl, lastUpdateTime, respondTime, weight, \n    (exploreUrl is not null and trim(exploreUrl) <> '') hasExploreUrl \n    from book_sources"
+      }
+    ],
+    "setupQueries": [
+      "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+      "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '7eb5b4729e665212e9cddaf3dfac2101')"
+    ]
+  }
+}

+ 3 - 2
app/src/main/java/io/legado/app/data/AppDatabase.kt

@@ -108,7 +108,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 96,
+    version = 97,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -179,7 +179,8 @@ val appDb by lazy {
         AutoMigration(from = 92, to = 93),
         AutoMigration(from = 93, to = 94),
         AutoMigration(from = 94, to = 95),
-        AutoMigration(from = 95, to = 96)
+        AutoMigration(from = 95, to = 96),
+        AutoMigration(from = 96, to = 97)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {

+ 15 - 0
app/src/main/java/io/legado/app/data/dao/BookKnowledgeDao.kt

@@ -156,4 +156,19 @@ interface BookKnowledgeDao {
 
     @Insert(onConflict = OnConflictStrategy.REPLACE)
     suspend fun upsertOutlineNode(node: BookOutlineNode)
+
+    @Query("delete from book_character_profiles where bookUrl = :bookUrl and id = :characterId")
+    suspend fun deleteCharacterProfile(bookUrl: String, characterId: String)
+
+    @Query("update book_character_relations set status = ${BookCharacterProfile.STATUS_DELETED} where fromCharacterId = :characterId or toCharacterId = :characterId")
+    suspend fun deleteRelationsForCharacter(characterId: String)
+
+    @Query("delete from book_character_events where bookUrl = :bookUrl and characterId = :characterId")
+    suspend fun deleteEventsForCharacter(bookUrl: String, characterId: String)
+
+    @Query("delete from book_character_relations where id = :relationId")
+    suspend fun deleteCharacterRelation(relationId: String)
+
+    @Query("delete from book_knowledge_entries where id = :entryId")
+    suspend fun deleteKnowledgeEntry(entryId: String)
 }

+ 21 - 0
app/src/main/java/io/legado/app/data/entities/BookKnowledge.kt

@@ -1,5 +1,6 @@
 package io.legado.app.data.entities
 
+import androidx.room.ColumnInfo
 import androidx.room.Entity
 import androidx.room.Index
 import androidx.room.PrimaryKey
@@ -20,6 +21,10 @@ data class BookCharacterProfile(
     val avatarUri: String? = null,
     val tagsJson: String = "[]",
     val role: String = "",
+    @ColumnInfo(defaultValue = "unknown")
+    val voiceGender: String = VOICE_GENDER_UNKNOWN,
+    @ColumnInfo(defaultValue = "unknown")
+    val voiceAgeBand: String = VOICE_AGE_UNKNOWN,
     val personality: String = "",
     val summary: String = "",
     val status: Int = STATUS_ACTIVE,
@@ -45,6 +50,22 @@ data class BookCharacterProfile(
         const val ROLE_FEMALE_SUPPORTING = "female_supporting"
         val ALL_ROLES =
             listOf(ROLE_MALE_LEAD, ROLE_FEMALE_LEAD, ROLE_MALE_SUPPORTING, ROLE_FEMALE_SUPPORTING)
+
+        const val VOICE_GENDER_MALE = "male"
+        const val VOICE_GENDER_FEMALE = "female"
+        const val VOICE_GENDER_UNKNOWN = "unknown"
+        val ALL_VOICE_GENDERS = listOf(VOICE_GENDER_MALE, VOICE_GENDER_FEMALE, VOICE_GENDER_UNKNOWN)
+
+        const val VOICE_AGE_CHILD = "child"
+        const val VOICE_AGE_TEEN = "teen"
+        const val VOICE_AGE_YOUNG_ADULT = "young_adult"
+        const val VOICE_AGE_ADULT = "adult"
+        const val VOICE_AGE_ELDERLY = "elderly"
+        const val VOICE_AGE_UNKNOWN = "unknown"
+        val ALL_VOICE_AGE_BANDS = listOf(
+            VOICE_AGE_CHILD, VOICE_AGE_TEEN, VOICE_AGE_YOUNG_ADULT,
+            VOICE_AGE_ADULT, VOICE_AGE_ELDERLY, VOICE_AGE_UNKNOWN,
+        )
     }
 }
 

+ 4 - 1
app/src/main/java/io/legado/app/data/entities/HttpTTS.kt

@@ -6,6 +6,7 @@ import androidx.room.PrimaryKey
 import com.jayway.jsonpath.DocumentContext
 import io.legado.app.utils.GSON
 import io.legado.app.utils.jsonPath
+import io.legado.app.utils.readBool
 import io.legado.app.utils.readLong
 import io.legado.app.utils.readString
 
@@ -55,6 +56,8 @@ data class HttpTTS(
                     loginUrl = doc.readString("$.loginUrl"),
                     loginUi = if (loginUi is List<*>) GSON.toJson(loginUi) else loginUi?.toString(),
                     header = doc.readString("$.header"),
+                    jsLib = doc.readString("$.jsLib"),
+                    enabledCookieJar = doc.readBool("$.enabledCookieJar"),
                     loginCheckJs = doc.readString("$.loginCheckJs"),
                     lastUpdateTime = doc.readLong("$.lastUpdateTime") ?: System.currentTimeMillis()
                 )
@@ -81,4 +84,4 @@ data class HttpTTS(
 
     }
 
-}
+}

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

@@ -506,6 +506,10 @@ class AiToolRepository(
             avatarUri = args.string("avatarUri") ?: existing?.avatarUri,
             tagsJson = args.string("tagsJson") ?: existing?.tagsJson ?: "[]",
             role = args.string("role") ?: existing?.role.orEmpty(),
+            voiceGender = args.string("voiceGender") ?: existing?.voiceGender
+            ?: BookCharacterProfile.VOICE_GENDER_UNKNOWN,
+            voiceAgeBand = args.string("voiceAgeBand") ?: existing?.voiceAgeBand
+            ?: BookCharacterProfile.VOICE_AGE_UNKNOWN,
             personality = args.string("personality") ?: existing?.personality.orEmpty(),
             summary = args.string("summary") ?: existing?.summary.orEmpty(),
             status = args.int("status", existing?.status ?: BookCharacterProfile.STATUS_ACTIVE),
@@ -763,6 +767,8 @@ class AiToolRepository(
             "avatarUri" to avatarUri,
             "tagsJson" to tagsJson,
             "role" to role,
+            "voiceGender" to voiceGender,
+            "voiceAgeBand" to voiceAgeBand,
             "personality" to personality,
             "summary" to summary,
             "status" to status,

+ 19 - 0
app/src/main/java/io/legado/app/data/repository/BookKnowledgeRepository.kt

@@ -113,4 +113,23 @@ class BookKnowledgeRepository(
     override suspend fun upsertOutlineNode(node: BookOutlineNode) = withContext(Dispatchers.IO) {
         dao.upsertOutlineNode(node)
     }
+
+    override suspend fun deleteCharacterProfile(
+        bookUrl: String,
+        characterId: String,
+        deleteRelations: Boolean,
+        deleteEvents: Boolean,
+    ) = withContext(Dispatchers.IO) {
+        dao.deleteCharacterProfile(bookUrl, characterId)
+        if (deleteRelations) dao.deleteRelationsForCharacter(characterId)
+        if (deleteEvents) dao.deleteEventsForCharacter(bookUrl, characterId)
+    }
+
+    override suspend fun deleteCharacterRelation(relationId: String) = withContext(Dispatchers.IO) {
+        dao.deleteCharacterRelation(relationId)
+    }
+
+    override suspend fun deleteKnowledgeEntry(entryId: String) = withContext(Dispatchers.IO) {
+        dao.deleteKnowledgeEntry(entryId)
+    }
 }

+ 1 - 0
app/src/main/java/io/legado/app/data/repository/HttpTtsEngineRepository.kt

@@ -20,6 +20,7 @@ class HttpTtsEngineRepository(
                 displayName = source.name,
                 providerName = "HTTP TTS",
                 supportsVoiceDiscovery = false,
+                loginUrl = source.loginUrl.orEmpty(),
             )
         }
     }

+ 10 - 1
app/src/main/java/io/legado/app/data/repository/ReadSettingsRepository.kt

@@ -344,6 +344,9 @@ class ReadSettingsRepository(
             textBottomJustify = compatDsValue(Keys.TextBottomJustify, true),
             adaptSpecialStyle = compatDsValue(Keys.AdaptSpecialStyle, true),
             useZhLayout = compatDsValue(Keys.UseZhLayout, false),
+            eyeProtectionEnabled = compatDsValue(Keys.EyeProtectionEnabled, false),
+            eyeProtectionIntensity = compatDsValue(Keys.EyeProtectionIntensity, 50),
+            eyeProtectionAutoNight = compatDsValue(Keys.EyeProtectionAutoNight, false),
             showBrightnessView = compatDsValue(Keys.ShowBrightnessView, "0"),
             brightnessVwPos = compatDsValue(Keys.BrightnessVwPos, "1"),
             readBrightness = compatDsValue(Keys.ReadBrightness, 100),
@@ -456,6 +459,9 @@ class ReadSettingsRepository(
         val TextBottomJustify = booleanPreferencesKey(PreferKey.textBottomJustify)
         val AdaptSpecialStyle = booleanPreferencesKey(PreferKey.adaptSpecialStyle)
         val UseZhLayout = booleanPreferencesKey(PreferKey.useZhLayout)
+        val EyeProtectionEnabled = booleanPreferencesKey(PreferKey.eyeProtectionEnabled)
+        val EyeProtectionIntensity = intPreferencesKey(PreferKey.colorTemperature)
+        val EyeProtectionAutoNight = booleanPreferencesKey(PreferKey.eyeProtectionAutoNight)
         val ShowBrightnessView = stringPreferencesKey(PreferKey.showBrightnessView)
         val BrightnessVwPos = stringPreferencesKey(PreferKey.brightnessVwPos)
         val ReadBrightness = intPreferencesKey(PreferKey.brightness)
@@ -553,7 +559,7 @@ class ReadSettingsRepository(
 }
 
 /**
- * ReadSettings 是 101 字段的读取超集;gateway update 当前只承诺持久化以下 45 个键。
+ * ReadSettings 是 101 字段的读取超集;gateway update 当前只承诺持久化以下 48 个键。
  * 扩展此边界时必须同步更新 ReadSettingsMappingTest 的显式键集契约。
  */
 internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
@@ -570,6 +576,9 @@ internal fun ReadSettings.toGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
+    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
+    PreferKey.colorTemperature to eyeProtectionIntensity,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

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

@@ -141,6 +141,7 @@ import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AiChatGenerationUseCase
+import io.legado.app.domain.usecase.AiTaskManager
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
 import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
 import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
@@ -162,6 +163,7 @@ import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
 import io.legado.app.domain.usecase.HomeDashboardUseCase
+import io.legado.app.domain.usecase.IdentifyBookCharactersUseCase
 import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
 import io.legado.app.domain.usecase.RefineSpeechWithAiUseCase
@@ -413,6 +415,8 @@ val appModule = module {
     singleOf(::ChangeSourceSearchUseCase)
     singleOf(::GetChapterContentUseCase)
     singleOf(::AiToolAwareGenerationUseCase)
+    singleOf(::AiTaskManager)
+    singleOf(::IdentifyBookCharactersUseCase)
     singleOf(::GenerateChapterSummaryUseCase)
     singleOf(::AiTextFactoryUseCase)
     singleOf(::CleanSelectedTextUseCase)
@@ -532,6 +536,7 @@ val appModule = module {
         BookCharacterListViewModel(
             bookUrl = bookUrl,
             bookKnowledgeGateway = get(),
+            identifyBookCharacters = get(),
         )
     }
     viewModel { (bookUrl: String) ->

+ 9 - 0
app/src/main/java/io/legado/app/domain/gateway/BookKnowledgeGateway.kt

@@ -61,4 +61,13 @@ interface BookKnowledgeGateway {
     suspend fun upsertCharacterRelation(relation: BookCharacterRelation)
     suspend fun upsertKnowledgeEntry(entry: BookKnowledgeEntry)
     suspend fun upsertOutlineNode(node: BookOutlineNode)
+    suspend fun deleteCharacterProfile(
+        bookUrl: String,
+        characterId: String,
+        deleteRelations: Boolean,
+        deleteEvents: Boolean,
+    )
+
+    suspend fun deleteCharacterRelation(relationId: String)
+    suspend fun deleteKnowledgeEntry(entryId: String)
 }

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

@@ -26,6 +26,7 @@ object AiTaskType {
     const val TEXT_FACTORY = "text_factory"
     const val REWRITE_TEXT = "rewrite_text"
     const val ANALYZE_SPEECH = "analyze_speech"
+    const val IDENTIFY_CHARACTERS = "identify_characters"
 }
 
 object AiPromptTemplate {

+ 11 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSpeechModels.kt

@@ -24,6 +24,13 @@ data class ReadAloudVoice(
     }
 }
 
+data class ReadAloudEngineSelection(
+    val engineType: String,
+    val engineId: String,
+    val speakerId: String = "",
+    val displayName: String = "",
+)
+
 data class VoiceCatalogEntry(
     val engineType: String,
     val engineId: String,
@@ -132,6 +139,8 @@ data class ChapterSpeechAnalysisResult(
 data class CharacterPerformanceProfile(
     val characterId: String,
     val role: String = "",
+    val voiceGender: String = "unknown",
+    val voiceAgeBand: String = "unknown",
     val personality: String = "",
     val updatedAt: Long = 0L,
 )
@@ -152,6 +161,8 @@ data class SpeakerCharacter(
     val name: String,
     val aliases: List<String> = emptyList(),
     val role: String = "",
+    val voiceGender: String = "unknown",
+    val voiceAgeBand: String = "unknown",
     val updatedAt: Long = 0L,
 )
 

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

@@ -35,6 +35,8 @@ object SpeechIdentity {
                 character.name,
                 character.aliases.sorted().joinToString("\u0002"),
                 character.role,
+                character.voiceGender,
+                character.voiceAgeBand,
                 character.updatedAt.toString(),
             ).joinToString("\u0000")
         }

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

@@ -8,6 +8,7 @@ data class TtsEngineDescriptor(
     val displayName: String,
     val providerName: String = "",
     val supportsVoiceDiscovery: Boolean = false,
+    val loginUrl: String = "",
 )
 
 enum class TtsEngineKind {

+ 145 - 0
app/src/main/java/io/legado/app/domain/usecase/AiTaskManager.kt

@@ -0,0 +1,145 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.domain.gateway.AiArtifactGateway
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import java.util.concurrent.ConcurrentHashMap
+
+data class AiTaskSnapshot(
+    val id: String,
+    val taskType: String,
+    val bookUrl: String,
+    val chapterIndex: Int? = null,
+    val status: Int,
+    val output: String? = null,
+    val reasoning: String = "",
+    val toolNames: List<String> = emptyList(),
+    val errorMessage: String? = null,
+)
+
+interface AiTaskReporter {
+    fun appendContent(text: String)
+    fun appendReasoning(text: String)
+    fun reportToolCall(name: String)
+}
+
+/**
+ * Application-scoped runner for AI work. UI collectors may disappear without cancelling work.
+ * Results and terminal errors are persisted as [AiArtifact] records.
+ */
+class AiTaskManager(
+    private val aiArtifactGateway: AiArtifactGateway,
+) {
+    private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+    private val jobs = ConcurrentHashMap<String, Job>()
+    private val snapshots = MutableStateFlow<Map<String, AiTaskSnapshot>>(emptyMap())
+
+    fun observeTask(taskId: String): Flow<AiTaskSnapshot?> = snapshots.map { it[taskId] }
+
+    fun observeBookTask(
+        bookUrl: String,
+        taskType: String,
+        chapterIndex: Int? = null,
+    ): Flow<AiTaskSnapshot?> = snapshots.map {
+        it.values.lastOrNull { snapshot ->
+            snapshot.bookUrl == bookUrl && snapshot.taskType == taskType &&
+                    (chapterIndex == null || snapshot.chapterIndex == chapterIndex)
+        }
+    }
+
+    fun submit(
+        artifact: AiArtifact,
+        work: suspend AiTaskReporter.() -> String,
+    ): String {
+        jobs[artifact.id]?.takeIf { it.isActive }?.let { return artifact.id }
+        jobs.remove(artifact.id)?.cancel()
+        publish(artifact.toSnapshot(status = AiArtifact.STATUS_RUNNING))
+        jobs[artifact.id] = scope.launch {
+            aiArtifactGateway.upsertArtifact(
+                artifact.copy(
+                    status = AiArtifact.STATUS_RUNNING,
+                    updatedAt = System.currentTimeMillis(),
+                )
+            )
+            try {
+                val output = work(Reporter(artifact.id))
+                val success = artifact.copy(
+                    status = AiArtifact.STATUS_SUCCESS,
+                    output = output,
+                    errorMessage = null,
+                    updatedAt = System.currentTimeMillis(),
+                )
+                aiArtifactGateway.upsertArtifact(success)
+                publish(success.toSnapshot().withProgress(taskId = artifact.id))
+            } catch (error: CancellationException) {
+                throw error
+            } catch (error: Throwable) {
+                val failed = artifact.copy(
+                    status = AiArtifact.STATUS_FAILED,
+                    errorMessage = error.localizedMessage ?: error.javaClass.simpleName,
+                    updatedAt = System.currentTimeMillis(),
+                )
+                aiArtifactGateway.upsertArtifact(failed)
+                publish(failed.toSnapshot().withProgress(taskId = artifact.id))
+            } finally {
+                jobs.remove(artifact.id)
+            }
+        }
+        return artifact.id
+    }
+
+    private fun publish(snapshot: AiTaskSnapshot) {
+        snapshots.update { it + (snapshot.id to snapshot) }
+    }
+
+    private fun AiArtifact.toSnapshot(
+        status: Int = this.status,
+    ) = AiTaskSnapshot(
+        id = id,
+        taskType = taskType,
+        bookUrl = bookUrl,
+        chapterIndex = chapterIndex,
+        status = status,
+        output = output,
+        errorMessage = errorMessage,
+    )
+
+    private fun AiTaskSnapshot.withProgress(taskId: String): AiTaskSnapshot {
+        val active = snapshots.value[taskId] ?: return this
+        return copy(reasoning = active.reasoning, toolNames = active.toolNames)
+    }
+
+    private inner class Reporter(
+        private val taskId: String,
+    ) : AiTaskReporter {
+        override fun appendContent(text: String) {
+            snapshots.update { current ->
+                val snapshot = current[taskId] ?: return@update current
+                current + (taskId to snapshot.copy(output = snapshot.output.orEmpty() + text))
+            }
+        }
+
+        override fun appendReasoning(text: String) {
+            snapshots.update { current ->
+                val snapshot = current[taskId] ?: return@update current
+                current + (taskId to snapshot.copy(reasoning = snapshot.reasoning + text))
+            }
+        }
+
+        override fun reportToolCall(name: String) {
+            snapshots.update { current ->
+                val snapshot = current[taskId] ?: return@update current
+                current + (taskId to snapshot.copy(toolNames = (snapshot.toolNames + name).distinct()))
+            }
+        }
+    }
+}

+ 40 - 0
app/src/main/java/io/legado/app/domain/usecase/AiTextFactoryUseCase.kt

@@ -24,6 +24,7 @@ class AiTextFactoryUseCase(
     private val aiProfileGateway: AiProfileGateway,
     private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
     private val aiArtifactGateway: AiArtifactGateway,
+    private val aiTaskManager: AiTaskManager,
 ) {
 
     data class Request(
@@ -45,6 +46,45 @@ class AiTextFactoryUseCase(
         data class Done(val text: String, val reasoning: String) : StreamEvent
     }
 
+    fun observeTask(bookUrl: String, taskType: String, chapterIndex: Int?) =
+        aiTaskManager.observeBookTask(bookUrl, taskType, chapterIndex)
+
+    fun observeTaskById(taskId: String) = aiTaskManager.observeTask(taskId)
+
+    suspend fun start(request: Request): String {
+        val preset = resolvePreset(request.taskType) ?: error("No AI model configured")
+        val systemPrompt = buildSystemPrompt(preset, request.userInstruction)
+        val userInput =
+            buildUserInput(request.chapterTitle, request.inputText, request.referenceText)
+        val contentHash = request.artifactContentHash ?: MD5Utils.md5Encode(userInput)
+        val promptHash = MD5Utils.md5Encode(
+            systemPrompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION,
+        )
+        val now = System.currentTimeMillis()
+        val artifact = AiArtifact(
+            id = request.buildArtifactId(contentHash, preset.model.id, now),
+            taskType = request.taskType,
+            bookUrl = request.bookUrl,
+            chapterIndex = request.chapterIndex,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = preset.model.id,
+            createdAt = now,
+            updatedAt = now,
+        )
+        return aiTaskManager.submit(artifact) {
+            var output = ""
+            executeStream(request).collect { event ->
+                when (event) {
+                    is StreamEvent.Content -> appendContent(event.text)
+                    is StreamEvent.Reasoning -> appendReasoning(event.text)
+                    is StreamEvent.Done -> output = event.text
+                }
+            }
+            output
+        }
+    }
+
     suspend fun execute(request: Request): Result<String> = withContext(Dispatchers.IO) {
         runCatching {
             require(request.inputText.isNotBlank()) { "Input text is empty" }

+ 4 - 1
app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt

@@ -41,7 +41,10 @@ class AiToolAwareGenerationUseCase(
                     }
 
                     is AiStreamEvent.Reasoning -> emit(event)
-                    is AiStreamEvent.ToolCallDelta -> toolTrace.append(event)
+                    is AiStreamEvent.ToolCallDelta -> {
+                        toolTrace.append(event)
+                        emit(event)
+                    }
                 }
             }
 

+ 25 - 18
app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt

@@ -48,19 +48,15 @@ class BuildSpeechPlanUseCase(
                         voicesById,
                     )
                 }
-                val characterRole = segment.characterId?.let(characterPerformances::get)?.role
-                val roleVoice = characterRole?.let { role ->
-                    bindings.voice(role, role, voicesById)
-                }
-                val genderFallback = when (characterRole) {
-                    BookVoiceBinding.SUBJECT_MALE_LEAD,
-                    BookVoiceBinding.SUBJECT_MALE_SUPPORTING -> bindings.voice(
+                val performance = segment.characterId?.let(characterPerformances::get)
+                val genderFallback = when (performance?.resolvedGender()) {
+                    "male" -> bindings.voice(
                         BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
                         BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
                         voicesById,
                     )
-                    BookVoiceBinding.SUBJECT_FEMALE_LEAD,
-                    BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> bindings.voice(
+
+                    "female" -> bindings.voice(
                         BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
                         BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
                         voicesById,
@@ -69,7 +65,7 @@ class BuildSpeechPlanUseCase(
                 }
                 when (segment.roleType) {
                     SpeechRoleType.Character,
-                    SpeechRoleType.Thought -> characterVoice ?: roleVoice ?: genderFallback
+                    SpeechRoleType.Thought -> characterVoice ?: genderFallback
                         ?: unknown ?: narrator ?: defaultVoice
                     SpeechRoleType.Unknown -> unknown ?: narrator ?: defaultVoice
                     SpeechRoleType.Narrator -> narrator ?: defaultVoice
@@ -80,18 +76,16 @@ class BuildSpeechPlanUseCase(
             } else {
                 buildList {
                     if (segment.roleType != SpeechRoleType.Narrator) {
-                        val characterRole = segment.characterId
-                            ?.let(characterPerformances::get)?.role
-                        add(characterRole?.let { bindings.voice(it, it, voicesById) })
-                        add(when (characterRole) {
-                            BookVoiceBinding.SUBJECT_MALE_LEAD,
-                            BookVoiceBinding.SUBJECT_MALE_SUPPORTING -> bindings.voice(
+                        val performance = segment.characterId?.let(characterPerformances::get)
+                        add(
+                            when (performance?.resolvedGender()) {
+                                "male" -> bindings.voice(
                                 BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
                                 BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
                                 voicesById,
                             )
-                            BookVoiceBinding.SUBJECT_FEMALE_LEAD,
-                            BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> bindings.voice(
+
+                                "female" -> bindings.voice(
                                 BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
                                 BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
                                 voicesById,
@@ -120,4 +114,17 @@ class BuildSpeechPlanUseCase(
         subjectId: String,
         voicesById: Map<String, ReadAloudVoice>,
     ): ReadAloudVoice? = get(subjectType to subjectId)?.voiceId?.let(voicesById::get)
+
+    private fun CharacterPerformanceProfile.resolvedGender(): String? = when (voiceGender) {
+        "male", "female" -> voiceGender
+        else -> when (role) {
+            BookVoiceBinding.SUBJECT_MALE_LEAD,
+            BookVoiceBinding.SUBJECT_MALE_SUPPORTING -> "male"
+
+            BookVoiceBinding.SUBJECT_FEMALE_LEAD,
+            BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> "female"
+
+            else -> null
+        }
+    }
 }

+ 61 - 0
app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt

@@ -25,6 +25,7 @@ class CleanSelectedTextUseCase(
     private val aiProfileGateway: AiProfileGateway,
     private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
     private val aiArtifactGateway: AiArtifactGateway,
+    private val aiTaskManager: AiTaskManager,
 ) {
 
     sealed interface StreamEvent {
@@ -37,6 +38,66 @@ class CleanSelectedTextUseCase(
         ) : StreamEvent
     }
 
+    fun observeTask(bookUrl: String, chapterIndex: Int) = aiTaskManager.observeBookTask(
+        bookUrl = bookUrl,
+        taskType = AiTaskType.CLEAN_SELECTION,
+        chapterIndex = chapterIndex,
+    )
+
+    fun observeTaskById(taskId: String) = aiTaskManager.observeTask(taskId)
+
+    suspend fun start(
+        bookUrl: String,
+        chapterIndex: Int,
+        chapterTitle: String,
+        selectedText: String,
+        contextBefore: String,
+        contextAfter: String,
+    ): String {
+        val preset = resolvePreset() ?: error("No AI model configured")
+        val input = buildUserContent(
+            chapterTitle = chapterTitle,
+            selectedText = selectedText,
+            contextBefore = contextBefore.takeLast(MAX_CONTEXT_CHARS),
+            contextAfter = contextAfter.take(MAX_CONTEXT_CHARS),
+        )
+        val contentHash = MD5Utils.md5Encode(input)
+        val promptHash = MD5Utils.md5Encode(
+            AiPromptTemplate.DEFAULT_CLEAN_SELECTION + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION,
+        )
+        val now = System.currentTimeMillis()
+        val artifact = AiArtifact(
+            id = "${bookUrl}_${chapterIndex}_${AiTaskType.CLEAN_SELECTION}_${contentHash}_${preset.model.id}",
+            taskType = AiTaskType.CLEAN_SELECTION,
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = preset.model.id,
+            createdAt = now,
+            updatedAt = now,
+        )
+        return aiTaskManager.submit(artifact) {
+            var replacement = ""
+            executeStream(
+                bookUrl,
+                chapterIndex,
+                chapterTitle,
+                selectedText,
+                contextBefore,
+                contextAfter
+            )
+                .collect { event ->
+                    when (event) {
+                        is StreamEvent.Content -> appendContent(event.text)
+                        is StreamEvent.Reasoning -> appendReasoning(event.text)
+                        is StreamEvent.Done -> replacement = event.replacement
+                    }
+                }
+            replacement
+        }
+    }
+
     suspend fun execute(
         bookUrl: String,
         chapterIndex: Int,

+ 46 - 1
app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt

@@ -25,7 +25,8 @@ import kotlinx.coroutines.withContext
 class GenerateChapterSummaryUseCase(
     private val aiProfileGateway: AiProfileGateway,
     private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
-    private val aiArtifactGateway: AiArtifactGateway
+    private val aiArtifactGateway: AiArtifactGateway,
+    private val aiTaskManager: AiTaskManager,
 ) {
 
     sealed interface StreamEvent {
@@ -34,6 +35,50 @@ class GenerateChapterSummaryUseCase(
         data class Done(val text: String, val reasoning: String) : StreamEvent
     }
 
+    fun observeTask(bookUrl: String, chapterIndex: Int) = aiTaskManager.observeBookTask(
+        bookUrl = bookUrl,
+        taskType = AiTaskType.SUMMARIZE_CHAPTER,
+        chapterIndex = chapterIndex,
+    )
+
+    suspend fun start(
+        book: Book,
+        bookChapter: BookChapter,
+        contentOverride: String? = null,
+        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK,
+    ): String {
+        val content = contentOverride ?: BookHelp.getContent(book, bookChapter)
+        ?: error("Failed to read chapter content")
+        val preset = resolvePreset() ?: error("No AI model configured for chapter summary")
+        val contentHash = MD5Utils.md5Encode(content)
+        val promptHash = MD5Utils.md5Encode(
+            preset.promptTemplate + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION,
+        )
+        val now = System.currentTimeMillis()
+        val artifact = AiArtifact(
+            id = "${book.bookUrl}_${bookChapter.index}_${AiTaskType.SUMMARIZE_CHAPTER}_${contentHash}_${preset.model.id}",
+            taskType = AiTaskType.SUMMARIZE_CHAPTER,
+            bookUrl = book.bookUrl,
+            chapterIndex = bookChapter.index,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = preset.model.id,
+            createdAt = now,
+            updatedAt = now,
+        )
+        return aiTaskManager.submit(artifact) {
+            var summary = ""
+            executeStream(book, bookChapter, content, maxCharsPerChunk).collect { event ->
+                when (event) {
+                    is StreamEvent.Content -> appendContent(event.text)
+                    is StreamEvent.Reasoning -> appendReasoning(event.text)
+                    is StreamEvent.Done -> summary = event.text
+                }
+            }
+            summary.ifBlank { error("AI returned an empty chapter summary") }
+        }
+    }
+
     suspend fun execute(
         book: Book,
         bookChapter: BookChapter,

+ 216 - 0
app/src/main/java/io/legado/app/domain/usecase/IdentifyBookCharactersUseCase.kt

@@ -0,0 +1,216 @@
+package io.legado.app.domain.usecase
+
+import com.google.gson.JsonParser
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.domain.gateway.AiArtifactGateway
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+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.AiTaskType
+import io.legado.app.domain.model.AiToolContext
+import io.legado.app.utils.GSON
+import io.legado.app.utils.MD5Utils
+import io.legado.app.utils.fromJsonArray
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.flow
+import java.util.UUID
+
+class IdentifyBookCharactersUseCase(
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiToolAwareGenerationUseCase: AiToolAwareGenerationUseCase,
+    private val aiArtifactGateway: AiArtifactGateway,
+    private val aiTaskManager: AiTaskManager,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) {
+    data class Candidate(
+        val name: String,
+        val aliases: List<String>,
+        val voiceGender: String,
+        val voiceAgeBand: String,
+        val role: String,
+        val personality: String,
+        val summary: String,
+        val evidence: String,
+        val confidence: Float,
+    )
+
+    sealed interface Progress {
+        data class Reasoning(val text: String) : Progress
+        data class ToolCall(val name: String) : Progress
+        data class Done(val candidates: List<Candidate>) : Progress
+    }
+
+    suspend fun identify(bookUrl: String): List<Candidate> {
+        var candidates = emptyList<Candidate>()
+        identifyStream(bookUrl).collect { progress ->
+            if (progress is Progress.Done) candidates = progress.candidates
+        }
+        return candidates
+    }
+
+    suspend fun loadLatest(bookUrl: String): List<Candidate> {
+        val artifact =
+            aiArtifactGateway.observeBookArtifacts(bookUrl, AiTaskType.IDENTIFY_CHARACTERS)
+                .first()
+                .firstOrNull { it.status == AiArtifact.STATUS_SUCCESS && !it.output.isNullOrBlank() }
+                ?: return emptyList()
+        return decodeCandidates(artifact.output)
+    }
+
+    fun observeTask(bookUrl: String) = aiTaskManager.observeBookTask(
+        bookUrl = bookUrl,
+        taskType = AiTaskType.IDENTIFY_CHARACTERS,
+    )
+
+    suspend fun start(bookUrl: String): String {
+        val identifyPreset = aiProfileGateway.getTaskPreset(AiTaskType.IDENTIFY_CHARACTERS)
+        val preset = identifyPreset
+            ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
+            ?: error("No AI model configured for character identification")
+        val prompt = identifyPreset?.promptTemplate?.takeIf(String::isNotBlank) ?: DEFAULT_PROMPT
+        val now = System.currentTimeMillis()
+        val contentHash = MD5Utils.md5Encode(bookUrl)
+        val promptHash =
+            MD5Utils.md5Encode(prompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION)
+        val artifact = AiArtifact(
+            id = "${contentHash}_${AiTaskType.IDENTIFY_CHARACTERS}_${promptHash}_${preset.model.id}",
+            taskType = AiTaskType.IDENTIFY_CHARACTERS,
+            bookUrl = bookUrl,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = preset.model.id,
+            createdAt = now,
+            updatedAt = now,
+        )
+        return aiTaskManager.submit(artifact) {
+            var candidates = emptyList<Candidate>()
+            identifyStream(bookUrl).collect { progress ->
+                when (progress) {
+                    is Progress.Reasoning -> appendReasoning(progress.text)
+                    is Progress.ToolCall -> reportToolCall(progress.name)
+                    is Progress.Done -> candidates = progress.candidates
+                }
+            }
+            GSON.toJson(candidates)
+        }
+    }
+
+    fun decodeCandidates(output: String?): List<Candidate> =
+        output?.let { GSON.fromJsonArray<Candidate>(it).getOrNull() }.orEmpty()
+
+    fun identifyStream(bookUrl: String): Flow<Progress> = flow {
+        val identifyPreset = aiProfileGateway.getTaskPreset(AiTaskType.IDENTIFY_CHARACTERS)
+        val preset = identifyPreset
+            ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
+            ?: error("No AI model configured for character identification")
+        val prompt = identifyPreset?.promptTemplate?.takeIf(String::isNotBlank) ?: DEFAULT_PROMPT
+        val response = StringBuilder()
+        aiToolAwareGenerationUseCase.generateStream(
+            AiGenerateRequest(
+                model = preset.model,
+                messages = listOf(
+                    AiMessage(AiMessageRole.SYSTEM, prompt),
+                    AiMessage(
+                        AiMessageRole.USER,
+                        "识别本书已下载章节中的人物。请先用工具读取已缓存章节,再只返回要求的 JSON。"
+                    ),
+                ),
+                params = preset.params.copy(temperature = 0f),
+                toolContext = AiToolContext(bookUrl = bookUrl),
+            )
+        ).collect { event ->
+            when (event) {
+                is io.legado.app.domain.gateway.AiStreamEvent.Content -> response.append(event.text)
+                is io.legado.app.domain.gateway.AiStreamEvent.Reasoning -> emit(
+                    Progress.Reasoning(
+                        event.text
+                    )
+                )
+
+                is io.legado.app.domain.gateway.AiStreamEvent.ToolCallDelta -> {
+                    event.name?.takeIf(String::isNotBlank)?.let { emit(Progress.ToolCall(it)) }
+                }
+            }
+        }
+        val jsonStart = response.indexOf('{')
+        val jsonEnd = response.lastIndexOf('}')
+        require(jsonStart >= 0 && jsonEnd > jsonStart) {
+            "Character identification did not return a JSON object"
+        }
+        val root = JsonParser.parseString(response.substring(jsonStart, jsonEnd + 1)).asJsonObject
+        val candidates = root.getAsJsonArray("characters").map { element ->
+            val item = element.asJsonObject
+            Candidate(
+                name = item.get("name")?.asString?.trim().orEmpty(),
+                aliases = item.getAsJsonArray("aliases")?.map { it.asString.trim() }.orEmpty(),
+                voiceGender = item.get("voiceGender")?.asString ?: "unknown",
+                voiceAgeBand = item.get("voiceAgeBand")?.asString ?: "unknown",
+                role = item.get("role")?.asString.orEmpty(),
+                personality = item.get("personality")?.asString.orEmpty(),
+                summary = item.get("summary")?.asString.orEmpty(),
+                evidence = item.get("evidence")?.asString.orEmpty(),
+                confidence = item.get("confidence")?.asFloat?.coerceIn(0f, 1f) ?: 0f,
+            )
+        }.filter { it.name.isNotBlank() && it.confidence >= MIN_CONFIDENCE }
+            .distinctBy { it.name }
+        val now = System.currentTimeMillis()
+        val contentHash = MD5Utils.md5Encode(bookUrl)
+        val promptHash =
+            MD5Utils.md5Encode(prompt + AiToolAwareGenerationUseCase.CACHE_PROMPT_VERSION)
+        aiArtifactGateway.upsertArtifact(
+            AiArtifact(
+                id = "${contentHash}_${AiTaskType.IDENTIFY_CHARACTERS}_${promptHash}_${preset.model.id}",
+                taskType = AiTaskType.IDENTIFY_CHARACTERS,
+                bookUrl = bookUrl,
+                contentHash = contentHash,
+                promptHash = promptHash,
+                modelProfileId = preset.model.id,
+                status = AiArtifact.STATUS_SUCCESS,
+                output = GSON.toJson(candidates),
+                createdAt = now,
+                updatedAt = now,
+            )
+        )
+        emit(Progress.Done(candidates))
+    }
+
+    suspend fun save(bookUrl: String, candidates: List<Candidate>) {
+        candidates.forEach { candidate ->
+            val existing = bookKnowledgeGateway.getCharacterProfile(bookUrl, candidate.name)
+            bookKnowledgeGateway.upsertCharacterProfile(
+                BookCharacterProfile(
+                    id = existing?.id ?: UUID.randomUUID().toString(),
+                    bookUrl = bookUrl,
+                    name = candidate.name,
+                    aliasesJson = GSON.toJson(
+                        (GSON.fromJsonArray<String>(existing?.aliasesJson.orEmpty()).getOrNull()
+                            .orEmpty()
+                                + candidate.aliases)
+                            .map(String::trim)
+                            .filter(String::isNotBlank)
+                            .distinct(),
+                    ),
+                    role = candidate.role,
+                    voiceGender = candidate.voiceGender,
+                    voiceAgeBand = candidate.voiceAgeBand,
+                    personality = candidate.personality,
+                    summary = candidate.summary,
+                    source = BookCharacterProfile.SOURCE_AI,
+                    confidence = candidate.confidence,
+                    createdAt = existing?.createdAt ?: System.currentTimeMillis(),
+                    updatedAt = System.currentTimeMillis(),
+                )
+            )
+        }
+    }
+
+    companion object {
+        private const val MIN_CONFIDENCE = 0.65f
+        const val DEFAULT_PROMPT =
+            """You identify stable fictional characters from downloaded local chapters. Use read-only tools to list and read cached chapters and inspect existing characters. Return JSON only: {\"characters\":[{\"name\":string,\"aliases\":[string],\"voiceGender\":\"male|female|unknown\",\"voiceAgeBand\":\"child|teen|young_adult|adult|elderly|unknown\",\"role\":\"male_lead|female_lead|male_supporting|female_supporting|\",\"personality\":string,\"summary\":string,\"evidence\":string,\"confidence\":number}]}. Do not include pronouns, generic titles, or one-off passers-by. Use unknown instead of guessing age or gender. Do not create duplicates of existing names or aliases."""
+    }
+}

+ 3 - 1
app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt

@@ -21,9 +21,9 @@ import io.legado.app.domain.model.readaloud.SpeechEmotion
 import io.legado.app.domain.model.readaloud.SpeechIdentity
 import io.legado.app.domain.model.readaloud.SpeechResolutionSource
 import io.legado.app.domain.model.readaloud.SpeechRoleType
-import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
 import io.legado.app.help.readaloud.segment.AiSpeechAtom
 import io.legado.app.help.readaloud.segment.AiSpeechAtomizer
+import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
 import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 
@@ -338,6 +338,8 @@ class RefineSpeechWithAiUseCase(
         "name" to name,
         "aliasesJson" to aliasesJson,
         "role" to role,
+        "voiceGender" to voiceGender,
+        "voiceAgeBand" to voiceAgeBand,
         "personality" to personality,
     )
 

+ 5 - 1
app/src/main/java/io/legado/app/domain/usecase/ResolveLocalSpeakersUseCase.kt

@@ -4,8 +4,8 @@ import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
-import io.legado.app.domain.model.readaloud.CharacterPerformanceProfile
 import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysisResult
+import io.legado.app.domain.model.readaloud.CharacterPerformanceProfile
 import io.legado.app.domain.model.readaloud.SpeakerCharacter
 import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
 import io.legado.app.domain.model.readaloud.SpeechIdentity
@@ -37,6 +37,8 @@ class ResolveLocalSpeakersUseCase(
                 name = profile.name,
                 aliases = GSON.fromJsonArray<String>(profile.aliasesJson).getOrNull().orEmpty(),
                 role = profile.role,
+                voiceGender = profile.voiceGender,
+                voiceAgeBand = profile.voiceAgeBand,
                 updatedAt = profile.updatedAt,
             )
         }
@@ -44,6 +46,8 @@ class ResolveLocalSpeakersUseCase(
             CharacterPerformanceProfile(
                 characterId = profile.id,
                 role = profile.role,
+                voiceGender = profile.voiceGender,
+                voiceAgeBand = profile.voiceAgeBand,
                 personality = profile.personality,
                 updatedAt = profile.updatedAt,
             )

+ 12 - 1
app/src/main/java/io/legado/app/help/readaloud/playback/CharacterPerformanceInstructionBuilder.kt

@@ -19,11 +19,13 @@ object CharacterPerformanceInstructionBuilder {
     ): String {
         if (provider !in supportedProviders || profile == null) return ""
         val role = profile.role.toRoleLabel()
+        val voiceAge = profile.voiceAgeBand.toAgeLabel()
         val personality = profile.personality.toSafeTraitText()
-        if (role.isBlank() && personality.isBlank()) return ""
+        if (role.isBlank() && voiceAge.isBlank() && personality.isBlank()) return ""
         return buildString {
             append("保持该角色的声音表演一致。")
             if (role.isNotBlank()) append("角色定位:$role。")
+            if (voiceAge.isNotBlank()) append("声音年龄:$voiceAge。")
             if (personality.isNotBlank()) append("性格特征:$personality。")
             append("这些内容仅用于控制表演,不要朗读,也不要修改台词。")
         }
@@ -43,6 +45,15 @@ object CharacterPerformanceInstructionBuilder {
             .trim()
             .take(MAX_PERSONALITY_LENGTH)
 
+    private fun String.toAgeLabel(): String = when (trim()) {
+        "child" -> "儿童"
+        "teen" -> "少年或少女"
+        "young_adult" -> "青年"
+        "adult" -> "成年人"
+        "elderly" -> "老人"
+        else -> ""
+    }
+
     private const val MAX_PERSONALITY_LENGTH = 240
     private val CONTROL_CHARACTERS = Regex("[\\p{Cc}\\p{Cf}]")
     private val WHITESPACE = Regex("\\s+")

+ 19 - 3
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -8,22 +8,23 @@ import io.legado.app.constant.EventBus
 import io.legado.app.constant.IntentAction
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.HttpTTS
+import io.legado.app.domain.model.PlaybackTimer
+import io.legado.app.domain.model.readaloud.ReadAloudEngineSelection
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.lib.dialogs.SelectItem
-import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.service.BaseReadAloudService
 import io.legado.app.service.HttpReadAloudService
 import io.legado.app.service.TTSReadAloudService
 import io.legado.app.ui.config.readConfig.ReadConfig
-import io.legado.app.utils.LogUtils
 import io.legado.app.utils.GSON
+import io.legado.app.utils.LogUtils
 import io.legado.app.utils.StringUtils
 import io.legado.app.utils.fromJsonObject
 import io.legado.app.utils.postEvent
 import io.legado.app.utils.startForegroundServiceCompat
 import io.legado.app.utils.toastOnUi
-import splitties.init.appCtx
 import kotlinx.coroutines.runBlocking
+import splitties.init.appCtx
 
 object ReadAloud {
     private var aloudClass: Class<*> = getReadAloudClass()
@@ -33,9 +34,22 @@ object ReadAloud {
         private set
     var coordinatorDefaultEngineId: String = ""
         private set
+    var coordinatorDefaultSpeakerId: String = ""
+        private set
 
     private fun getReadAloudClass(): Class<*> {
         val ttsEngine = ttsEngine
+        GSON.fromJsonObject<ReadAloudEngineSelection>(ttsEngine).getOrNull()
+            ?.takeIf { it.engineType == ReadAloudVoice.ENGINE_CLOUD }
+            ?.let { selection ->
+                coordinatorDefaultEngineType = selection.engineType
+                coordinatorDefaultEngineId = selection.engineId
+                coordinatorDefaultSpeakerId = selection.speakerId
+                httpTTS = HttpTTS(
+                    id = Long.MIN_VALUE,
+                    name = selection.displayName.ifBlank { "Cloud TTS" })
+                return HttpReadAloudService::class.java
+            }
         if (ttsEngine.isNullOrBlank()) {
             setSystemCoordinatorDefault(ttsEngine)
             findCoordinatorHttpSeed()?.let {
@@ -49,6 +63,7 @@ object ReadAloud {
             if (httpTTS != null) {
                 coordinatorDefaultEngineType = ReadAloudVoice.ENGINE_HTTP
                 coordinatorDefaultEngineId = ttsEngine
+                coordinatorDefaultSpeakerId = ""
                 return HttpReadAloudService::class.java
             }
         }
@@ -64,6 +79,7 @@ object ReadAloud {
         coordinatorDefaultEngineType = ReadAloudVoice.ENGINE_SYSTEM
         coordinatorDefaultEngineId = GSON.fromJsonObject<SelectItem<String>>(serializedEngine)
             .getOrNull()?.value.orEmpty()
+        coordinatorDefaultSpeakerId = ""
     }
 
     private fun findCoordinatorHttpSeed(): HttpTTS? {

+ 1 - 0
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -947,6 +947,7 @@ class HttpReadAloudService : BaseReadAloudService(),
             defaultRoute = SpeechEngineRoute(
                 engineType = ReadAloud.coordinatorDefaultEngineType,
                 engineId = ReadAloud.coordinatorDefaultEngineId,
+                speakerId = ReadAloud.coordinatorDefaultSpeakerId,
             ),
         ).voice!!
     }

+ 40 - 0
app/src/main/java/io/legado/app/ui/ai/AiTaskResultSheet.kt

@@ -0,0 +1,40 @@
+package io.legado.app.ui.ai
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+
+/** Shared shell for AI task results that need loading, error, and user confirmation content. */
+@Composable
+fun AiTaskResultSheet(
+    show: Boolean,
+    title: String,
+    onDismissRequest: () -> Unit,
+    startAction: @Composable (() -> Unit)? = null,
+    endAction: @Composable (() -> Unit)? = null,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    AppModalBottomSheet(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = title,
+        startAction = startAction,
+        endAction = endAction,
+        content = {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 560.dp)
+                    .verticalScroll(rememberScrollState()),
+                content = content,
+            )
+        },
+    )
+}

+ 139 - 7
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt

@@ -16,6 +16,7 @@ import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.shape.RoundedCornerShape
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -45,6 +46,7 @@ import io.legado.app.ui.widget.components.EmptyMessage
 import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -71,11 +73,13 @@ fun BookCharacterDetailScreen(
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val context = LocalContext.current
+    var showDeleteDialog by remember { mutableStateOf(false) }
 
     LaunchedEffect(effects) {
         effects.collectLatest { effect ->
             when (effect) {
                 is CharacterDetailEffect.ShowToast -> context.toastOnUi(effect.message)
+                CharacterDetailEffect.NavigateBack -> onBack()
             }
         }
     }
@@ -89,6 +93,13 @@ fun BookCharacterDetailScreen(
                     TopBarNavigationButton(onClick = onBack)
                 },
                 actions = {
+                    if (state.characterId != null) {
+                        TopBarActionButton(
+                            onClick = { showDeleteDialog = true },
+                            imageVector = Icons.Default.Delete,
+                            contentDescription = stringResource(R.string.delete),
+                        )
+                    }
                     TopBarActionButton(
                         onClick = { onIntent(CharacterDetailIntent.Save) },
                         imageVector = Icons.Default.Save,
@@ -122,6 +133,15 @@ fun BookCharacterDetailScreen(
             )
         }
     }
+
+    CharacterDeleteDialog(
+        show = showDeleteDialog,
+        onDismiss = { showDeleteDialog = false },
+        onDelete = { deleteRelations, deleteEvents ->
+            onIntent(CharacterDetailIntent.Delete(deleteRelations, deleteEvents))
+            showDeleteDialog = false
+        },
+    )
 }
 
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
@@ -136,6 +156,8 @@ private fun CharacterDetailContent(
     var dialogName by remember(state.name) { mutableStateOf(state.name) }
     var dialogAliases by remember(state.aliasesText) { mutableStateOf(state.aliasesText) }
     var dialogRole by remember(state.role) { mutableStateOf(state.role) }
+    var dialogVoiceGender by remember(state.voiceGender) { mutableStateOf(state.voiceGender) }
+    var dialogVoiceAgeBand by remember(state.voiceAgeBand) { mutableStateOf(state.voiceAgeBand) }
 
     Column(
         modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
@@ -146,12 +168,16 @@ private fun CharacterDetailContent(
             aliasesText = state.aliasesText,
             avatarUri = state.avatarUri,
             roleDisplayName = state.role,
+            voiceGender = state.voiceGender,
+            voiceAgeBand = state.voiceAgeBand,
             tags = state.tags,
             onPickAvatar = onPickAvatar,
             onInfoClick = {
                 dialogName = state.name
                 dialogAliases = state.aliasesText
                 dialogRole = state.role
+                dialogVoiceGender = state.voiceGender
+                dialogVoiceAgeBand = state.voiceAgeBand
                 showProfileDialog = true
             },
         )
@@ -211,6 +237,8 @@ private fun CharacterDetailContent(
             onIntent(CharacterDetailIntent.SetName(dialogName))
             onIntent(CharacterDetailIntent.SetAliasesText(dialogAliases))
             onIntent(CharacterDetailIntent.SetRole(dialogRole))
+            onIntent(CharacterDetailIntent.SetVoiceGender(dialogVoiceGender))
+            onIntent(CharacterDetailIntent.SetVoiceAgeBand(dialogVoiceAgeBand))
             showProfileDialog = false
         },
         dismissText = stringResource(R.string.cancel),
@@ -235,11 +263,112 @@ private fun CharacterDetailContent(
                     selectedRole = dialogRole,
                     onRoleSelected = { dialogRole = it },
                 )
+                ProfileVoiceTraitDropdown(
+                    label = stringResource(R.string.character_voice_gender),
+                    selected = dialogVoiceGender,
+                    options = BookCharacterProfile.ALL_VOICE_GENDERS,
+                    displayName = { voiceGenderDisplayName(it) },
+                    onSelected = { dialogVoiceGender = it },
+                )
+                ProfileVoiceTraitDropdown(
+                    label = stringResource(R.string.character_voice_age_band),
+                    selected = dialogVoiceAgeBand,
+                    options = BookCharacterProfile.ALL_VOICE_AGE_BANDS,
+                    displayName = { voiceAgeBandDisplayName(it) },
+                    onSelected = { dialogVoiceAgeBand = it },
+                )
+            }
+        },
+    )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun ProfileVoiceTraitDropdown(
+    label: String,
+    selected: String,
+    options: List<String>,
+    displayName: @Composable (String) -> String,
+    onSelected: (String) -> Unit,
+) {
+    var showDropdown by remember { mutableStateOf(false) }
+    AppText(
+        text = label,
+        style = LegadoTheme.typography.labelMedium,
+        color = LegadoTheme.colorScheme.onSurfaceVariant
+    )
+    Box {
+        GlassCard(
+            onClick = { showDropdown = true },
+            containerColor = LegadoTheme.colorScheme.surfaceContainerLow
+        ) {
+            AnimatedTextLine(
+                text = displayName(selected),
+                style = LegadoTheme.typography.bodyMedium,
+                modifier = Modifier.padding(12.dp)
+            )
+        }
+        RoundDropdownMenu(expanded = showDropdown, onDismissRequest = { showDropdown = false }) {
+            options.forEach { option ->
+                RoundDropdownMenuItem(text = displayName(option), onClick = {
+                    onSelected(option)
+                    showDropdown = false
+                })
+            }
+        }
+    }
+
+}
+
+@Composable
+private fun CharacterDeleteDialog(
+    show: Boolean,
+    onDismiss: () -> Unit,
+    onDelete: (deleteRelations: Boolean, deleteEvents: Boolean) -> Unit,
+) {
+    var deleteRelations by remember { mutableStateOf(false) }
+    var deleteEvents by remember { mutableStateOf(false) }
+    AppAlertDialog(
+        show = show,
+        onDismissRequest = onDismiss,
+        title = stringResource(R.string.delete),
+        text = stringResource(R.string.character_delete_message),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = { onDelete(deleteRelations, deleteEvents) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismiss,
+        content = {
+            Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                CheckboxItem(
+                    stringResource(R.string.character_delete_relations),
+                    checked = deleteRelations
+                ) { deleteRelations = it }
+                CheckboxItem(
+                    stringResource(R.string.character_delete_events),
+                    checked = deleteEvents
+                ) { deleteEvents = it }
             }
         },
     )
 }
 
+@Composable
+private fun voiceGenderDisplayName(value: String): String = when (value) {
+    BookCharacterProfile.VOICE_GENDER_MALE -> stringResource(R.string.voice_gender_male)
+    BookCharacterProfile.VOICE_GENDER_FEMALE -> stringResource(R.string.voice_gender_female)
+    else -> stringResource(R.string.voice_gender_unknown)
+}
+
+@Composable
+private fun voiceAgeBandDisplayName(value: String): String = when (value) {
+    BookCharacterProfile.VOICE_AGE_CHILD -> stringResource(R.string.voice_age_child)
+    BookCharacterProfile.VOICE_AGE_TEEN -> stringResource(R.string.voice_age_teen)
+    BookCharacterProfile.VOICE_AGE_YOUNG_ADULT -> stringResource(R.string.voice_age_young_adult)
+    BookCharacterProfile.VOICE_AGE_ADULT -> stringResource(R.string.voice_age_adult)
+    BookCharacterProfile.VOICE_AGE_ELDERLY -> stringResource(R.string.voice_age_elderly)
+    else -> stringResource(R.string.voice_age_unknown)
+}
+
 @OptIn(ExperimentalMaterial3ExpressiveApi::class)
 @Composable
 private fun ProfileRoleDropdown(
@@ -294,6 +423,8 @@ private fun CharacterHeader(
     aliasesText: String,
     avatarUri: String,
     roleDisplayName: String,
+    voiceGender: String,
+    voiceAgeBand: String,
     tags: ImmutableList<String>,
     onPickAvatar: () -> Unit,
     onInfoClick: () -> Unit,
@@ -362,6 +493,12 @@ private fun CharacterHeader(
                 if (resolvedRole.isNotBlank()) {
                     TextCard(text = resolvedRole)
                 }
+                if (voiceGender != BookCharacterProfile.VOICE_GENDER_UNKNOWN) {
+                    TextCard(text = voiceGenderDisplayName(voiceGender))
+                }
+                if (voiceAgeBand != BookCharacterProfile.VOICE_AGE_UNKNOWN) {
+                    TextCard(text = voiceAgeBandDisplayName(voiceAgeBand))
+                }
             }
             if (aliasesText.isNotBlank()) {
                 AnimatedTextLine(
@@ -390,10 +527,7 @@ private fun CharacterReadonlySection(
     content: @Composable ColumnScope.() -> Unit,
 ) {
     Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
-        AppText(
-            text = title,
-            style = LegadoTheme.typography.titleMedium,
-        )
+        AppText(text = title, style = LegadoTheme.typography.titleMedium)
         if (isEmpty) {
             GlassCard {
                 Box(
@@ -402,9 +536,7 @@ private fun CharacterReadonlySection(
                         .fillMaxWidth(),
                     contentAlignment = Alignment.Center
                 ) {
-                    EmptyMessage(
-                        message = emptyText
-                    )
+                    EmptyMessage(message = emptyText)
                 }
             }
         } else {

+ 35 - 2
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt

@@ -8,8 +8,6 @@ import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
-import splitties.init.appCtx
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -20,6 +18,7 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
+import splitties.init.appCtx
 import java.util.UUID
 
 class BookCharacterDetailViewModel(
@@ -70,9 +69,12 @@ class BookCharacterDetailViewModel(
             }
 
             is CharacterDetailIntent.SetRole -> _uiState.update { it.copy(role = intent.value) }
+            is CharacterDetailIntent.SetVoiceGender -> _uiState.update { it.copy(voiceGender = intent.value) }
+            is CharacterDetailIntent.SetVoiceAgeBand -> _uiState.update { it.copy(voiceAgeBand = intent.value) }
             is CharacterDetailIntent.SetPersonality -> _uiState.update { it.copy(personality = intent.value) }
             is CharacterDetailIntent.SetSummary -> _uiState.update { it.copy(summary = intent.value) }
             CharacterDetailIntent.Save -> save()
+            is CharacterDetailIntent.Delete -> delete(intent)
         }
     }
 
@@ -111,6 +113,10 @@ class BookCharacterDetailViewModel(
                         avatarUri = profile?.avatarUri.orEmpty(),
                         tags = profile?.tagsJson.toTagList(),
                         role = profile?.role.orEmpty(),
+                        voiceGender = profile?.voiceGender
+                            ?: BookCharacterProfile.VOICE_GENDER_UNKNOWN,
+                        voiceAgeBand = profile?.voiceAgeBand
+                            ?: BookCharacterProfile.VOICE_AGE_UNKNOWN,
                         personality = profile?.personality.orEmpty(),
                         summary = profile?.summary.orEmpty(),
                         events = events.map { event ->
@@ -169,6 +175,8 @@ class BookCharacterDetailViewModel(
                     avatarUri = state.avatarUri.trim().takeIf { it.isNotBlank() },
                     tagsJson = state.tags.toTagsJson(),
                     role = state.role,
+                    voiceGender = state.voiceGender,
+                    voiceAgeBand = state.voiceAgeBand,
                     personality = state.personality.trim(),
                     summary = state.summary.trim(),
                     status = existing?.status ?: BookCharacterProfile.STATUS_ACTIVE,
@@ -201,6 +209,31 @@ class BookCharacterDetailViewModel(
             }
         }
     }
+
+    private fun delete(intent: CharacterDetailIntent.Delete) {
+        val profile = currentProfile ?: return
+        viewModelScope.launch {
+            try {
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.deleteCharacterProfile(
+                        bookUrl = profile.bookUrl,
+                        characterId = profile.id,
+                        deleteRelations = intent.deleteRelations,
+                        deleteEvents = intent.deleteEvents,
+                    )
+                }
+                _effects.tryEmit(CharacterDetailEffect.NavigateBack)
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _effects.tryEmit(
+                    CharacterDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.delete_failed)
+                    )
+                )
+            }
+        }
+    }
 }
 
 private fun String?.toTagList(): ImmutableList<String> {

+ 25 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListContract.kt

@@ -10,6 +10,26 @@ data class CharacterListUiState(
     val isLoading: Boolean = false,
     val roleFilter: String = "",
     val characters: ImmutableList<CharacterListItemUi> = persistentListOf(),
+    val aiSheet: CharacterIdentifySheet? = null,
+    val isAiSheetVisible: Boolean = false,
+)
+
+@Stable
+data class CharacterIdentifySheet(
+    val loading: Boolean = false,
+    val error: String? = null,
+    val candidates: ImmutableList<CharacterIdentifyCandidateUi> = persistentListOf(),
+    val reasoning: String = "",
+    val toolNames: ImmutableList<String> = persistentListOf(),
+    val startedAt: Long = 0L,
+)
+
+@Stable
+data class CharacterIdentifyCandidateUi(
+    val id: String,
+    val name: String,
+    val summary: String,
+    val selected: Boolean = true
 )
 
 @Stable
@@ -26,6 +46,11 @@ sealed interface CharacterListIntent {
     data class SetRoleFilter(val role: String) : CharacterListIntent
     data class OpenCharacter(val characterId: String) : CharacterListIntent
     data object AddCharacter : CharacterListIntent
+    data object OpenAiIdentify : CharacterListIntent
+    data object RunAiIdentify : CharacterListIntent
+    data class ToggleAiCandidate(val id: String) : CharacterListIntent
+    data object SaveAiCandidates : CharacterListIntent
+    data object DismissAiIdentify : CharacterListIntent
 }
 
 sealed interface CharacterListEffect {

+ 135 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt

@@ -1,6 +1,7 @@
 package io.legado.app.ui.book.knowledge
 
 import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -17,13 +18,17 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.KeyboardArrowDown
 import androidx.compose.material.icons.filled.Person
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.clip
@@ -38,18 +43,23 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
 import coil.compose.AsyncImage
 import io.legado.app.R
 import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.ui.ai.AiTaskResultSheet
+import io.legado.app.ui.ai.chat.ReasoningCard
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.checkBox.CheckboxItem
 import io.legado.app.ui.widget.components.icon.AppIcon
 import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AnimatedTextLine
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.Flow
@@ -109,6 +119,13 @@ fun BookCharacterListScreen(
                 navigationIcon = {
                     TopBarNavigationButton(onClick = onBack)
                 },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(CharacterListIntent.OpenAiIdentify) },
+                        imageVector = Icons.Default.Person,
+                        contentDescription = stringResource(R.string.ai_identify_characters),
+                    )
+                },
                 scrollBehavior = scrollBehavior,
                 bottomContent = {
                     AppTabRow(
@@ -149,6 +166,124 @@ fun BookCharacterListScreen(
             )
         }
     }
+    CharacterIdentifySheet(state.aiSheet, state.isAiSheetVisible, onIntent)
+}
+
+@Composable
+private fun CharacterIdentifySheet(
+    sheet: CharacterIdentifySheet?,
+    visible: Boolean,
+    onIntent: (CharacterListIntent) -> Unit,
+) {
+    AiTaskResultSheet(
+        show = visible && sheet != null,
+        onDismissRequest = { onIntent(CharacterListIntent.DismissAiIdentify) },
+        title = stringResource(R.string.ai_identify_characters),
+        startAction = if (sheet != null) {
+            {
+                MediumTonalButton(
+                    onClick = { onIntent(CharacterListIntent.RunAiIdentify) },
+                    icon = Icons.Default.Refresh,
+                    contentDescription = stringResource(R.string.retry),
+                    enabled = !sheet.loading,
+                )
+            }
+        } else null,
+        endAction = if (sheet?.candidates?.isNotEmpty() == true) {
+            {
+                MediumTonalButton(
+                    onClick = { onIntent(CharacterListIntent.SaveAiCandidates) },
+                    contentDescription = stringResource(R.string.ai_identify_characters_save),
+                    icon = Icons.Default.Save,
+                    enabled = !sheet.loading,
+                )
+            }
+        } else null,
+    ) {
+        when {
+            sheet == null -> Unit
+            sheet.loading -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                Box(
+                    Modifier
+                        .fillMaxWidth()
+                        .padding(32.dp),
+                    contentAlignment = Alignment.Center
+                ) { AppCircularProgressIndicator() }
+                sheet.toolNames.forEach { toolName ->
+                    TextCard(text = toolName)
+                }
+                ReasoningCard(
+                    text = sheet.reasoning,
+                    isStreaming = true,
+                    messageCreatedAt = sheet.startedAt,
+                )
+            }
+
+            sheet.error != null -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                AnimatedTextLine(sheet.error, color = LegadoTheme.colorScheme.error)
+            }
+
+            sheet.candidates.isEmpty() -> Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                AnimatedTextLine(
+                    stringResource(R.string.ai_identify_characters_hint),
+                    color = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            }
+
+            else -> Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+                sheet.candidates.forEach { candidate ->
+                    CharacterIdentifyCandidateRow(candidate, onIntent)
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterIdentifyCandidateRow(
+    candidate: CharacterIdentifyCandidateUi,
+    onIntent: (CharacterListIntent) -> Unit,
+) {
+    val expanded = rememberSaveable(candidate.id) { mutableStateOf(false) }
+    Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+        CheckboxItem(
+            title = candidate.name,
+            checked = candidate.selected,
+            onCheckedChange = { onIntent(CharacterListIntent.ToggleAiCandidate(candidate.id)) },
+        )
+        if (candidate.summary.isNotBlank()) {
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clickable { expanded.value = !expanded.value }
+                    .padding(horizontal = 12.dp, vertical = 4.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                AnimatedTextLine(
+                    text = candidate.summary.lineSequence().firstOrNull().orEmpty(),
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    modifier = Modifier.weight(1f),
+                )
+                AppIcon(
+                    imageVector = Icons.Default.KeyboardArrowDown,
+                    contentDescription = null,
+                    tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+            androidx.compose.animation.AnimatedVisibility(
+                visible = expanded.value,
+                enter = androidx.compose.animation.expandVertically() + androidx.compose.animation.fadeIn(),
+                exit = androidx.compose.animation.shrinkVertically() + androidx.compose.animation.fadeOut(),
+            ) {
+                AnimatedTextLine(
+                    candidate.summary,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    modifier = Modifier.padding(horizontal = 12.dp),
+                )
+            }
+        }
+    }
 }
 
 @OptIn(ExperimentalLayoutApi::class)

+ 164 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListViewModel.kt

@@ -3,8 +3,10 @@ package io.legado.app.ui.book.knowledge
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
 import io.legado.app.R
+import io.legado.app.data.entities.AiArtifact
 import io.legado.app.data.entities.BookCharacterProfile
 import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.usecase.IdentifyBookCharactersUseCase
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import kotlinx.collections.immutable.toImmutableList
@@ -14,6 +16,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.collectLatest
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -29,6 +32,7 @@ private val ROLE_ORDER = mapOf(
 class BookCharacterListViewModel(
     bookUrl: String,
     private val bookKnowledgeGateway: BookKnowledgeGateway,
+    private val identifyBookCharacters: IdentifyBookCharactersUseCase,
 ) : ViewModel() {
 
     private val _uiState = MutableStateFlow(
@@ -40,9 +44,12 @@ class BookCharacterListViewModel(
     val effects = _effects.asSharedFlow()
 
     private var allProfiles: List<BookCharacterProfile> = emptyList()
+    private var identifiedCandidates: List<IdentifyBookCharactersUseCase.Candidate> = emptyList()
 
     init {
         load()
+        loadPersistedCandidates()
+        observeIdentifyTask()
     }
 
     fun onIntent(intent: CharacterListIntent) {
@@ -69,6 +76,22 @@ class BookCharacterListViewModel(
                     )
                 )
             }
+            CharacterListIntent.OpenAiIdentify -> _uiState.update {
+                it.copy(
+                    aiSheet = it.aiSheet ?: CharacterIdentifySheet(),
+                    isAiSheetVisible = true,
+                )
+            }
+
+            CharacterListIntent.DismissAiIdentify -> _uiState.update { it.copy(isAiSheetVisible = false) }
+            CharacterListIntent.RunAiIdentify -> identifyCharacters()
+            is CharacterListIntent.ToggleAiCandidate -> _uiState.update { state ->
+                state.copy(aiSheet = state.aiSheet?.copy(candidates = state.aiSheet.candidates.map { candidate ->
+                    if (candidate.id == intent.id) candidate.copy(selected = !candidate.selected) else candidate
+                }.toImmutableList()))
+            }
+
+            CharacterListIntent.SaveAiCandidates -> saveIdentifiedCandidates()
         }
     }
 
@@ -116,4 +139,145 @@ class BookCharacterListViewModel(
             }.toImmutableList()
         _uiState.update { it.copy(isLoading = false, characters = characters) }
     }
+
+    private fun loadPersistedCandidates() {
+        val bookUrl = _uiState.value.bookUrl
+        viewModelScope.launch {
+            val candidates = withContext(Dispatchers.IO) {
+                identifyBookCharacters.loadLatest(bookUrl)
+            }
+            if (candidates.isNotEmpty()) {
+                identifiedCandidates = candidates
+                _uiState.update { state ->
+                    val sheet = state.aiSheet
+                    if (sheet?.loading == true || sheet?.candidates?.isNotEmpty() == true) state else {
+                        state.copy(
+                            aiSheet = sheet?.copy(candidates = candidates.toCandidateUi())
+                                ?: CharacterIdentifySheet(candidates = candidates.toCandidateUi())
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    private fun observeIdentifyTask() {
+        val bookUrl = _uiState.value.bookUrl
+        viewModelScope.launch {
+            identifyBookCharacters.observeTask(bookUrl).collectLatest { task ->
+                when (task?.status) {
+                    AiArtifact.STATUS_RUNNING -> _uiState.update { state ->
+                        val sheet = state.aiSheet ?: CharacterIdentifySheet()
+                        state.copy(
+                            aiSheet = sheet.copy(
+                                loading = true,
+                                error = null,
+                                reasoning = task.reasoning,
+                                toolNames = task.toolNames.toImmutableList(),
+                                startedAt = if (sheet.startedAt == 0L) System.currentTimeMillis() else sheet.startedAt,
+                            )
+                        )
+                    }
+
+                    AiArtifact.STATUS_SUCCESS -> {
+                        identifiedCandidates = identifyBookCharacters.decodeCandidates(task.output)
+                        _uiState.update { state ->
+                            state.copy(
+                                aiSheet = (state.aiSheet ?: CharacterIdentifySheet()).copy(
+                                    loading = false,
+                                    error = null,
+                                    candidates = identifiedCandidates.toCandidateUi(),
+                                    reasoning = task.reasoning,
+                                    toolNames = task.toolNames.toImmutableList(),
+                                )
+                            )
+                        }
+                    }
+
+                    AiArtifact.STATUS_FAILED -> _uiState.update { state ->
+                        state.copy(
+                            aiSheet = (state.aiSheet ?: CharacterIdentifySheet()).copy(
+                                loading = false,
+                                error = task.errorMessage ?: appCtx.getString(R.string.load_failed),
+                            )
+                        )
+                    }
+
+                    else -> Unit
+                }
+            }
+        }
+    }
+
+    private fun identifyCharacters() {
+        val bookUrl = _uiState.value.bookUrl
+        viewModelScope.launch {
+            _uiState.update {
+                it.copy(
+                    aiSheet = CharacterIdentifySheet(
+                        loading = true,
+                        startedAt = System.currentTimeMillis()
+                    ),
+                    isAiSheetVisible = true,
+                )
+            }
+            try {
+                withContext(Dispatchers.IO) { identifyBookCharacters.start(bookUrl) }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update {
+                    it.copy(
+                        aiSheet = CharacterIdentifySheet(
+                            error = e.localizedMessage ?: appCtx.getString(R.string.load_failed)
+                        ),
+                        isAiSheetVisible = true,
+                    )
+                }
+            }
+        }
+    }
+
+    private fun saveIdentifiedCandidates() {
+        val sheet = _uiState.value.aiSheet ?: return
+        val selected = sheet.candidates.filter { it.selected }.mapNotNull { item ->
+            identifiedCandidates.getOrNull(item.id.toIntOrNull() ?: -1)
+        }
+        if (selected.isEmpty()) return
+        viewModelScope.launch {
+            _uiState.update { it.copy(aiSheet = sheet.copy(loading = true)) }
+            try {
+                withContext(Dispatchers.IO) {
+                    identifyBookCharacters.save(
+                        _uiState.value.bookUrl,
+                        selected
+                    )
+                }
+                _uiState.update { it.copy(aiSheet = null, isAiSheetVisible = false) }
+                load()
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update {
+                    it.copy(
+                        aiSheet = sheet.copy(
+                            loading = false,
+                            error = e.localizedMessage ?: appCtx.getString(R.string.save_failed)
+                        )
+                    )
+                }
+            }
+        }
+    }
 }
+
+private fun List<IdentifyBookCharactersUseCase.Candidate>.toCandidateUi() =
+    mapIndexed { index, candidate ->
+        CharacterIdentifyCandidateUi(
+            id = index.toString(),
+            name = candidate.name,
+            summary = listOf(candidate.summary, candidate.evidence)
+                .filter(String::isNotBlank)
+                .joinToString("\n"),
+        )
+    }.toImmutableList()

+ 22 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkScreen.kt

@@ -17,6 +17,7 @@ import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
@@ -42,6 +43,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 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.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallTonalButton
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -315,6 +317,7 @@ private fun RelationEditorCard(
     isSaving: Boolean,
     onIntent: (CharacterNetworkIntent) -> Unit,
 ) {
+    val showDeleteDialog = remember { mutableStateOf(false) }
     GlassCard(
         modifier = Modifier.fillMaxWidth(),
         containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
@@ -340,6 +343,12 @@ private fun RelationEditorCard(
                     icon = Icons.Default.Save,
                     contentDescription = stringResource(R.string.save),
                 )
+                SmallTonalButton(
+                    onClick = { showDeleteDialog.value = true },
+                    enabled = !isSaving,
+                    icon = Icons.Default.Delete,
+                    contentDescription = stringResource(R.string.delete),
+                )
             }
             AppTextField(
                 value = relation.relationType,
@@ -371,6 +380,19 @@ private fun RelationEditorCard(
             )
         }
     }
+    AppAlertDialog(
+        show = showDeleteDialog.value,
+        onDismissRequest = { showDeleteDialog.value = false },
+        title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_delete),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = {
+            onIntent(CharacterNetworkIntent.DeleteRelation(relation.id))
+            showDeleteDialog.value = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showDeleteDialog.value = false },
+    )
 }
 
 @Composable

+ 27 - 2
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt

@@ -76,6 +76,7 @@ class BookCharacterNetworkViewModel(
             }
 
             is CharacterNetworkIntent.SaveRelation -> saveRelation(intent.relationId)
+            is CharacterNetworkIntent.DeleteRelation -> deleteRelation(intent.relationId)
             CharacterNetworkIntent.AddRelation -> addRelation()
         }
     }
@@ -227,6 +228,28 @@ class BookCharacterNetworkViewModel(
         }
     }
 
+    private fun deleteRelation(relationId: String) {
+        if (relationMap[relationId] == null) return
+        viewModelScope.launch {
+            try {
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.deleteCharacterRelation(
+                        relationId
+                    )
+                }
+                load()
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _effects.tryEmit(
+                    CharacterNetworkEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.delete_failed)
+                    )
+                )
+            }
+        }
+    }
+
     private suspend fun findOrCreateProfile(name: String): BookCharacterProfile {
         val bookUrl = _uiState.value.bookUrl
         profileMap.values.firstOrNull { it.name == name }?.let { return it }
@@ -253,8 +276,10 @@ class BookCharacterNetworkViewModel(
             id = id,
             fromCharacterId = fromCharacterId,
             toCharacterId = toCharacterId,
-            fromName = profileMap[fromCharacterId]?.name.orEmpty(),
-            toName = profileMap[toCharacterId]?.name.orEmpty(),
+            fromName = profileMap[fromCharacterId]?.name
+                ?: appCtx.getString(R.string.character_deleted),
+            toName = profileMap[toCharacterId]?.name
+                ?: appCtx.getString(R.string.character_deleted),
             relationType = relationType,
             summary = summary,
             attitude = attitude,

+ 10 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeContract.kt

@@ -16,6 +16,8 @@ data class CharacterDetailUiState(
     val tags: ImmutableList<String> = persistentListOf(),
     val tagInput: String = "",
     val role: String = "",
+    val voiceGender: String = "unknown",
+    val voiceAgeBand: String = "unknown",
     val personality: String = "",
     val summary: String = "",
     val events: ImmutableList<CharacterEventUi> = persistentListOf(),
@@ -47,13 +49,20 @@ sealed interface CharacterDetailIntent {
     data class AddTag(val tag: String) : CharacterDetailIntent
     data class RemoveTag(val index: Int) : CharacterDetailIntent
     data class SetRole(val value: String) : CharacterDetailIntent
+    data class SetVoiceGender(val value: String) : CharacterDetailIntent
+    data class SetVoiceAgeBand(val value: String) : CharacterDetailIntent
     data class SetPersonality(val value: String) : CharacterDetailIntent
     data class SetSummary(val value: String) : CharacterDetailIntent
     data object Save : CharacterDetailIntent
+    data class Delete(
+        val deleteRelations: Boolean,
+        val deleteEvents: Boolean,
+    ) : CharacterDetailIntent
 }
 
 sealed interface CharacterDetailEffect {
     data class ShowToast(val message: String) : CharacterDetailEffect
+    data object NavigateBack : CharacterDetailEffect
 }
 
 @Stable
@@ -105,6 +114,7 @@ sealed interface CharacterNetworkIntent {
         CharacterNetworkIntent
 
     data class SaveRelation(val relationId: String) : CharacterNetworkIntent
+    data class DeleteRelation(val relationId: String) : CharacterNetworkIntent
     data object AddRelation : CharacterNetworkIntent
 }
 

+ 22 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailScreen.kt

@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.rememberScrollState
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
 import androidx.compose.material.icons.filled.Save
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -29,6 +30,7 @@ import androidx.compose.ui.unit.dp
 import io.legado.app.R
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
@@ -64,11 +66,13 @@ fun BookKnowledgeDetailScreen(
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     val context = LocalContext.current
+    var showDeleteDialog by remember { mutableStateOf(false) }
 
     LaunchedEffect(effects) {
         effects.collectLatest { effect ->
             when (effect) {
                 is KnowledgeDetailEffect.ShowToast -> context.toastOnUi(effect.message)
+                KnowledgeDetailEffect.NavigateBack -> onBack()
             }
         }
     }
@@ -82,6 +86,13 @@ fun BookKnowledgeDetailScreen(
                     TopBarNavigationButton(onClick = onBack)
                 },
                 actions = {
+                    if (state.entryId != null) {
+                        TopBarActionButton(
+                            onClick = { showDeleteDialog = true },
+                            imageVector = Icons.Default.Delete,
+                            contentDescription = stringResource(R.string.delete),
+                        )
+                    }
                     TopBarActionButton(
                         onClick = { onIntent(KnowledgeDetailIntent.Save) },
                         imageVector = Icons.Default.Save,
@@ -114,6 +125,17 @@ fun BookKnowledgeDetailScreen(
             )
         }
     }
+
+    AppAlertDialog(
+        show = showDeleteDialog,
+        onDismissRequest = { showDeleteDialog = false },
+        title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_delete),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = { onIntent(KnowledgeDetailIntent.Delete); showDeleteDialog = false },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showDeleteDialog = false },
+    )
 }
 
 @Composable

+ 19 - 1
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt

@@ -9,7 +9,6 @@ import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.utils.GSON
 import io.legado.app.utils.fromJsonArray
 import kotlinx.collections.immutable.ImmutableList
-import kotlinx.collections.immutable.persistentListOf
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
@@ -74,6 +73,7 @@ class BookKnowledgeDetailViewModel(
             is KnowledgeDetailIntent.SetScopeEnd -> _uiState.update { it.copy(scopeEndChapter = intent.value) }
             is KnowledgeDetailIntent.SetPriority -> _uiState.update { it.copy(priority = intent.value) }
             KnowledgeDetailIntent.Save -> save()
+            KnowledgeDetailIntent.Delete -> delete()
         }
     }
 
@@ -175,6 +175,24 @@ class BookKnowledgeDetailViewModel(
             }
         }
     }
+
+    private fun delete() {
+        val entry = currentEntry ?: return
+        viewModelScope.launch {
+            try {
+                withContext(Dispatchers.IO) { bookKnowledgeGateway.deleteKnowledgeEntry(entry.id) }
+                _effects.tryEmit(KnowledgeDetailEffect.NavigateBack)
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _effects.tryEmit(
+                    KnowledgeDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.delete_failed)
+                    )
+                )
+            }
+        }
+    }
 }
 
 private fun String?.toKeywordList(): ImmutableList<String> {

+ 2 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListContract.kt

@@ -59,8 +59,10 @@ sealed interface KnowledgeDetailIntent {
     data class SetScopeEnd(val value: String) : KnowledgeDetailIntent
     data class SetPriority(val value: Int) : KnowledgeDetailIntent
     data object Save : KnowledgeDetailIntent
+    data object Delete : KnowledgeDetailIntent
 }
 
 sealed interface KnowledgeDetailEffect {
     data class ShowToast(val message: String) : KnowledgeDetailEffect
+    data object NavigateBack : KnowledgeDetailEffect
 }

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

@@ -57,6 +57,7 @@ sealed interface ReadBookMenuRoute {
     data object Main : ReadBookMenuRoute
     data object ReadStyle : ReadBookMenuRoute
     data object TextTitle : ReadBookMenuRoute
+    data object ReadAloud : ReadBookMenuRoute
     data object AutoRead : ReadBookMenuRoute
     data object PaddingConfig : ReadBookMenuRoute
     data object HeaderFooterConfig : ReadBookMenuRoute
@@ -973,7 +974,6 @@ sealed interface ReadBookSheet {
     data object MoreConfig : ReadBookSheet
     data object BgTextConfig : ReadBookSheet
     data object ReadAloudConfig : ReadBookSheet
-    data object ReadAloudControls : ReadBookSheet
     data object ReadAloudPlayer : ReadBookSheet
     data object SpeakEngineConfig : ReadBookSheet
     data class HttpTtsEdit(val engineId: Long? = null) : ReadBookSheet

+ 33 - 2
app/src/main/java/io/legado/app/ui/book/read/ReadBookMenuBar.kt

@@ -160,6 +160,7 @@ import io.legado.app.ui.animation.DampedDragAnimation
 import io.legado.app.ui.book.read.sheet.AutoReadContent
 import io.legado.app.ui.book.read.sheet.HeaderFooterPage
 import io.legado.app.ui.book.read.sheet.PaddingConfigContent
+import io.legado.app.ui.book.read.sheet.ReadAloudContent
 import io.legado.app.ui.book.read.sheet.ReadMenuButtonInfo
 import io.legado.app.ui.book.read.sheet.ReadStyleContent
 import io.legado.app.ui.book.read.sheet.ReadStyleTextTitleContent
@@ -787,6 +788,36 @@ private fun ReadBookMenuSurface(
                         }
                     }
 
+                    ReadBookMenuRoute.ReadAloud -> {
+                        ReadBookMenuRoutePage(
+                            title = stringResource(R.string.aloud_config),
+                            maxHeight = maxHeight,
+                            scrollContent = true,
+                            bottomPadding = if (extendSurfaceToNavigationBar) navBarHeight else 0.dp,
+                            onBack = { onIntent(ReadBookIntent.ReadMenuBack) },
+                        ) {
+                            ReadAloudContent(
+                                state = state,
+                                onIntent = onIntent,
+                                onDismissRequest = { onIntent(ReadBookIntent.HideMenu) },
+                                onOpenChapterList = {
+                                    onIntent(ReadBookIntent.HideMenu)
+                                    onIntent(ReadBookIntent.OpenChapterList)
+                                },
+                                onGoToBackground = {
+                                    onIntent(ReadBookIntent.CloseReadBook(keepReadAloud = true))
+                                },
+                                onOpenMainMenu = {
+                                    onIntent(ReadBookIntent.ReadMenuBack)
+                                },
+                                onShowReadAloudConfig = {
+                                    onIntent(ReadBookIntent.ShowReadAloudConfig)
+                                },
+                                modifier = Modifier.padding(horizontal = 16.dp)
+                            )
+                        }
+                    }
+
                 ReadBookMenuRoute.AutoRead -> {
                     ReadBookMenuRoutePage(
                         title = stringResource(R.string.auto_page_speed),
@@ -3054,7 +3085,7 @@ private fun loadToolButtons(
         infoMap.getValue("read_aloud").toButton(
             isActive = state.isReadAloudRunning,
             onLongClick = {
-                onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudControls))
+                onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud))
             },
         ) {
             if (state.isReadAloudRunning) {
@@ -3369,7 +3400,7 @@ private fun loadFloatingIcons(
                 onClick = actionMap[id] ?: {},
                 onLongClick = when (id) {
                     "read_aloud" -> {
-                        { onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudControls)) }
+                        { onIntent(ReadBookIntent.OpenReadMenuRoute(ReadBookMenuRoute.ReadAloud)) }
                     }
 
                     "replace" -> {

+ 0 - 30
app/src/main/java/io/legado/app/ui/book/read/ReadBookScreen.kt

@@ -30,7 +30,6 @@ import io.legado.app.ui.book.read.sheet.EffectiveReplacesSheet
 import io.legado.app.ui.book.read.sheet.EyeProtectionConfigSheet
 import io.legado.app.ui.book.read.sheet.FloatingBarIconConfigSheet
 import io.legado.app.ui.book.read.sheet.HighlightRuleConfigSheet
-import io.legado.app.ui.book.read.sheet.HttpTtsEditSheet
 import io.legado.app.ui.book.read.sheet.MoreConfigSheet
 import io.legado.app.ui.book.read.sheet.PageAnimConfigSheet
 import io.legado.app.ui.book.read.sheet.PageKeyConfigSheet
@@ -40,7 +39,6 @@ import io.legado.app.ui.book.read.sheet.ReadAloudPage
 import io.legado.app.ui.book.read.sheet.ReadAloudScreen
 import io.legado.app.ui.book.read.sheet.ShadowSetSheet
 import io.legado.app.ui.book.read.sheet.SimulatedReadingSheet
-import io.legado.app.ui.book.read.sheet.SpeakEngineConfigSheet
 import io.legado.app.ui.book.read.sheet.ToolButtonConfigSheet
 import io.legado.app.ui.book.read.sheet.UnderlineConfigSheet
 import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerEffect
@@ -307,22 +305,6 @@ fun ReadBookScreen(
         },
         onOpenTextSelectMenuConfig = onOpenTextSelectMenuConfig,
     )
-    SpeakEngineConfigSheet(
-        show = state.activeSheet is ReadBookSheet.SpeakEngineConfig,
-        state = state,
-        onIntent = onIntent,
-        onDismissRequest = {
-            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.ReadAloudConfig))
-        },
-    )
-    HttpTtsEditSheet(
-        show = state.activeSheet is ReadBookSheet.HttpTtsEdit,
-        httpTTS = state.editingHttpTts,
-        onIntent = onIntent,
-        onDismissRequest = {
-            onIntent(ReadBookIntent.ShowSheet(ReadBookSheet.SpeakEngineConfig))
-        },
-    )
     ReadAloudNumberConfigSheet(
         show = state.activeSheet is ReadBookSheet.PreDownloadConfig,
         title = stringResource(R.string.read_aloud_preload),
@@ -438,7 +420,6 @@ fun ReadBookScreen(
         rememberThemeOverride(seedColor)
     }
     val readAloudPage = when (state.activeSheet) {
-        ReadBookSheet.ReadAloudControls -> ReadAloudPage.Controls
         ReadBookSheet.ReadAloudConfig -> ReadAloudPage.Config
         ReadBookSheet.ReadAloudPlayer -> ReadAloudPage.Player
         else -> null
@@ -451,17 +432,6 @@ fun ReadBookScreen(
         onIntent = onIntent,
         onPlayerIntent = aloudPlayerViewModel::onIntent,
         onDismissRequest = dismissSheet,
-        onOpenChapterList = {
-            onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.OpenChapterList)
-        },
-        onGoToBackground = {
-            onIntent(ReadBookIntent.CloseReadBook(keepReadAloud = true))
-        },
-        onOpenMainMenu = {
-            onIntent(ReadBookIntent.DismissSheet)
-            onIntent(ReadBookIntent.ShowMenu)
-        },
     )
     LaunchedEffect(state.activeSheet) {
         if (state.activeSheet is ReadBookSheet.ReadAloudPlayer) {

+ 139 - 186
app/src/main/java/io/legado/app/ui/book/read/ReadBookViewModel.kt

@@ -20,6 +20,7 @@ import io.legado.app.constant.EventBus
 import io.legado.app.constant.PreferKey
 import io.legado.app.constant.ReadMenuBlurMode
 import io.legado.app.data.appDb
+import io.legado.app.data.entities.AiArtifact
 import io.legado.app.data.entities.AiPromptPreset
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
@@ -532,19 +533,7 @@ class ReadBookViewModel(
             }
 
             is ReadBookIntent.ShowSheet -> {
-                if (intent.sheet is ReadBookSheet.ReadAloudControls) {
-                    _uiState.update {
-                        it.copy(
-                            menuState = ReadBookMenuState(),
-                            activeSheet = intent.sheet,
-                            readAloudTtsTimer = if (BaseReadAloudService.isRun) {
-                                BaseReadAloudService.timeMinute.coerceAtLeast(0)
-                            } else {
-                                it.readAloudTtsTimer
-                            },
-                        )
-                    }
-                } else if (intent.sheet is ReadBookSheet.HighlightRuleConfig) {
+                if (intent.sheet is ReadBookSheet.HighlightRuleConfig) {
                     loadHighlightRules()
                     _uiState.update { it.copy(activeSheet = intent.sheet) }
                 } else if (intent.sheet is ReadBookSheet.ContentProcesses) {
@@ -1378,7 +1367,8 @@ class ReadBookViewModel(
                 }
             }
             ReadBookIntent.OpenClassicReadAloudControls -> {
-                _uiState.update { it.copy(activeSheet = ReadBookSheet.ReadAloudControls) }
+                _uiState.update { it.copy(activeSheet = null) }
+                openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
             }
 
             is ReadBookIntent.SelectFont -> selectFont(intent.path)
@@ -1603,9 +1593,14 @@ class ReadBookViewModel(
                 }
             }
             is ReadBookIntent.SyncEyeProtectionForTheme -> {
-                viewModelScope.launch {
-                    readSettingsRepository.update {
-                        it.copy(eyeProtectionEnabled = false)
+                EyeProtection.syncEnabledForNight(
+                    isNight = intent.isNight,
+                    autoNight = _readPreferences.value.eyeProtectionAutoNight,
+                )?.let { enabled ->
+                    viewModelScope.launch {
+                        readSettingsRepository.update {
+                            it.copy(eyeProtectionEnabled = enabled)
+                        }
                     }
                 }
             }
@@ -3157,66 +3152,49 @@ class ReadBookViewModel(
                 return@launch
             }
             try {
-                val fullSummary = StringBuilder()
-                val fullReasoning = StringBuilder()
-                var thinkingStartTime = 0L
-                generateChapterSummaryUseCase.executeStream(
-                    book = book,
-                    bookChapter = chapter,
-                    contentOverride = content,
-                ).collect { event ->
-                    if (!isCurrentChapterSummary(bookUrl, chapterIndex)) return@collect
-                    when (event) {
-                        is GenerateChapterSummaryUseCase.StreamEvent.Content -> {
-                            if (fullSummary.isEmpty() && thinkingStartTime > 0L) {
-                                val duration =
-                                    ((System.currentTimeMillis() - thinkingStartTime) / 1000).toInt()
-                                _uiState.update {
-                                    it.copy(
-                                        chapterSummary = it.chapterSummary.copy(
-                                            thinkingDuration = duration,
-                                        )
-                                    )
-                                }
-                            }
-                            fullSummary.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    chapterSummary = it.chapterSummary.copy(
-                                        summary = fullSummary.toString(),
-                                        errorMessage = null,
-                                    )
-                                )
-                            }
-                        }
+                withContext(IO) {
+                    generateChapterSummaryUseCase.start(
+                        book = book,
+                        bookChapter = chapter,
+                        contentOverride = content,
+                    )
+                }
+                generateChapterSummaryUseCase.observeTask(bookUrl, chapterIndex).collect { task ->
+                    if (!isCurrentChapterSummary(
+                            bookUrl,
+                            chapterIndex
+                        ) || task == null
+                    ) return@collect
+                    _uiState.update { state ->
+                        val summary = state.chapterSummary
+                        when (task.status) {
+                            AiArtifact.STATUS_RUNNING -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = true,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
 
-                        is GenerateChapterSummaryUseCase.StreamEvent.Reasoning -> {
-                            if (thinkingStartTime == 0L) {
-                                thinkingStartTime = System.currentTimeMillis()
-                            }
-                            fullReasoning.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    chapterSummary = it.chapterSummary.copy(
-                                        reasoningText = fullReasoning.toString(),
-                                    )
-                                )
-                            }
-                        }
+                            AiArtifact.STATUS_SUCCESS -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    summary = task.output.orEmpty(),
+                                    reasoningText = task.reasoning,
+                                    errorMessage = null,
+                                ),
+                            )
 
-                        is GenerateChapterSummaryUseCase.StreamEvent.Done -> {
-                            _uiState.update {
-                                it.copy(
-                                    chapterSummary = it.chapterSummary.copy(
-                                        isLoading = false,
-                                        summary = event.text,
-                                        reasoningText = event.reasoning.ifBlank {
-                                            fullReasoning.toString()
-                                        },
-                                        errorMessage = null,
-                                    )
-                                )
-                            }
+                            AiArtifact.STATUS_FAILED -> state.copy(
+                                chapterSummary = summary.copy(
+                                    isLoading = false,
+                                    errorMessage = task.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                ),
+                            )
+
+                            else -> state
                         }
                     }
                 }
@@ -3339,71 +3317,55 @@ class ReadBookViewModel(
         aiTextCleanJob?.cancel()
         aiTextCleanJob = viewModelScope.launch {
             try {
-                val rawText = StringBuilder()
-                val fullReasoning = StringBuilder()
-                var thinkingStartTime = 0L
-                cleanSelectedTextUseCase.executeStream(
-                    bookUrl = request.bookUrl,
-                    chapterIndex = request.chapterIndex,
-                    chapterTitle = request.chapterTitle,
-                    selectedText = request.originalText,
-                    contextBefore = request.contextBefore,
-                    contextAfter = request.contextAfter,
-                ).collect { event ->
+                val taskId = withContext(IO) {
+                    cleanSelectedTextUseCase.start(
+                        bookUrl = request.bookUrl,
+                        chapterIndex = request.chapterIndex,
+                        chapterTitle = request.chapterTitle,
+                        selectedText = request.originalText,
+                        contextBefore = request.contextBefore,
+                        contextAfter = request.contextAfter,
+                    )
+                }
+                cleanSelectedTextUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
                     if (!isCurrentAiTextClean(request)) return@collect
-                    when (event) {
-                        is CleanSelectedTextUseCase.StreamEvent.Content -> {
-                            if (rawText.isEmpty() && thinkingStartTime > 0L) {
-                                val duration =
-                                    ((System.currentTimeMillis() - thinkingStartTime) / 1000).toInt()
-                                _uiState.update {
-                                    it.copy(
-                                        aiTextClean = it.aiTextClean.copy(
-                                            thinkingDuration = duration,
-                                        )
-                                    )
-                                }
-                            }
-                            rawText.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    aiTextClean = it.aiTextClean.copy(
-                                        streamingText = rawText.toString(),
-                                        errorMessage = null,
-                                    )
+                    val clean = _uiState.value.aiTextClean
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = true,
+                                    streamingText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
                                 )
-                            }
+                            )
                         }
 
-                        is CleanSelectedTextUseCase.StreamEvent.Reasoning -> {
-                            if (thinkingStartTime == 0L) {
-                                thinkingStartTime = System.currentTimeMillis()
-                            }
-                            fullReasoning.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    aiTextClean = it.aiTextClean.copy(
-                                        reasoningText = fullReasoning.toString(),
-                                    )
+                        AiArtifact.STATUS_SUCCESS -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    replacementText = snapshot.output.orEmpty(),
+                                    streamingText = "",
+                                    reasoningText = snapshot.reasoning,
+                                    errorMessage = null,
                                 )
-                            }
+                            )
                         }
 
-                        is CleanSelectedTextUseCase.StreamEvent.Done -> {
-                            _uiState.update {
-                                it.copy(
-                                    aiTextClean = it.aiTextClean.copy(
-                                        isLoading = false,
-                                        replacementText = event.replacement,
-                                        streamingText = "",
-                                        reasoningText = event.reasoning.ifBlank {
-                                            fullReasoning.toString()
-                                        },
-                                        errorMessage = null,
-                                    )
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextClean = clean.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
                                 )
-                            }
+                            )
                         }
+
+                        else -> Unit
                     }
                 }
             } catch (error: CancellationException) {
@@ -3747,11 +3709,7 @@ class ReadBookViewModel(
         aiTextRewriteJob = viewModelScope.launch {
             try {
                 val referenceContext = buildAiRewriteReferenceContext(request)
-                val fullText = StringBuilder()
-                val fullReasoning = StringBuilder()
-                var thinkingStartTime = 0L
-                aiTextFactoryUseCase.executeStream(
-                    AiTextFactoryUseCase.Request(
+                val aiRequest = AiTextFactoryUseCase.Request(
                         bookUrl = request.bookUrl,
                         chapterIndex = request.chapterIndex,
                         chapterTitle = request.chapterTitle,
@@ -3765,57 +3723,31 @@ class ReadBookViewModel(
                         skipCache = true,
                         artifactContentHash = request.sourceContentHash,
                     )
-                ).collect { event ->
+                val taskId = withContext(IO) { aiTextFactoryUseCase.start(aiRequest) }
+                aiTextFactoryUseCase.observeTaskById(taskId).collect { task ->
+                    val snapshot = task ?: return@collect
                     if (!isCurrentAiTextRewrite(request)) return@collect
-                    when (event) {
-                        is AiTextFactoryUseCase.StreamEvent.Content -> {
-                            if (fullText.isEmpty() && thinkingStartTime > 0L) {
-                                val duration =
-                                    ((System.currentTimeMillis() - thinkingStartTime) / 1000).toInt()
-                                _uiState.update {
-                                    it.copy(
-                                        aiTextRewrite = it.aiTextRewrite.copy(
-                                            thinkingDuration = duration,
-                                        )
-                                    )
-                                }
-                            }
-                            fullText.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    aiTextRewrite = it.aiTextRewrite.copy(
-                                        rewrittenText = fullText.toString(),
-                                        referenceCount = referenceContext.count,
-                                        errorMessage = null,
-                                    )
-                                )
-                            }
-                        }
-
-                        is AiTextFactoryUseCase.StreamEvent.Reasoning -> {
-                            if (thinkingStartTime == 0L) {
-                                thinkingStartTime = System.currentTimeMillis()
-                            }
-                            fullReasoning.append(event.text)
-                            _uiState.update {
-                                it.copy(
-                                    aiTextRewrite = it.aiTextRewrite.copy(
-                                        reasoningText = fullReasoning.toString(),
-                                        referenceCount = referenceContext.count,
-                                    )
+                    val rewrite = _uiState.value.aiTextRewrite
+                    when (snapshot.status) {
+                        AiArtifact.STATUS_RUNNING -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = true,
+                                    rewrittenText = snapshot.output.orEmpty(),
+                                    reasoningText = snapshot.reasoning,
+                                    referenceCount = referenceContext.count,
+                                    errorMessage = null,
                                 )
-                            }
+                            )
                         }
 
-                        is AiTextFactoryUseCase.StreamEvent.Done -> {
+                        AiArtifact.STATUS_SUCCESS -> {
                             _uiState.update {
                                 it.copy(
-                                    aiTextRewrite = it.aiTextRewrite.copy(
+                                    aiTextRewrite = rewrite.copy(
                                         isLoading = false,
-                                        rewrittenText = event.text,
-                                        reasoningText = event.reasoning.ifBlank {
-                                            fullReasoning.toString()
-                                        },
+                                        rewrittenText = snapshot.output.orEmpty(),
+                                        reasoningText = snapshot.reasoning,
                                         referenceCount = referenceContext.count,
                                         errorMessage = null,
                                     )
@@ -3823,6 +3755,18 @@ class ReadBookViewModel(
                             }
                             loadAiRewriteHistory(request, selectLatest = false)
                         }
+
+                        AiArtifact.STATUS_FAILED -> _uiState.update {
+                            it.copy(
+                                aiTextRewrite = rewrite.copy(
+                                    isLoading = false,
+                                    errorMessage = snapshot.errorMessage
+                                        ?: context.getString(R.string.load_failed),
+                                )
+                            )
+                        }
+
+                        else -> Unit
                     }
                 }
             } catch (error: CancellationException) {
@@ -4848,21 +4792,30 @@ class ReadBookViewModel(
                         route,
                     ),
                 ),
+                readAloudTtsTimer = if (
+                    route == ReadBookMenuRoute.ReadAloud && BaseReadAloudService.isRun
+                ) {
+                    BaseReadAloudService.timeMinute.coerceAtLeast(0)
+                } else {
+                    it.readAloudTtsTimer
+                },
             )
         }
     }
 
     private fun openDefaultReadAloudInterface() {
-        val sheet = if (
+        if (
             _uiState.value.defaultReadAloudInterface ==
             ReadAloudSettingsRepository.DEFAULT_INTERFACE_PLAYER
         ) {
-            ReadBookSheet.ReadAloudPlayer
+            _uiState.update {
+                it.copy(
+                    menuState = ReadBookMenuState(),
+                    activeSheet = ReadBookSheet.ReadAloudPlayer
+                )
+            }
         } else {
-            ReadBookSheet.ReadAloudControls
-        }
-        _uiState.update {
-            it.copy(menuState = ReadBookMenuState(), activeSheet = sheet)
+            openReadMenuRoute(ReadBookMenuRoute.ReadAloud)
         }
     }
 

+ 25 - 10
app/src/main/java/io/legado/app/ui/book/read/sheet/ReadAloudConfigContent.kt

@@ -33,9 +33,12 @@ import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.constant.ReadAloudBgMode
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.ui.book.read.ReadBookIntent
 import io.legado.app.ui.book.read.ReadBookUiState
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerIntent
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerUiState
 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.series.SmallTonalButton
@@ -56,15 +59,18 @@ import kotlinx.coroutines.launch
 @Composable
 fun ReadAloudConfigContent(
     state: ReadBookUiState,
+    playerState: ReadAloudPlayerUiState,
     onIntent: (ReadBookIntent) -> Unit,
+    onPlayerIntent: (ReadAloudPlayerIntent) -> Unit,
     modifier: Modifier = Modifier,
 ) {
     val pagerState = rememberPagerState(pageCount = { 2 })
     val scope = rememberCoroutineScope()
     Column(
-        modifier = modifier.fillMaxWidth(),
+        modifier = Modifier.fillMaxWidth(),
     ) {
         CardTabRow(
+            modifier = modifier,
             tabTitles = listOf(
                 stringResource(R.string.read_aloud_settings_general_tab),
                 stringResource(R.string.read_aloud_settings_voice_tab),
@@ -85,7 +91,7 @@ fun ReadAloudConfigContent(
                 modifier = Modifier
                     .fillMaxWidth()
                     .verticalScroll(rememberScrollState())
-                    .padding(top = 8.dp, bottom = 16.dp),
+                    .padding(top = 8.dp, bottom = 16.dp, start = 16.dp, end = 16.dp),
             ) {
                 if (page == 0) {
                     TinyDropdownSettingItem(
@@ -99,6 +105,23 @@ fun ReadAloudConfigContent(
                         description = stringResource(R.string.default_read_aloud_interface_summary),
                         onValueChange = { onIntent(ReadBookIntent.SetDefaultReadAloudInterface(it)) },
                     )
+                    TinyDropdownSettingItem(
+                        title = stringResource(R.string.read_aloud_player_background),
+                        selectedValue = playerState.bgMode.toString(),
+                        displayEntries = arrayOf(
+                            stringResource(R.string.read_aloud_bg_solid),
+                            stringResource(R.string.read_aloud_bg_blur),
+                            stringResource(R.string.read_aloud_bg_flowing_light),
+                            stringResource(R.string.read_aloud_bg_transparent),
+                        ),
+                        entryValues = arrayOf(
+                            ReadAloudBgMode.Solid.toString(),
+                            ReadAloudBgMode.Blur.toString(),
+                            ReadAloudBgMode.FlowingLight.toString(),
+                            ReadAloudBgMode.Transparent.toString(),
+                        ),
+                        onValueChange = { onPlayerIntent(ReadAloudPlayerIntent.SetBgMode(it.toInt())) },
+                    )
                     TinySwitchSettingItem(
                         title = stringResource(R.string.show_read_aloud_capsule),
                         description = stringResource(R.string.show_read_aloud_capsule_summary),
@@ -180,14 +203,6 @@ fun ReadAloudConfigContent(
                         onClick = { onIntent(ReadBookIntent.ResetReadAloudCapsulePosition) },
                     )
                 } else {
-                    TinyClickableSettingItem(
-                        title = stringResource(R.string.read_aloud_default_engine),
-                        description = stringResource(
-                            R.string.read_aloud_default_engine_summary,
-                            state.speakEngineName.ifEmpty { stringResource(R.string.system_tts) },
-                        ),
-                        onClick = { onIntent(ReadBookIntent.SelectSpeakEngine) },
-                    )
                     TinyClickableSettingItem(
                         title = stringResource(R.string.read_aloud_engines_and_voices),
                         description = stringResource(R.string.read_aloud_engines_and_voices_summary),

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

@@ -1,14 +1,8 @@
 package io.legado.app.ui.book.read.sheet
 
 import androidx.activity.compose.BackHandler
+import androidx.activity.compose.PredictiveBackHandler
 import androidx.compose.animation.AnimatedContent
-import androidx.compose.animation.SizeTransform
-import androidx.compose.animation.core.tween
-import androidx.compose.animation.fadeIn
-import androidx.compose.animation.fadeOut
-import androidx.compose.animation.slideInHorizontally
-import androidx.compose.animation.slideOutHorizontally
-import androidx.compose.animation.togetherWith
 import androidx.compose.foundation.layout.Arrangement
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
@@ -19,7 +13,9 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBars
 import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.windowInsetsPadding
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowBack
 import androidx.compose.material.icons.automirrored.filled.List
@@ -36,12 +32,15 @@ import androidx.compose.material.icons.filled.VisibilityOff
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.MaterialTheme
 import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.ModalBottomSheetProperties
 import androidx.compose.material3.SheetValue.Expanded
 import androidx.compose.material3.SheetValue.Hidden
 import androidx.compose.material3.Text
 import androidx.compose.material3.rememberBottomSheetState
 import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
 import androidx.compose.runtime.setValue
@@ -49,6 +48,8 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.platform.LocalDensity
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.Dp
 import androidx.compose.ui.unit.dp
@@ -69,7 +70,6 @@ import io.legado.app.ui.widget.components.settingItem.TinySliderSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 
 enum class ReadAloudPage {
-    Controls,
     Config,
     Player,
 }
@@ -84,23 +84,39 @@ fun ReadAloudScreen(
     onIntent: (ReadBookIntent) -> Unit,
     onPlayerIntent: (ReadAloudPlayerIntent) -> Unit,
     onDismissRequest: () -> Unit,
-    onOpenChapterList: () -> Unit,
-    onGoToBackground: () -> Unit,
-    onOpenMainMenu: () -> Unit,
 ) {
     val sheetState = rememberBottomSheetState(
         initialValue = Hidden,
         enabledValues = setOf(Hidden, Expanded),
     )
     val isPlayer = page == ReadAloudPage.Player
-    BackHandler(enabled = page == ReadAloudPage.Config) {
-        onIntent(ReadBookIntent.ShowSheet(io.legado.app.ui.book.read.ReadBookSheet.ReadAloudControls))
+    var backProgress by remember { mutableFloatStateOf(0f) }
+    val predictiveBackOffset = with(LocalDensity.current) { 120.dp.toPx() }
+    var configParentIsPlayer by remember { mutableStateOf(false) }
+    LaunchedEffect(page) {
+        when (page) {
+            ReadAloudPage.Player -> configParentIsPlayer = true
+            null -> configParentIsPlayer = false
+            ReadAloudPage.Config -> Unit
+        }
+    }
+    val returnFromConfig = {
+        if (configParentIsPlayer) {
+            onIntent(
+                ReadBookIntent.ShowSheet(
+                    io.legado.app.ui.book.read.ReadBookSheet.ReadAloudPlayer
+                )
+            )
+        } else {
+            onIntent(ReadBookIntent.OpenClassicReadAloudControls)
+        }
     }
     if (page != null) {
         ModalBottomSheet(
             onDismissRequest = onDismissRequest,
             sheetState = sheetState,
-            modifier = if (isPlayer) Modifier.fillMaxSize() else Modifier,
+            modifier = (if (isPlayer) Modifier.fillMaxSize() else Modifier)
+                .graphicsLayer { translationY = backProgress * predictiveBackOffset },
             shape = if (isPlayer) RectangleShape else MaterialTheme.shapes.extraLarge,
             sheetMaxWidth = if (isPlayer) Dp.Unspecified else 640.dp,
             containerColor = if (isPlayer) {
@@ -110,56 +126,39 @@ fun ReadAloudScreen(
             },
             contentColor = LegadoTheme.colorScheme.onSurface,
             contentWindowInsets = {
-                if (isPlayer) WindowInsets(0, 0, 0, 0) else WindowInsets(0)
-            },
-            dragHandle = if (isPlayer) null else {
-                { androidx.compose.material3.BottomSheetDefaults.DragHandle() }
+                WindowInsets(0, 0, 0, 0)
             },
+            dragHandle = null,
+            properties = ModalBottomSheetProperties(shouldDismissOnBackPress = false),
         ) {
+            BackHandler(enabled = page == ReadAloudPage.Config, onBack = returnFromConfig)
+            PredictiveBackHandler(enabled = page != ReadAloudPage.Config) { progress ->
+                try {
+                    progress.collect { event ->
+                        backProgress = event.progress
+                    }
+                    sheetState.hide()
+                    onDismissRequest()
+                } finally {
+                    backProgress = 0f
+                }
+            }
             AnimatedContent(
                 targetState = page,
-                transitionSpec = {
-                    val forward = targetState.ordinal > initialState.ordinal
-                    val enter = slideInHorizontally(
-                        animationSpec = tween(durationMillis = 300),
-                        initialOffsetX = { width -> if (forward) width else -width },
-                    ) + fadeIn(animationSpec = tween(durationMillis = 220))
-                    val exit = slideOutHorizontally(
-                        animationSpec = tween(durationMillis = 300),
-                        targetOffsetX = { width -> if (forward) -width else width },
-                    ) + fadeOut(animationSpec = tween(durationMillis = 180))
-                    (enter togetherWith exit).using(SizeTransform(clip = false))
-                },
                 label = "ReadAloudPage",
             ) { targetPage ->
                 when (targetPage) {
-                    ReadAloudPage.Controls -> ReadAloudContent(
-                        state = state,
-                        onIntent = onIntent,
-                        onDismissRequest = onDismissRequest,
-                        onOpenChapterList = onOpenChapterList,
-                        onGoToBackground = onGoToBackground,
-                        onOpenMainMenu = onOpenMainMenu,
-                        onShowReadAloudConfig = {
-                            onIntent(ReadBookIntent.ShowReadAloudConfig)
-                        },
-                        modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
-                    )
-
                     ReadAloudPage.Config -> Column {
                         Row(
                             modifier = Modifier
                                 .fillMaxWidth()
+                                .windowInsetsPadding(WindowInsets.statusBars)
                                 .padding(horizontal = 16.dp),
                             verticalAlignment = Alignment.CenterVertically,
                         ) {
                             MediumTonalButton(
                                 onClick = {
-                                    onIntent(
-                                        ReadBookIntent.ShowSheet(
-                                            io.legado.app.ui.book.read.ReadBookSheet.ReadAloudControls
-                                        )
-                                    )
+                                    returnFromConfig()
                                 },
                                 icon = Icons.AutoMirrored.Filled.ArrowBack,
                                 contentDescription = stringResource(R.string.back),
@@ -167,13 +166,16 @@ fun ReadAloudScreen(
                             Spacer(Modifier.width(12.dp))
                             Text(
                                 text = stringResource(R.string.aloud_config),
-                                style = MaterialTheme.typography.titleLarge,
+                                style = LegadoTheme.typography.titleLarge,
                             )
                         }
+                        Spacer(modifier = Modifier.padding(vertical = 8.dp))
                         ReadAloudConfigContent(
                             state = state,
+                            playerState = playerState,
                             onIntent = onIntent,
-                            modifier = Modifier.padding(horizontal = 16.dp),
+                            onPlayerIntent = onPlayerIntent,
+                            modifier = Modifier.padding(horizontal = 16.dp)
                         )
                     }
 

+ 0 - 4
app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingContract.kt

@@ -46,10 +46,6 @@ data class VoicePickerUi(
 
 enum class CastingSubjectKind {
     Narrator,
-    MaleLead,
-    FemaleLead,
-    MaleSupporting,
-    FemaleSupporting,
     UnknownMale,
     UnknownFemale,
     Unknown,

+ 0 - 4
app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingScreen.kt

@@ -334,10 +334,6 @@ private fun VoicePickerSheet(
 @Composable
 private fun subjectTitle(kind: CastingSubjectKind, name: String): String = when (kind) {
     CastingSubjectKind.Narrator -> stringResource(R.string.voice_role_narrator)
-    CastingSubjectKind.MaleLead -> stringResource(R.string.voice_role_male_lead)
-    CastingSubjectKind.FemaleLead -> stringResource(R.string.voice_role_female_lead)
-    CastingSubjectKind.MaleSupporting -> stringResource(R.string.voice_role_male_supporting)
-    CastingSubjectKind.FemaleSupporting -> stringResource(R.string.voice_role_female_supporting)
     CastingSubjectKind.UnknownMale -> stringResource(R.string.voice_role_unknown_male)
     CastingSubjectKind.UnknownFemale -> stringResource(R.string.voice_role_unknown_female)
     CastingSubjectKind.Unknown -> stringResource(R.string.voice_role_unknown)

+ 0 - 5
app/src/main/java/io/legado/app/ui/book/readaloud/casting/BookVoiceCastingViewModel.kt

@@ -17,7 +17,6 @@ import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
 import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
-import kotlinx.coroutines.flow.collect
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
 import kotlinx.coroutines.withContext
@@ -92,10 +91,6 @@ class BookVoiceCastingViewModel(
         val bindingsBySubject = bindings.associateBy { it.subjectType to it.subjectId }
         val specialItems = listOf(
             specialItem(BookVoiceBinding.SUBJECT_NARRATOR, CastingSubjectKind.Narrator),
-            specialItem(BookVoiceBinding.SUBJECT_MALE_LEAD, CastingSubjectKind.MaleLead),
-            specialItem(BookVoiceBinding.SUBJECT_FEMALE_LEAD, CastingSubjectKind.FemaleLead),
-            specialItem(BookVoiceBinding.SUBJECT_MALE_SUPPORTING, CastingSubjectKind.MaleSupporting),
-            specialItem(BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING, CastingSubjectKind.FemaleSupporting),
             specialItem(BookVoiceBinding.SUBJECT_UNKNOWN_MALE, CastingSubjectKind.UnknownMale),
             specialItem(BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE, CastingSubjectKind.UnknownFemale),
             specialItem(BookVoiceBinding.SUBJECT_UNKNOWN, CastingSubjectKind.Unknown),

+ 53 - 3
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsContract.kt

@@ -1,6 +1,9 @@
 package io.legado.app.ui.book.readaloud.cloudtts
 
+import android.net.Uri
 import androidx.compose.runtime.Stable
+import io.legado.app.data.entities.HttpTTS
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
 import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.persistentListOf
 
@@ -8,11 +11,15 @@ import kotlinx.collections.immutable.persistentListOf
 data class CloudTtsUiState(
     val loading: Boolean = true,
     val engines: ImmutableList<CloudTtsEngineItemUi> = persistentListOf(),
+    val systemEngines: ImmutableList<TtsManagedEngineItemUi> = persistentListOf(),
+    val httpEngines: ImmutableList<TtsManagedEngineItemUi> = persistentListOf(),
     val voices: ImmutableList<CloudTtsVoiceItemUi> = persistentListOf(),
     val discoveredVoices: ImmutableList<CloudTtsDiscoveredVoiceUi> = persistentListOf(),
     val availableEngines: ImmutableList<TtsEngineOptionUi> = persistentListOf(),
     val engineEditor: CloudTtsEngineEditorUi? = null,
     val voiceEditor: TtsVoicePresetEditorUi? = null,
+    val httpTtsEditor: HttpTTS? = null,
+    val httpTtsImportState: BaseImportUiState<HttpTTS> = BaseImportUiState.Idle,
     val showVoiceEnginePicker: Boolean = false,
     val activeDialog: CloudTtsDialog? = null,
     val testing: Boolean = false,
@@ -22,7 +29,23 @@ data class CloudTtsUiState(
 
 enum class CloudTtsTab { Voices, Engines }
 
-@Stable data class CloudTtsEngineItemUi(val id: String, val title: String, val summary: String)
+@Stable
+data class CloudTtsEngineItemUi(
+    val id: String,
+    val title: String,
+    val summary: String,
+    val selected: Boolean = false,
+)
+
+@Stable
+data class TtsManagedEngineItemUi(
+    val engineType: String,
+    val engineId: String,
+    val title: String,
+    val summary: String = "",
+    val selected: Boolean = false,
+    val loginUrl: String = "",
+)
 @Stable data class CloudTtsVoiceItemUi(
     val id: String,
     val title: String,
@@ -84,12 +107,34 @@ data class TtsVoicePresetEditorUi(
 
 sealed interface CloudTtsIntent {
     data class SelectTab(val tab: CloudTtsTab) : CloudTtsIntent
+    data class SetBookContext(val bookUrl: String?) : CloudTtsIntent
     data object AddEngine : CloudTtsIntent
     data object AddVoice : CloudTtsIntent
     data class AddVoiceForEngine(val engineType: String, val engineId: String) : CloudTtsIntent
     data class EditEngine(val id: String) : CloudTtsIntent
     data class DeleteEngine(val id: String) : CloudTtsIntent
-    data class DeleteVoice(val id: String) : CloudTtsIntent
+    data class SetDefaultEngine(val engineType: String, val engineId: String) : CloudTtsIntent
+    data object ApplyDefaultEngineGlobally : CloudTtsIntent
+    data object ApplyDefaultEngineForBook : CloudTtsIntent
+    data class EditHttpTts(val engineId: String? = null) : CloudTtsIntent
+    data class SaveHttpTts(val value: HttpTTS) : CloudTtsIntent
+    data class DeleteHttpTts(val engineId: String) : CloudTtsIntent
+    data class OpenHttpTtsLogin(val engineId: String) : CloudTtsIntent
+    data object DismissHttpTtsEditor : CloudTtsIntent
+    data class ImportHttpTtsSource(val text: String) : CloudTtsIntent
+    data object ImportHttpTtsFile : CloudTtsIntent
+    data class ImportHttpTtsFileSelected(val uri: Uri) : CloudTtsIntent
+    data object CancelHttpTtsImport : CloudTtsIntent
+    data class ToggleHttpTtsImportSelection(val index: Int) : CloudTtsIntent
+    data class ToggleHttpTtsImportAll(val selected: Boolean) : CloudTtsIntent
+    data class UpdateHttpTtsImportItem(val index: Int, val value: HttpTTS) : CloudTtsIntent
+    data object SaveImportedHttpTts : CloudTtsIntent
+    data object ExportHttpTtsFile : CloudTtsIntent
+    data class ExportHttpTtsFileSelected(val uri: Uri) : CloudTtsIntent
+    data object ExportHttpTtsUrl : CloudTtsIntent
+    data object ClearTtsCache : CloudTtsIntent
+    data class RequestDeleteVoice(val id: String) : CloudTtsIntent
+    data object ConfirmDeleteVoice : CloudTtsIntent
     data class EditVoice(val id: String) : CloudTtsIntent
     data class UpdateEngineEditor(val editor: CloudTtsEngineEditorUi) : CloudTtsIntent
     data class UpdateVoiceEditor(val editor: TtsVoicePresetEditorUi) : CloudTtsIntent
@@ -110,9 +155,14 @@ sealed interface CloudTtsIntent {
 sealed interface CloudTtsEffect {
     data class ShowToast(val message: String) : CloudTtsEffect
     data class PlayPreview(val path: String) : CloudTtsEffect
-    data class CopyText(val text: String) : CloudTtsEffect
+    data class CopyText(val text: String, val message: String) : CloudTtsEffect
+    data object OpenHttpTtsImportPicker : CloudTtsEffect
+    data object OpenHttpTtsExportPicker : CloudTtsEffect
+    data class OpenHttpTtsLogin(val engineId: Long) : CloudTtsEffect
 }
 
 sealed interface CloudTtsDialog {
     data class Error(val message: String) : CloudTtsDialog
+    data class DeleteVoice(val id: String, val title: String) : CloudTtsDialog
+    data class DefaultEngineScope(val value: String?, val title: String) : CloudTtsDialog
 }

+ 553 - 98
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsScreen.kt

@@ -3,26 +3,38 @@ package io.legado.app.ui.book.readaloud.cloudtts
 import android.content.ClipData
 import android.media.MediaPlayer
 import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
 import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.padding
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.pager.HorizontalPager
+import androidx.compose.foundation.pager.rememberPagerState
 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.Cloud
 import androidx.compose.material.icons.filled.ContentCopy
 import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.MoreVert
 import androidx.compose.material.icons.filled.RecordVoiceOver
+import androidx.compose.material.icons.filled.Save
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.getValue
 import androidx.compose.runtime.mutableStateOf
 import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
 import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.platform.ClipEntry
@@ -31,28 +43,39 @@ import androidx.compose.ui.platform.LocalContext
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
 import io.legado.app.R
+import io.legado.app.data.entities.HttpTTS
 import io.legado.app.domain.model.readaloud.CloudTtsProviderType
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.profile
+import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppFloatingActionButton
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.SearchBar
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.filePicker.FilePickerSheet
+import io.legado.app.ui.widget.components.importComponents.BatchImportDialog
+import io.legado.app.ui.widget.components.importComponents.SourceInputDialog
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenu
 import io.legado.app.ui.widget.components.menuItem.RoundDropdownMenuItem
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionCard
+import io.legado.app.ui.widget.components.modalBottomSheet.OptionSheet
 import io.legado.app.ui.widget.components.settingItem.TinyClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.TinySwitchSettingItem
 import io.legado.app.ui.widget.components.tabRow.AppTabRow
 import io.legado.app.ui.widget.components.text.AppText
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
+import io.legado.app.ui.widget.components.topbar.TopBarActionButton
 import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
+import io.legado.app.utils.GSON
 import io.legado.app.utils.toastOnUi
 import kotlinx.coroutines.flow.Flow
 import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
 
 @Composable
 fun CloudTtsScreen(
@@ -63,9 +86,14 @@ fun CloudTtsScreen(
 ) {
     val context = LocalContext.current
     val clipboard = LocalClipboard.current
+    val pagerState =
+        rememberPagerState(initialPage = state.selectedTab.ordinal) { CloudTtsTab.entries.size }
+    val pagerScope = rememberCoroutineScope()
+    var showAddEngineSheet by remember { mutableStateOf(false) }
+    var showHttpTtsImportSheet by remember { mutableStateOf(false) }
+    var showHttpTtsUrlInput by remember { mutableStateOf(false) }
     val player = remember { MediaPlayer() }
     val errorTitle = stringResource(R.string.cloud_tts_error)
-    val errorCopied = stringResource(R.string.cloud_tts_error_copied)
     val previewPlaybackFailed = stringResource(R.string.cloud_tts_preview_playback_failed)
     DisposableEffect(player) { onDispose { player.release() } }
     LaunchedEffect(effects) {
@@ -74,7 +102,7 @@ fun CloudTtsScreen(
                 is CloudTtsEffect.ShowToast -> context.toastOnUi(effect.message)
                 is CloudTtsEffect.CopyText -> {
                     clipboard.setClipEntry(ClipEntry(ClipData.newPlainText(errorTitle, effect.text)))
-                    context.toastOnUi(errorCopied)
+                    context.toastOnUi(effect.message)
                 }
                 is CloudTtsEffect.PlayPreview -> runCatching {
                     player.reset()
@@ -84,9 +112,24 @@ fun CloudTtsScreen(
                 }.onFailure { onIntent(CloudTtsIntent.ReportError(
                     "$previewPlaybackFailed\n\n${it::class.java.simpleName}: ${it.localizedMessage.orEmpty()}"
                 )) }
+                CloudTtsEffect.OpenHttpTtsImportPicker,
+                CloudTtsEffect.OpenHttpTtsExportPicker,
+                is CloudTtsEffect.OpenHttpTtsLogin -> Unit
             }
         }
     }
+    LaunchedEffect(state.selectedTab) {
+        if (pagerState.currentPage != state.selectedTab.ordinal) {
+            pagerState.animateScrollToPage(state.selectedTab.ordinal)
+        }
+    }
+    LaunchedEffect(pagerState) {
+
+        snapshotFlow { pagerState.currentPage }.collect { page ->
+            CloudTtsTab.entries.getOrNull(page)?.takeIf { it != state.selectedTab }
+                ?.let { onIntent(CloudTtsIntent.SelectTab(it)) }
+        }
+    }
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
@@ -94,6 +137,42 @@ fun CloudTtsScreen(
             GlassMediumFlexibleTopAppBar(
                 title = stringResource(R.string.read_aloud_engines_and_voices),
                 navigationIcon = { TopBarNavigationButton(onClick = onBack) },
+                actions = {
+                    if (state.selectedTab == CloudTtsTab.Engines) {
+                        var expanded by remember { mutableStateOf(false) }
+                        Box {
+                            TopBarActionButton(
+                                onClick = { expanded = true },
+                                imageVector = Icons.Default.MoreVert,
+                                contentDescription = stringResource(R.string.more_menu),
+                            )
+                            RoundDropdownMenu(expanded, { expanded = false }) {
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.import_tts),
+                                    onClick = { expanded = false; showHttpTtsImportSheet = true },
+                                )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.export),
+                                    onClick = {
+                                        expanded = false; onIntent(CloudTtsIntent.ExportHttpTtsFile)
+                                    },
+                                )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.copy_url),
+                                    onClick = {
+                                        expanded = false; onIntent(CloudTtsIntent.ExportHttpTtsUrl)
+                                    },
+                                )
+                                RoundDropdownMenuItem(
+                                    text = stringResource(R.string.clear_cache),
+                                    onClick = {
+                                        expanded = false; onIntent(CloudTtsIntent.ClearTtsCache)
+                                    },
+                                )
+                            }
+                        }
+                    }
+                },
                 scrollBehavior = scrollBehavior,
                 bottomContent = {
                     AppTabRow(
@@ -102,7 +181,10 @@ fun CloudTtsScreen(
                             stringResource(R.string.cloud_tts_engines_tab),
                         ),
                         selectedTabIndex = state.selectedTab.ordinal,
-                        onTabSelected = { onIntent(CloudTtsIntent.SelectTab(CloudTtsTab.entries[it])) },
+                        onTabSelected = { page ->
+                            onIntent(CloudTtsIntent.SelectTab(CloudTtsTab.entries[page]))
+                            pagerScope.launch { pagerState.animateScrollToPage(page) }
+                        },
                         isScrollable = false,
                     )
                 }
@@ -111,10 +193,8 @@ fun CloudTtsScreen(
         floatingActionButton = {
             AppFloatingActionButton(
                 onClick = {
-                    onIntent(
-                        if (state.selectedTab == CloudTtsTab.Engines) CloudTtsIntent.AddEngine
-                        else CloudTtsIntent.AddVoice
-                    )
+                    if (state.selectedTab == CloudTtsTab.Engines) showAddEngineSheet = true
+                    else onIntent(CloudTtsIntent.AddVoice)
                 },
                 icon = if (state.selectedTab == CloudTtsTab.Engines) Icons.Default.Add
                     else Icons.Default.RecordVoiceOver,
@@ -125,14 +205,18 @@ fun CloudTtsScreen(
             )
         },
     ) { padding ->
-        LazyColumn(
+        HorizontalPager(
+            state = pagerState,
             modifier = Modifier.fillMaxSize(),
-            contentPadding = adaptiveContentPadding(
-                top = padding.calculateTopPadding() + 8.dp,
-                bottom = padding.calculateBottomPadding() + 96.dp,
-            ),
-        ) {
-            if (state.selectedTab == CloudTtsTab.Voices) {
+        ) { page ->
+            LazyColumn(
+                modifier = Modifier.fillMaxSize(),
+                contentPadding = adaptiveContentPadding(
+                    top = padding.calculateTopPadding() + 8.dp,
+                    bottom = padding.calculateBottomPadding() + 96.dp,
+                ),
+            ) {
+                if (page == CloudTtsTab.Voices.ordinal) {
                 if (state.voices.isEmpty() && !state.loading) {
                     item { AppText(stringResource(R.string.cloud_tts_no_saved_voices), Modifier.padding(24.dp)) }
                 }
@@ -142,7 +226,7 @@ fun CloudTtsScreen(
                         description = voice.summary,
                         trailingContent = if (voice.deletable) {{
                             MediumTonalButton(
-                                onClick = { onIntent(CloudTtsIntent.DeleteVoice(voice.id)) },
+                                onClick = { onIntent(CloudTtsIntent.RequestDeleteVoice(voice.id)) },
                                 icon = Icons.Default.Delete,
                                 contentDescription = stringResource(R.string.delete),
                             )
@@ -153,40 +237,184 @@ fun CloudTtsScreen(
                     )
                 }
             } else {
-                item { AppText(stringResource(R.string.cloud_tts_engines), Modifier.padding(horizontal = 24.dp, vertical = 12.dp)) }
-                if (state.engines.isEmpty() && !state.loading) {
-                    item { AppText(stringResource(R.string.cloud_tts_no_engines), Modifier.padding(24.dp)) }
-                }
+                    item { EngineSectionTitle(R.string.cloud_tts_cloud_engines) }
                 items(state.engines, key = { "engine:${it.id}" }) { engine ->
                     TinyClickableSettingItem(
                         title = engine.title,
-                        description = engine.summary,
+                        description = listOf(
+                            engine.summary,
+                            stringResource(R.string.read_aloud_current_default_summary).takeIf { engine.selected },
+                        ).filterNotNull().filter(String::isNotBlank).joinToString(" | "),
                         trailingContent = {
-                            MediumTonalButton(
-                                onClick = { onIntent(CloudTtsIntent.DeleteEngine(engine.id)) },
-                                icon = Icons.Default.Delete,
-                                contentDescription = stringResource(R.string.delete),
+                            Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+                                MediumTonalButton(
+                                    onClick = { onIntent(CloudTtsIntent.EditEngine(engine.id)) },
+                                    icon = Icons.Default.Edit,
+                                    contentDescription = stringResource(R.string.edit),
+                                )
+                                MediumTonalButton(
+                                    onClick = { onIntent(CloudTtsIntent.DeleteEngine(engine.id)) },
+                                    icon = Icons.Default.Delete,
+                                    contentDescription = stringResource(R.string.delete),
+                                )
+                            }
+                        },
+                        onClick = {
+                            onIntent(
+                                CloudTtsIntent.SetDefaultEngine(
+                                    ReadAloudVoice.ENGINE_CLOUD,
+                                    engine.id
+                                )
                             )
                         },
-                        onClick = { onIntent(CloudTtsIntent.EditEngine(engine.id)) },
                     )
                 }
-                item { AppText(stringResource(R.string.cloud_tts_available_engines), Modifier.padding(24.dp, 12.dp)) }
-                items(state.availableEngines, key = { "engine-info:${it.engineType}:${it.engineId}" }) { engine ->
+                    item { EngineSectionTitle(R.string.cloud_tts_http_engines) }
+                    items(state.httpEngines, key = { "http:${it.engineId}" }) { engine ->
+                        TinyClickableSettingItem(
+                            title = engine.title,
+                            description = listOf(
+                                engine.summary,
+                                stringResource(R.string.read_aloud_current_default_summary).takeIf { engine.selected },
+                            ).filterNotNull().filter(String::isNotBlank).joinToString(" | "),
+                            onClick = {
+                                onIntent(
+                                    CloudTtsIntent.SetDefaultEngine(
+                                        engine.engineType,
+                                        engine.engineId
+                                    )
+                                )
+                            },
+                            onLongClick = engine.loginUrl.takeIf(String::isNotBlank)?.let {
+                                { onIntent(CloudTtsIntent.OpenHttpTtsLogin(engine.engineId)) }
+                            },
+                            trailingContent = {
+                                Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
+                                    MediumTonalButton(
+                                        onClick = { onIntent(CloudTtsIntent.EditHttpTts(engine.engineId)) },
+                                        icon = Icons.Default.Edit,
+                                        contentDescription = stringResource(R.string.edit),
+                                    )
+                                    MediumTonalButton(
+                                        onClick = { onIntent(CloudTtsIntent.DeleteHttpTts(engine.engineId)) },
+                                        icon = Icons.Default.Delete,
+                                        contentDescription = stringResource(R.string.delete),
+                                    )
+                                }
+                        },
+                    )
+                }
+                    item { EngineSectionTitle(R.string.cloud_tts_system_engines) }
+                    items(state.systemEngines, key = { "system:${it.engineId}" }) { engine ->
                     TinyClickableSettingItem(
                         title = engine.title,
-                        description = listOf(engine.summary, engine.catalogHint)
-                            .filter(String::isNotBlank)
-                            .joinToString(" | "),
-                        onClick = { onIntent(CloudTtsIntent.AddVoiceForEngine(engine.engineType, engine.engineId)) },
+                        description = listOf(
+                            engine.summary,
+                            stringResource(R.string.read_aloud_current_default_summary).takeIf { engine.selected },
+                        ).filterNotNull().filter(String::isNotBlank).joinToString(" | "),
+                        onClick = {
+                            onIntent(
+                                CloudTtsIntent.SetDefaultEngine(
+                                    engine.engineType,
+                                    engine.engineId
+                                )
+                            )
+                        },
                     )
                 }
             }
+            }
         }
     }
+    OptionSheet(
+        show = showAddEngineSheet,
+        onDismissRequest = { showAddEngineSheet = false },
+        title = stringResource(R.string.speak_engine),
+    ) {
+        OptionCard(
+            icon = Icons.Default.Cloud,
+            text = stringResource(R.string.cloud_tts_add_engine),
+            onClick = {
+                showAddEngineSheet = false
+                onIntent(CloudTtsIntent.AddEngine)
+            },
+        )
+        OptionCard(
+            icon = Icons.Default.RecordVoiceOver,
+            text = stringResource(R.string.cloud_tts_add_http_engine),
+            onClick = {
+                showAddEngineSheet = false
+                onIntent(CloudTtsIntent.EditHttpTts())
+            },
+        )
+    }
     CloudTtsEngineEditorSheet(state, state.engineEditor, onIntent)
     VoiceEnginePickerSheet(state.showVoiceEnginePicker, state, onIntent)
     TtsVoicePresetEditorSheet(state, state.voiceEditor, onIntent)
+    HttpTtsEditorSheet(state.httpTtsEditor, onIntent)
+    FilePickerSheet(
+        show = showHttpTtsImportSheet,
+        onDismissRequest = { showHttpTtsImportSheet = false },
+        title = stringResource(R.string.import_tts),
+        onSelectSysFile = {
+            showHttpTtsImportSheet = false; onIntent(CloudTtsIntent.ImportHttpTtsFile)
+        },
+        onManualInput = { showHttpTtsImportSheet = false; showHttpTtsUrlInput = true },
+        allowExtensions = arrayOf("json", "txt"),
+    )
+    SourceInputDialog(
+        show = showHttpTtsUrlInput,
+        title = stringResource(R.string.import_on_line),
+        onDismissRequest = { showHttpTtsUrlInput = false },
+        onConfirm = {
+            showHttpTtsUrlInput = false; onIntent(CloudTtsIntent.ImportHttpTtsSource(it))
+        },
+    )
+    BatchImportDialog(
+        title = stringResource(R.string.import_tts),
+        importState = state.httpTtsImportState,
+        onDismissRequest = { onIntent(CloudTtsIntent.CancelHttpTtsImport) },
+        onToggleItem = { onIntent(CloudTtsIntent.ToggleHttpTtsImportSelection(it)) },
+        onToggleAll = { onIntent(CloudTtsIntent.ToggleHttpTtsImportAll(it)) },
+        onUpdateItem = { index, value ->
+            onIntent(
+                CloudTtsIntent.UpdateHttpTtsImportItem(
+                    index,
+                    value
+                )
+            )
+        },
+        onConfirm = { onIntent(CloudTtsIntent.SaveImportedHttpTts) },
+        itemTitle = { it.name },
+        itemSubtitle = { it.url },
+    )
+    val deleteVoice = state.activeDialog as? CloudTtsDialog.DeleteVoice
+    AppAlertDialog(
+        show = deleteVoice != null,
+        onDismissRequest = { onIntent(CloudTtsIntent.DismissError) },
+        title = stringResource(R.string.delete),
+        text = deleteVoice?.let {
+            stringResource(
+                R.string.cloud_tts_delete_voice_message,
+                it.title
+            )
+        },
+        confirmText = stringResource(R.string.delete),
+        onConfirm = { onIntent(CloudTtsIntent.ConfirmDeleteVoice) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(CloudTtsIntent.DismissError) },
+    )
+    val defaultScope = state.activeDialog as? CloudTtsDialog.DefaultEngineScope
+    AppAlertDialog(
+        show = defaultScope != null,
+        onDismissRequest = { onIntent(CloudTtsIntent.DismissError) },
+        title = defaultScope?.title ?: stringResource(R.string.read_aloud_default_engine),
+        text = stringResource(R.string.speak_engine_apply_scope),
+        confirmText = stringResource(R.string.general),
+        onConfirm = { onIntent(CloudTtsIntent.ApplyDefaultEngineGlobally) },
+        dismissText = stringResource(R.string.book),
+        onDismiss = { onIntent(CloudTtsIntent.ApplyDefaultEngineForBook) },
+    )
     AppModalBottomSheet(
         data = state.activeDialog as? CloudTtsDialog.Error,
         onDismissRequest = { onIntent(CloudTtsIntent.DismissError) },
@@ -213,6 +441,201 @@ fun CloudTtsScreen(
     }
 }
 
+@Composable
+private fun EngineSectionTitle(titleRes: Int) {
+    AppText(
+        text = stringResource(titleRes),
+        modifier = Modifier.padding(horizontal = 12.dp, vertical = 12.dp),
+        style = LegadoTheme.typography.labelMediumEmphasized
+    )
+}
+
+@Composable
+private fun HttpTtsEditorSheet(
+    value: HttpTTS?,
+    onIntent: (CloudTtsIntent) -> Unit,
+) {
+    var cachedSource by remember { mutableStateOf(value) }
+    if (value != null) cachedSource = value
+    val source = cachedSource ?: return
+    val clipboard = LocalClipboard.current
+    val context = LocalContext.current
+    val scope = rememberCoroutineScope()
+    var name by remember(source) { mutableStateOf(source.name) }
+    var url by remember(source) { mutableStateOf(source.url) }
+    var contentType by remember(source) { mutableStateOf(source.contentType.orEmpty()) }
+    var concurrentRate by remember(source) { mutableStateOf(source.concurrentRate ?: "0") }
+    var header by remember(source) { mutableStateOf(source.header.orEmpty()) }
+    var loginUrl by remember(source) { mutableStateOf(source.loginUrl.orEmpty()) }
+    var loginUi by remember(source) { mutableStateOf(source.loginUi.orEmpty()) }
+    var loginCheckJs by remember(source) { mutableStateOf(source.loginCheckJs.orEmpty()) }
+    var jsLib by remember(source) { mutableStateOf(source.jsLib.orEmpty()) }
+
+    fun current() = source.copy(
+        name = name,
+        url = url,
+        contentType = contentType.ifBlank { null },
+        concurrentRate = concurrentRate,
+        header = header.ifBlank { null },
+        loginUrl = loginUrl.ifBlank { null },
+        loginUi = loginUi.ifBlank { null },
+        loginCheckJs = loginCheckJs.ifBlank { null },
+        jsLib = jsLib.ifBlank { null },
+    )
+
+    AppModalBottomSheet(
+        show = value != null,
+        onDismissRequest = { onIntent(CloudTtsIntent.DismissHttpTtsEditor) },
+        title = stringResource(R.string.speak_engine),
+        startAction = {
+            var expanded by remember { mutableStateOf(false) }
+            Box {
+                MediumTonalButton(
+                    onClick = { expanded = true },
+                    icon = Icons.Default.MoreVert,
+                    contentDescription = stringResource(R.string.more_menu),
+                )
+                RoundDropdownMenu(expanded, { expanded = false }) {
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.copy_text),
+                        onClick = {
+                            expanded = false
+                            scope.launch {
+                                clipboard.setClipEntry(
+                                    ClipEntry(
+                                        ClipData.newPlainText(
+                                            "httpTTS",
+                                            GSON.toJson(current())
+                                        )
+                                    )
+                                )
+                            }
+                        },
+                    )
+                    RoundDropdownMenuItem(
+                        text = stringResource(R.string.paste_source),
+                        onClick = {
+                            expanded = false
+                            scope.launch {
+                                val text = clipboard.getClipEntry()?.clipData
+                                    ?.getItemAt(0)?.coerceToText(context)?.toString()
+                                    ?: return@launch
+                                HttpTTS.fromJson(text).getOrNull()?.let { imported ->
+                                    name = imported.name; url = imported.url
+                                    contentType = imported.contentType.orEmpty()
+                                    concurrentRate = imported.concurrentRate ?: "0"
+                                    header = imported.header.orEmpty(); loginUrl =
+                                    imported.loginUrl.orEmpty()
+                                    loginUi = imported.loginUi.orEmpty(); loginCheckJs =
+                                    imported.loginCheckJs.orEmpty()
+                                    jsLib = imported.jsLib.orEmpty()
+                                }
+                            }
+                        },
+                    )
+                }
+            }
+        },
+        endAction = {
+            MediumTonalButton(
+                onClick = { onIntent(CloudTtsIntent.SaveHttpTts(current())) },
+                icon = Icons.Default.Save,
+                contentDescription = stringResource(R.string.save),
+            )
+        },
+    ) {
+        LazyColumn(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(vertical = 8.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            item {
+                AppTextField(
+                    name,
+                    { name = it },
+                    label = stringResource(R.string.name),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    url,
+                    { url = it },
+                    label = "URL",
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    contentType,
+                    { contentType = it },
+                    label = "Content-Type",
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    concurrentRate,
+                    { concurrentRate = it },
+                    label = stringResource(R.string.concurrent_rate),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    header,
+                    { header = it },
+                    label = stringResource(R.string.source_http_header),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    loginUrl,
+                    { loginUrl = it },
+                    label = stringResource(R.string.login_url),
+                    singleLine = true,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    loginUi,
+                    { loginUi = it },
+                    label = stringResource(R.string.login_ui),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    loginCheckJs,
+                    { loginCheckJs = it },
+                    label = stringResource(R.string.login_check_js),
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item {
+                AppTextField(
+                    jsLib,
+                    { jsLib = it },
+                    label = "jsLib",
+                    minLines = 2,
+                    modifier = Modifier.fillMaxWidth()
+                )
+            }
+            item { Spacer(Modifier.height(16.dp)) }
+        }
+    }
+}
+
 @Composable
 private fun CloudTtsEngineEditorSheet(
     state: CloudTtsUiState,
@@ -265,84 +688,119 @@ private fun CloudTtsEngineEditorContent(
         ?: CloudTtsProviderType.Mimo
     val profile = provider.profile
     LazyColumn {
-                item { Column(Modifier.padding(horizontal = 16.dp)) {
-                    MenuButton(profile.displayName) { providerMenu = true }
-                    RoundDropdownMenu(providerMenu, { providerMenu = false }) {
-                        CloudTtsProviderType.entries.forEach { option ->
-                            RoundDropdownMenuItem(
-                                text = option.profile.displayName,
-                                onClick = {
-                                    providerMenu = false
-                                    update(editor.copy(
+        item {
+            Column(
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                MenuButton(profile.displayName) { providerMenu = true }
+                RoundDropdownMenu(providerMenu, { providerMenu = false }) {
+                    CloudTtsProviderType.entries.forEach { option ->
+                        RoundDropdownMenuItem(
+                            text = option.profile.displayName,
+                            onClick = {
+                                providerMenu = false
+                                update(
+                                    editor.copy(
                                         provider = option.storageValue,
                                         name = if (editor.name.isBlank() || editor.name == profile.displayName) {
                                             option.profile.displayName
                                         } else editor.name,
                                         model = option.profile.defaultModel,
-                                        region = option.profile.regionOptions.firstOrNull().orEmpty(),
-                                    ))
-                                },
-                            )
-                        }
-                    }
-                    Field(editor.name, { update(editor.copy(name = it)) }, stringResource(R.string.cloud_tts_engine_name))
-                    Field(editor.baseUrl, { update(editor.copy(baseUrl = it)) }, stringResource(R.string.cloud_tts_custom_base_url))
-                    Field(editor.apiKey, { update(editor.copy(apiKey = it)) }, profile.apiKeyLabel)
-                    if (provider == CloudTtsProviderType.AwsPolly) {
-                        Field(editor.secretKey, { update(editor.copy(secretKey = it)) }, "Secret Access Key")
-                    }
-                    if (profile.regionOptions.isNotEmpty()) {
-                        MenuButton(stringResource(R.string.cloud_tts_region_value, editor.region.ifBlank { stringResource(R.string.cloud_tts_please_select) })) { regionMenu = true }
-                        RoundDropdownMenu(regionMenu, { regionMenu = false }) {
-                            profile.regionOptions.forEach { region ->
-                                RoundDropdownMenuItem(
-                                    text = region,
-                                    onClick = {
-                                        regionMenu = false
-                                        customRegion = false
-                                        update(editor.copy(region = region))
-                                    },
+                                        region = option.profile.regionOptions.firstOrNull()
+                                            .orEmpty(),
+                                    )
                                 )
-                            }
+                            },
+                        )
+                    }
+                }
+                Field(
+                    editor.name,
+                    { update(editor.copy(name = it)) },
+                    stringResource(R.string.cloud_tts_engine_name)
+                )
+                Field(
+                    editor.baseUrl,
+                    { update(editor.copy(baseUrl = it)) },
+                    stringResource(R.string.cloud_tts_custom_base_url)
+                )
+                Field(editor.apiKey, { update(editor.copy(apiKey = it)) }, profile.apiKeyLabel)
+                if (provider == CloudTtsProviderType.AwsPolly) {
+                    Field(
+                        editor.secretKey,
+                        { update(editor.copy(secretKey = it)) },
+                        "Secret Access Key"
+                    )
+                }
+                if (profile.regionOptions.isNotEmpty()) {
+                    MenuButton(
+                        stringResource(
+                            R.string.cloud_tts_region_value,
+                            editor.region.ifBlank { stringResource(R.string.cloud_tts_please_select) })
+                    ) { regionMenu = true }
+                    RoundDropdownMenu(regionMenu, { regionMenu = false }) {
+                        profile.regionOptions.forEach { region ->
                             RoundDropdownMenuItem(
-                                text = stringResource(R.string.cloud_tts_custom_region_action),
+                                text = region,
                                 onClick = {
                                     regionMenu = false
-                                    customRegion = true
-                                    update(editor.copy(region = ""))
+                                    customRegion = false
+                                    update(editor.copy(region = region))
                                 },
                             )
                         }
-                        if (customRegion) {
-                            Field(editor.region, { update(editor.copy(region = it)) }, stringResource(R.string.cloud_tts_custom_region))
-                        }
+                        RoundDropdownMenuItem(
+                            text = stringResource(R.string.cloud_tts_custom_region_action),
+                            onClick = {
+                                regionMenu = false
+                                customRegion = true
+                                update(editor.copy(region = ""))
+                            },
+                        )
                     }
-                    if (provider == CloudTtsProviderType.Volcengine) {
-                        Field(editor.appId, { update(editor.copy(appId = it)) }, "App ID")
+                    if (customRegion) {
+                        Field(
+                            editor.region,
+                            { update(editor.copy(region = it)) },
+                            stringResource(R.string.cloud_tts_custom_region)
+                        )
                     }
-                    if (profile.modelOptions.isNotEmpty()) {
-                        MenuButton(stringResource(R.string.cloud_tts_model_value, editor.model.ifBlank { "neural" })) { modelMenu = true }
-                        RoundDropdownMenu(modelMenu, { modelMenu = false }) {
-                            profile.modelOptions.forEach { model ->
-                                RoundDropdownMenuItem(
-                                    text = model,
-                                    onClick = { modelMenu = false; update(editor.copy(model = model)) },
-                                )
-                            }
+                }
+                if (provider == CloudTtsProviderType.Volcengine) {
+                    Field(editor.appId, { update(editor.copy(appId = it)) }, "App ID")
+                }
+                if (profile.modelOptions.isNotEmpty()) {
+                    MenuButton(
+                        stringResource(
+                            R.string.cloud_tts_model_value,
+                            editor.model.ifBlank { "neural" })
+                    ) { modelMenu = true }
+                    RoundDropdownMenu(modelMenu, { modelMenu = false }) {
+                        profile.modelOptions.forEach { model ->
+                            RoundDropdownMenuItem(
+                                text = model,
+                                onClick = { modelMenu = false; update(editor.copy(model = model)) },
+                            )
                         }
-                    } else {
-                        Field(editor.model, { update(editor.copy(model = it)) }, profile.modelLabel)
-                    }
-                    if (provider == CloudTtsProviderType.Volcengine) {
-                        Field(editor.optionsJson, { update(editor.copy(optionsJson = it)) }, stringResource(R.string.cloud_tts_options_json))
                     }
-                    MediumTonalButton(
-                        onClick = { onIntent(CloudTtsIntent.TestEngine) },
-                        enabled = !state.testing,
-                        modifier = Modifier.fillMaxWidth(),
-                        text = stringResource(if (state.testing) R.string.cloud_tts_testing else R.string.cloud_tts_connection_test),
+                } else {
+                    Field(editor.model, { update(editor.copy(model = it)) }, profile.modelLabel)
+                }
+                if (provider == CloudTtsProviderType.Volcengine) {
+                    Field(
+                        editor.optionsJson,
+                        { update(editor.copy(optionsJson = it)) },
+                        stringResource(R.string.cloud_tts_options_json)
                     )
-                } }
+                }
+                MediumTonalButton(
+                    onClick = { onIntent(CloudTtsIntent.TestEngine) },
+                    enabled = !state.testing,
+                    modifier = Modifier.fillMaxWidth(),
+                    text = stringResource(if (state.testing) R.string.cloud_tts_testing else R.string.cloud_tts_connection_test),
+                )
+            }
+        }
     }
 }
 
@@ -402,14 +860,11 @@ private fun TtsVoicePresetEditorContent(
 
     LazyColumn(
             Modifier.fillMaxWidth(),
-            verticalArrangement = Arrangement.spacedBy(2.dp)
+        verticalArrangement = Arrangement.spacedBy(4.dp)
         ) {
             if (editor.engineId.isNotBlank()) {
                 item {
-                    AppText(stringResource(
-                        R.string.cloud_tts_step_voice),
-                        Modifier.padding(horizontal = 24.dp, vertical = 12.dp)
-                    )
+                    EngineSectionTitle(R.string.cloud_tts_step_voice)
                     if (editor.editingVoiceId == null && state.discoveredVoices.isNotEmpty()) {
                         SearchBar(
                             query = voiceQuery,
@@ -480,7 +935,7 @@ private fun TtsVoicePresetEditorContent(
                             Modifier.padding(horizontal = 24.dp),
                         )
                     }
-                    AppText(stringResource(R.string.cloud_tts_step_preset), Modifier.padding(horizontal = 24.dp, vertical = 12.dp))
+                    EngineSectionTitle(R.string.cloud_tts_step_preset)
                     Field(editor.voiceName, { update(editor.copy(voiceName = it)) }, stringResource(R.string.cloud_tts_preset_name))
                     if (editor.engineType == ReadAloudVoice.ENGINE_CLOUD) {
                         if (!selectedVoice?.styles.isNullOrEmpty()) {

+ 449 - 12
app/src/main/java/io/legado/app/ui/book/readaloud/cloudtts/CloudTtsViewModel.kt

@@ -1,30 +1,54 @@
 package io.legado.app.ui.book.readaloud.cloudtts
 
 import android.app.Application
-import io.legado.app.R
+import android.net.Uri
+import android.util.Base64
 import androidx.lifecycle.ViewModel
 import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.constant.AppConst
+import io.legado.app.constant.AppPattern
+import io.legado.app.data.appDb
+import io.legado.app.data.entities.HttpTTS
+import io.legado.app.data.repository.UploadRepository
 import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.HttpTtsEngineGateway
+import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudVoiceGateway
 import io.legado.app.domain.model.readaloud.CloudTtsEngine
 import io.legado.app.domain.model.readaloud.CloudTtsProviderType
-import io.legado.app.domain.model.readaloud.CloudTtsVoiceCatalogType
 import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceCatalogType
 import io.legado.app.domain.model.readaloud.CloudTtsVoiceConfig
+import io.legado.app.domain.model.readaloud.ReadAloudEngineSelection
 import io.legado.app.domain.model.readaloud.ReadAloudVoice
 import io.legado.app.domain.model.readaloud.SpeechIdentity
-import io.legado.app.domain.model.readaloud.TtsEngineDescriptor
 import io.legado.app.domain.model.readaloud.SystemTtsVoiceConfig
+import io.legado.app.domain.model.readaloud.TtsEngineDescriptor
 import io.legado.app.domain.model.readaloud.profile
+import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.http.decompressed
+import io.legado.app.help.http.newCallResponseBody
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.text
 import io.legado.app.help.readaloud.playback.CloudTtsAudioSynthesizer
 import io.legado.app.help.readaloud.playback.SystemTtsFileSynthesizer
 import io.legado.app.help.readaloud.playback.SystemTtsVoiceCatalog
+import io.legado.app.lib.dialogs.SelectItem
+import io.legado.app.model.ReadAloud
+import io.legado.app.model.ReadBook
+import io.legado.app.ui.widget.components.importComponents.BaseImportUiState
+import io.legado.app.ui.widget.components.importComponents.ImportItemWrapper
+import io.legado.app.ui.widget.components.importComponents.ImportStatus
 import io.legado.app.utils.GSON
-import java.io.File
-import java.util.UUID
-import kotlinx.collections.immutable.toImmutableList
+import io.legado.app.utils.fromJsonObject
+import io.legado.app.utils.isAbsUrl
+import io.legado.app.utils.isDataUrl
+import io.legado.app.utils.isJsonArray
+import io.legado.app.utils.isJsonObject
 import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.asSharedFlow
@@ -32,12 +56,17 @@ import kotlinx.coroutines.flow.asStateFlow
 import kotlinx.coroutines.flow.combine
 import kotlinx.coroutines.flow.update
 import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.UUID
 
 class CloudTtsViewModel(
     private val application: Application,
     private val engineGateway: CloudTtsEngineGateway,
     private val httpTtsEngineGateway: HttpTtsEngineGateway,
     private val voiceGateway: ReadAloudVoiceGateway,
+    private val readAloudSettingsGateway: ReadAloudSettingsGateway,
+    private val uploadRepository: UploadRepository,
 ) : ViewModel() {
     private val _uiState = MutableStateFlow(CloudTtsUiState())
     val uiState = _uiState.asStateFlow()
@@ -50,6 +79,8 @@ class CloudTtsViewModel(
     private var voices = emptyList<ReadAloudVoice>()
     private var systemEngines = emptyList<TtsEngineDescriptor>()
     private var httpEngines = emptyList<TtsEngineDescriptor>()
+    private var bookUrl: String? = null
+    private var bookEngineValue: String? = null
 
     init {
         viewModelScope.launch {
@@ -72,7 +103,23 @@ class CloudTtsViewModel(
                 _uiState.update { state -> state.copy(
                     loading = false,
                     engines = allEngines.map { engine ->
-                        CloudTtsEngineItemUi(engine.id, engine.name, engine.provider.profile.displayName)
+                        CloudTtsEngineItemUi(
+                            engine.id,
+                            engine.name,
+                            engine.provider.profile.displayName,
+                            isDefaultEngine(ReadAloudVoice.ENGINE_CLOUD, engine.id),
+                        )
+                    }.toImmutableList(),
+                    systemEngines = systemEngineItems(),
+                    httpEngines = allHttpEngines.map { engine ->
+                        TtsManagedEngineItemUi(
+                            engineType = ReadAloudVoice.ENGINE_HTTP,
+                            engineId = engine.sourceId,
+                            title = engine.displayName,
+                            summary = engine.providerName,
+                            selected = isDefaultEngine(ReadAloudVoice.ENGINE_HTTP, engine.sourceId),
+                            loginUrl = engine.loginUrl,
+                        )
                     }.toImmutableList(),
                     voices = savedVoices.map { voice ->
                         CloudTtsVoiceItemUi(
@@ -93,13 +140,19 @@ class CloudTtsViewModel(
         }
         viewModelScope.launch {
             systemEngines = systemCatalog.getEngines()
-            _uiState.update { it.copy(availableEngines = engineOptions()) }
+            _uiState.update {
+                it.copy(
+                    availableEngines = engineOptions(),
+                    systemEngines = systemEngineItems(),
+                )
+            }
         }
     }
 
     fun onIntent(intent: CloudTtsIntent) {
         when (intent) {
             is CloudTtsIntent.SelectTab -> _uiState.update { it.copy(selectedTab = intent.tab) }
+            is CloudTtsIntent.SetBookContext -> setBookContext(intent.bookUrl)
             CloudTtsIntent.AddEngine -> _uiState.update { it.copy(
                 engineEditor = CloudTtsEngineEditorUi(name = "MiMo", model = "mimo-v2.5-tts"),
             ) }
@@ -110,7 +163,43 @@ class CloudTtsViewModel(
             }
             is CloudTtsIntent.EditEngine -> editEngine(intent.id)
             is CloudTtsIntent.DeleteEngine -> deleteEngine(intent.id)
-            is CloudTtsIntent.DeleteVoice -> deleteVoice(intent.id)
+            is CloudTtsIntent.SetDefaultEngine -> setDefaultEngine(
+                intent.engineType,
+                intent.engineId
+            )
+
+            CloudTtsIntent.ApplyDefaultEngineGlobally -> applyPendingDefaultEngine(forBook = false)
+            CloudTtsIntent.ApplyDefaultEngineForBook -> applyPendingDefaultEngine(forBook = true)
+            is CloudTtsIntent.EditHttpTts -> editHttpTts(intent.engineId)
+            is CloudTtsIntent.SaveHttpTts -> saveHttpTts(intent.value)
+            is CloudTtsIntent.DeleteHttpTts -> deleteHttpTts(intent.engineId)
+            is CloudTtsIntent.OpenHttpTtsLogin -> intent.engineId.toLongOrNull()?.let {
+                _effects.tryEmit(CloudTtsEffect.OpenHttpTtsLogin(it))
+            }
+
+            CloudTtsIntent.DismissHttpTtsEditor -> _uiState.update { it.copy(httpTtsEditor = null) }
+            is CloudTtsIntent.ImportHttpTtsSource -> importHttpTtsSource(intent.text)
+            CloudTtsIntent.ImportHttpTtsFile -> _effects.tryEmit(CloudTtsEffect.OpenHttpTtsImportPicker)
+            is CloudTtsIntent.ImportHttpTtsFileSelected -> importHttpTtsFile(intent.uri)
+            CloudTtsIntent.CancelHttpTtsImport -> _uiState.update { it.copy(httpTtsImportState = BaseImportUiState.Idle) }
+            is CloudTtsIntent.ToggleHttpTtsImportSelection -> toggleHttpTtsImportSelection(intent.index)
+            is CloudTtsIntent.ToggleHttpTtsImportAll -> toggleHttpTtsImportAll(intent.selected)
+            is CloudTtsIntent.UpdateHttpTtsImportItem -> updateHttpTtsImportItem(
+                intent.index,
+                intent.value
+            )
+
+            CloudTtsIntent.SaveImportedHttpTts -> saveImportedHttpTts()
+            CloudTtsIntent.ExportHttpTtsFile -> _effects.tryEmit(CloudTtsEffect.OpenHttpTtsExportPicker)
+            is CloudTtsIntent.ExportHttpTtsFileSelected -> exportHttpTtsFile(intent.uri)
+            CloudTtsIntent.ExportHttpTtsUrl -> exportHttpTtsUrl()
+            CloudTtsIntent.ClearTtsCache -> {
+                io.legado.app.utils.TTSCacheUtils.clearTtsCache()
+                toast(application.getString(R.string.clear_cache_success))
+            }
+
+            is CloudTtsIntent.RequestDeleteVoice -> requestDeleteVoice(intent.id)
+            CloudTtsIntent.ConfirmDeleteVoice -> confirmDeleteVoice()
             is CloudTtsIntent.EditVoice -> editVoice(intent.id)
             is CloudTtsIntent.UpdateEngineEditor -> _uiState.update { it.copy(engineEditor = intent.editor) }
             is CloudTtsIntent.UpdateVoiceEditor -> _uiState.update { it.copy(voiceEditor = intent.editor) }
@@ -562,18 +651,360 @@ class CloudTtsViewModel(
             persistentListOf()
         }
 
+    private fun systemEngineItems() = buildList {
+        add(
+            TtsManagedEngineItemUi(
+                engineType = ReadAloudVoice.ENGINE_SYSTEM,
+                engineId = "",
+                title = application.getString(R.string.system_tts),
+                selected = isDefaultEngine(ReadAloudVoice.ENGINE_SYSTEM, ""),
+            )
+        )
+        addAll(systemEngines.map { engine ->
+            TtsManagedEngineItemUi(
+                engineType = ReadAloudVoice.ENGINE_SYSTEM,
+                engineId = engine.sourceId,
+                title = engine.displayName,
+                summary = engine.providerName,
+                selected = isDefaultEngine(ReadAloudVoice.ENGINE_SYSTEM, engine.sourceId),
+            )
+        })
+    }.toImmutableList()
+
+    private fun isDefaultEngine(engineType: String, engineId: String): Boolean {
+        val value = bookEngineValue ?: readAloudSettingsGateway.currentSettings.ttsEngine
+        return when (engineType) {
+            ReadAloudVoice.ENGINE_HTTP -> value == engineId
+            ReadAloudVoice.ENGINE_CLOUD -> GSON.fromJsonObject<ReadAloudEngineSelection>(value)
+                .getOrNull()?.let { it.engineType == engineType && it.engineId == engineId } == true
+
+            ReadAloudVoice.ENGINE_SYSTEM -> if (engineId.isBlank()) {
+                value.isNullOrBlank()
+            } else {
+                GSON.fromJsonObject<SelectItem<String>>(value).getOrNull()?.value == engineId
+            }
+
+            else -> false
+        }
+    }
+
+    private fun refreshEngineSelection() {
+        _uiState.update { state ->
+            state.copy(
+                engines = engines.map { engine ->
+                    CloudTtsEngineItemUi(
+                        engine.id,
+                        engine.name,
+                        engine.provider.profile.displayName,
+                        isDefaultEngine(ReadAloudVoice.ENGINE_CLOUD, engine.id),
+                    )
+                }.toImmutableList(),
+                systemEngines = systemEngineItems(),
+                httpEngines = httpEngines.map { engine ->
+                    TtsManagedEngineItemUi(
+                        engineType = ReadAloudVoice.ENGINE_HTTP,
+                        engineId = engine.sourceId,
+                        title = engine.displayName,
+                        summary = engine.providerName,
+                        selected = isDefaultEngine(ReadAloudVoice.ENGINE_HTTP, engine.sourceId),
+                        loginUrl = engine.loginUrl,
+                    )
+                }.toImmutableList(),
+            )
+        }
+    }
+
+    private fun setDefaultEngine(engineType: String, engineId: String) = viewModelScope.launch {
+        val (value, title) = when (engineType) {
+            ReadAloudVoice.ENGINE_SYSTEM -> if (engineId.isBlank()) {
+                null to application.getString(R.string.system_tts)
+            } else {
+                val name =
+                    systemEngines.firstOrNull { it.sourceId == engineId }?.displayName ?: engineId
+                GSON.toJson(SelectItem(name, engineId)) to name
+            }
+
+            ReadAloudVoice.ENGINE_HTTP -> engineId to
+                    (httpEngines.firstOrNull { it.sourceId == engineId }?.displayName ?: engineId)
+
+            ReadAloudVoice.ENGINE_CLOUD -> {
+                val voice = voices.firstOrNull {
+                    it.engineType == engineType && it.engineId == engineId && it.enabled && it.available
+                }
+                    ?: return@launch toast(application.getString(R.string.cloud_tts_default_requires_voice))
+                val name = engines.firstOrNull { it.id == engineId }?.name ?: engineId
+                GSON.toJson(
+                    ReadAloudEngineSelection(
+                        engineType = engineType,
+                        engineId = engineId,
+                        speakerId = voice.speakerId,
+                        displayName = name,
+                    )
+                ) to name
+            }
+
+            else -> return@launch
+        }
+        if (bookUrl != null) {
+            _uiState.update {
+                it.copy(
+                    activeDialog = CloudTtsDialog.DefaultEngineScope(
+                        value,
+                        title
+                    )
+                )
+            }
+        } else {
+            applyDefaultEngine(value, forBook = false)
+        }
+    }
+
+    private fun setBookContext(value: String?) = viewModelScope.launch {
+        bookUrl = value
+        bookEngineValue = withContext(Dispatchers.IO) {
+            value?.let(appDb.bookDao::getBook)?.getTtsEngine()
+        }
+        refreshEngineSelection()
+    }
+
+    private fun applyPendingDefaultEngine(forBook: Boolean) = viewModelScope.launch {
+        val dialog =
+            _uiState.value.activeDialog as? CloudTtsDialog.DefaultEngineScope ?: return@launch
+        applyDefaultEngine(dialog.value, forBook)
+    }
+
+    private suspend fun applyDefaultEngine(value: String?, forBook: Boolean) {
+        if (forBook && bookUrl != null) {
+            ReadBook.book?.takeIf { it.bookUrl == bookUrl }?.setTtsEngine(value)
+            withContext(Dispatchers.IO) {
+                appDb.bookDao.getBook(bookUrl!!)?.let { book ->
+                    book.setTtsEngine(value)
+                    appDb.bookDao.update(book)
+                }
+            }
+            bookEngineValue = value
+        } else {
+            ReadBook.book?.takeIf { it.bookUrl == bookUrl }?.setTtsEngine(null)
+            bookUrl?.let { url ->
+                withContext(Dispatchers.IO) {
+                    appDb.bookDao.getBook(url)?.let { book ->
+                        book.setTtsEngine(null)
+                        appDb.bookDao.update(book)
+                    }
+                }
+            }
+            readAloudSettingsGateway.update { it.copy(ttsEngine = value) }
+            bookEngineValue = null
+        }
+        ReadAloud.upReadAloudClass()
+        _uiState.update { it.copy(activeDialog = null) }
+        refreshEngineSelection()
+        toast(application.getString(R.string.read_aloud_default_engine_updated))
+    }
+
+    private fun editHttpTts(engineId: String?) = viewModelScope.launch {
+        val value = withContext(Dispatchers.IO) {
+            engineId?.toLongOrNull()?.let(appDb.httpTTSDao::get) ?: HttpTTS()
+        }
+        _uiState.update { it.copy(httpTtsEditor = value) }
+    }
+
+    private fun saveHttpTts(value: HttpTTS) = viewModelScope.launch {
+        withContext(Dispatchers.IO) { appDb.httpTTSDao.insert(value) }
+        _uiState.update { it.copy(httpTtsEditor = null) }
+        toast(application.getString(R.string.success))
+    }
+
+    private fun deleteHttpTts(engineId: String) = viewModelScope.launch {
+        val id = engineId.toLongOrNull() ?: return@launch
+        withContext(Dispatchers.IO) { appDb.httpTTSDao.get(id)?.let(appDb.httpTTSDao::delete) }
+        if (isDefaultEngine(ReadAloudVoice.ENGINE_HTTP, engineId)) {
+            readAloudSettingsGateway.update { it.copy(ttsEngine = null) }
+            ReadAloud.upReadAloudClass()
+        }
+    }
+
+    private fun importHttpTtsFile(uri: Uri) = viewModelScope.launch {
+        val text = withContext(Dispatchers.IO) {
+            application.contentResolver.openInputStream(uri)?.bufferedReader()
+                ?.use { it.readText() }
+        }
+        if (!text.isNullOrBlank()) importHttpTtsSource(text)
+    }
+
+    private fun importHttpTtsSource(text: String) = viewModelScope.launch {
+        _uiState.update { it.copy(httpTtsImportState = BaseImportUiState.Loading) }
+        runCatching {
+            val source = text.trim()
+            val list = withContext(Dispatchers.IO) { parseHttpTtsSource(source) }
+            val items = withContext(Dispatchers.IO) {
+                list.map { value ->
+                    val old = appDb.httpTTSDao.get(value.id)
+                    val status = when {
+                        old == null -> ImportStatus.New
+                        value.lastUpdateTime > old.lastUpdateTime -> ImportStatus.Update
+                        else -> ImportStatus.Existing
+                    }
+                    ImportItemWrapper(
+                        data = value,
+                        oldData = old,
+                        isSelected = status != ImportStatus.Existing,
+                        status = status,
+                    )
+                }
+            }
+            if (items.isEmpty()) throw NoStackTraceException(application.getString(R.string.wrong_format))
+            BaseImportUiState.Success(source, items)
+        }.onSuccess { result -> _uiState.update { it.copy(httpTtsImportState = result) } }
+            .onFailure { error ->
+                _uiState.update {
+                    it.copy(
+                        httpTtsImportState = BaseImportUiState.Error(
+                            error.localizedMessage ?: application.getString(R.string.wrong_format)
+                        )
+                    )
+                }
+            }
+    }
+
+    private suspend fun parseHttpTtsSource(text: String): List<HttpTTS> = when {
+        text.isHttpTtsImportUri() -> Uri.parse(text).getQueryParameter("src")
+            ?.let { parseHttpTtsSource(it) }
+            ?: throw NoStackTraceException(application.getString(R.string.wrong_format))
+
+        text.isJsonObject() -> listOf(HttpTTS.fromJson(text).getOrThrow())
+        text.isJsonArray() -> HttpTTS.fromJsonArray(text).getOrThrow()
+        text.isDataUrl() -> {
+            val data = AppPattern.dataUriRegex.find(text)?.groupValues?.getOrNull(1)
+                ?: throw NoStackTraceException(application.getString(R.string.wrong_format))
+            parseHttpTtsSource(Base64.decode(data, Base64.DEFAULT).toString(Charsets.UTF_8))
+        }
+
+        text.isAbsUrl() -> parseHttpTtsSource(okHttpClient.newCallResponseBody {
+            if (text.endsWith("#requestWithoutUA")) {
+                url(text.substringBeforeLast("#requestWithoutUA")); header(AppConst.UA_NAME, "null")
+            } else url(text)
+        }.decompressed().text())
+
+        else -> throw NoStackTraceException(application.getString(R.string.wrong_format))
+    }
+
+    private fun toggleHttpTtsImportSelection(index: Int) {
+        val state =
+            _uiState.value.httpTtsImportState as? BaseImportUiState.Success<HttpTTS> ?: return
+        if (index !in state.items.indices) return
+        val items = state.items.toMutableList()
+        items[index] = items[index].copy(isSelected = !items[index].isSelected)
+        _uiState.update { it.copy(httpTtsImportState = state.copy(items = items)) }
+    }
+
+    private fun toggleHttpTtsImportAll(selected: Boolean) {
+        val state =
+            _uiState.value.httpTtsImportState as? BaseImportUiState.Success<HttpTTS> ?: return
+        _uiState.update {
+            it.copy(
+                httpTtsImportState = state.copy(
+                items = state.items.map { item -> item.copy(isSelected = selected) }
+            ))
+        }
+    }
+
+    private fun updateHttpTtsImportItem(index: Int, value: HttpTTS) {
+        val state =
+            _uiState.value.httpTtsImportState as? BaseImportUiState.Success<HttpTTS> ?: return
+        if (index !in state.items.indices) return
+        val items = state.items.toMutableList()
+        items[index] = items[index].copy(data = value)
+        _uiState.update {
+            it.copy(
+                httpTtsImportState = state.copy(
+                    items = items,
+                    version = state.version + 1
+                )
+            )
+        }
+    }
+
+    private fun saveImportedHttpTts() = viewModelScope.launch {
+        val state = _uiState.value.httpTtsImportState as? BaseImportUiState.Success<HttpTTS>
+            ?: return@launch
+        val selected = state.items.filter { it.isSelected }.map { it.data }
+        if (selected.isEmpty()) return@launch
+        withContext(Dispatchers.IO) { appDb.httpTTSDao.insert(*selected.toTypedArray()) }
+        _uiState.update { it.copy(httpTtsImportState = BaseImportUiState.Idle) }
+        toast(application.getString(R.string.success))
+    }
+
+    private fun exportHttpTtsFile(uri: Uri) = viewModelScope.launch {
+        withContext(Dispatchers.IO) {
+            val json = GSON.toJson(appDb.httpTTSDao.all)
+            application.contentResolver.openOutputStream(uri)?.use { it.write(json.toByteArray()) }
+        }
+        toast(application.getString(R.string.export_success))
+    }
+
+    private fun exportHttpTtsUrl() = viewModelScope.launch {
+        val url = withContext(Dispatchers.IO) {
+            uploadRepository.upload(
+                "httpTTS.json",
+                GSON.toJson(appDb.httpTTSDao.all),
+                "application/json"
+            )
+        }
+        _effects.tryEmit(CloudTtsEffect.CopyText(url, application.getString(R.string.copy_url)))
+    }
+
     private fun deleteEngine(id: String) = viewModelScope.launch {
         voices.filter { it.engineId == id }.forEach { voiceGateway.deleteVoice(it) }
         engines.firstOrNull { it.id == id }?.let { engineGateway.delete(it) }
+        if (isDefaultEngine(ReadAloudVoice.ENGINE_CLOUD, id)) {
+            readAloudSettingsGateway.update { it.copy(ttsEngine = null) }
+            ReadAloud.upReadAloudClass()
+        }
     }
-    private fun deleteVoice(id: String) = viewModelScope.launch {
-        voices.firstOrNull { it.id == id }?.let { voiceGateway.deleteVoice(it) }
+
+    private fun requestDeleteVoice(id: String) {
+        val voice = voices.firstOrNull {
+            it.id == id && it.managedBy == ReadAloudVoice.MANAGED_BY_USER
+        } ?: return
+        _uiState.update {
+            it.copy(
+                activeDialog = CloudTtsDialog.DeleteVoice(
+                    voice.id,
+                    voice.displayName
+                )
+            )
+        }
+    }
+
+    private fun confirmDeleteVoice() = viewModelScope.launch {
+        val id = (_uiState.value.activeDialog as? CloudTtsDialog.DeleteVoice)?.id ?: return@launch
+        val voice = voices.firstOrNull {
+            it.id == id && it.managedBy == ReadAloudVoice.MANAGED_BY_USER
+        }
+        voice?.let { voiceGateway.deleteVoice(it) }
+        val selection = GSON.fromJsonObject<ReadAloudEngineSelection>(
+            readAloudSettingsGateway.currentSettings.ttsEngine
+        ).getOrNull()
+        if (voice != null && selection?.engineType == voice.engineType &&
+            selection.engineId == voice.engineId && selection.speakerId == voice.speakerId
+        ) {
+            readAloudSettingsGateway.update { it.copy(ttsEngine = null) }
+            ReadAloud.upReadAloudClass()
+            refreshEngineSelection()
+        }
+        _uiState.update { it.copy(activeDialog = null) }
     }
     private fun toast(message: String) { _effects.tryEmit(CloudTtsEffect.ShowToast(message)) }
     private fun showError(message: String) { _uiState.update { it.copy(activeDialog = CloudTtsDialog.Error(message)) } }
     private fun copyError() {
         val message = (_uiState.value.activeDialog as? CloudTtsDialog.Error)?.message ?: return
-        _effects.tryEmit(CloudTtsEffect.CopyText(message))
+        _effects.tryEmit(
+            CloudTtsEffect.CopyText(
+                message,
+                application.getString(R.string.cloud_tts_error_copied),
+            )
+        )
     }
     private fun formatError(error: Throwable): String = buildString {
         append(application.getString(R.string.cloud_tts_request_failed))
@@ -587,4 +1018,10 @@ class CloudTtsViewModel(
     }
 }
 
+private fun String.isHttpTtsImportUri(): Boolean {
+    val uri = runCatching { Uri.parse(this) }.getOrNull() ?: return false
+    return uri.scheme in setOf("legado", "yuedu") &&
+            uri.host == "import" && uri.path.equals("/httpTTS", ignoreCase = true)
+}
+
 private const val DEFAULT_ENGINE_VOICE_ID = "__engine_default__"

+ 1 - 1
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerContract.kt

@@ -44,7 +44,7 @@ sealed interface ReadAloudPlayerIntent {
     data object OpenSettings : ReadAloudPlayerIntent
     data object SwitchToClassic : ReadAloudPlayerIntent
     data object OpenToc : ReadAloudPlayerIntent
-    data object CycleBgMode : ReadAloudPlayerIntent
+    data class SetBgMode(val value: Int) : ReadAloudPlayerIntent
     data class SetSpeed(val value: Int) : ReadAloudPlayerIntent
     data class SetTimer(val minutes: Int) : ReadAloudPlayerIntent
     data class SeekTo(val chapterPosition: Int) : ReadAloudPlayerIntent

+ 28 - 23
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerScreen.kt

@@ -45,10 +45,11 @@ import androidx.compose.material.icons.filled.FastRewind
 import androidx.compose.material.icons.filled.Pause
 import androidx.compose.material.icons.filled.PlayArrow
 import androidx.compose.material.icons.filled.Settings
+import androidx.compose.material.icons.filled.SkipNext
+import androidx.compose.material.icons.filled.SkipPrevious
 import androidx.compose.material.icons.filled.Speed
 import androidx.compose.material.icons.filled.Timer
 import androidx.compose.material.icons.filled.Tune
-import androidx.compose.material.icons.filled.WbTwilight
 import androidx.compose.material3.Slider
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.DisposableEffect
@@ -95,6 +96,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.hazeStyle.HazeLegado
 import io.legado.app.ui.util.rememberBlurBackdrop
 import io.legado.app.ui.widget.components.AppScaffold
+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.TextCard
@@ -240,17 +242,21 @@ fun ReadAloudPlayerScreenContent(
                         .clip(RectangleShape)
                         .then(hazeModifier)
                         .windowInsetsPadding(WindowInsets.navigationBars)
-                        .padding(horizontal = 24.dp, vertical = 8.dp),
+                        .padding(vertical = 8.dp),
                     horizontalAlignment = Alignment.CenterHorizontally,
                 ) {
                     PlayerProgressSlider(
+                        modifier = Modifier
+                            .padding(horizontal = 24.dp),
                         value = state.chapterPosition.coerceIn(0, state.chapterLength).toFloat(),
                         onValueChange = { onIntent(ReadAloudPlayerIntent.SeekTo(it.toInt())) },
                         valueRange = 0f..state.chapterLength.coerceAtLeast(1).toFloat(),
                     )
 
                     Row(
-                        modifier = Modifier.fillMaxWidth(),
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceBetween
                     ) {
                         AppText(formatPosition(state.chapterPosition), style = LegadoTheme.typography.labelSmall, color = LegadoTheme.colorScheme.onSurfaceVariant)
@@ -260,10 +266,15 @@ fun ReadAloudPlayerScreenContent(
                     Row(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .padding(top = 8.dp),
+                            .padding(top = 12.dp),
                         horizontalArrangement = Arrangement.SpaceEvenly,
                         verticalAlignment = Alignment.CenterVertically,
                     ) {
+                        MediumPlainButton(
+                            onClick = { onIntent(ReadAloudPlayerIntent.PreviousParagraph) },
+                            icon = Icons.Default.SkipPrevious,
+                            contentDescription = stringResource(R.string.prev_sentence),
+                        )
                         MediumTonalButton(
                             onClick = { onIntent(ReadAloudPlayerIntent.PreviousChapter) },
                             icon = Icons.Default.FastRewind,
@@ -284,11 +295,16 @@ fun ReadAloudPlayerScreenContent(
                             contentDescription = stringResource(R.string.next_chapter),
                             modifier = Modifier.size(48.dp),
                         )
+                        MediumPlainButton(
+                            onClick = { onIntent(ReadAloudPlayerIntent.NextParagraph) },
+                            icon = Icons.Default.SkipNext,
+                            contentDescription = stringResource(R.string.next_sentence),
+                        )
                     }
                     Row(
                         modifier = Modifier
                             .fillMaxWidth()
-                            .padding(vertical = 16.dp),
+                            .padding(vertical = 16.dp, horizontal = 24.dp),
                         horizontalArrangement = Arrangement.SpaceEvenly,
                     ) {
                         SmallPlainButton(
@@ -302,10 +318,9 @@ fun ReadAloudPlayerScreenContent(
                             contentDescription = stringResource(R.string.switch_to_classic_read_aloud),
                         )
                         SmallPlainButton(
-                            onClick = { onIntent(ReadAloudPlayerIntent.CycleBgMode) },
-                            icon = Icons.Default.WbTwilight,
-                            contentDescription = bgModeLabel(state.bgMode),
-                            selected = state.bgMode == ReadAloudBgMode.FlowingLight,
+                            onClick = { onIntent(ReadAloudPlayerIntent.OpenSettings) },
+                            icon = Icons.Default.Settings,
+                            contentDescription = stringResource(R.string.setting),
                         )
                         SmallPlainButton(
                             onClick = {
@@ -388,7 +403,6 @@ fun ReadAloudPlayerScreenContent(
         }
     }
 }
-
 @Composable
 private fun PlayerProgressSlider(
     value: Float,
@@ -482,7 +496,6 @@ private fun PlayerProgressSlider(
         }
     }
 }
-
 @Composable
 private fun CoverPage(
     state: ReadAloudPlayerUiState,
@@ -769,9 +782,10 @@ private fun ReadAloudBackground(
         }
 
         ReadAloudBgMode.Solid -> {
-            CoverBlurBackdrop(
-                state.bookName, state.author, state.coverPath, state.sourceOrigin,
-                modifier = modifier,
+            Box(
+                modifier = modifier
+                    .fillMaxSize()
+                    .background(LegadoTheme.colorScheme.surface),
             )
         }
 
@@ -865,12 +879,3 @@ private fun flowingTextBlend(): List<BlendColorEntry> {
         }
     }
 }
-
-@Composable
-private fun bgModeLabel(mode: Int): String = when (mode) {
-    ReadAloudBgMode.Solid -> stringResource(R.string.read_aloud_bg_solid)
-    ReadAloudBgMode.Blur -> stringResource(R.string.read_aloud_bg_blur)
-    ReadAloudBgMode.FlowingLight -> stringResource(R.string.read_aloud_bg_flowing_light)
-    ReadAloudBgMode.Transparent -> stringResource(R.string.read_aloud_bg_transparent)
-    else -> stringResource(R.string.read_aloud_bg_blur)
-}

+ 4 - 12
app/src/main/java/io/legado/app/ui/book/readaloud/player/ReadAloudPlayerViewModel.kt

@@ -42,7 +42,10 @@ class ReadAloudPlayerViewModel(
             ReadAloudPlayerIntent.OpenSettings -> effect(ReadAloudPlayerEffect.ReturnToReaderSettings)
             ReadAloudPlayerIntent.SwitchToClassic -> effect(ReadAloudPlayerEffect.ReturnToClassic)
             ReadAloudPlayerIntent.OpenToc -> effect(ReadAloudPlayerEffect.OpenToc)
-            ReadAloudPlayerIntent.CycleBgMode -> cycleBgMode()
+            is ReadAloudPlayerIntent.SetBgMode -> AppConfigStore.putInt(
+                PreferKey.readAloudPlayerBgMode,
+                intent.value,
+            )
             is ReadAloudPlayerIntent.SetSpeed -> viewModelScope.launch {
                 coordinator.setSpeed(intent.value)
             }
@@ -54,17 +57,6 @@ class ReadAloudPlayerViewModel(
         }
     }
 
-    private fun cycleBgMode() {
-        val current = readBgMode()
-        val next = when (current) {
-            ReadAloudBgMode.Solid -> ReadAloudBgMode.Blur
-            ReadAloudBgMode.Blur -> ReadAloudBgMode.FlowingLight
-            ReadAloudBgMode.FlowingLight -> ReadAloudBgMode.Transparent
-            else -> ReadAloudBgMode.Blur
-        }
-        AppConfigStore.putInt(PreferKey.readAloudPlayerBgMode, next)
-    }
-
     private fun toUiState(
         source: ReadAloudPlayerSourceState,
         bgMode: Int,

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

@@ -7,7 +7,6 @@ import io.legado.app.domain.gateway.AiProfileGateway
 import io.legado.app.domain.model.AiTaskType
 import io.legado.app.domain.model.TranslationConstants
 import io.legado.app.utils.toastOnUi
-import kotlinx.collections.immutable.ImmutableList
 import kotlinx.collections.immutable.toImmutableList
 import kotlinx.coroutines.flow.MutableSharedFlow
 import kotlinx.coroutines.flow.MutableStateFlow
@@ -70,6 +69,12 @@ class AiPromptConfigViewModel(
             nameResId = R.string.ai_prompt_task_analyze_speech,
             descResId = R.string.ai_prompt_task_analyze_speech_desc,
             defaultPromptResId = R.string.ai_prompt_default_analyze_speech
+        ),
+        TaskPromptMeta(
+            taskType = AiTaskType.IDENTIFY_CHARACTERS,
+            nameResId = R.string.ai_prompt_task_identify_characters,
+            descResId = R.string.ai_prompt_task_identify_characters_desc,
+            defaultPromptResId = R.string.ai_prompt_default_identify_characters
         )
     )
 

+ 1 - 1
app/src/main/java/io/legado/app/ui/login/SourceLoginViewModel.kt

@@ -8,10 +8,10 @@ import io.legado.app.constant.AppLog
 import io.legado.app.constant.BookType
 import io.legado.app.data.appDb
 import io.legado.app.data.entities.BaseSource
-import io.legado.app.help.config.AppConfig
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.help.config.AppConfig
 import io.legado.app.model.AudioPlay
 import io.legado.app.model.ReadBook
 import io.legado.app.utils.toastOnUi

+ 36 - 3
app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt

@@ -1,5 +1,6 @@
 package io.legado.app.ui.main
 
+import android.content.Intent
 import android.net.Uri
 import androidx.activity.compose.rememberLauncherForActivityResult
 import androidx.activity.result.contract.ActivityResultContracts
@@ -70,6 +71,8 @@ import io.legado.app.ui.book.readRecord.ReadRecordRouteScreen
 import io.legado.app.ui.book.readaloud.cache.TtsCacheRouteScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingScreen
 import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingViewModel
+import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsEffect
+import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsIntent
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsScreen
 import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
 import io.legado.app.ui.book.search.SearchIntent
@@ -96,6 +99,7 @@ import io.legado.app.ui.config.themeConfig.ThemeConfigRouteScreen
 import io.legado.app.ui.config.themeManage.ThemeManageRouteScreen
 import io.legado.app.ui.config.translation.TranslationConfigRouteScreen
 import io.legado.app.ui.highlightTagRule.HighlightTagRuleRouteScreen
+import io.legado.app.ui.login.SourceLoginActivity
 import io.legado.app.ui.rss.article.MainRouteRssSort
 import io.legado.app.ui.rss.article.RssSortRouteScreen
 import io.legado.app.ui.rss.favorites.RssFavoritesRouteScreen
@@ -452,7 +456,7 @@ fun MainActivity.mainEntryProvider(
                 onNavigateToRoute(MainRouteBookVoiceCasting(bookUrl))
             },
             onOpenTtsEnginesAndVoices = {
-                onNavigateToRoute(MainRouteCloudTtsEngines)
+                onNavigateToRoute(MainRouteCloudTtsEngines(route.bookUrl))
             },
             onOpenTtsCache = {
                 onNavigateToRoute(MainRouteTtsCache)
@@ -854,12 +858,41 @@ fun MainActivity.mainEntryProvider(
             onIntent = viewModel::onIntent,
             effects = viewModel.effects,
             onBack = { onNavigateBack() },
-            onManageCloudTts = { onNavigateToRoute(MainRouteCloudTtsEngines) },
+            onManageCloudTts = { onNavigateToRoute(MainRouteCloudTtsEngines(route.bookUrl)) },
         )
     }
 
-    entry<MainRouteCloudTtsEngines> {
+    entry<MainRouteCloudTtsEngines> { route ->
         val viewModel = koinViewModel<CloudTtsViewModel>()
+        LaunchedEffect(route.bookUrl) {
+            viewModel.onIntent(CloudTtsIntent.SetBookContext(route.bookUrl))
+        }
+        val context = LocalContext.current
+        val importLauncher = rememberLauncherForActivityResult(
+            ActivityResultContracts.OpenDocument()
+        ) { uri -> uri?.let { viewModel.onIntent(CloudTtsIntent.ImportHttpTtsFileSelected(it)) } }
+        val exportLauncher = rememberLauncherForActivityResult(
+            ActivityResultContracts.CreateDocument("application/json")
+        ) { uri -> uri?.let { viewModel.onIntent(CloudTtsIntent.ExportHttpTtsFileSelected(it)) } }
+        LaunchedEffect(viewModel) {
+            viewModel.effects.collectLatest { effect ->
+                when (effect) {
+                    CloudTtsEffect.OpenHttpTtsImportPicker -> importLauncher.launch(
+                        arrayOf("application/json", "text/plain")
+                    )
+
+                    CloudTtsEffect.OpenHttpTtsExportPicker -> exportLauncher.launch("httpTTS.json")
+                    is CloudTtsEffect.OpenHttpTtsLogin -> context.startActivity(
+                        Intent(context, SourceLoginActivity::class.java).apply {
+                            putExtra("type", "httpTts")
+                            putExtra("key", effect.engineId.toString())
+                        }
+                    )
+
+                    else -> Unit
+                }
+            }
+        }
         CloudTtsScreen(
             state = viewModel.uiState.collectAsStateWithLifecycle().value,
             onIntent = viewModel::onIntent,

+ 1 - 1
app/src/main/java/io/legado/app/ui/main/MainNavKey.kt

@@ -132,7 +132,7 @@ data class MainRouteBookVoiceCasting(
 ) : MainRoute
 
 @Serializable
-data object MainRouteCloudTtsEngines : MainRoute
+data class MainRouteCloudTtsEngines(val bookUrl: String? = null) : MainRoute
 
 @Serializable
 data object MainRouteTtsCache : MainRoute

+ 2 - 2
app/src/main/java/io/legado/app/ui/main/MainNavigator.kt

@@ -126,7 +126,7 @@ object MainNavigator {
             is MainRouteBookCharacterNetwork,
             is MainRouteBookCharacterList,
             is MainRouteBookVoiceCasting,
-            MainRouteCloudTtsEngines,
+            is MainRouteCloudTtsEngines,
             MainRouteTtsCache,
             is MainRouteBookKnowledgeList,
             is MainRouteBookKnowledgeDetail,
@@ -138,7 +138,7 @@ object MainNavigator {
                     currentRoute is MainRouteBookCharacterNetwork ||
                     currentRoute is MainRouteBookCharacterList ||
                     currentRoute is MainRouteBookVoiceCasting ||
-                    currentRoute == MainRouteCloudTtsEngines ||
+                    currentRoute is MainRouteCloudTtsEngines ||
                     currentRoute == MainRouteTtsCache ||
                     currentRoute is MainRouteBookKnowledgeList ||
                     currentRoute is MainRouteBookKnowledgeDetail ||

+ 22 - 10
app/src/main/java/io/legado/app/ui/widget/components/checkBox/CheckboxItem.kt

@@ -1,16 +1,14 @@
 package io.legado.app.ui.widget.components.checkBox
 
-import androidx.compose.foundation.background
-import androidx.compose.foundation.selection.toggleable
+import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
 import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
+import androidx.compose.foundation.selection.toggleable
 import androidx.compose.runtime.Composable
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.alpha
-import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.semantics.Role
 import androidx.compose.ui.semantics.clearAndSetSemantics
@@ -25,6 +23,7 @@ fun CheckboxItem(
     color: Color = LegadoTheme.colorScheme.onSheetContent,
     checked: Boolean,
     enabled: Boolean = true,
+    description: String? = null,
     onCheckedChange: (Boolean) -> Unit
 ) {
     val alpha = if (enabled) 1f else 0.5f
@@ -53,14 +52,27 @@ fun CheckboxItem(
                     .alpha(alpha)
                     .clearAndSetSemantics { }
             )
-            AppText(
-                text = title,
-                style = LegadoTheme.typography.bodyMediumEmphasized,
-                maxLines = 1,
+            Column(
                 modifier = Modifier
+                    .weight(1f)
                     .padding(start = 12.dp)
-                    .alpha(alpha)
-            )
+            ) {
+                AppText(
+                    text = title,
+                    style = LegadoTheme.typography.bodyMediumEmphasized,
+                    maxLines = 1,
+                    modifier = Modifier.alpha(alpha)
+                )
+                if (description != null) {
+                    AppText(
+                        text = description,
+                        style = LegadoTheme.typography.labelSmallEmphasized,
+                        maxLines = 1,
+                        modifier = Modifier
+                            .alpha(alpha)
+                    )
+                }
+            }
         }
     }
 

+ 45 - 3
app/src/main/res/values-zh-rCN/strings.xml

@@ -2668,7 +2668,7 @@
     <string name="read_aloud_default_engine">默认朗读引擎</string>
     <string name="read_aloud_default_engine_summary">未指定角色时使用 · %1$s</string>
     <string name="read_aloud_default_engine_scope_summary">未匹配角色时使用;系统 TTS 或 HTTP TTS</string>
-    <string name="read_aloud_current_default_summary">当前默认 · 用于未指定角色的段落</string>
+    <string name="read_aloud_current_default_summary">当前默认 · 未开启多角色朗读时使用</string>
     <string name="read_aloud_engines_and_voices">朗读引擎与音色</string>
     <string name="read_aloud_engines_and_voices_summary">管理系统、HTTP 和云 TTS 音色</string>
     <string name="read_aloud_character_casting">人物与角色配音</string>
@@ -2700,6 +2700,13 @@
     <string name="read_aloud_position_kchars">%1$.1f 千字</string>
     <string name="cloud_tts_engine_saved">云 TTS 引擎已保存</string>
     <string name="cloud_tts_voice_saved">音色预设已保存</string>
+    <string name="cloud_tts_add_http_engine">新增 HTTP TTS 引擎</string>
+    <string name="cloud_tts_cloud_engines">云 TTS</string>
+    <string name="cloud_tts_http_engines">HTTP 协议 TTS</string>
+    <string name="cloud_tts_system_engines">系统 TTS</string>
+    <string name="cloud_tts_default_requires_voice">请先为该云 TTS 引擎保存至少一个音色,再设为默认引擎。</string>
+    <string name="read_aloud_default_engine_updated">默认朗读引擎已更新</string>
+    <string name="cloud_tts_delete_voice_message">删除音色预设“%1$s”?使用它的人物绑定将回退到其他音色。</string>
     <string name="cloud_tts_no_test_voice">厂商没有返回可用于测试的音色</string>
     <string name="cloud_tts_connection_test_text">欢迎使用云端多角色朗读,这是连接测试。</string>
     <string name="cloud_tts_no_audio">云 TTS 没有返回有效音频</string>
@@ -2741,7 +2748,7 @@
     <string name="cloud_tts_select">选择</string>
     <string name="cloud_tts_select_engine">选择引擎</string>
     <string name="cloud_tts_edit_voice_binding_notice">为保持人物绑定稳定,编辑时不能更换引擎和原生音色。</string>
-    <string name="cloud_tts_step_voice">2. 音色</string>
+    <string name="cloud_tts_step_voice">音色</string>
     <string name="cloud_tts_search_voice">搜索音色、语言或 ID</string>
     <string name="cloud_tts_loading_voices">正在获取音色…</string>
     <string name="cloud_tts_no_matching_voice">没有匹配的音色</string>
@@ -2752,7 +2759,7 @@
     <string name="cloud_tts_manual_voice_action">目录中没有?手动填写音色 ID</string>
     <string name="cloud_tts_native_voice_id">原生音色 ID(可手动填写)</string>
     <string name="cloud_tts_manual_voice_hint">适用于厂商自定义、复刻音色,或目录中尚未列出的音色。</string>
-    <string name="cloud_tts_step_preset">3. 音色预设</string>
+    <string name="cloud_tts_step_preset">音色预设</string>
     <string name="cloud_tts_preset_name">预设名称</string>
     <string name="cloud_tts_style_value">风格/情感:%1$s</string>
     <string name="cloud_tts_role_value">角色风格:%1$s</string>
@@ -2825,7 +2832,42 @@
     <string name="capsule_auto_collapse">自动收缩胶囊</string>
     <string name="capsule_auto_collapse_summary">3秒无操作后收缩为迷你胶囊</string>
     <string name="read_aloud_bg_solid">纯色</string>
+    <string name="read_aloud_player_background">播放器背景</string>
     <string name="read_aloud_bg_blur">模糊</string>
     <string name="read_aloud_bg_flowing_light">流光</string>
     <string name="read_aloud_bg_transparent">透明</string>
+    <string name="character_voice_gender">声音性别</string>
+    <string name="character_voice_age_band">声音年龄段</string>
+    <string name="voice_gender_male">男性</string>
+    <string name="voice_gender_female">女性</string>
+    <string name="voice_gender_unknown">未知</string>
+    <string name="voice_age_child">儿童</string>
+    <string name="voice_age_teen">少年/少女</string>
+    <string name="voice_age_young_adult">青年</string>
+    <string name="voice_age_adult">成年人</string>
+    <string name="voice_age_elderly">老人</string>
+    <string name="voice_age_unknown">未知</string>
+    <string name="voice_demographic_fallbacks">年龄与性别兜底音色</string>
+    <string name="voice_male_child">男童</string>
+    <string name="voice_female_child">女童</string>
+    <string name="voice_male_teen">少年</string>
+    <string name="voice_female_teen">少女</string>
+    <string name="voice_male_young_adult">青年男声</string>
+    <string name="voice_female_young_adult">青年女声</string>
+    <string name="voice_male_adult">成年男声</string>
+    <string name="voice_female_adult">成年女声</string>
+    <string name="voice_male_elderly">老年男声</string>
+    <string name="voice_female_elderly">老年女声</string>
+    <string name="delete_failed">删除失败</string>
+    <string name="character_delete_message">删除此人物资料?除非在下方选择,否则会保留关联数据。</string>
+    <string name="character_delete_relations">同时删除关联关系</string>
+    <string name="character_delete_events">同时删除人物事件</string>
+    <string name="character_deleted">已删除人物</string>
+    <string name="ai_identify_characters">AI识别人物</string>
+    <string name="ai_identify_characters_hint">AI 将读取已下载章节并给出人物候选,确认前不会保存。</string>
+    <string name="ai_identify_characters_start">开始识别</string>
+    <string name="ai_identify_characters_save">保存选中的人物</string>
+    <string name="ai_prompt_task_identify_characters">人物识别</string>
+    <string name="ai_prompt_task_identify_characters_desc">读取已下载章节并建议稳定的人物档案</string>
+    <string name="ai_prompt_default_identify_characters">使用只读工具查看已下载的本地章节和既有人物。只返回 JSON:{"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}。不要把代词、泛称谓或一次性路人识别为人物;不确定时使用 unknown;不要与已有姓名或别名重复。</string>
 </resources>

+ 37 - 3
app/src/main/res/values-zh-rHK/strings.xml

@@ -2485,7 +2485,7 @@
     <string name="read_aloud_default_engine">預設朗讀引擎</string>
     <string name="read_aloud_default_engine_summary">未指定角色時使用 · %1$s</string>
     <string name="read_aloud_default_engine_scope_summary">未配對角色時使用;系統 TTS 或 HTTP TTS</string>
-    <string name="read_aloud_current_default_summary">目前預設 · 用於未指定角色的段落</string>
+    <string name="read_aloud_current_default_summary">目前預設 · 未啟用多角色朗讀時使用</string>
     <string name="read_aloud_engines_and_voices">朗讀引擎與音色</string>
     <string name="read_aloud_engines_and_voices_summary">管理系統、HTTP 和雲端 TTS 音色</string>
     <string name="read_aloud_character_casting">人物與角色配音</string>
@@ -2517,6 +2517,13 @@
     <string name="read_aloud_position_kchars">%1$.1f 千字</string>
     <string name="cloud_tts_engine_saved">雲端 TTS 引擎已儲存</string>
     <string name="cloud_tts_voice_saved">音色預設已儲存</string>
+    <string name="cloud_tts_add_http_engine">新增 HTTP TTS 引擎</string>
+    <string name="cloud_tts_cloud_engines">雲端 TTS</string>
+    <string name="cloud_tts_http_engines">HTTP 協議 TTS</string>
+    <string name="cloud_tts_system_engines">系統 TTS</string>
+    <string name="cloud_tts_default_requires_voice">請先為此雲端 TTS 引擎儲存至少一個音色,再設為預設引擎。</string>
+    <string name="read_aloud_default_engine_updated">預設朗讀引擎已更新</string>
+    <string name="cloud_tts_delete_voice_message">刪除音色預設「%1$s」?使用它的人物綁定將回退到其他音色。</string>
     <string name="cloud_tts_no_test_voice">廠商沒有傳回可用於測試的音色</string>
     <string name="cloud_tts_connection_test_text">歡迎使用雲端多角色朗讀,這是連線測試。</string>
     <string name="cloud_tts_no_audio">雲端 TTS 沒有傳回有效音訊</string>
@@ -2558,7 +2565,7 @@
     <string name="cloud_tts_select">選擇</string>
     <string name="cloud_tts_select_engine">選擇引擎</string>
     <string name="cloud_tts_edit_voice_binding_notice">為保持人物綁定穩定,編輯時不能更換引擎和原生音色。</string>
-    <string name="cloud_tts_step_voice">2. 音色</string>
+    <string name="cloud_tts_step_voice">音色</string>
     <string name="cloud_tts_search_voice">搜尋音色、語言或 ID</string>
     <string name="cloud_tts_loading_voices">正在取得音色…</string>
     <string name="cloud_tts_no_matching_voice">沒有符合的音色</string>
@@ -2569,7 +2576,7 @@
     <string name="cloud_tts_manual_voice_action">目錄中沒有?手動填寫音色 ID</string>
     <string name="cloud_tts_native_voice_id">原生音色 ID(可手動填寫)</string>
     <string name="cloud_tts_manual_voice_hint">適用於廠商自訂、複刻音色,或目錄中尚未列出的音色。</string>
-    <string name="cloud_tts_step_preset">3. 音色預設</string>
+    <string name="cloud_tts_step_preset">音色預設</string>
     <string name="cloud_tts_preset_name">預設名稱</string>
     <string name="cloud_tts_style_value">風格/情感:%1$s</string>
     <string name="cloud_tts_role_value">角色風格:%1$s</string>
@@ -2624,4 +2631,31 @@
     <string name="merged_in_multiline">多行模式下將合併顯示</string>
     <string name="source_categories">書源分類</string>
     <string name="my_tags">我的標籤</string>
+    <string name="character_voice_gender">聲音性別</string>
+    <string name="character_voice_age_band">聲音年齡層</string>
+    <string name="voice_gender_male">男性</string>
+    <string name="voice_gender_female">女性</string>
+    <string name="voice_gender_unknown">未知</string>
+    <string name="voice_age_child">兒童</string>
+    <string name="voice_age_teen">少年/少女</string>
+    <string name="voice_age_young_adult">青年</string>
+    <string name="voice_age_adult">成年人</string>
+    <string name="voice_age_elderly">老人</string>
+    <string name="voice_age_unknown">未知</string>
+    <string name="voice_demographic_fallbacks">年齡與性別後備音色</string>
+    <string name="voice_male_child">男童</string>
+    <string name="voice_female_child">女童</string>
+    <string name="voice_male_teen">少年</string>
+    <string name="voice_female_teen">少女</string>
+    <string name="voice_male_young_adult">青年男聲</string>
+    <string name="voice_female_young_adult">青年女聲</string>
+    <string name="voice_male_adult">成年男聲</string>
+    <string name="voice_female_adult">成年女聲</string>
+    <string name="voice_male_elderly">老年男聲</string>
+    <string name="voice_female_elderly">老年女聲</string>
+    <string name="delete_failed">刪除失敗</string>
+    <string name="character_delete_message">刪除此人物資料?除非在下方選擇,否則會保留關聯資料。</string>
+    <string name="character_delete_relations">同時刪除關聯關係</string>
+    <string name="character_delete_events">同時刪除人物事件</string>
+    <string name="character_deleted">已刪除人物</string>
 </resources>

+ 37 - 3
app/src/main/res/values-zh-rTW/strings.xml

@@ -2490,7 +2490,7 @@
     <string name="read_aloud_default_engine">預設朗讀引擎</string>
     <string name="read_aloud_default_engine_summary">未指定角色時使用 · %1$s</string>
     <string name="read_aloud_default_engine_scope_summary">未配對角色時使用;系統 TTS 或 HTTP TTS</string>
-    <string name="read_aloud_current_default_summary">目前預設 · 用於未指定角色的段落</string>
+    <string name="read_aloud_current_default_summary">目前預設 · 未啟用多角色朗讀時使用</string>
     <string name="read_aloud_engines_and_voices">朗讀引擎與音色</string>
     <string name="read_aloud_engines_and_voices_summary">管理系統、HTTP 和雲端 TTS 音色</string>
     <string name="read_aloud_character_casting">人物與角色配音</string>
@@ -2522,6 +2522,13 @@
     <string name="read_aloud_position_kchars">%1$.1f 千字</string>
     <string name="cloud_tts_engine_saved">雲端 TTS 引擎已儲存</string>
     <string name="cloud_tts_voice_saved">音色預設已儲存</string>
+    <string name="cloud_tts_add_http_engine">新增 HTTP TTS 引擎</string>
+    <string name="cloud_tts_cloud_engines">雲端 TTS</string>
+    <string name="cloud_tts_http_engines">HTTP 協定 TTS</string>
+    <string name="cloud_tts_system_engines">系統 TTS</string>
+    <string name="cloud_tts_default_requires_voice">請先為此雲端 TTS 引擎儲存至少一個音色,再設為預設引擎。</string>
+    <string name="read_aloud_default_engine_updated">預設朗讀引擎已更新</string>
+    <string name="cloud_tts_delete_voice_message">刪除音色預設「%1$s」?使用它的人物綁定將回退到其他音色。</string>
     <string name="cloud_tts_no_test_voice">廠商沒有傳回可用於測試的音色</string>
     <string name="cloud_tts_connection_test_text">歡迎使用雲端多角色朗讀,這是連線測試。</string>
     <string name="cloud_tts_no_audio">雲端 TTS 沒有傳回有效音訊</string>
@@ -2563,7 +2570,7 @@
     <string name="cloud_tts_select">選擇</string>
     <string name="cloud_tts_select_engine">選擇引擎</string>
     <string name="cloud_tts_edit_voice_binding_notice">為保持人物綁定穩定,編輯時不能更換引擎和原生音色。</string>
-    <string name="cloud_tts_step_voice">2. 音色</string>
+    <string name="cloud_tts_step_voice">音色</string>
     <string name="cloud_tts_search_voice">搜尋音色、語言或 ID</string>
     <string name="cloud_tts_loading_voices">正在取得音色…</string>
     <string name="cloud_tts_no_matching_voice">沒有符合的音色</string>
@@ -2574,7 +2581,7 @@
     <string name="cloud_tts_manual_voice_action">目錄中沒有?手動填寫音色 ID</string>
     <string name="cloud_tts_native_voice_id">原生音色 ID(可手動填寫)</string>
     <string name="cloud_tts_manual_voice_hint">適用於廠商自訂、複刻音色,或目錄中尚未列出的音色。</string>
-    <string name="cloud_tts_step_preset">3. 音色預設</string>
+    <string name="cloud_tts_step_preset">音色預設</string>
     <string name="cloud_tts_preset_name">預設名稱</string>
     <string name="cloud_tts_style_value">風格/情感:%1$s</string>
     <string name="cloud_tts_role_value">角色風格:%1$s</string>
@@ -2629,4 +2636,31 @@
     <string name="merged_in_multiline">多行模式下將合併顯示</string>
     <string name="source_categories">書源分類</string>
     <string name="my_tags">我的標籤</string>
+    <string name="character_voice_gender">聲音性別</string>
+    <string name="character_voice_age_band">聲音年齡層</string>
+    <string name="voice_gender_male">男性</string>
+    <string name="voice_gender_female">女性</string>
+    <string name="voice_gender_unknown">未知</string>
+    <string name="voice_age_child">兒童</string>
+    <string name="voice_age_teen">少年/少女</string>
+    <string name="voice_age_young_adult">青年</string>
+    <string name="voice_age_adult">成年人</string>
+    <string name="voice_age_elderly">老人</string>
+    <string name="voice_age_unknown">未知</string>
+    <string name="voice_demographic_fallbacks">年齡與性別備用音色</string>
+    <string name="voice_male_child">男童</string>
+    <string name="voice_female_child">女童</string>
+    <string name="voice_male_teen">少年</string>
+    <string name="voice_female_teen">少女</string>
+    <string name="voice_male_young_adult">青年男聲</string>
+    <string name="voice_female_young_adult">青年女聲</string>
+    <string name="voice_male_adult">成年男聲</string>
+    <string name="voice_female_adult">成年女聲</string>
+    <string name="voice_male_elderly">老年男聲</string>
+    <string name="voice_female_elderly">老年女聲</string>
+    <string name="delete_failed">刪除失敗</string>
+    <string name="character_delete_message">刪除此人物資料?除非在下方選擇,否則會保留關聯資料。</string>
+    <string name="character_delete_relations">同時刪除關聯關係</string>
+    <string name="character_delete_events">同時刪除人物事件</string>
+    <string name="character_deleted">已刪除人物</string>
 </resources>

+ 45 - 3
app/src/main/res/values/strings.xml

@@ -2679,7 +2679,7 @@
     <string name="read_aloud_default_engine">Default reading engine</string>
     <string name="read_aloud_default_engine_summary">Used when no character voice is assigned · %1$s</string>
     <string name="read_aloud_default_engine_scope_summary">Used when no character voice is assigned; System TTS or HTTP TTS</string>
-    <string name="read_aloud_current_default_summary">Current default · Used for passages without an assigned character</string>
+    <string name="read_aloud_current_default_summary">Current default · Used when multi-speaker reading is disabled</string>
     <string name="read_aloud_engines_and_voices">Reading engines and voices</string>
     <string name="read_aloud_engines_and_voices_summary">Manage System, HTTP and cloud TTS voices</string>
     <string name="read_aloud_character_casting">Characters and voice assignments</string>
@@ -2711,6 +2711,13 @@
     <string name="read_aloud_position_kchars">%1$.1fK chars</string>
     <string name="cloud_tts_engine_saved">Cloud TTS engine saved</string>
     <string name="cloud_tts_voice_saved">Voice preset saved</string>
+    <string name="cloud_tts_add_http_engine">Add HTTP TTS engine</string>
+    <string name="cloud_tts_cloud_engines">Cloud TTS</string>
+    <string name="cloud_tts_http_engines">HTTP protocol TTS</string>
+    <string name="cloud_tts_system_engines">System TTS</string>
+    <string name="cloud_tts_default_requires_voice">Save at least one voice for this cloud engine before making it the default.</string>
+    <string name="read_aloud_default_engine_updated">Default read-aloud engine updated</string>
+    <string name="cloud_tts_delete_voice_message">Delete the voice preset “%1$s”? Character assignments using it will fall back to another voice.</string>
     <string name="cloud_tts_no_test_voice">The provider returned no voice available for testing</string>
     <string name="cloud_tts_connection_test_text">Welcome to cloud multi-speaker reading. This is a connection test.</string>
     <string name="cloud_tts_no_audio">Cloud TTS returned no valid audio</string>
@@ -2752,7 +2759,7 @@
     <string name="cloud_tts_select">Select</string>
     <string name="cloud_tts_select_engine">Select engine</string>
     <string name="cloud_tts_edit_voice_binding_notice">The engine and native voice cannot be changed while editing, to keep character assignments stable.</string>
-    <string name="cloud_tts_step_voice">2. Voice</string>
+    <string name="cloud_tts_step_voice">Voice</string>
     <string name="cloud_tts_search_voice">Search voice, language or ID</string>
     <string name="cloud_tts_loading_voices">Loading voices…</string>
     <string name="cloud_tts_no_matching_voice">No matching voice</string>
@@ -2763,7 +2770,7 @@
     <string name="cloud_tts_manual_voice_action">Not listed? Enter a voice ID manually</string>
     <string name="cloud_tts_native_voice_id">Native voice ID (manual input)</string>
     <string name="cloud_tts_manual_voice_hint">For custom or cloned provider voices, or voices not yet listed in the catalog.</string>
-    <string name="cloud_tts_step_preset">3. Voice preset</string>
+    <string name="cloud_tts_step_preset">Voice preset</string>
     <string name="cloud_tts_preset_name">Preset name</string>
     <string name="cloud_tts_style_value">Style/emotion: %1$s</string>
     <string name="cloud_tts_role_value">Role style: %1$s</string>
@@ -2836,7 +2843,42 @@
     <string name="capsule_auto_collapse">Auto-collapse capsule</string>
     <string name="capsule_auto_collapse_summary">Collapse to mini capsule after 3 seconds of inactivity</string>
     <string name="read_aloud_bg_solid">Solid</string>
+    <string name="read_aloud_player_background">Player background</string>
     <string name="read_aloud_bg_blur">Blur</string>
     <string name="read_aloud_bg_flowing_light">Flowing light</string>
     <string name="read_aloud_bg_transparent">Transparent</string>
+    <string name="character_voice_gender">Voice gender</string>
+    <string name="character_voice_age_band">Voice age</string>
+    <string name="voice_gender_male">Male</string>
+    <string name="voice_gender_female">Female</string>
+    <string name="voice_gender_unknown">Unknown</string>
+    <string name="voice_age_child">Child</string>
+    <string name="voice_age_teen">Teen</string>
+    <string name="voice_age_young_adult">Young adult</string>
+    <string name="voice_age_adult">Adult</string>
+    <string name="voice_age_elderly">Elderly</string>
+    <string name="voice_age_unknown">Unknown</string>
+    <string name="voice_demographic_fallbacks">Age and gender fallbacks</string>
+    <string name="voice_male_child">Boy</string>
+    <string name="voice_female_child">Girl</string>
+    <string name="voice_male_teen">Teen boy</string>
+    <string name="voice_female_teen">Teen girl</string>
+    <string name="voice_male_young_adult">Young man</string>
+    <string name="voice_female_young_adult">Young woman</string>
+    <string name="voice_male_adult">Adult man</string>
+    <string name="voice_female_adult">Adult woman</string>
+    <string name="voice_male_elderly">Elderly man</string>
+    <string name="voice_female_elderly">Elderly woman</string>
+    <string name="delete_failed">Could not delete</string>
+    <string name="character_delete_message">Delete this character profile? Related data is kept unless selected below.</string>
+    <string name="character_delete_relations">Also delete relationships</string>
+    <string name="character_delete_events">Also delete character events</string>
+    <string name="character_deleted">Deleted character</string>
+    <string name="ai_identify_characters">Identify characters with AI</string>
+    <string name="ai_identify_characters_hint">AI will read downloaded chapters and suggest characters. Nothing is saved until you confirm.</string>
+    <string name="ai_identify_characters_start">Start identification</string>
+    <string name="ai_identify_characters_save">Save selected characters</string>
+    <string name="ai_prompt_task_identify_characters">Character Identification</string>
+    <string name="ai_prompt_task_identify_characters_desc">Read downloaded chapters and suggest stable character profiles</string>
+    <string name="ai_prompt_default_identify_characters">Use read-only tools to inspect downloaded local chapters and existing characters. Return JSON only: {"characters":[{"name":string,"aliases":[string],"voiceGender":"male|female|unknown","voiceAgeBand":"child|teen|young_adult|adult|elderly|unknown","role":"male_lead|female_lead|male_supporting|female_supporting|","personality":string,"summary":string,"evidence":string,"confidence":number}]}. Do not create pronouns, generic titles, or one-off passers-by as characters. Use unknown rather than guessing. Do not duplicate existing names or aliases.</string>
 </resources>

+ 26 - 0
app/src/test/java/io/legado/app/data/entities/HttpTTSJsonImportTest.kt

@@ -0,0 +1,26 @@
+package io.legado.app.data.entities
+
+import org.junit.Assert.assertEquals
+import org.junit.Test
+
+class HttpTTSJsonImportTest {
+
+    @Test
+    fun `导入保留登录脚本依赖`() {
+        val source = """
+            {
+              "id": 1,
+              "name": "测试引擎",
+              "url": "https://example.com/tts",
+              "loginUi": "@js:styleM()",
+              "jsLib": "function styleM() { return 'ok' }",
+              "enabledCookieJar": true
+            }
+        """.trimIndent()
+
+        val result = HttpTTS.fromJson(source).getOrThrow()
+
+        assertEquals("function styleM() { return 'ok' }", result.jsLib)
+        assertEquals(true, result.enabledCookieJar)
+    }
+}

+ 9 - 3
app/src/test/java/io/legado/app/data/repository/ReadSettingsMappingTest.kt

@@ -13,16 +13,16 @@ import org.junit.Test
 class ReadSettingsMappingTest {
 
     @Test
-    fun `gateway 持久化边界固定为显式声明的 45 键`() {
+    fun `gateway 持久化边界固定为显式声明的 48 键`() {
         val actualKeys = ReadSettings().toGatewayPrefMap().keys
         val expectedKeys = ReadSettings().expectedGatewayPrefMap().keys
 
-        assertEquals(45, actualKeys.size)
+        assertEquals(48, actualKeys.size)
         assertEquals(expectedKeys, actualKeys)
     }
 
     @Test
-    fun `阅读设置 gateway 45 键写读映射逐字段对应`() {
+    fun `阅读设置 gateway 48 键写读映射逐字段对应`() {
         val repository = ReadSettingsRepository(
             settingsRepository = SettingsRepository(),
             preferencesFlow = MutableStateFlow(mutablePreferencesOf()),
@@ -92,6 +92,7 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         keepLight = "keep-light",
         titleBarMode = "title-mode",
         readMenuBlurAlpha = 37,
+        eyeProtectionIntensity = 73,
         showBrightnessView = "brightness-view",
         brightnessVwPos = "brightness-pos",
         readBrightness = 73,
@@ -118,6 +119,8 @@ private fun readSettingsMappingSamples(): List<ReadSettings> {
         base.copy(textBottomJustify = false),
         base.copy(adaptSpecialStyle = false),
         base.copy(useZhLayout = true),
+        base.copy(eyeProtectionEnabled = true),
+        base.copy(eyeProtectionAutoNight = true),
         base.copy(brightnessAuto = true),
         base.copy(useUnderline = true),
         base.copy(mouseWheelPage = false),
@@ -153,6 +156,9 @@ private fun ReadSettings.expectedGatewayPrefMap(): Map<String, Any?> = mapOf(
     PreferKey.textBottomJustify to textBottomJustify,
     PreferKey.adaptSpecialStyle to adaptSpecialStyle,
     PreferKey.useZhLayout to useZhLayout,
+    PreferKey.eyeProtectionEnabled to eyeProtectionEnabled,
+    PreferKey.colorTemperature to eyeProtectionIntensity,
+    PreferKey.eyeProtectionAutoNight to eyeProtectionAutoNight,
     PreferKey.showBrightnessView to showBrightnessView,
     PreferKey.brightnessVwPos to brightnessVwPos,
     PreferKey.brightness to readBrightness,

+ 1 - 0
app/src/test/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCaseTest.kt

@@ -159,6 +159,7 @@ class BuildSpeechPlanUseCaseTest {
         assertEquals(characterVoice, plan.voice)
     }
 
+
     private fun voice(id: String) = ReadAloudVoice(
         id = id,
         engineType = "http",