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

Merge pull request #1813 from HapeLee/feature/tts-multi-speaker

feat: 多角色 TTS 与听书播放器
Kudomaga 3 месяцев назад
Родитель
Сommit
6b853432c2
100 измененных файлов с 14800 добавлено и 74 удалено
  1. 4518 0
      app/schemas/io.legado.app.data.AppDatabase/95.json
  2. 4 0
      app/src/main/java/io/legado/app/constant/PreferKey.kt
  3. 26 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  4. 159 0
      app/src/main/java/io/legado/app/data/dao/BookKnowledgeDao.kt
  5. 70 0
      app/src/main/java/io/legado/app/data/dao/ChapterSpeechDao.kt
  6. 27 0
      app/src/main/java/io/legado/app/data/dao/CloudTtsEngineDao.kt
  7. 60 0
      app/src/main/java/io/legado/app/data/dao/ReadAloudVoiceDao.kt
  8. 172 0
      app/src/main/java/io/legado/app/data/entities/BookKnowledge.kt
  9. 26 0
      app/src/main/java/io/legado/app/data/entities/CloudTtsEngineEntity.kt
  10. 121 0
      app/src/main/java/io/legado/app/data/entities/ReadAloudSpeech.kt
  11. 3 0
      app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt
  12. 569 2
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  13. 116 0
      app/src/main/java/io/legado/app/data/repository/BookKnowledgeRepository.kt
  14. 130 0
      app/src/main/java/io/legado/app/data/repository/ChapterSpeechRepository.kt
  15. 89 0
      app/src/main/java/io/legado/app/data/repository/CloudTtsEngineRepository.kt
  16. 26 0
      app/src/main/java/io/legado/app/data/repository/HttpTtsEngineRepository.kt
  17. 43 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt
  18. 122 0
      app/src/main/java/io/legado/app/data/repository/ReadAloudVoiceRepository.kt
  19. 65 0
      app/src/main/java/io/legado/app/data/security/CloudTtsCredentialCipher.kt
  20. 4 0
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  21. 103 1
      app/src/main/java/io/legado/app/di/appModule.kt
  22. 64 0
      app/src/main/java/io/legado/app/domain/gateway/BookKnowledgeGateway.kt
  23. 22 0
      app/src/main/java/io/legado/app/domain/gateway/ChapterSpeechGateway.kt
  24. 12 0
      app/src/main/java/io/legado/app/domain/gateway/CloudTtsEngineGateway.kt
  25. 8 0
      app/src/main/java/io/legado/app/domain/gateway/HttpTtsEngineGateway.kt
  26. 25 0
      app/src/main/java/io/legado/app/domain/gateway/ReadAloudVoiceGateway.kt
  27. 1 0
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  28. 151 0
      app/src/main/java/io/legado/app/domain/model/readaloud/CloudTtsModels.kt
  29. 16 0
      app/src/main/java/io/legado/app/domain/model/readaloud/CloudTtsVoiceConfig.kt
  30. 98 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueue.kt
  31. 13 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSessionState.kt
  32. 194 0
      app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSpeechModels.kt
  33. 18 0
      app/src/main/java/io/legado/app/domain/model/readaloud/SpeechEmotion.kt
  34. 46 0
      app/src/main/java/io/legado/app/domain/model/readaloud/SpeechIdentity.kt
  35. 48 0
      app/src/main/java/io/legado/app/domain/model/readaloud/SpeechVoiceRouter.kt
  36. 6 0
      app/src/main/java/io/legado/app/domain/model/readaloud/SystemTtsVoiceConfig.kt
  37. 33 0
      app/src/main/java/io/legado/app/domain/model/readaloud/TtsCatalogModels.kt
  38. 6 0
      app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt
  39. 1 1
      app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt
  40. 1 1
      app/src/main/java/io/legado/app/domain/usecase/AiToolAwareGenerationUseCase.kt
  41. 95 0
      app/src/main/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCase.kt
  42. 123 0
      app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt
  43. 1 1
      app/src/main/java/io/legado/app/domain/usecase/CleanSelectedTextUseCase.kt
  44. 72 0
      app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt
  45. 391 0
      app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt
  46. 102 0
      app/src/main/java/io/legado/app/domain/usecase/ResolveLocalSpeakersUseCase.kt
  47. 92 0
      app/src/main/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCase.kt
  48. 95 0
      app/src/main/java/io/legado/app/help/readaloud/playback/AlibabaCloudTtsProvider.kt
  49. 149 0
      app/src/main/java/io/legado/app/help/readaloud/playback/AwsPollyCloudTtsProvider.kt
  50. 96 0
      app/src/main/java/io/legado/app/help/readaloud/playback/AzureSpeechCloudTtsProvider.kt
  51. 49 0
      app/src/main/java/io/legado/app/help/readaloud/playback/CharacterPerformanceInstructionBuilder.kt
  52. 98 0
      app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsAudioSynthesizer.kt
  53. 93 0
      app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsEmotionMapper.kt
  54. 73 0
      app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsProvider.kt
  55. 20 0
      app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsRoleInstructionMapper.kt
  56. 94 0
      app/src/main/java/io/legado/app/help/readaloud/playback/GeminiCloudTtsProvider.kt
  57. 89 0
      app/src/main/java/io/legado/app/help/readaloud/playback/MimoCloudTtsProvider.kt
  58. 73 0
      app/src/main/java/io/legado/app/help/readaloud/playback/OpenAiCloudTtsProvider.kt
  59. 102 0
      app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsFileSynthesizer.kt
  60. 88 0
      app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsVoiceCatalog.kt
  61. 86 0
      app/src/main/java/io/legado/app/help/readaloud/playback/VolcengineCloudTtsProvider.kt
  62. 108 0
      app/src/main/java/io/legado/app/help/readaloud/resolve/LocalCharacterSpeakerResolver.kt
  63. 65 0
      app/src/main/java/io/legado/app/help/readaloud/segment/AiSpeechAtomizer.kt
  64. 211 0
      app/src/main/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenter.kt
  65. 30 0
      app/src/main/java/io/legado/app/help/readaloud/segment/SpeechEmotionDetector.kt
  66. 19 0
      app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt
  67. 58 0
      app/src/main/java/io/legado/app/model/ReadAloud.kt
  68. 31 0
      app/src/main/java/io/legado/app/model/ReadAloudSessionStore.kt
  69. 181 19
      app/src/main/java/io/legado/app/service/BaseReadAloudService.kt
  70. 212 27
      app/src/main/java/io/legado/app/service/HttpReadAloudService.kt
  71. 107 13
      app/src/main/java/io/legado/app/service/TTSReadAloudService.kt
  72. 22 1
      app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt
  73. 21 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt
  74. 57 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt
  75. 25 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt
  76. 342 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt
  77. 153 0
      app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt
  78. 14 1
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt
  79. 83 4
      app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt
  80. 443 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt
  81. 232 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt
  82. 36 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListContract.kt
  83. 275 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListScreen.kt
  84. 119 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterListViewModel.kt
  85. 401 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkScreen.kt
  86. 263 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt
  87. 128 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailScreen.kt
  88. 175 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailViewModel.kt
  89. 58 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListContract.kt
  90. 175 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListScreen.kt
  91. 98 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListViewModel.kt
  92. 116 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeContract.kt
  93. 208 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailScreen.kt
  94. 190 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt
  95. 66 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListContract.kt
  96. 170 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListScreen.kt
  97. 100 0
      app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListViewModel.kt
  98. 168 0
      app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarCropDialog.kt
  99. 103 0
      app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarImage.kt
  100. 240 0
      app/src/main/java/io/legado/app/ui/book/knowledge/KnowledgeSharedComponents.kt

+ 4518 - 0
app/schemas/io.legado.app.data.AppDatabase/95.json

@@ -0,0 +1,4518 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 95,
+    "identityHash": "f5e225667da6bf6e50b9435bfb2ffb6d",
+    "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, `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": "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, `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": "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, 'f5e225667da6bf6e50b9435bfb2ffb6d')"
+    ]
+  }
+}

+ 4 - 0
app/src/main/java/io/legado/app/constant/PreferKey.kt

@@ -159,6 +159,8 @@ object PreferKey {
     const val webServiceWakeLock = "webServiceWakeLock"
     const val audioPlayWakeLock = "audioPlayWakeLock"
     const val readAloudWakeLock = "readAloudWakeLock"
+    const val showReadAloudCapsule = "showReadAloudCapsule"
+    const val defaultReadAloudInterface = "defaultReadAloudInterface"
     const val showLastUpdateTime = "showLastUpdateTime"
     const val showWaitUpCount = "showWaitUpCount"
     const val clearWebViewData = "clearWebViewData"
@@ -242,6 +244,8 @@ object PreferKey {
     const val showReadTitleAddition = "showReadTitleAddition"
     const val readBarStyleFollowPage = "readBarStyleFollowPage"
     const val contentSelectSpeakMod = "contentReadAloudMod"
+    const val speechAnalysisMode = "speechAnalysisMode"
+    const val useMultiSpeaker = "useMultiSpeaker"
     const val themePref = "themePref"
     const val bookshelfSortOrder = "bookshelfSortOrder"
     const val showBottomView = "showBottomView"

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

@@ -8,9 +8,11 @@ import androidx.room.Room
 import androidx.room.RoomDatabase
 import androidx.sqlite.db.SupportSQLiteDatabase
 import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.ChapterSpeechDao
 import io.legado.app.data.dao.BookContentProcessDao
 import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.BookGroupDao
+import io.legado.app.data.dao.BookKnowledgeDao
 import io.legado.app.data.dao.BookSourceDao
 import io.legado.app.data.dao.BookmarkDao
 import io.legado.app.data.dao.AiArtifactDao
@@ -20,6 +22,7 @@ import io.legado.app.data.dao.AiProfileDao
 import io.legado.app.data.dao.AiPromptPresetDao
 import io.legado.app.data.dao.CacheDao
 import io.legado.app.data.dao.CookieDao
+import io.legado.app.data.dao.CloudTtsEngineDao
 import io.legado.app.data.dao.DictRuleDao
 import io.legado.app.data.dao.HighlightRuleDao
 import io.legado.app.data.dao.HighlightTagRuleDao
@@ -29,6 +32,7 @@ import io.legado.app.data.dao.HomepageModuleDao
 import io.legado.app.data.dao.HttpTTSDao
 import io.legado.app.data.dao.KeyboardAssistsDao
 import io.legado.app.data.dao.ReadRecordDao
+import io.legado.app.data.dao.ReadAloudVoiceDao
 import io.legado.app.data.dao.ReplaceRuleDao
 import io.legado.app.data.dao.RssArticleDao
 import io.legado.app.data.dao.RssReadRecordDao
@@ -44,8 +48,14 @@ import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookContentProcess
 import io.legado.app.data.entities.BookGroup
+import io.legado.app.data.entities.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookOutlineNode
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
+import io.legado.app.data.entities.BookVoiceBindingEntity
 import io.legado.app.data.entities.Bookmark
 import io.legado.app.data.entities.AiArtifact
 import io.legado.app.data.entities.AiChatConversation
@@ -56,7 +66,10 @@ import io.legado.app.data.entities.AiPromptPreset
 import io.legado.app.data.entities.AiProviderProfile
 import io.legado.app.data.entities.AiTaskPreset
 import io.legado.app.data.entities.Cache
+import io.legado.app.data.entities.ChapterSpeechAnalysisEntity
+import io.legado.app.data.entities.ChapterSpeechSegmentEntity
 import io.legado.app.data.entities.Cookie
+import io.legado.app.data.entities.CloudTtsEngineEntity
 import io.legado.app.data.entities.DictRule
 import io.legado.app.data.entities.HighlightRule
 import io.legado.app.data.entities.HighlightTagRule
@@ -66,6 +79,7 @@ import io.legado.app.data.entities.HomepageModule
 import io.legado.app.data.entities.HttpTTS
 import io.legado.app.data.entities.KeyboardAssist
 import io.legado.app.data.entities.ReplaceRule
+import io.legado.app.data.entities.ReadAloudVoiceEntity
 import io.legado.app.data.entities.RssArticle
 import io.legado.app.data.entities.RssReadRecord
 import io.legado.app.data.entities.RssSource
@@ -94,7 +108,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 94,
+    version = 95,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -106,7 +120,11 @@ val appDb by lazy {
         HighlightRule::class, AiProviderProfile::class, AiModelProfile::class,
         AiTaskPreset::class, AiArtifact::class, AiChatConversation::class,
         AiChatMessage::class, AiMemory::class, HighlightTagRule::class, TagGroupRule::class,
-        BookContentProcess::class, AiPromptPreset::class],
+        BookContentProcess::class, AiPromptPreset::class, BookCharacterProfile::class,
+        BookCharacterEvent::class, BookCharacterRelation::class, BookKnowledgeEntry::class,
+        BookOutlineNode::class, ReadAloudVoiceEntity::class, BookVoiceBindingEntity::class,
+        ChapterSpeechAnalysisEntity::class, ChapterSpeechSegmentEntity::class,
+        CloudTtsEngineEntity::class],
     views = [BookSourcePart::class],
     autoMigrations = [
         AutoMigration(from = 43, to = 44),
@@ -159,7 +177,8 @@ val appDb by lazy {
         AutoMigration(from = 90, to = 91),
         AutoMigration(from = 91, to = 92),
         AutoMigration(from = 92, to = 93),
-        AutoMigration(from = 93, to = 94)
+        AutoMigration(from = 93, to = 94),
+        AutoMigration(from = 94, to = 95)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {
@@ -169,6 +188,10 @@ abstract class AppDatabase : RoomDatabase() {
     abstract val bookSourceDao: BookSourceDao
     abstract val bookChapterDao: BookChapterDao
     abstract val bookContentProcessDao: BookContentProcessDao
+    abstract val bookKnowledgeDao: BookKnowledgeDao
+    abstract val readAloudVoiceDao: ReadAloudVoiceDao
+    abstract val chapterSpeechDao: ChapterSpeechDao
+    abstract val cloudTtsEngineDao: CloudTtsEngineDao
     abstract val replaceRuleDao: ReplaceRuleDao
     abstract val searchBookDao: SearchBookDao
     abstract val searchKeywordDao: SearchKeywordDao

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

@@ -0,0 +1,159 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookOutlineNode
+
+@Dao
+interface BookKnowledgeDao {
+
+    @Query(
+        """
+        select * from book_character_profiles
+        where bookUrl = :bookUrl
+          and status != ${BookCharacterProfile.STATUS_DELETED}
+          and (:query = '' or name like '%' || :query || '%' or aliasesJson like '%' || :query || '%' or summary like '%' || :query || '%')
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun searchCharacterProfiles(
+        bookUrl: String,
+        query: String,
+        limit: Int,
+    ): List<BookCharacterProfile>
+
+    @Query(
+        """
+        select * from book_character_profiles
+        where bookUrl = :bookUrl
+          and status != ${BookCharacterProfile.STATUS_DELETED}
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun getCharacterProfiles(
+        bookUrl: String,
+        limit: Int,
+    ): List<BookCharacterProfile>
+
+    @Query(
+        """
+        select * from book_character_profiles
+        where bookUrl = :bookUrl
+          and status != ${BookCharacterProfile.STATUS_DELETED}
+          and (id = :idOrName or name = :idOrName or aliasesJson like '%' || :idOrName || '%')
+        order by updatedAt desc
+        limit 1
+        """
+    )
+    suspend fun getCharacterProfile(bookUrl: String, idOrName: String): BookCharacterProfile?
+
+    @Query(
+        """
+        select * from book_character_events
+        where bookUrl = :bookUrl
+          and (:characterId is null or characterId = :characterId)
+          and (:maxChapterIndex is null or chapterIndex is null or chapterIndex <= :maxChapterIndex)
+        order by coalesce(chapterIndex, 2147483647), importance desc, updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun getCharacterEvents(
+        bookUrl: String,
+        characterId: String?,
+        maxChapterIndex: Int?,
+        limit: Int,
+    ): List<BookCharacterEvent>
+
+    @Query(
+        """
+        select * from book_character_relations
+        where bookUrl = :bookUrl
+          and status != ${BookCharacterProfile.STATUS_DELETED}
+          and (fromCharacterId = :characterId or toCharacterId = :characterId)
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun getCharacterRelations(
+        bookUrl: String,
+        characterId: String,
+        limit: Int,
+    ): List<BookCharacterRelation>
+
+    @Query(
+        """
+        select * from book_character_relations
+        where bookUrl = :bookUrl
+          and status != ${BookCharacterProfile.STATUS_DELETED}
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun getBookCharacterRelations(
+        bookUrl: String,
+        limit: Int,
+    ): List<BookCharacterRelation>
+
+    @Query(
+        """
+        select * from book_knowledge_entries
+        where bookUrl = :bookUrl
+          and (:type is null or type = :type)
+          and (:chapterIndex is null
+               or (scopeStartChapter is null or scopeStartChapter <= :chapterIndex)
+               and (scopeEndChapter is null or scopeEndChapter >= :chapterIndex))
+          and (:query = '' or title like '%' || :query || '%' or keywordsJson like '%' || :query || '%' or content like '%' || :query || '%')
+        order by priority desc, updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun searchKnowledgeEntries(
+        bookUrl: String,
+        query: String,
+        type: String?,
+        chapterIndex: Int?,
+        limit: Int,
+    ): List<BookKnowledgeEntry>
+
+    @Query(
+        """
+        select * from book_outline_nodes
+        where bookUrl = :bookUrl
+          and (:nodeType is null or nodeType = :nodeType)
+          and (:chapterIndex is null
+               or (startChapterIndex is null or startChapterIndex <= :chapterIndex)
+               and (endChapterIndex is null or endChapterIndex >= :chapterIndex))
+        order by coalesce(startChapterIndex, -1), `order`, updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun getOutlineNodes(
+        bookUrl: String,
+        chapterIndex: Int?,
+        nodeType: String?,
+        limit: Int,
+    ): List<BookOutlineNode>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertCharacterProfile(profile: BookCharacterProfile)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertCharacterEvent(event: BookCharacterEvent)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertCharacterRelation(relation: BookCharacterRelation)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertKnowledgeEntry(entry: BookKnowledgeEntry)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertOutlineNode(node: BookOutlineNode)
+}

+ 70 - 0
app/src/main/java/io/legado/app/data/dao/ChapterSpeechDao.kt

@@ -0,0 +1,70 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import androidx.room.Transaction
+import io.legado.app.data.entities.ChapterSpeechAnalysisEntity
+import io.legado.app.data.entities.ChapterSpeechSegmentEntity
+
+@Dao
+interface ChapterSpeechDao {
+
+    @Query(
+        "select * from chapter_speech_analysis where bookUrl = :bookUrl " +
+            "and chapterIndex = :chapterIndex and contentHash = :contentHash " +
+            "and resolverVersion = :resolverVersion limit 1"
+    )
+    suspend fun getAnalysis(
+        bookUrl: String,
+        chapterIndex: Int,
+        contentHash: String,
+        resolverVersion: String,
+    ): ChapterSpeechAnalysisEntity?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertAnalysis(analysis: ChapterSpeechAnalysisEntity)
+
+    @Query(
+        "select * from chapter_speech_segments where analysisId = :analysisId " +
+            "order by paragraphIndex, start, end"
+    )
+    suspend fun getSegments(analysisId: String): List<ChapterSpeechSegmentEntity>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertSegments(segments: List<ChapterSpeechSegmentEntity>)
+
+    @Query("delete from chapter_speech_segments where analysisId = :analysisId")
+    suspend fun deleteSegments(analysisId: String)
+
+    @Transaction
+    suspend fun replaceSegments(
+        analysisId: String,
+        segments: List<ChapterSpeechSegmentEntity>,
+    ) {
+        deleteSegments(analysisId)
+        if (segments.isNotEmpty()) upsertSegments(segments)
+    }
+
+    @Transaction
+    suspend fun saveAnalysis(
+        analysis: ChapterSpeechAnalysisEntity,
+        segments: List<ChapterSpeechSegmentEntity>,
+    ) {
+        upsertAnalysis(analysis)
+        replaceSegments(analysis.id, segments)
+    }
+
+    @Query("delete from chapter_speech_segments where bookUrl = :bookUrl and chapterIndex = :chapterIndex")
+    suspend fun deleteChapterSegments(bookUrl: String, chapterIndex: Int)
+
+    @Query("delete from chapter_speech_analysis where bookUrl = :bookUrl and chapterIndex = :chapterIndex")
+    suspend fun deleteChapterAnalyses(bookUrl: String, chapterIndex: Int)
+
+    @Transaction
+    suspend fun deleteChapter(bookUrl: String, chapterIndex: Int) {
+        deleteChapterSegments(bookUrl, chapterIndex)
+        deleteChapterAnalyses(bookUrl, chapterIndex)
+    }
+}

+ 27 - 0
app/src/main/java/io/legado/app/data/dao/CloudTtsEngineDao.kt

@@ -0,0 +1,27 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.CloudTtsEngineEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface CloudTtsEngineDao {
+    @Query("select * from cloud_tts_engines order by enabled desc, name collate nocase")
+    fun observeAll(): Flow<List<CloudTtsEngineEntity>>
+
+    @Query("select * from cloud_tts_engines order by enabled desc, name collate nocase")
+    suspend fun getAll(): List<CloudTtsEngineEntity>
+
+    @Query("select * from cloud_tts_engines where id = :id limit 1")
+    suspend fun get(id: String): CloudTtsEngineEntity?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(engine: CloudTtsEngineEntity)
+
+    @Delete
+    suspend fun delete(engine: CloudTtsEngineEntity)
+}

+ 60 - 0
app/src/main/java/io/legado/app/data/dao/ReadAloudVoiceDao.kt

@@ -0,0 +1,60 @@
+package io.legado.app.data.dao
+
+import androidx.room.Dao
+import androidx.room.Delete
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.Query
+import io.legado.app.data.entities.BookVoiceBindingEntity
+import io.legado.app.data.entities.ReadAloudVoiceEntity
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface ReadAloudVoiceDao {
+
+    @Query("select * from read_aloud_voices order by available desc, enabled desc, displayName collate nocase")
+    fun observeVoices(): Flow<List<ReadAloudVoiceEntity>>
+
+    @Query("select * from read_aloud_voices order by available desc, enabled desc, displayName collate nocase")
+    suspend fun getVoices(): List<ReadAloudVoiceEntity>
+
+    @Query("select * from read_aloud_voices where enabled = 1 and available = 1 order by displayName collate nocase")
+    suspend fun getEnabledVoices(): List<ReadAloudVoiceEntity>
+
+    @Query("select * from read_aloud_voices where id = :id limit 1")
+    suspend fun getVoice(id: String): ReadAloudVoiceEntity?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertVoice(voice: ReadAloudVoiceEntity)
+
+    @Delete
+    suspend fun deleteVoice(voice: ReadAloudVoiceEntity)
+
+    @Query(
+        "select * from book_voice_bindings where bookUrl = :bookUrl " +
+            "order by subjectType, subjectId"
+    )
+    fun observeBindings(bookUrl: String): Flow<List<BookVoiceBindingEntity>>
+
+    @Query(
+        "select * from book_voice_bindings where bookUrl = :bookUrl " +
+            "order by subjectType, subjectId"
+    )
+    suspend fun getBindings(bookUrl: String): List<BookVoiceBindingEntity>
+
+    @Query(
+        "select * from book_voice_bindings where bookUrl = :bookUrl " +
+            "and subjectType = :subjectType and subjectId = :subjectId limit 1"
+    )
+    suspend fun getBinding(
+        bookUrl: String,
+        subjectType: String,
+        subjectId: String,
+    ): BookVoiceBindingEntity?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsertBinding(binding: BookVoiceBindingEntity)
+
+    @Delete
+    suspend fun deleteBinding(binding: BookVoiceBindingEntity)
+}

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

@@ -0,0 +1,172 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "book_character_profiles",
+    indices = [
+        Index(value = ["bookUrl", "name"], unique = true),
+        Index(value = ["bookUrl", "updatedAt"]),
+    ]
+)
+data class BookCharacterProfile(
+    @PrimaryKey
+    val id: String,
+    val bookUrl: String,
+    val name: String,
+    val aliasesJson: String = "[]",
+    val avatarUri: String? = null,
+    val tagsJson: String = "[]",
+    val role: String = "",
+    val personality: String = "",
+    val summary: String = "",
+    val status: Int = STATUS_ACTIVE,
+    val source: String = SOURCE_USER,
+    val confidence: Float = 1f,
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+) {
+    companion object {
+        const val STATUS_DRAFT = 0
+        const val STATUS_ACTIVE = 1
+        const val STATUS_DISABLED = 2
+        const val STATUS_DELETED = 3
+
+        const val SOURCE_USER = "user"
+        const val SOURCE_AI = "ai"
+        const val SOURCE_IMPORT = "import"
+
+        const val ROLE_MALE_LEAD = "male_lead"
+        const val ROLE_FEMALE_LEAD = "female_lead"
+        const val ROLE_MALE_SUPPORTING = "male_supporting"
+        const val ROLE_FEMALE_SUPPORTING = "female_supporting"
+        val ALL_ROLES =
+            listOf(ROLE_MALE_LEAD, ROLE_FEMALE_LEAD, ROLE_MALE_SUPPORTING, ROLE_FEMALE_SUPPORTING)
+    }
+}
+
+@Entity(
+    tableName = "book_character_events",
+    indices = [
+        Index(value = ["bookUrl", "characterId", "chapterIndex"]),
+        Index(value = ["bookUrl", "chapterIndex"]),
+    ]
+)
+data class BookCharacterEvent(
+    @PrimaryKey
+    val id: String,
+    val bookUrl: String,
+    val characterId: String,
+    val chapterIndex: Int? = null,
+    val chapterTitle: String = "",
+    val eventTimeText: String = "",
+    val content: String,
+    val importance: Int = 0,
+    val sourceTextHash: String? = null,
+    val evidenceJson: String = "[]",
+    val source: String = BookCharacterProfile.SOURCE_USER,
+    val confidence: Float = 1f,
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+    tableName = "book_character_relations",
+    indices = [
+        Index(value = ["bookUrl", "fromCharacterId", "toCharacterId"], unique = true),
+        Index(value = ["bookUrl", "fromCharacterId"]),
+        Index(value = ["bookUrl", "toCharacterId"]),
+    ]
+)
+data class BookCharacterRelation(
+    @PrimaryKey
+    val id: String,
+    val bookUrl: String,
+    val fromCharacterId: String,
+    val toCharacterId: String,
+    val relationType: String,
+    val summary: String = "",
+    val attitude: String = "",
+    val evidenceJson: String = "[]",
+    val chapterIndex: Int? = null,
+    val status: Int = BookCharacterProfile.STATUS_ACTIVE,
+    val source: String = BookCharacterProfile.SOURCE_USER,
+    val confidence: Float = 1f,
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+    tableName = "book_knowledge_entries",
+    indices = [
+        Index(value = ["bookUrl", "type", "priority"]),
+        Index(value = ["bookUrl", "title"]),
+    ]
+)
+data class BookKnowledgeEntry(
+    @PrimaryKey
+    val id: String,
+    val bookUrl: String,
+    val type: String,
+    val title: String,
+    val keywordsJson: String = "[]",
+    val content: String,
+    val scopeStartChapter: Int? = null,
+    val scopeEndChapter: Int? = null,
+    val priority: Int = 0,
+    val source: String = BookCharacterProfile.SOURCE_USER,
+    val confidence: Float = 1f,
+    val evidenceJson: String = "[]",
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+) {
+    companion object {
+        const val TYPE_WORLD_RULE = "world_rule"
+        const val TYPE_LOCATION = "location"
+        const val TYPE_FACTION = "faction"
+        const val TYPE_OBJECT = "object"
+        const val TYPE_TERMINOLOGY = "terminology"
+        const val TYPE_TIMELINE = "timeline"
+        const val TYPE_STYLE = "style"
+        const val TYPE_THEME = "theme"
+    }
+}
+
+@Entity(
+    tableName = "book_outline_nodes",
+    indices = [
+        Index(value = ["bookUrl", "nodeType", "startChapterIndex", "endChapterIndex"]),
+        Index(value = ["bookUrl", "parentId", "order"]),
+    ]
+)
+data class BookOutlineNode(
+    @PrimaryKey
+    val id: String,
+    val bookUrl: String,
+    val parentId: String? = null,
+    val nodeType: String,
+    val title: String,
+    val summary: String,
+    val startChapterIndex: Int? = null,
+    val endChapterIndex: Int? = null,
+    val order: Int = 0,
+    val source: String = BookCharacterProfile.SOURCE_USER,
+    val confidence: Float = 1f,
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+) {
+    companion object {
+        const val TYPE_BOOK = "book"
+        const val TYPE_VOLUME = "volume"
+        const val TYPE_ARC = "arc"
+        const val TYPE_CHAPTER = "chapter"
+        const val TYPE_SCENE = "scene"
+    }
+}

+ 26 - 0
app/src/main/java/io/legado/app/data/entities/CloudTtsEngineEntity.kt

@@ -0,0 +1,26 @@
+package io.legado.app.data.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+
+@Entity(
+    tableName = "cloud_tts_engines",
+    primaryKeys = ["id"],
+    indices = [Index(value = ["provider", "enabled"])],
+)
+data class CloudTtsEngineEntity(
+    val id: String,
+    val name: String,
+    val provider: String,
+    @ColumnInfo(defaultValue = "") val baseUrl: String = "",
+    @ColumnInfo(defaultValue = "") val apiKey: String = "",
+    @ColumnInfo(defaultValue = "") val secretKey: String = "",
+    @ColumnInfo(defaultValue = "") val region: String = "",
+    @ColumnInfo(defaultValue = "") val appId: String = "",
+    @ColumnInfo(defaultValue = "") val model: String = "",
+    @ColumnInfo(defaultValue = "{}") val optionsJson: String = "{}",
+    @ColumnInfo(defaultValue = "1") val enabled: Boolean = true,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)

+ 121 - 0
app/src/main/java/io/legado/app/data/entities/ReadAloudSpeech.kt

@@ -0,0 +1,121 @@
+package io.legado.app.data.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+
+@Entity(
+    tableName = "read_aloud_voices",
+    indices = [
+        Index(value = ["engineType", "engineId", "speakerId"], unique = true),
+        Index(value = ["enabled", "displayName"]),
+    ],
+    primaryKeys = ["id"],
+)
+data class ReadAloudVoiceEntity(
+    val id: String,
+    val engineType: String,
+    @ColumnInfo(defaultValue = "")
+    val engineId: String = "",
+    @ColumnInfo(defaultValue = "")
+    val speakerId: String = "",
+    val displayName: String,
+    @ColumnInfo(defaultValue = "[]")
+    val traitsJson: String = "[]",
+    @ColumnInfo(defaultValue = "[]")
+    val emotionCatalogJson: String = "[]",
+    @ColumnInfo(defaultValue = "user")
+    val managedBy: String = "user",
+    @ColumnInfo(defaultValue = "1")
+    val enabled: Boolean = true,
+    @ColumnInfo(defaultValue = "1")
+    val available: Boolean = true,
+    @ColumnInfo(defaultValue = "0")
+    val revision: Long = 0L,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+    tableName = "book_voice_bindings",
+    primaryKeys = ["bookUrl", "subjectType", "subjectId"],
+    indices = [
+        Index(value = ["bookUrl", "subjectType"]),
+        Index(value = ["voiceId"]),
+    ],
+)
+data class BookVoiceBindingEntity(
+    val bookUrl: String,
+    val subjectType: String,
+    val subjectId: String,
+    val voiceId: String,
+    @ColumnInfo(defaultValue = "0")
+    val locked: Boolean = false,
+    @ColumnInfo(defaultValue = "user")
+    val source: String = "user",
+    @ColumnInfo(defaultValue = "1")
+    val confidence: Float = 1f,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+    tableName = "chapter_speech_analysis",
+    indices = [
+        Index(
+            value = ["bookUrl", "chapterIndex", "contentHash", "resolverVersion"],
+            unique = true,
+        ),
+        Index(value = ["bookUrl", "chapterIndex", "updatedAt"]),
+    ],
+    primaryKeys = ["id"],
+)
+data class ChapterSpeechAnalysisEntity(
+    val id: String,
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val contentHash: String,
+    val resolverVersion: String,
+    @ColumnInfo(defaultValue = "")
+    val characterRevision: String = "",
+    @ColumnInfo(defaultValue = "pending")
+    val status: String = "pending",
+    @ColumnInfo(defaultValue = "")
+    val error: String = "",
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+@Entity(
+    tableName = "chapter_speech_segments",
+    indices = [
+        Index(value = ["analysisId", "paragraphIndex", "start", "end"], unique = true),
+        Index(value = ["bookUrl", "chapterIndex", "paragraphIndex"]),
+        Index(value = ["characterId"]),
+    ],
+    primaryKeys = ["id"],
+)
+data class ChapterSpeechSegmentEntity(
+    val id: String,
+    val analysisId: String,
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val paragraphIndex: Int,
+    val start: Int,
+    val end: Int,
+    val chapterPosition: Int,
+    val text: String,
+    val roleType: String,
+    val characterId: String? = null,
+    @ColumnInfo(defaultValue = "")
+    val characterName: String = "",
+    @ColumnInfo(defaultValue = "")
+    val emotion: String = "",
+    @ColumnInfo(defaultValue = "0")
+    val confidence: Float = 0f,
+    val source: String,
+    @ColumnInfo(defaultValue = "0")
+    val userLocked: Boolean = false,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)

+ 3 - 0
app/src/main/java/io/legado/app/data/local/preferences/LocalPreferences.kt

@@ -4,6 +4,7 @@ import android.content.Context
 import androidx.datastore.core.DataStore
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
 import androidx.datastore.preferences.core.intPreferencesKey
 import androidx.datastore.preferences.core.longPreferencesKey
 import androidx.datastore.preferences.core.stringPreferencesKey
@@ -26,6 +27,8 @@ object LocalPreferencesKeys {
     val PRIVACY_POLICY_OK = booleanPreferencesKey("privacy_policy_ok")
     val PERMISSION_CHECKED = booleanPreferencesKey("permission_checked")
     val DAILY_READING_GOAL_MINUTES = intPreferencesKey("daily_reading_goal_minutes")
+    val READ_ALOUD_CAPSULE_OFFSET_X = floatPreferencesKey("read_aloud_capsule_offset_x")
+    val READ_ALOUD_CAPSULE_OFFSET_Y = floatPreferencesKey("read_aloud_capsule_offset_y")
 
     // ENABLE_READ_RECORD也许需要换个地方存,但先放这
     val ENABLE_READ_RECORD = booleanPreferencesKey("enableReadRecord")

+ 569 - 2
app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt

@@ -9,8 +9,14 @@ import io.legado.app.data.dao.ReadRecordDao
 import io.legado.app.data.entities.AiArtifact
 import io.legado.app.data.entities.AiMemory
 import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookOutlineNode
 import io.legado.app.domain.gateway.AiMemoryGateway
 import io.legado.app.domain.gateway.AiToolGateway
+import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.domain.model.AiToolCall
 import io.legado.app.domain.model.AiToolDefinition
 import io.legado.app.domain.model.AiToolResult
@@ -20,6 +26,7 @@ import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.withContext
+import java.util.UUID
 
 class AiToolRepository(
     private val bookDao: BookDao,
@@ -27,7 +34,8 @@ class AiToolRepository(
     private val bookmarkDao: BookmarkDao,
     private val readRecordDao: ReadRecordDao,
     private val aiArtifactDao: AiArtifactDao,
-    private val aiMemoryGateway: AiMemoryGateway
+    private val aiMemoryGateway: AiMemoryGateway,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
 ) : AiToolGateway {
 
     override fun availableTools(): List<AiToolDefinition> = tools
@@ -48,7 +56,16 @@ class AiToolRepository(
             TOOL_SEARCH_BOOKMARKS -> searchBookmarks(args)
             TOOL_GET_READING_STATS -> getReadingStats(args)
             TOOL_GET_AI_ARTIFACTS -> getAiArtifacts(args)
+            TOOL_SEARCH_BOOK_CHARACTERS -> searchBookCharacters(args)
+            TOOL_GET_CHARACTER_PROFILE -> getCharacterProfile(args)
+            TOOL_SEARCH_BOOK_KNOWLEDGE -> searchBookKnowledge(args)
+            TOOL_GET_BOOK_OUTLINE -> getBookOutline(args)
             TOOL_SAVE_AI_ARTIFACT -> saveAiArtifact(args)
+            TOOL_SAVE_BOOK_CHARACTER_PROFILE -> saveBookCharacterProfile(args)
+            TOOL_SAVE_BOOK_CHARACTER_EVENT -> saveBookCharacterEvent(args)
+            TOOL_SAVE_BOOK_CHARACTER_RELATION -> saveBookCharacterRelation(args)
+            TOOL_SAVE_BOOK_KNOWLEDGE_ENTRY -> saveBookKnowledgeEntry(args)
+            TOOL_SAVE_BOOK_OUTLINE_NODE -> saveBookOutlineNode(args)
             TOOL_SAVE_MEMORY -> saveMemory(args)
             TOOL_RECALL_MEMORY -> recallMemory(args)
             TOOL_DELETE_MEMORY -> deleteMemory(args)
@@ -343,6 +360,101 @@ class AiToolRepository(
         )
     }
 
+    private suspend fun searchBookCharacters(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val query = args.string("query").orEmpty().trim()
+        val limit = args.int("limit", 8).coerceIn(1, 30)
+        val profiles = bookKnowledgeGateway.searchCharacterProfiles(
+            bookUrl = book.bookUrl,
+            query = query,
+            limit = limit,
+        ).map { it.toToolMap() }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "query" to query,
+                "characters" to profiles,
+            )
+        )
+    }
+
+    private suspend fun getCharacterProfile(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val idOrName = args.string("characterId")
+            ?: args.string("characterName")
+            ?: return """{"error":"characterId or characterName is required"}"""
+        val profile = bookKnowledgeGateway.getCharacterProfile(book.bookUrl, idOrName.trim())
+            ?: return """{"error":"Character not found"}"""
+        val maxChapterIndex = args.string("maxChapterIndex")?.toIntOrNull()
+            ?: args.string("chapterIndex")?.toIntOrNull()
+        val limit = args.int("limit", 20).coerceIn(1, 60)
+        val events = bookKnowledgeGateway.getCharacterEvents(
+            bookUrl = book.bookUrl,
+            characterId = profile.id,
+            maxChapterIndex = maxChapterIndex,
+            limit = limit,
+        ).map { it.toToolMap() }
+        val relations = bookKnowledgeGateway.getCharacterRelations(
+            bookUrl = book.bookUrl,
+            characterId = profile.id,
+            limit = limit,
+        ).map { it.toToolMap() }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "character" to profile.toToolMap(),
+                "maxChapterIndex" to maxChapterIndex,
+                "events" to events,
+                "relations" to relations,
+            )
+        )
+    }
+
+    private suspend fun searchBookKnowledge(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val query = args.string("query").orEmpty().trim()
+        val type = args.string("type")?.trim()?.takeIf { it.isNotBlank() }
+        val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
+        val limit = args.int("limit", 12).coerceIn(1, 50)
+        val entries = bookKnowledgeGateway.searchKnowledgeEntries(
+            bookUrl = book.bookUrl,
+            query = query,
+            type = type,
+            chapterIndex = chapterIndex,
+            limit = limit,
+        ).map { it.toToolMap() }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "query" to query,
+                "type" to type,
+                "chapterIndex" to chapterIndex,
+                "entries" to entries,
+            )
+        )
+    }
+
+    private suspend fun getBookOutline(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
+        val nodeType = args.string("nodeType")?.trim()?.takeIf { it.isNotBlank() }
+        val limit = args.int("limit", 20).coerceIn(1, 80)
+        val nodes = bookKnowledgeGateway.getOutlineNodes(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            nodeType = nodeType,
+            limit = limit,
+        ).map { it.toToolMap() }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "chapterIndex" to chapterIndex,
+                "nodeType" to nodeType,
+                "outline" to nodes,
+            )
+        )
+    }
+
     private suspend fun saveAiArtifact(args: JsonObject): String {
         val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
         val output = args.string("output")?.trim().orEmpty()
@@ -378,6 +490,187 @@ class AiToolRepository(
         )
     }
 
+    private suspend fun saveBookCharacterProfile(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val name = args.string("name")?.trim().orEmpty()
+        if (name.isBlank()) return """{"error":"name is required"}"""
+        val now = System.currentTimeMillis()
+        val existing = bookKnowledgeGateway.getCharacterProfile(book.bookUrl, name)
+        val profile = BookCharacterProfile(
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() }
+                ?: existing?.id
+                ?: "character_${MD5Utils.md5Encode("${book.bookUrl}:$name")}",
+            bookUrl = book.bookUrl,
+            name = name,
+            aliasesJson = args.string("aliasesJson") ?: existing?.aliasesJson ?: "[]",
+            avatarUri = args.string("avatarUri") ?: existing?.avatarUri,
+            tagsJson = args.string("tagsJson") ?: existing?.tagsJson ?: "[]",
+            role = args.string("role") ?: existing?.role.orEmpty(),
+            personality = args.string("personality") ?: existing?.personality.orEmpty(),
+            summary = args.string("summary") ?: existing?.summary.orEmpty(),
+            status = args.int("status", existing?.status ?: BookCharacterProfile.STATUS_ACTIVE),
+            source = args.string("source") ?: BookCharacterProfile.SOURCE_AI,
+            confidence = args.float("confidence", existing?.confidence ?: 0.8f),
+            createdAt = existing?.createdAt ?: now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertCharacterProfile(profile)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "book" to book.toIdentityMap(),
+                "character" to profile.toToolMap()
+            )
+        )
+    }
+
+    private suspend fun saveBookCharacterEvent(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val character = resolveCharacterForKnowledgeTool(book, args)
+            ?: return """{"error":"Character not found"}"""
+        val content = args.string("content")?.trim().orEmpty()
+        if (content.isBlank()) return """{"error":"content is required"}"""
+        val now = System.currentTimeMillis()
+        val event = BookCharacterEvent(
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+                .toString(),
+            bookUrl = book.bookUrl,
+            characterId = character.id,
+            chapterIndex = args.string("chapterIndex")?.toIntOrNull(),
+            chapterTitle = args.string("chapterTitle").orEmpty(),
+            eventTimeText = args.string("eventTimeText").orEmpty(),
+            content = content,
+            importance = args.int("importance", 0),
+            sourceTextHash = args.string("sourceTextHash"),
+            evidenceJson = args.string("evidenceJson") ?: "[]",
+            source = args.string("source") ?: BookCharacterProfile.SOURCE_AI,
+            confidence = args.float("confidence", 0.8f),
+            createdAt = now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertCharacterEvent(event)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "book" to book.toIdentityMap(),
+                "event" to event.toToolMap()
+            )
+        )
+    }
+
+    private suspend fun saveBookCharacterRelation(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val from = args.string("fromCharacterId")?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+        } ?: args.string("fromCharacterName")?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+        } ?: return """{"error":"fromCharacterId or fromCharacterName is required"}"""
+        val to = args.string("toCharacterId")?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+        } ?: args.string("toCharacterName")?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+        } ?: return """{"error":"toCharacterId or toCharacterName is required"}"""
+        val relationType = args.string("relationType")?.trim().orEmpty()
+        if (relationType.isBlank()) return """{"error":"relationType is required"}"""
+        val now = System.currentTimeMillis()
+        val relation = BookCharacterRelation(
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() }
+                ?: "relation_${MD5Utils.md5Encode("${book.bookUrl}:${from.id}:${to.id}")}",
+            bookUrl = book.bookUrl,
+            fromCharacterId = from.id,
+            toCharacterId = to.id,
+            relationType = relationType,
+            summary = args.string("summary").orEmpty(),
+            attitude = args.string("attitude").orEmpty(),
+            evidenceJson = args.string("evidenceJson") ?: "[]",
+            chapterIndex = args.string("chapterIndex")?.toIntOrNull(),
+            status = args.int("status", BookCharacterProfile.STATUS_ACTIVE),
+            source = args.string("source") ?: BookCharacterProfile.SOURCE_AI,
+            confidence = args.float("confidence", 0.8f),
+            createdAt = now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertCharacterRelation(relation)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "book" to book.toIdentityMap(),
+                "relation" to relation.toToolMap()
+            )
+        )
+    }
+
+    private suspend fun saveBookKnowledgeEntry(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val type = args.string("type")?.trim().orEmpty()
+        val title = args.string("title")?.trim().orEmpty()
+        val content = args.string("content")?.trim().orEmpty()
+        if (type.isBlank()) return """{"error":"type is required"}"""
+        if (title.isBlank()) return """{"error":"title is required"}"""
+        if (content.isBlank()) return """{"error":"content is required"}"""
+        val now = System.currentTimeMillis()
+        val entry = BookKnowledgeEntry(
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+                .toString(),
+            bookUrl = book.bookUrl,
+            type = type,
+            title = title,
+            keywordsJson = args.string("keywordsJson") ?: "[]",
+            content = content,
+            scopeStartChapter = args.string("scopeStartChapter")?.toIntOrNull(),
+            scopeEndChapter = args.string("scopeEndChapter")?.toIntOrNull(),
+            priority = args.int("priority", 0),
+            source = args.string("source") ?: BookCharacterProfile.SOURCE_AI,
+            confidence = args.float("confidence", 0.8f),
+            evidenceJson = args.string("evidenceJson") ?: "[]",
+            createdAt = now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertKnowledgeEntry(entry)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "book" to book.toIdentityMap(),
+                "entry" to entry.toToolMap()
+            )
+        )
+    }
+
+    private suspend fun saveBookOutlineNode(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val nodeType = args.string("nodeType")?.trim().orEmpty()
+        val title = args.string("title")?.trim().orEmpty()
+        val summary = args.string("summary")?.trim().orEmpty()
+        if (nodeType.isBlank()) return """{"error":"nodeType is required"}"""
+        if (title.isBlank()) return """{"error":"title is required"}"""
+        if (summary.isBlank()) return """{"error":"summary is required"}"""
+        val now = System.currentTimeMillis()
+        val node = BookOutlineNode(
+            id = args.string("id")?.trim()?.takeIf { it.isNotBlank() } ?: UUID.randomUUID()
+                .toString(),
+            bookUrl = book.bookUrl,
+            parentId = args.string("parentId")?.trim()?.takeIf { it.isNotBlank() },
+            nodeType = nodeType,
+            title = title,
+            summary = summary,
+            startChapterIndex = args.string("startChapterIndex")?.toIntOrNull(),
+            endChapterIndex = args.string("endChapterIndex")?.toIntOrNull(),
+            order = args.int("order", 0),
+            source = args.string("source") ?: BookCharacterProfile.SOURCE_AI,
+            confidence = args.float("confidence", 0.8f),
+            createdAt = now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertOutlineNode(node)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "book" to book.toIdentityMap(),
+                "outlineNode" to node.toToolMap()
+            )
+        )
+    }
+
     private suspend fun saveMemory(args: JsonObject): String {
         val key = args.string("key")?.trim().orEmpty()
         if (key.isBlank()) return """{"error":"key is required"}"""
@@ -446,6 +739,106 @@ class AiToolRepository(
         )
     }
 
+    private suspend fun resolveCharacterForKnowledgeTool(
+        book: Book,
+        args: JsonObject,
+    ): BookCharacterProfile? {
+        args.string("characterId")?.trim()?.takeIf { it.isNotBlank() }?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+                ?.let { profile -> return profile }
+        }
+        args.string("characterName")?.trim()?.takeIf { it.isNotBlank() }?.let {
+            bookKnowledgeGateway.getCharacterProfile(book.bookUrl, it)
+                ?.let { profile -> return profile }
+        }
+        return null
+    }
+
+    private fun BookCharacterProfile.toToolMap(): Map<String, Any?> {
+        return mapOf(
+            "id" to id,
+            "bookUrl" to bookUrl,
+            "name" to name,
+            "aliasesJson" to aliasesJson,
+            "avatarUri" to avatarUri,
+            "tagsJson" to tagsJson,
+            "role" to role,
+            "personality" to personality,
+            "summary" to summary,
+            "status" to status,
+            "source" to source,
+            "confidence" to confidence,
+            "updatedAt" to updatedAt,
+        )
+    }
+
+    private fun BookCharacterEvent.toToolMap(): Map<String, Any?> {
+        return mapOf(
+            "id" to id,
+            "characterId" to characterId,
+            "chapterIndex" to chapterIndex,
+            "chapterTitle" to chapterTitle,
+            "eventTimeText" to eventTimeText,
+            "content" to content,
+            "importance" to importance,
+            "sourceTextHash" to sourceTextHash,
+            "evidenceJson" to evidenceJson,
+            "source" to source,
+            "confidence" to confidence,
+            "updatedAt" to updatedAt,
+        )
+    }
+
+    private fun BookCharacterRelation.toToolMap(): Map<String, Any?> {
+        return mapOf(
+            "id" to id,
+            "fromCharacterId" to fromCharacterId,
+            "toCharacterId" to toCharacterId,
+            "relationType" to relationType,
+            "summary" to summary,
+            "attitude" to attitude,
+            "evidenceJson" to evidenceJson,
+            "chapterIndex" to chapterIndex,
+            "status" to status,
+            "source" to source,
+            "confidence" to confidence,
+            "updatedAt" to updatedAt,
+        )
+    }
+
+    private fun BookKnowledgeEntry.toToolMap(): Map<String, Any?> {
+        return mapOf(
+            "id" to id,
+            "type" to type,
+            "title" to title,
+            "keywordsJson" to keywordsJson,
+            "content" to content,
+            "scopeStartChapter" to scopeStartChapter,
+            "scopeEndChapter" to scopeEndChapter,
+            "priority" to priority,
+            "source" to source,
+            "confidence" to confidence,
+            "evidenceJson" to evidenceJson,
+            "updatedAt" to updatedAt,
+        )
+    }
+
+    private fun BookOutlineNode.toToolMap(): Map<String, Any?> {
+        return mapOf(
+            "id" to id,
+            "parentId" to parentId,
+            "nodeType" to nodeType,
+            "title" to title,
+            "summary" to summary,
+            "startChapterIndex" to startChapterIndex,
+            "endChapterIndex" to endChapterIndex,
+            "order" to order,
+            "source" to source,
+            "confidence" to confidence,
+            "updatedAt" to updatedAt,
+        )
+    }
+
     private fun JsonObject.string(name: String): String? {
         return get(name)?.takeIf { !it.isJsonNull }?.asString
     }
@@ -454,6 +847,16 @@ class AiToolRepository(
         return runCatching { get(name)?.takeIf { !it.isJsonNull }?.asInt }.getOrNull() ?: defaultValue
     }
 
+    private fun JsonObject.float(name: String, defaultValue: Float): Float {
+        return runCatching { get(name)?.takeIf { !it.isJsonNull }?.asFloat }.getOrNull()
+            ?: defaultValue
+    }
+
+    private fun JsonObject.bool(name: String, defaultValue: Boolean): Boolean {
+        return runCatching { get(name)?.takeIf { !it.isJsonNull }?.asBoolean }.getOrNull()
+            ?: defaultValue
+    }
+
     private fun String.toJsonObject(): JsonObject {
         return runCatching { GSON.fromJson(this, JsonObject::class.java) }.getOrNull() ?: JsonObject()
     }
@@ -468,12 +871,30 @@ class AiToolRepository(
         const val TOOL_SEARCH_BOOKMARKS = "search_bookmarks"
         const val TOOL_GET_READING_STATS = "get_reading_stats"
         const val TOOL_GET_AI_ARTIFACTS = "get_ai_artifacts"
+        const val TOOL_SEARCH_BOOK_CHARACTERS = "search_book_characters"
+        const val TOOL_GET_CHARACTER_PROFILE = "get_character_profile"
+        const val TOOL_SEARCH_BOOK_KNOWLEDGE = "search_book_knowledge"
+        const val TOOL_GET_BOOK_OUTLINE = "get_book_outline"
         const val TOOL_SAVE_AI_ARTIFACT = "save_ai_artifact"
+        const val TOOL_SAVE_BOOK_CHARACTER_PROFILE = "save_book_character_profile"
+        const val TOOL_SAVE_BOOK_CHARACTER_EVENT = "save_book_character_event"
+        const val TOOL_SAVE_BOOK_CHARACTER_RELATION = "save_book_character_relation"
+        const val TOOL_SAVE_BOOK_KNOWLEDGE_ENTRY = "save_book_knowledge_entry"
+        const val TOOL_SAVE_BOOK_OUTLINE_NODE = "save_book_outline_node"
         const val TOOL_SAVE_MEMORY = "save_memory"
         const val TOOL_RECALL_MEMORY = "recall_memory"
         const val TOOL_DELETE_MEMORY = "delete_memory"
 
-        private val confirmationRequiredTools = setOf(TOOL_SAVE_AI_ARTIFACT, TOOL_SAVE_MEMORY, TOOL_DELETE_MEMORY)
+        private val confirmationRequiredTools = setOf(
+            TOOL_SAVE_AI_ARTIFACT,
+            TOOL_SAVE_BOOK_CHARACTER_PROFILE,
+            TOOL_SAVE_BOOK_CHARACTER_EVENT,
+            TOOL_SAVE_BOOK_CHARACTER_RELATION,
+            TOOL_SAVE_BOOK_KNOWLEDGE_ENTRY,
+            TOOL_SAVE_BOOK_OUTLINE_NODE,
+            TOOL_SAVE_MEMORY,
+            TOOL_DELETE_MEMORY,
+        )
 
         private val tools = listOf(
             AiToolDefinition(
@@ -573,6 +994,56 @@ class AiToolRepository(
                     "limit" to intSchema("Maximum number of artifacts to return.")
                 )
             ),
+            AiToolDefinition(
+                name = TOOL_SEARCH_BOOK_CHARACTERS,
+                description = "Search saved character profiles for a local book. Use this to find character names, aliases, personality, and profile ids.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "query" to stringSchema("Character name, alias, or profile keyword. Leave empty to list recently updated profiles."),
+                    "limit" to intSchema("Maximum number of character profiles to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_CHARACTER_PROFILE,
+                description = "Get one saved character profile with events and relationship notes. Events can be limited to chapters up to a specified index to avoid future spoilers.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "characterId" to stringSchema("Character profile id."),
+                    "characterName" to stringSchema("Character name or alias when id is unavailable."),
+                    "maxChapterIndex" to intSchema("Only include events at or before this zero-based chapter index."),
+                    "chapterIndex" to intSchema("Alias for maxChapterIndex."),
+                    "limit" to intSchema("Maximum number of events and relations to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SEARCH_BOOK_KNOWLEDGE,
+                description = "Search saved world-book knowledge entries such as world rules, locations, factions, objects, terminology, timeline, style, or theme.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "query" to stringSchema("Keyword for title, keywords, or content. Leave empty to list high-priority entries."),
+                    "type" to stringSchema("Optional entry type, e.g. world_rule, location, faction, object, terminology, timeline, style, theme."),
+                    "chapterIndex" to intSchema("Only include entries whose chapter scope contains this zero-based chapter index."),
+                    "limit" to intSchema("Maximum number of entries to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_BOOK_OUTLINE,
+                description = "Read saved outline nodes for a book, volume, arc, chapter, or scene. Can be scoped to the current chapter.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "chapterIndex" to intSchema("Optional zero-based chapter index; returns outline nodes covering this chapter."),
+                    "nodeType" to stringSchema("Optional node type, e.g. book, volume, arc, chapter, scene."),
+                    "limit" to intSchema("Maximum number of outline nodes to return.")
+                )
+            ),
             AiToolDefinition(
                 name = TOOL_SAVE_AI_ARTIFACT,
                 description = "Save a user-approved AI note or summary into local AI artifacts. Use only when the user explicitly asks to save content.",
@@ -585,6 +1056,98 @@ class AiToolRepository(
                     "output" to stringSchema("The note or summary content to save.")
                 )
             ),
+            AiToolDefinition(
+                name = TOOL_SAVE_BOOK_CHARACTER_PROFILE,
+                description = "Save or update a user-approved character profile for a local book. Requires user confirmation.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "id" to stringSchema("Optional existing character id."),
+                    "name" to stringSchema("Character name."),
+                    "aliasesJson" to stringSchema("JSON string representing an array of aliases, e.g. \"[\\\"别名1\\\",\\\"别名2\\\"]\". Must be a string, not an array."),
+                    "avatarUri" to stringSchema("Optional avatar uri or local path."),
+                    "tagsJson" to stringSchema("JSON string representing an array of simple tags, e.g. \"[\\\"修仙者\\\",\\\"白衣\\\",\\\"年轻\\\"]\". Must be a string, not an array."),
+                    "role" to stringSchema("Character role: one of \"male_lead\", \"female_lead\", \"male_supporting\", \"female_supporting\", or empty."),
+                    "personality" to stringSchema("Personality description."),
+                    "summary" to stringSchema("Concise character summary."),
+                    "confidence" to floatSchema("Confidence from 0 to 1.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SAVE_BOOK_CHARACTER_EVENT,
+                description = "Save a user-approved character event or timeline item for a local book. Requires user confirmation.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "characterId" to stringSchema("Character profile id."),
+                    "characterName" to stringSchema("Character name when id is unavailable."),
+                    "chapterIndex" to intSchema("Zero-based chapter index where the event appears."),
+                    "chapterTitle" to stringSchema("Chapter title."),
+                    "eventTimeText" to stringSchema("In-story time label if known."),
+                    "content" to stringSchema("Event content."),
+                    "importance" to intSchema("Importance score."),
+                    "sourceTextHash" to stringSchema("Optional hash of source excerpt."),
+                    "evidenceJson" to stringSchema("JSON array of evidence excerpts or references."),
+                    "confidence" to floatSchema("Confidence from 0 to 1.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SAVE_BOOK_CHARACTER_RELATION,
+                description = "Save a user-approved directed relation between two character profiles. Requires user confirmation.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "fromCharacterId" to stringSchema("Source character id."),
+                    "fromCharacterName" to stringSchema("Source character name when id is unavailable."),
+                    "toCharacterId" to stringSchema("Target character id."),
+                    "toCharacterName" to stringSchema("Target character name when id is unavailable."),
+                    "relationType" to stringSchema("Relation type. Use a concise Chinese label describing the relationship, e.g. 恋人, 夫妻, 师徒, 同门, 兄弟, 姐妹, 主仆, 盟友, 敌对, 上下级, 知己, 情敌, 仇人, 亲属, 朋友."),
+                    "summary" to stringSchema("Relation summary."),
+                    "attitude" to stringSchema("Source character's attitude toward target, e.g. 信任, 敌视, 尊敬, 嫉妒, 爱慕, 怀戒备, 利用."),
+                    "chapterIndex" to intSchema("Chapter index where the relation is established or updated."),
+                    "evidenceJson" to stringSchema("JSON array of evidence excerpts or references."),
+                    "confidence" to floatSchema("Confidence from 0 to 1.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SAVE_BOOK_KNOWLEDGE_ENTRY,
+                description = "Save a user-approved world-book knowledge entry for a local book. Requires user confirmation.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "type" to stringSchema("Entry type, e.g. world_rule, location, faction, object, terminology, timeline, style, theme."),
+                    "title" to stringSchema("Entry title."),
+                    "keywordsJson" to stringSchema("JSON array of trigger keywords."),
+                    "content" to stringSchema("Entry content."),
+                    "scopeStartChapter" to intSchema("Optional first chapter index where this entry applies."),
+                    "scopeEndChapter" to intSchema("Optional last chapter index where this entry applies."),
+                    "priority" to intSchema("Higher priority entries are returned first."),
+                    "evidenceJson" to stringSchema("JSON array of evidence excerpts or references."),
+                    "confidence" to floatSchema("Confidence from 0 to 1.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SAVE_BOOK_OUTLINE_NODE,
+                description = "Save a user-approved outline node for a book, volume, arc, chapter, or scene. Requires user confirmation.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
+                    "bookAuthor" to stringSchema("Book author."),
+                    "id" to stringSchema("Optional existing outline node id."),
+                    "parentId" to stringSchema("Optional parent outline node id."),
+                    "nodeType" to stringSchema("Node type, e.g. book, volume, arc, chapter, scene."),
+                    "title" to stringSchema("Outline node title."),
+                    "summary" to stringSchema("Outline node summary."),
+                    "startChapterIndex" to intSchema("Optional first chapter index covered by this node."),
+                    "endChapterIndex" to intSchema("Optional last chapter index covered by this node."),
+                    "order" to intSchema("Sort order among siblings."),
+                    "confidence" to floatSchema("Confidence from 0 to 1.")
+                )
+            ),
             AiToolDefinition(
                 name = TOOL_SAVE_MEMORY,
                 description = "Save a fact or preference about the user to long-term memory. Use when the user shares a preference, fact, or instruction you should remember for future conversations.",
@@ -626,6 +1189,10 @@ class AiToolRepository(
         private fun intSchema(description: String): Map<String, Any?> {
             return mapOf("type" to "integer", "description" to description)
         }
+
+        private fun floatSchema(description: String): Map<String, Any?> {
+            return mapOf("type" to "number", "description" to description)
+        }
     }
 }
 

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

@@ -0,0 +1,116 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.BookKnowledgeDao
+import io.legado.app.data.entities.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookOutlineNode
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class BookKnowledgeRepository(
+    private val dao: BookKnowledgeDao,
+) : BookKnowledgeGateway {
+
+    override suspend fun searchCharacterProfiles(
+        bookUrl: String,
+        query: String,
+        limit: Int,
+    ): List<BookCharacterProfile> = withContext(Dispatchers.IO) {
+        dao.searchCharacterProfiles(bookUrl, query.trim(), limit.coerceIn(1, 30))
+    }
+
+    override suspend fun getCharacterProfiles(
+        bookUrl: String,
+        limit: Int,
+    ): List<BookCharacterProfile> = withContext(Dispatchers.IO) {
+        dao.getCharacterProfiles(bookUrl, limit.coerceIn(1, 80))
+    }
+
+    override suspend fun getCharacterProfile(
+        bookUrl: String,
+        idOrName: String,
+    ): BookCharacterProfile? = withContext(Dispatchers.IO) {
+        dao.getCharacterProfile(bookUrl, idOrName.trim())
+    }
+
+    override suspend fun getCharacterEvents(
+        bookUrl: String,
+        characterId: String?,
+        maxChapterIndex: Int?,
+        limit: Int,
+    ): List<BookCharacterEvent> = withContext(Dispatchers.IO) {
+        dao.getCharacterEvents(bookUrl, characterId, maxChapterIndex, limit.coerceIn(1, 80))
+    }
+
+    override suspend fun getCharacterRelations(
+        bookUrl: String,
+        characterId: String,
+        limit: Int,
+    ): List<BookCharacterRelation> = withContext(Dispatchers.IO) {
+        dao.getCharacterRelations(bookUrl, characterId, limit.coerceIn(1, 80))
+    }
+
+    override suspend fun getBookCharacterRelations(
+        bookUrl: String,
+        limit: Int,
+    ): List<BookCharacterRelation> = withContext(Dispatchers.IO) {
+        dao.getBookCharacterRelations(bookUrl, limit.coerceIn(1, 200))
+    }
+
+    override suspend fun searchKnowledgeEntries(
+        bookUrl: String,
+        query: String,
+        type: String?,
+        chapterIndex: Int?,
+        limit: Int,
+    ): List<BookKnowledgeEntry> = withContext(Dispatchers.IO) {
+        dao.searchKnowledgeEntries(
+            bookUrl = bookUrl,
+            query = query.trim(),
+            type = type?.trim()?.takeIf { it.isNotBlank() },
+            chapterIndex = chapterIndex,
+            limit = limit.coerceIn(1, 80),
+        )
+    }
+
+    override suspend fun getOutlineNodes(
+        bookUrl: String,
+        chapterIndex: Int?,
+        nodeType: String?,
+        limit: Int,
+    ): List<BookOutlineNode> = withContext(Dispatchers.IO) {
+        dao.getOutlineNodes(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            nodeType = nodeType?.trim()?.takeIf { it.isNotBlank() },
+            limit = limit.coerceIn(1, 80),
+        )
+    }
+
+    override suspend fun upsertCharacterProfile(profile: BookCharacterProfile) =
+        withContext(Dispatchers.IO) {
+            dao.upsertCharacterProfile(profile)
+        }
+
+    override suspend fun upsertCharacterEvent(event: BookCharacterEvent) =
+        withContext(Dispatchers.IO) {
+            dao.upsertCharacterEvent(event)
+        }
+
+    override suspend fun upsertCharacterRelation(relation: BookCharacterRelation) =
+        withContext(Dispatchers.IO) {
+            dao.upsertCharacterRelation(relation)
+        }
+
+    override suspend fun upsertKnowledgeEntry(entry: BookKnowledgeEntry) =
+        withContext(Dispatchers.IO) {
+            dao.upsertKnowledgeEntry(entry)
+        }
+
+    override suspend fun upsertOutlineNode(node: BookOutlineNode) = withContext(Dispatchers.IO) {
+        dao.upsertOutlineNode(node)
+    }
+}

+ 130 - 0
app/src/main/java/io/legado/app/data/repository/ChapterSpeechRepository.kt

@@ -0,0 +1,130 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.ChapterSpeechDao
+import io.legado.app.data.entities.ChapterSpeechAnalysisEntity
+import io.legado.app.data.entities.ChapterSpeechSegmentEntity
+import io.legado.app.domain.gateway.ChapterSpeechGateway
+import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysis
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
+import io.legado.app.domain.model.readaloud.SpeechResolutionSource
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ChapterSpeechRepository(
+    private val dao: ChapterSpeechDao,
+) : ChapterSpeechGateway {
+
+    override suspend fun getAnalysis(
+        bookUrl: String,
+        chapterIndex: Int,
+        contentHash: String,
+        resolverVersion: String,
+    ): ChapterSpeechAnalysis? = withContext(Dispatchers.IO) {
+        dao.getAnalysis(bookUrl, chapterIndex, contentHash, resolverVersion)?.toDomain()
+    }
+
+    override suspend fun upsertAnalysis(analysis: ChapterSpeechAnalysis) =
+        withContext(Dispatchers.IO) {
+            dao.upsertAnalysis(analysis.toEntity())
+        }
+
+    override suspend fun saveAnalysis(
+        analysis: ChapterSpeechAnalysis,
+        segments: List<ChapterSpeechSegment>,
+    ) = withContext(Dispatchers.IO) {
+        require(segments.all { it.analysisId == analysis.id }) {
+            "All speech segments must belong to analysis ${analysis.id}"
+        }
+        dao.saveAnalysis(analysis.toEntity(), segments.map(ChapterSpeechSegment::toEntity))
+    }
+
+    override suspend fun getSegments(analysisId: String): List<ChapterSpeechSegment> =
+        withContext(Dispatchers.IO) {
+            dao.getSegments(analysisId).map(ChapterSpeechSegmentEntity::toDomain)
+        }
+
+    override suspend fun replaceSegments(
+        analysisId: String,
+        segments: List<ChapterSpeechSegment>,
+    ) = withContext(Dispatchers.IO) {
+        require(segments.all { it.analysisId == analysisId }) {
+            "All speech segments must belong to analysis $analysisId"
+        }
+        dao.replaceSegments(analysisId, segments.map(ChapterSpeechSegment::toEntity))
+    }
+
+    override suspend fun deleteChapter(bookUrl: String, chapterIndex: Int) =
+        withContext(Dispatchers.IO) {
+            dao.deleteChapter(bookUrl, chapterIndex)
+        }
+}
+
+private fun ChapterSpeechAnalysisEntity.toDomain() = ChapterSpeechAnalysis(
+    id = id,
+    bookUrl = bookUrl,
+    chapterIndex = chapterIndex,
+    contentHash = contentHash,
+    resolverVersion = resolverVersion,
+    characterRevision = characterRevision,
+    status = SpeechAnalysisStatus.fromStorage(status),
+    error = error,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun ChapterSpeechAnalysis.toEntity() = ChapterSpeechAnalysisEntity(
+    id = id,
+    bookUrl = bookUrl,
+    chapterIndex = chapterIndex,
+    contentHash = contentHash,
+    resolverVersion = resolverVersion,
+    characterRevision = characterRevision,
+    status = status.storageValue,
+    error = error,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun ChapterSpeechSegmentEntity.toDomain() = ChapterSpeechSegment(
+    id = id,
+    analysisId = analysisId,
+    bookUrl = bookUrl,
+    chapterIndex = chapterIndex,
+    paragraphIndex = paragraphIndex,
+    start = start,
+    end = end,
+    chapterPosition = chapterPosition,
+    text = text,
+    roleType = SpeechRoleType.fromStorage(roleType),
+    characterId = characterId,
+    characterName = characterName,
+    emotion = emotion,
+    confidence = confidence,
+    source = SpeechResolutionSource.fromStorage(source),
+    userLocked = userLocked,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun ChapterSpeechSegment.toEntity() = ChapterSpeechSegmentEntity(
+    id = id,
+    analysisId = analysisId,
+    bookUrl = bookUrl,
+    chapterIndex = chapterIndex,
+    paragraphIndex = paragraphIndex,
+    start = start,
+    end = end,
+    chapterPosition = chapterPosition,
+    text = text,
+    roleType = roleType.storageValue,
+    characterId = characterId,
+    characterName = characterName,
+    emotion = emotion,
+    confidence = confidence,
+    source = source.storageValue,
+    userLocked = userLocked,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)

+ 89 - 0
app/src/main/java/io/legado/app/data/repository/CloudTtsEngineRepository.kt

@@ -0,0 +1,89 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.CloudTtsEngineDao
+import io.legado.app.data.entities.CloudTtsEngineEntity
+import io.legado.app.data.security.CloudTtsCredentialCipher
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.flow.onEach
+import kotlinx.coroutines.withContext
+
+class CloudTtsEngineRepository(
+    private val dao: CloudTtsEngineDao,
+    private val credentialCipher: CloudTtsCredentialCipher,
+) : CloudTtsEngineGateway {
+    override fun observeAll(): Flow<List<CloudTtsEngine>> =
+        dao.observeAll()
+            .onEach(::encryptLegacyCredentials)
+            .map { engines -> engines.map(::toDomain) }
+
+    override suspend fun getAll(): List<CloudTtsEngine> = withContext(Dispatchers.IO) {
+        dao.getAll().also { encryptLegacyCredentials(it) }.map(::toDomain)
+    }
+
+    override suspend fun get(id: String): CloudTtsEngine? = withContext(Dispatchers.IO) {
+        dao.get(id)?.let { engine ->
+            val encrypted = engine.encryptCredentials(credentialCipher)
+            if (encrypted != engine) dao.upsert(encrypted)
+            toDomain(engine)
+        }
+    }
+
+    override suspend fun upsert(engine: CloudTtsEngine) = withContext(Dispatchers.IO) {
+        dao.upsert(engine.toEntity(credentialCipher))
+    }
+
+    override suspend fun delete(engine: CloudTtsEngine) = withContext(Dispatchers.IO) {
+        dao.delete(engine.toEntity(credentialCipher))
+    }
+
+    private fun toDomain(entity: CloudTtsEngineEntity) = entity.toDomain(credentialCipher)
+
+    private suspend fun encryptLegacyCredentials(engines: List<CloudTtsEngineEntity>) {
+        engines.forEach { engine ->
+            val encrypted = engine.encryptCredentials(credentialCipher)
+            if (encrypted != engine) dao.upsert(encrypted)
+        }
+    }
+}
+
+private fun CloudTtsEngineEntity.encryptCredentials(cipher: CloudTtsCredentialCipher) = copy(
+    apiKey = cipher.encrypt(apiKey),
+    secretKey = cipher.encrypt(secretKey),
+)
+
+private fun CloudTtsEngineEntity.toDomain(cipher: CloudTtsCredentialCipher) = CloudTtsEngine(
+    id = id,
+    name = name,
+    provider = CloudTtsProviderType.fromStorage(provider),
+    baseUrl = baseUrl,
+    apiKey = cipher.decrypt(apiKey),
+    secretKey = cipher.decrypt(secretKey),
+    region = region,
+    appId = appId,
+    model = model,
+    optionsJson = optionsJson,
+    enabled = enabled,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun CloudTtsEngine.toEntity(cipher: CloudTtsCredentialCipher) = CloudTtsEngineEntity(
+    id = id,
+    name = name,
+    provider = provider.storageValue,
+    baseUrl = baseUrl,
+    apiKey = cipher.encrypt(apiKey),
+    secretKey = cipher.encrypt(secretKey),
+    region = region,
+    appId = appId,
+    model = model,
+    optionsJson = optionsJson,
+    enabled = enabled,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)

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

@@ -0,0 +1,26 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.HttpTTSDao
+import io.legado.app.domain.gateway.HttpTtsEngineGateway
+import io.legado.app.domain.model.readaloud.TtsEngineDescriptor
+import io.legado.app.domain.model.readaloud.TtsEngineKind
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+
+class HttpTtsEngineRepository(
+    private val httpTtsDao: HttpTTSDao,
+) : HttpTtsEngineGateway {
+
+    override fun observeAll(): Flow<List<TtsEngineDescriptor>> = httpTtsDao.flowAll().map { sources ->
+        sources.map { source ->
+            TtsEngineDescriptor(
+                id = "http:${source.id}",
+                kind = TtsEngineKind.Http,
+                sourceId = source.id.toString(),
+                displayName = source.name,
+                providerName = "HTTP TTS",
+                supportsVoiceDiscovery = false,
+            )
+        }
+    }
+}

+ 43 - 0
app/src/main/java/io/legado/app/data/repository/ReadAloudSettingsRepository.kt

@@ -2,6 +2,8 @@ package io.legado.app.data.repository
 
 import androidx.datastore.preferences.core.Preferences
 import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.floatPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
 import io.legado.app.constant.PreferKey
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
@@ -66,6 +68,17 @@ class ReadAloudSettingsRepository(
         ReadConfig.readAloudWakeLock = value
     }
 
+    suspend fun setShowReadAloudCapsule(value: Boolean) {
+        ReadConfig.showReadAloudCapsule = value
+    }
+
+    suspend fun setCapsulePosition(x: Float, y: Float) {
+        settingsRepository.putFloat(Keys.CapsuleOffsetX.name, x)
+        settingsRepository.putFloat(Keys.CapsuleOffsetY.name, y)
+    }
+
+    suspend fun resetCapsulePosition() = setCapsulePosition(0f, 0f)
+
     suspend fun setMediaButtonPerNext(value: Boolean) {
         ReadConfig.mediaButtonPerNext = value
     }
@@ -95,6 +108,21 @@ class ReadAloudSettingsRepository(
         ReadConfig.ttsSpeechRate = value.coerceIn(0, 80)
     }
 
+    suspend fun setSpeechAnalysisMode(value: String) {
+        ReadConfig.speechAnalysisMode = value
+    }
+
+    suspend fun setUseMultiSpeaker(value: Boolean) {
+        ReadConfig.useMultiSpeaker = value
+    }
+
+    suspend fun setDefaultInterface(value: String) {
+        settingsRepository.putString(
+            PreferKey.defaultReadAloudInterface,
+            value.takeIf { it in AVAILABLE_INTERFACES } ?: DEFAULT_INTERFACE_CLASSIC,
+        )
+    }
+
     private fun Preferences.toReadAloudPreferences(): ReadAloudPreferences {
         return ReadAloudPreferences(
             ignoreAudioFocus = compatDsValue(Keys.IgnoreAudioFocus, false),
@@ -102,6 +130,9 @@ class ReadAloudSettingsRepository(
             readAloudByMediaButton = compatDsValue(Keys.ReadAloudByMediaButton, false),
             pauseReadAloudWhilePhoneCalls = compatDsValue(Keys.PauseReadAloudWhilePhoneCalls, false),
             readAloudWakeLock = compatDsValue(Keys.ReadAloudWakeLock, false),
+            showReadAloudCapsule = compatDsValue(Keys.ShowReadAloudCapsule, true),
+            capsuleOffsetX = compatDsValue(Keys.CapsuleOffsetX, 0f),
+            capsuleOffsetY = compatDsValue(Keys.CapsuleOffsetY, 0f),
             mediaButtonPerNext = compatDsValue(Keys.MediaButtonPerNext, false),
             readAloudByPage = compatDsValue(Keys.ReadAloudByPage, false),
             systemMediaControlCompatibilityChange =
@@ -110,6 +141,9 @@ class ReadAloudSettingsRepository(
             ttsTimer = PlaybackTimer.normalize(compatDsValue(Keys.TtsTimer, 0)),
             ttsFollowSys = compatDsValue(Keys.TtsFollowSys, true),
             ttsSpeechRate = compatDsValue(Keys.TtsSpeechRate, 5),
+            speechAnalysisMode = compatDsValue(Keys.SpeechAnalysisMode, "rule"),
+            useMultiSpeaker = compatDsValue(Keys.UseMultiSpeaker, true),
+            defaultInterface = compatDsValue(Keys.DefaultInterface, DEFAULT_INTERFACE_CLASSIC),
         )
     }
 
@@ -120,6 +154,9 @@ class ReadAloudSettingsRepository(
         val PauseReadAloudWhilePhoneCalls =
             booleanPreferencesKey(PreferKey.pauseReadAloudWhilePhoneCalls)
         val ReadAloudWakeLock = booleanPreferencesKey(PreferKey.readAloudWakeLock)
+        val ShowReadAloudCapsule = booleanPreferencesKey(PreferKey.showReadAloudCapsule)
+        val CapsuleOffsetX = floatPreferencesKey("read_aloud_capsule_offset_x")
+        val CapsuleOffsetY = floatPreferencesKey("read_aloud_capsule_offset_y")
         val MediaButtonPerNext = booleanPreferencesKey(KEY_MEDIA_BUTTON_PER_NEXT)
         val ReadAloudByPage = booleanPreferencesKey(PreferKey.readAloudByPage)
         val SystemMediaControlCompatibilityChange =
@@ -130,9 +167,15 @@ class ReadAloudSettingsRepository(
         val TtsSpeechRate = androidx.datastore.preferences.core.intPreferencesKey(
             PreferKey.ttsSpeechRate
         )
+        val SpeechAnalysisMode = stringPreferencesKey(PreferKey.speechAnalysisMode)
+        val UseMultiSpeaker = booleanPreferencesKey(PreferKey.useMultiSpeaker)
+        val DefaultInterface = stringPreferencesKey(PreferKey.defaultReadAloudInterface)
     }
 
     companion object {
         const val KEY_MEDIA_BUTTON_PER_NEXT = "mediaButtonPerNext"
+        const val DEFAULT_INTERFACE_CLASSIC = "classic"
+        const val DEFAULT_INTERFACE_PLAYER = "player"
+        val AVAILABLE_INTERFACES = setOf(DEFAULT_INTERFACE_CLASSIC, DEFAULT_INTERFACE_PLAYER)
     }
 }

+ 122 - 0
app/src/main/java/io/legado/app/data/repository/ReadAloudVoiceRepository.kt

@@ -0,0 +1,122 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.ReadAloudVoiceDao
+import io.legado.app.data.entities.BookVoiceBindingEntity
+import io.legado.app.data.entities.ReadAloudVoiceEntity
+import io.legado.app.domain.gateway.ReadAloudVoiceGateway
+import io.legado.app.domain.model.readaloud.BookVoiceBinding
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.map
+import kotlinx.coroutines.withContext
+
+class ReadAloudVoiceRepository(
+    private val dao: ReadAloudVoiceDao,
+) : ReadAloudVoiceGateway {
+
+    override fun observeVoices(): Flow<List<ReadAloudVoice>> =
+        dao.observeVoices().map { voices -> voices.map(ReadAloudVoiceEntity::toDomain) }
+
+    override suspend fun getVoices(): List<ReadAloudVoice> = withContext(Dispatchers.IO) {
+        dao.getVoices().map(ReadAloudVoiceEntity::toDomain)
+    }
+
+    override suspend fun getEnabledVoices(): List<ReadAloudVoice> = withContext(Dispatchers.IO) {
+        dao.getEnabledVoices().map(ReadAloudVoiceEntity::toDomain)
+    }
+
+    override suspend fun getVoice(id: String): ReadAloudVoice? = withContext(Dispatchers.IO) {
+        dao.getVoice(id)?.toDomain()
+    }
+
+    override suspend fun upsertVoice(voice: ReadAloudVoice) = withContext(Dispatchers.IO) {
+        dao.upsertVoice(voice.toEntity())
+    }
+
+    override suspend fun deleteVoice(voice: ReadAloudVoice) = withContext(Dispatchers.IO) {
+        dao.deleteVoice(voice.toEntity())
+    }
+
+    override fun observeBindings(bookUrl: String): Flow<List<BookVoiceBinding>> =
+        dao.observeBindings(bookUrl).map { bindings ->
+            bindings.map(BookVoiceBindingEntity::toDomain)
+        }
+
+    override suspend fun getBindings(bookUrl: String): List<BookVoiceBinding> =
+        withContext(Dispatchers.IO) {
+            dao.getBindings(bookUrl).map(BookVoiceBindingEntity::toDomain)
+        }
+
+    override suspend fun getBinding(
+        bookUrl: String,
+        subjectType: String,
+        subjectId: String,
+    ): BookVoiceBinding? = withContext(Dispatchers.IO) {
+        dao.getBinding(bookUrl, subjectType, subjectId)?.toDomain()
+    }
+
+    override suspend fun upsertBinding(binding: BookVoiceBinding) = withContext(Dispatchers.IO) {
+        dao.upsertBinding(binding.toEntity())
+    }
+
+    override suspend fun deleteBinding(binding: BookVoiceBinding) = withContext(Dispatchers.IO) {
+        dao.deleteBinding(binding.toEntity())
+    }
+}
+
+private fun ReadAloudVoiceEntity.toDomain() = ReadAloudVoice(
+    id = id,
+    engineType = engineType,
+    engineId = engineId,
+    speakerId = speakerId,
+    displayName = displayName,
+    traitsJson = traitsJson,
+    emotionCatalogJson = emotionCatalogJson,
+    managedBy = managedBy,
+    enabled = enabled,
+    available = available,
+    revision = revision,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun ReadAloudVoice.toEntity() = ReadAloudVoiceEntity(
+    id = id,
+    engineType = engineType,
+    engineId = engineId,
+    speakerId = speakerId,
+    displayName = displayName,
+    traitsJson = traitsJson,
+    emotionCatalogJson = emotionCatalogJson,
+    managedBy = managedBy,
+    enabled = enabled,
+    available = available,
+    revision = revision,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun BookVoiceBindingEntity.toDomain() = BookVoiceBinding(
+    bookUrl = bookUrl,
+    subjectType = subjectType,
+    subjectId = subjectId,
+    voiceId = voiceId,
+    locked = locked,
+    source = source,
+    confidence = confidence,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)
+
+private fun BookVoiceBinding.toEntity() = BookVoiceBindingEntity(
+    bookUrl = bookUrl,
+    subjectType = subjectType,
+    subjectId = subjectId,
+    voiceId = voiceId,
+    locked = locked,
+    source = source,
+    confidence = confidence,
+    createdAt = createdAt,
+    updatedAt = updatedAt,
+)

+ 65 - 0
app/src/main/java/io/legado/app/data/security/CloudTtsCredentialCipher.kt

@@ -0,0 +1,65 @@
+package io.legado.app.data.security
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyStore
+import javax.crypto.Cipher
+import javax.crypto.KeyGenerator
+import javax.crypto.SecretKey
+import javax.crypto.spec.GCMParameterSpec
+
+/** Encrypts cloud TTS credentials before they enter Room or database backups. */
+class CloudTtsCredentialCipher {
+
+    @Synchronized
+    fun encrypt(value: String): String {
+        if (value.isEmpty() || value.startsWith(PREFIX)) return value
+        val cipher = Cipher.getInstance(TRANSFORMATION)
+        cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
+        val payload = cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
+        return PREFIX + Base64.encodeToString(payload, Base64.NO_WRAP)
+    }
+
+    @Synchronized
+    fun decrypt(value: String): String {
+        if (value.isEmpty() || !value.startsWith(PREFIX)) return value
+        return runCatching {
+            val payload = Base64.decode(value.removePrefix(PREFIX), Base64.NO_WRAP)
+            val cipher = Cipher.getInstance(TRANSFORMATION)
+            cipher.init(
+                Cipher.DECRYPT_MODE,
+                getOrCreateKey(),
+                GCMParameterSpec(GCM_TAG_LENGTH_BITS, payload, 0, IV_LENGTH_BYTES),
+            )
+            cipher.doFinal(payload, IV_LENGTH_BYTES, payload.size - IV_LENGTH_BYTES)
+                .toString(Charsets.UTF_8)
+        }.getOrDefault("")
+    }
+
+    private fun getOrCreateKey(): SecretKey {
+        val keyStore = KeyStore.getInstance(KEY_STORE).apply { load(null) }
+        (keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
+        return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEY_STORE).run {
+            init(
+                KeyGenParameterSpec.Builder(
+                    KEY_ALIAS,
+                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
+                )
+                    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
+                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
+                    .build()
+            )
+            generateKey()
+        }
+    }
+
+    private companion object {
+        const val PREFIX = "enc:v1:"
+        const val KEY_STORE = "AndroidKeyStore"
+        const val KEY_ALIAS = "legado_cloud_tts_credentials_v1"
+        const val TRANSFORMATION = "AES/GCM/NoPadding"
+        const val IV_LENGTH_BYTES = 12
+        const val GCM_TAG_LENGTH_BITS = 128
+    }
+}

+ 4 - 0
app/src/main/java/io/legado/app/di/appDatabaseModule.kt

@@ -25,6 +25,10 @@ val appDatabaseModule = module {
     factory<BookSourceDao> { get<AppDatabase>().bookSourceDao }
     factory<BookChapterDao> { get<AppDatabase>().bookChapterDao }
     factory<BookContentProcessDao> { get<AppDatabase>().bookContentProcessDao }
+    factory<BookKnowledgeDao> { get<AppDatabase>().bookKnowledgeDao }
+    factory<ReadAloudVoiceDao> { get<AppDatabase>().readAloudVoiceDao }
+    factory<ChapterSpeechDao> { get<AppDatabase>().chapterSpeechDao }
+    factory<CloudTtsEngineDao> { get<AppDatabase>().cloudTtsEngineDao }
     factory<ReplaceRuleDao> { get<AppDatabase>().replaceRuleDao }
     factory<SearchBookDao> { get<AppDatabase>().searchBookDao }
     factory<SearchKeywordDao> { get<AppDatabase>().searchKeywordDao }

+ 103 - 1
app/src/main/java/io/legado/app/di/appModule.kt

@@ -23,6 +23,8 @@ import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookContentProcessRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookGroupRepository
+import io.legado.app.data.repository.BookKnowledgeRepository
+import io.legado.app.data.repository.ChapterSpeechRepository
 import io.legado.app.data.repository.BookRepository
 import io.legado.app.data.repository.BookSourceCallbackRepository
 import io.legado.app.data.repository.BookSourceRepository
@@ -45,6 +47,9 @@ import io.legado.app.data.repository.LabSettingsRepository
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.OtherSettingsRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
+import io.legado.app.data.repository.ReadAloudVoiceRepository
+import io.legado.app.data.repository.CloudTtsEngineRepository
+import io.legado.app.data.security.CloudTtsCredentialCipher
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.ReadSettingsRepository
@@ -81,6 +86,8 @@ import io.legado.app.domain.gateway.BackupSettingsGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookContentProcessGateway
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.gateway.ChapterSpeechGateway
 import io.legado.app.domain.gateway.BookSearchGateway
 import io.legado.app.domain.gateway.BookSourceCallbackGateway
 import io.legado.app.domain.gateway.BookshelfSettingsGateway
@@ -96,6 +103,8 @@ import io.legado.app.domain.gateway.LabSettingsGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.OtherSettingsGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
+import io.legado.app.domain.gateway.ReadAloudVoiceGateway
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.domain.gateway.ReadSettingsGateway
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.domain.gateway.ThemeSettingsGateway
@@ -104,6 +113,7 @@ import io.legado.app.domain.gateway.TranslationSettingsGateway
 import io.legado.app.domain.gateway.WebDavBackupGateway
 import io.legado.app.domain.repository.BookDomainRepository
 import io.legado.app.domain.usecase.AddBookUseCase
+import io.legado.app.domain.usecase.AnalyzeChapterSpeechUseCase
 import io.legado.app.domain.usecase.AddToBookshelfUseCase
 import io.legado.app.domain.usecase.AiChatGenerationUseCase
 import io.legado.app.domain.usecase.AiTextFactoryUseCase
@@ -111,6 +121,7 @@ import io.legado.app.domain.usecase.AiToolAwareGenerationUseCase
 import io.legado.app.domain.usecase.AppStartupMaintenanceUseCase
 import io.legado.app.domain.usecase.BackupRestoreUseCase
 import io.legado.app.domain.usecase.BatchCacheDownloadUseCase
+import io.legado.app.domain.usecase.BuildSpeechPlanUseCase
 import io.legado.app.domain.usecase.CacheBookChaptersUseCase
 import io.legado.app.domain.usecase.ChangeBookSourceUseCase
 import io.legado.app.domain.usecase.ChangeSourceSearchUseCase
@@ -129,13 +140,20 @@ import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
 import io.legado.app.domain.usecase.ResolveBookShelfStateUseCase
+import io.legado.app.domain.usecase.ResolveLocalSpeakersUseCase
+import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
+import io.legado.app.domain.usecase.RefineSpeechWithAiUseCase
 import io.legado.app.domain.usecase.SaveSearchBooksUseCase
 import io.legado.app.domain.usecase.SearchBooksUseCase
 import io.legado.app.domain.usecase.SaveBookContentProcessUseCase
 import io.legado.app.domain.usecase.ShrinkDatabaseUseCase
+import io.legado.app.domain.usecase.SyncReadAloudVoicesUseCase
+import io.legado.app.domain.gateway.HttpTtsEngineGateway
+import io.legado.app.data.repository.HttpTtsEngineRepository
 import io.legado.app.domain.usecase.TranslateChapterUseCase
 import io.legado.app.domain.usecase.UpdateBooksGroupUseCase
 import io.legado.app.domain.usecase.UploadReadingProgressUseCase
+import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.domain.usecase.WebDavBackupUseCase
 import io.legado.app.domain.usecase.readRecord.GetReadRecordOverviewUseCase
 import io.legado.app.help.coil.CoverFetcher
@@ -159,9 +177,20 @@ import io.legado.app.ui.book.import.remote.RemoteBookViewModel
 import io.legado.app.ui.book.import.remote.ServerConfigViewModel
 import io.legado.app.ui.book.import.remote.ServersViewModel
 import io.legado.app.ui.book.info.BookInfoViewModel
+import io.legado.app.ui.book.knowledge.BookCharacterDetailViewModel
+import io.legado.app.ui.book.knowledge.BookCharacterNetworkViewModel
+import io.legado.app.ui.book.knowledge.BookCharacterListViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeListViewModel
+import io.legado.app.ui.book.knowledge.BookKnowledgeDetailViewModel
+import io.legado.app.ui.book.knowledge.BookEventListViewModel
+import io.legado.app.ui.book.knowledge.BookEventDetailViewModel
 import io.legado.app.ui.book.manage.BookshelfManageScreenViewModel
 import io.legado.app.ui.book.manga.ReadMangaViewModel
 import io.legado.app.ui.book.read.ReadBookViewModel
+import io.legado.app.ui.book.readaloud.casting.BookVoiceCastingViewModel
+import io.legado.app.ui.book.readaloud.cloudtts.CloudTtsViewModel
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerViewModel
+import io.legado.app.ui.book.readaloud.player.ReadAloudPlayerCoordinator
 import io.legado.app.ui.book.readRecord.ReadRecordOverviewViewModel
 import io.legado.app.ui.book.readRecord.ReadRecordViewModel
 import io.legado.app.ui.book.search.SearchViewModel
@@ -173,6 +202,7 @@ import io.legado.app.ui.config.ai.AiConfigViewModel
 import io.legado.app.ui.config.ai.AiModelEditViewModel
 import io.legado.app.ui.config.ai.AiProviderEditViewModel
 import io.legado.app.ui.config.ai.summary.AiSummaryConfigViewModel
+import io.legado.app.ui.config.ai.prompt.AiPromptConfigViewModel
 import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.coverConfig.CoverAlbumManageViewModel
@@ -255,6 +285,8 @@ val appModule = module {
     singleOf(::ReadSettingsRepository)
     single<ReadSettingsGateway> { get<ReadSettingsRepository>() }
     singleOf(::ReadAloudSettingsRepository)
+    singleOf(::ReadAloudSessionStore)
+    single<HttpTtsEngineGateway> { HttpTtsEngineRepository(get()) }
     single<ReadAloudSettingsGateway> { get<ReadAloudSettingsRepository>() }
     singleOf(::ApplyReadSettingUseCase)
     singleOf(::HighlightRuleRepository)
@@ -266,6 +298,12 @@ val appModule = module {
     singleOf(::AppStartupMaintenanceUseCase)
     singleOf(::BackupRestoreUseCase)
     singleOf(::BatchCacheDownloadUseCase)
+    singleOf(::BuildSpeechPlanUseCase)
+    singleOf(::AnalyzeChapterSpeechUseCase)
+    singleOf(::ResolveLocalSpeakersUseCase)
+    singleOf(::PrepareChapterSpeechPlanUseCase)
+    singleOf(::RefineSpeechWithAiUseCase)
+    singleOf(::SyncReadAloudVoicesUseCase)
     singleOf(::CacheBookChaptersUseCase)
     singleOf(::ChangeBookSourceUseCase)
     singleOf(::ClearBookCacheUseCase)
@@ -296,7 +334,7 @@ val appModule = module {
     single<AiMemoryGateway> { AiMemoryRepository(get()) }
     single<AiPromptPresetGateway> { AiPromptPresetRepository(get()) }
     single<AiTextGateway> { AiTextRepositoryImpl() }
-    single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
+    single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get(), get()) }
     single<AppStartupGateway> { AppStartupRepository(get()) }
     single<BackupRestoreGateway> { BackupRestoreRepository() }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
@@ -310,6 +348,11 @@ val appModule = module {
     single<HomepageModulesGateway> { HomepageModulesRepository(get(), get()) }
     single<BookDomainRepository> { BookDomainRepositoryImpl(get(), get()) }
     single<BookContentProcessGateway> { BookContentProcessRepository(get()) }
+    single<BookKnowledgeGateway> { BookKnowledgeRepository(get()) }
+    single<ReadAloudVoiceGateway> { ReadAloudVoiceRepository(get()) }
+    singleOf(::CloudTtsCredentialCipher)
+    single<CloudTtsEngineGateway> { CloudTtsEngineRepository(get(), get()) }
+    single<ChapterSpeechGateway> { ChapterSpeechRepository(get()) }
     single { ExploreRepositoryImpl(get()) }
     single<ExploreRepository> { get<ExploreRepositoryImpl>() }
     single<ExploreBooksGateway> { get<ExploreRepositoryImpl>() }
@@ -387,6 +430,7 @@ val appModule = module {
     viewModelOf(::TranslationConfigViewModel)
     viewModelOf(::AiConfigViewModel)
     viewModelOf(::AiSummaryConfigViewModel)
+    viewModelOf(::AiPromptConfigViewModel)
     viewModelOf(::AiChatViewModel)
     viewModel { (providerId: String?) ->
         AiProviderEditViewModel(
@@ -409,6 +453,61 @@ val appModule = module {
     viewModelOf(::ServerConfigViewModel)
     viewModelOf(::ServersViewModel)
     viewModelOf(::BookInfoViewModel)
+    viewModel { (bookUrl: String, characterId: String?) ->
+        BookCharacterDetailViewModel(
+            bookUrl = bookUrl,
+            characterId = characterId,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String) ->
+        BookCharacterNetworkViewModel(
+            bookUrl = bookUrl,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String) ->
+        BookKnowledgeListViewModel(
+            bookUrl = bookUrl,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String) ->
+        BookCharacterListViewModel(
+            bookUrl = bookUrl,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String) ->
+        BookVoiceCastingViewModel(
+            bookUrl = bookUrl,
+            bookKnowledgeGateway = get(),
+            voiceGateway = get(),
+        )
+    }
+    viewModelOf(::CloudTtsViewModel)
+    singleOf(::ReadAloudPlayerCoordinator)
+    viewModelOf(::ReadAloudPlayerViewModel)
+    viewModel { (bookUrl: String, entryId: String?) ->
+        BookKnowledgeDetailViewModel(
+            bookUrl = bookUrl,
+            entryId = entryId,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String) ->
+        BookEventListViewModel(
+            bookUrl = bookUrl,
+            bookKnowledgeGateway = get(),
+        )
+    }
+    viewModel { (bookUrl: String, eventId: String?) ->
+        BookEventDetailViewModel(
+            bookUrl = bookUrl,
+            eventId = eventId,
+            bookKnowledgeGateway = get(),
+        )
+    }
     viewModelOf(::ReadMangaViewModel)
     viewModel {
         ReadBookViewModel(
@@ -430,6 +529,9 @@ val appModule = module {
             bookContentProcessGateway = get(),
             aiArtifactGateway = get(),
             aiPromptPresetGateway = get(),
+            aiProfileGateway = get(),
+            syncReadAloudVoicesUseCase = get(),
+            readAloudSessionStore = get(),
             replaceRuleRepository = get(),
         )
     }

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

@@ -0,0 +1,64 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.data.entities.BookKnowledgeEntry
+import io.legado.app.data.entities.BookOutlineNode
+
+interface BookKnowledgeGateway {
+    suspend fun searchCharacterProfiles(
+        bookUrl: String,
+        query: String,
+        limit: Int = 8,
+    ): List<BookCharacterProfile>
+
+    suspend fun getCharacterProfiles(
+        bookUrl: String,
+        limit: Int = 30,
+    ): List<BookCharacterProfile>
+
+    suspend fun getCharacterProfile(
+        bookUrl: String,
+        idOrName: String,
+    ): BookCharacterProfile?
+
+    suspend fun getCharacterEvents(
+        bookUrl: String,
+        characterId: String?,
+        maxChapterIndex: Int?,
+        limit: Int = 20,
+    ): List<BookCharacterEvent>
+
+    suspend fun getCharacterRelations(
+        bookUrl: String,
+        characterId: String,
+        limit: Int = 20,
+    ): List<BookCharacterRelation>
+
+    suspend fun getBookCharacterRelations(
+        bookUrl: String,
+        limit: Int = 120,
+    ): List<BookCharacterRelation>
+
+    suspend fun searchKnowledgeEntries(
+        bookUrl: String,
+        query: String,
+        type: String?,
+        chapterIndex: Int?,
+        limit: Int = 12,
+    ): List<BookKnowledgeEntry>
+
+    suspend fun getOutlineNodes(
+        bookUrl: String,
+        chapterIndex: Int?,
+        nodeType: String?,
+        limit: Int = 20,
+    ): List<BookOutlineNode>
+
+    suspend fun upsertCharacterProfile(profile: BookCharacterProfile)
+    suspend fun upsertCharacterEvent(event: BookCharacterEvent)
+    suspend fun upsertCharacterRelation(relation: BookCharacterRelation)
+    suspend fun upsertKnowledgeEntry(entry: BookKnowledgeEntry)
+    suspend fun upsertOutlineNode(node: BookOutlineNode)
+}

+ 22 - 0
app/src/main/java/io/legado/app/domain/gateway/ChapterSpeechGateway.kt

@@ -0,0 +1,22 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysis
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+
+interface ChapterSpeechGateway {
+    suspend fun getAnalysis(
+        bookUrl: String,
+        chapterIndex: Int,
+        contentHash: String,
+        resolverVersion: String,
+    ): ChapterSpeechAnalysis?
+
+    suspend fun upsertAnalysis(analysis: ChapterSpeechAnalysis)
+    suspend fun saveAnalysis(
+        analysis: ChapterSpeechAnalysis,
+        segments: List<ChapterSpeechSegment>,
+    )
+    suspend fun getSegments(analysisId: String): List<ChapterSpeechSegment>
+    suspend fun replaceSegments(analysisId: String, segments: List<ChapterSpeechSegment>)
+    suspend fun deleteChapter(bookUrl: String, chapterIndex: Int)
+}

+ 12 - 0
app/src/main/java/io/legado/app/domain/gateway/CloudTtsEngineGateway.kt

@@ -0,0 +1,12 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import kotlinx.coroutines.flow.Flow
+
+interface CloudTtsEngineGateway {
+    fun observeAll(): Flow<List<CloudTtsEngine>>
+    suspend fun getAll(): List<CloudTtsEngine>
+    suspend fun get(id: String): CloudTtsEngine?
+    suspend fun upsert(engine: CloudTtsEngine)
+    suspend fun delete(engine: CloudTtsEngine)
+}

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

@@ -0,0 +1,8 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.readaloud.TtsEngineDescriptor
+import kotlinx.coroutines.flow.Flow
+
+interface HttpTtsEngineGateway {
+    fun observeAll(): Flow<List<TtsEngineDescriptor>>
+}

+ 25 - 0
app/src/main/java/io/legado/app/domain/gateway/ReadAloudVoiceGateway.kt

@@ -0,0 +1,25 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.readaloud.BookVoiceBinding
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import kotlinx.coroutines.flow.Flow
+
+interface ReadAloudVoiceGateway {
+    fun observeVoices(): Flow<List<ReadAloudVoice>>
+    suspend fun getVoices(): List<ReadAloudVoice>
+    suspend fun getEnabledVoices(): List<ReadAloudVoice>
+    suspend fun getVoice(id: String): ReadAloudVoice?
+    suspend fun upsertVoice(voice: ReadAloudVoice)
+    suspend fun deleteVoice(voice: ReadAloudVoice)
+
+    fun observeBindings(bookUrl: String): Flow<List<BookVoiceBinding>>
+    suspend fun getBindings(bookUrl: String): List<BookVoiceBinding>
+    suspend fun getBinding(
+        bookUrl: String,
+        subjectType: String,
+        subjectId: String,
+    ): BookVoiceBinding?
+
+    suspend fun upsertBinding(binding: BookVoiceBinding)
+    suspend fun deleteBinding(binding: BookVoiceBinding)
+}

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

@@ -25,6 +25,7 @@ object AiTaskType {
     const val CLEAN_SELECTION = "clean_selection"
     const val TEXT_FACTORY = "text_factory"
     const val REWRITE_TEXT = "rewrite_text"
+    const val ANALYZE_SPEECH = "analyze_speech"
 }
 
 object AiPromptTemplate {

+ 151 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/CloudTtsModels.kt

@@ -0,0 +1,151 @@
+package io.legado.app.domain.model.readaloud
+
+data class CloudTtsEngine(
+    val id: String,
+    val name: String,
+    val provider: CloudTtsProviderType,
+    val baseUrl: String = "",
+    val apiKey: String = "",
+    val secretKey: String = "",
+    val region: String = "",
+    val appId: String = "",
+    val model: String = "",
+    val optionsJson: String = "{}",
+    val enabled: Boolean = true,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+enum class CloudTtsProviderType(val storageValue: String) {
+    OpenAiSpeech("openai_speech"),
+    GeminiTts("gemini_tts"),
+    Mimo("mimo"),
+    AzureSpeech("azure_speech"),
+    AlibabaCloud("alibaba_cloud"),
+    AwsPolly("aws_polly"),
+    Volcengine("volcengine");
+
+    companion object {
+        fun fromStorage(value: String): CloudTtsProviderType =
+            entries.firstOrNull { it.storageValue == value } ?: Mimo
+    }
+}
+
+data class CloudTtsProviderProfile(
+    val displayName: String,
+    val defaultModel: String = "",
+    val apiKeyLabel: String = "API Key",
+    val modelLabel: String = "模型",
+    val modelOptions: List<String> = emptyList(),
+    val regionOptions: List<String> = emptyList(),
+    val audioFormats: List<String> = listOf("mp3"),
+    val voiceCatalogType: CloudTtsVoiceCatalogType = CloudTtsVoiceCatalogType.BuiltIn,
+)
+
+enum class CloudTtsVoiceCatalogType {
+    BuiltIn,
+    Remote,
+    Partial,
+}
+
+val CloudTtsProviderType.profile: CloudTtsProviderProfile
+    get() = when (this) {
+        CloudTtsProviderType.OpenAiSpeech -> CloudTtsProviderProfile(
+            displayName = "OpenAI Speech",
+            defaultModel = "gpt-4o-mini-tts",
+            modelOptions = listOf(
+                "gpt-4o-mini-tts",
+                "gpt-4o-mini-tts-2025-12-15",
+                "tts-1",
+                "tts-1-hd",
+            ),
+            audioFormats = listOf("mp3", "opus", "aac", "flac", "wav", "pcm"),
+        )
+        CloudTtsProviderType.GeminiTts -> CloudTtsProviderProfile(
+            displayName = "Google Gemini TTS",
+            defaultModel = "gemini-3.1-flash-tts-preview",
+            modelOptions = listOf(
+                "gemini-3.1-flash-tts-preview",
+                "gemini-2.5-flash-preview-tts",
+                "gemini-2.5-pro-preview-tts",
+            ),
+            audioFormats = listOf("wav"),
+        )
+        CloudTtsProviderType.Mimo -> CloudTtsProviderProfile(
+            displayName = "Xiaomi MiMo",
+            defaultModel = "mimo-v2.5-tts",
+            audioFormats = listOf("wav"),
+        )
+        CloudTtsProviderType.AzureSpeech -> CloudTtsProviderProfile(
+            displayName = "Microsoft Azure Speech",
+            apiKeyLabel = "Speech Key",
+            modelLabel = "模型(可选)",
+            regionOptions = listOf(
+                "eastasia", "southeastasia", "australiaeast", "northeurope", "westeurope",
+                "eastus", "eastus2", "southcentralus", "westus2", "westus3",
+                "japaneast", "koreacentral", "centralindia",
+            ),
+            audioFormats = listOf("mp3", "wav", "ogg"),
+            voiceCatalogType = CloudTtsVoiceCatalogType.Remote,
+        )
+        CloudTtsProviderType.AlibabaCloud -> CloudTtsProviderProfile(
+            displayName = "Alibaba Cloud Model Studio",
+            defaultModel = "qwen3-tts-flash",
+            modelOptions = listOf("qwen3-tts-flash", "qwen3-tts-instruct-flash"),
+            audioFormats = listOf("wav"),
+            voiceCatalogType = CloudTtsVoiceCatalogType.Partial,
+        )
+        CloudTtsProviderType.AwsPolly -> CloudTtsProviderProfile(
+            displayName = "Amazon Polly",
+            defaultModel = "neural",
+            apiKeyLabel = "Access Key ID",
+            modelLabel = "合成引擎",
+            modelOptions = listOf("standard", "neural", "long-form", "generative"),
+            regionOptions = listOf(
+                "us-east-1", "us-east-2", "us-west-2", "ca-central-1",
+                "eu-central-1", "eu-west-1", "eu-west-2", "eu-west-3",
+                "ap-south-1", "ap-southeast-1", "ap-southeast-2",
+                "ap-northeast-1", "ap-northeast-2",
+            ),
+            audioFormats = listOf("mp3", "ogg_vorbis", "wav"),
+            voiceCatalogType = CloudTtsVoiceCatalogType.Remote,
+        )
+        CloudTtsProviderType.Volcengine -> CloudTtsProviderProfile(
+            displayName = "Volcengine Speech",
+            apiKeyLabel = "Access Token",
+            modelLabel = "模型(可选)",
+            audioFormats = listOf("mp3", "ogg_opus", "wav", "pcm"),
+            voiceCatalogType = CloudTtsVoiceCatalogType.Partial,
+        )
+    }
+
+data class CloudTtsVoiceDescriptor(
+    val id: String,
+    val displayName: String,
+    val locale: String = "",
+    val gender: String = "",
+    val styles: List<String> = emptyList(),
+    val roles: List<String> = emptyList(),
+    val supportedModels: List<String> = emptyList(),
+    val sampleRate: Int? = null,
+)
+
+data class CloudTtsSynthesisRequest(
+    val text: String,
+    val voiceId: String,
+    val locale: String = "",
+    val style: String = "",
+    val role: String = "",
+    val instructions: String = "",
+    val speed: Float = 1f,
+    val pitch: Float = 1f,
+    val volume: Float = 1f,
+    val format: String = "mp3",
+    val sampleRate: Int? = null,
+)
+
+data class CloudTtsAudio(
+    val bytes: ByteArray,
+    val format: String,
+    val sampleRate: Int? = null,
+)

+ 16 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/CloudTtsVoiceConfig.kt

@@ -0,0 +1,16 @@
+package io.legado.app.domain.model.readaloud
+
+data class CloudTtsVoiceConfig(
+    val locale: String = "",
+    val style: String = "",
+    val role: String = "",
+    val instructions: String = "",
+    val automaticEmotion: Boolean? = null,
+    val characterPersonality: Boolean? = null,
+    val thoughtPerformance: Boolean? = null,
+    val speed: Float = 1f,
+    val pitch: Float = 1f,
+    val volume: Float = 1f,
+    val format: String = "mp3",
+    val sampleRate: Int? = null,
+)

+ 98 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudPlaybackQueue.kt

@@ -0,0 +1,98 @@
+package io.legado.app.domain.model.readaloud
+
+data class ReadAloudPlaybackCue(
+    val text: String,
+    val chapterStart: Int,
+    val chapterEnd: Int,
+    val paragraphIndex: Int,
+    val voice: ReadAloudVoice?,
+    val fallbackVoices: List<ReadAloudVoice>,
+    val roleType: SpeechRoleType,
+    val characterId: String?,
+    val emotion: String = "",
+    val characterPerformance: CharacterPerformanceProfile? = null,
+) {
+    init {
+        require(chapterStart >= 0)
+        require(chapterEnd == chapterStart + text.length)
+    }
+}
+
+data class ReadAloudPlaybackCursor(
+    val cueIndex: Int,
+    val offset: Int,
+)
+
+data class ReadAloudPlaybackInfo(
+    val chapterPosition: Int = 0,
+    val chapterLength: Int = 0,
+    val text: String = "",
+    val engineName: String = "",
+    val characterName: String = "",
+    val roleType: SpeechRoleType = SpeechRoleType.Narrator,
+)
+
+/** Position-based playback queue independent from reader pagination. */
+class ReadAloudPlaybackQueue private constructor(
+    val cues: List<ReadAloudPlaybackCue>,
+) {
+
+    val isEmpty: Boolean get() = cues.isEmpty()
+
+    fun cursorAt(chapterPosition: Int): ReadAloudPlaybackCursor? {
+        if (cues.isEmpty()) return null
+        val position = chapterPosition.coerceAtLeast(0)
+        val containingIndex = cues.binarySearch { cue ->
+            when {
+                cue.chapterEnd <= position -> -1
+                cue.chapterStart > position -> 1
+                else -> 0
+            }
+        }
+        val index = if (containingIndex >= 0) {
+            containingIndex
+        } else {
+            (-containingIndex - 1).coerceAtMost(cues.lastIndex)
+        }
+        val cue = cues[index]
+        return ReadAloudPlaybackCursor(
+            cueIndex = index,
+            offset = (position - cue.chapterStart).coerceIn(0, cue.text.length),
+        )
+    }
+
+    fun previous(cursor: ReadAloudPlaybackCursor): ReadAloudPlaybackCursor? =
+        (cursor.cueIndex - 1).takeIf { it in cues.indices }
+            ?.let { ReadAloudPlaybackCursor(it, 0) }
+
+    fun next(cursor: ReadAloudPlaybackCursor): ReadAloudPlaybackCursor? =
+        (cursor.cueIndex + 1).takeIf { it in cues.indices }
+            ?.let { ReadAloudPlaybackCursor(it, 0) }
+
+    companion object {
+        val Empty = ReadAloudPlaybackQueue(emptyList())
+
+        fun from(plan: List<SpeechPlanItem>): ReadAloudPlaybackQueue {
+            if (plan.isEmpty()) return Empty
+            val cues = plan.map { item ->
+                val segment = item.segment
+                ReadAloudPlaybackCue(
+                    text = segment.text,
+                    chapterStart = segment.chapterPosition,
+                    chapterEnd = segment.chapterPosition + segment.text.length,
+                    paragraphIndex = segment.paragraphIndex,
+                    voice = item.voice,
+                    fallbackVoices = item.fallbackVoices,
+                    roleType = segment.roleType,
+                    characterId = segment.characterId,
+                    emotion = segment.emotion,
+                    characterPerformance = item.characterPerformance,
+                )
+            }.sortedWith(compareBy(ReadAloudPlaybackCue::chapterStart, ReadAloudPlaybackCue::chapterEnd))
+            require(cues.zipWithNext().none { (left, right) -> left.chapterEnd > right.chapterStart }) {
+                "Playback cues must not overlap"
+            }
+            return ReadAloudPlaybackQueue(cues)
+        }
+    }
+}

+ 13 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/ReadAloudSessionState.kt

@@ -0,0 +1,13 @@
+package io.legado.app.domain.model.readaloud
+
+enum class ReadAloudSessionStatus {
+    Idle,
+    Playing,
+    Paused,
+}
+
+data class ReadAloudSessionState(
+    val status: ReadAloudSessionStatus = ReadAloudSessionStatus.Idle,
+    val playback: ReadAloudPlaybackInfo = ReadAloudPlaybackInfo(),
+    val timerMinutes: Int = 0,
+)

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

@@ -0,0 +1,194 @@
+package io.legado.app.domain.model.readaloud
+
+data class ReadAloudVoice(
+    val id: String,
+    val engineType: String,
+    val engineId: String,
+    val speakerId: String,
+    val displayName: String,
+    val traitsJson: String = "[]",
+    val emotionCatalogJson: String = "[]",
+    val managedBy: String = MANAGED_BY_USER,
+    val enabled: Boolean = true,
+    val available: Boolean = true,
+    val revision: Long = 0L,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+) {
+    companion object {
+        const val ENGINE_SYSTEM = "system"
+        const val ENGINE_HTTP = "http"
+        const val ENGINE_CLOUD = "cloud_tts"
+        const val MANAGED_BY_USER = "user"
+        const val MANAGED_BY_CONFIGURED_TTS = "configured_tts"
+    }
+}
+
+data class VoiceCatalogEntry(
+    val engineType: String,
+    val engineId: String,
+    val speakerId: String = "",
+    val displayName: String,
+    val traitsJson: String = "[]",
+    val emotionCatalogJson: String = "[]",
+    val sourceRevision: Long = 0L,
+    val managedBy: String = ReadAloudVoice.MANAGED_BY_CONFIGURED_TTS,
+)
+
+data class BookVoiceBinding(
+    val bookUrl: String,
+    val subjectType: String,
+    val subjectId: String,
+    val voiceId: String,
+    val locked: Boolean = false,
+    val source: String = SOURCE_USER,
+    val confidence: Float = 1f,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+) {
+    companion object {
+        const val SUBJECT_CHARACTER = "character"
+        const val SUBJECT_NARRATOR = "narrator"
+        const val SUBJECT_UNKNOWN_MALE = "unknown_male"
+        const val SUBJECT_UNKNOWN_FEMALE = "unknown_female"
+        const val SUBJECT_MALE_LEAD = "male_lead"
+        const val SUBJECT_FEMALE_LEAD = "female_lead"
+        const val SUBJECT_MALE_SUPPORTING = "male_supporting"
+        const val SUBJECT_FEMALE_SUPPORTING = "female_supporting"
+        const val SUBJECT_UNKNOWN = "unknown"
+
+        const val SOURCE_USER = "user"
+        const val SOURCE_AUTO = "auto"
+        const val SOURCE_IMPORT = "import"
+    }
+}
+
+data class ChapterSpeechAnalysis(
+    val id: String,
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val contentHash: String,
+    val resolverVersion: String,
+    val characterRevision: String,
+    val status: SpeechAnalysisStatus,
+    val error: String = "",
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+data class ChapterSpeechSegment(
+    val id: String,
+    val analysisId: String,
+    val bookUrl: String,
+    val chapterIndex: Int,
+    val paragraphIndex: Int,
+    val start: Int,
+    val end: Int,
+    val chapterPosition: Int,
+    val text: String,
+    val roleType: SpeechRoleType,
+    val characterId: String? = null,
+    val characterName: String = "",
+    val emotion: String = "",
+    val confidence: Float = 0f,
+    val source: SpeechResolutionSource,
+    val userLocked: Boolean = false,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis(),
+)
+
+data class CanonicalSpeechParagraph(
+    val index: Int,
+    val text: String,
+    val chapterPosition: Int,
+)
+
+data class SpeechSegmentDraft(
+    val paragraphIndex: Int,
+    val start: Int,
+    val end: Int,
+    val chapterPosition: Int,
+    val text: String,
+    val roleType: SpeechRoleType,
+    val confidence: Float,
+    val emotion: String = "",
+    val source: SpeechResolutionSource = SpeechResolutionSource.Rule,
+)
+
+data class SpeechPlanItem(
+    val segment: ChapterSpeechSegment,
+    val voice: ReadAloudVoice?,
+    val fallbackVoices: List<ReadAloudVoice>,
+    val characterPerformance: CharacterPerformanceProfile? = null,
+)
+
+data class ChapterSpeechAnalysisResult(
+    val analysis: ChapterSpeechAnalysis,
+    val segments: List<ChapterSpeechSegment>,
+    val fromCache: Boolean,
+    val characterPerformances: List<CharacterPerformanceProfile> = emptyList(),
+)
+
+data class CharacterPerformanceProfile(
+    val characterId: String,
+    val role: String = "",
+    val personality: String = "",
+    val updatedAt: Long = 0L,
+)
+
+enum class SpeechAnalysisMode(val storageValue: String) {
+    Rule("rule"),
+    RuleWithAi("rule_with_ai"),
+    AiUnderstanding("ai_understanding");
+
+    companion object {
+        fun fromStorage(value: String): SpeechAnalysisMode =
+            entries.firstOrNull { it.storageValue == value } ?: Rule
+    }
+}
+
+data class SpeakerCharacter(
+    val id: String,
+    val name: String,
+    val aliases: List<String> = emptyList(),
+    val role: String = "",
+    val updatedAt: Long = 0L,
+)
+
+enum class SpeechRoleType(val storageValue: String) {
+    Narrator("narrator"),
+    Character("character"),
+    Thought("thought"),
+    Unknown("unknown");
+
+    companion object {
+        fun fromStorage(value: String): SpeechRoleType =
+            entries.firstOrNull { it.storageValue == value } ?: Unknown
+    }
+}
+
+enum class SpeechResolutionSource(val storageValue: String) {
+    Rule("rule"),
+    Local("local"),
+    Ai("ai"),
+    User("user"),
+    Fallback("fallback");
+
+    companion object {
+        fun fromStorage(value: String): SpeechResolutionSource =
+            entries.firstOrNull { it.storageValue == value } ?: Fallback
+    }
+}
+
+enum class SpeechAnalysisStatus(val storageValue: String) {
+    Pending("pending"),
+    Running("running"),
+    Success("success"),
+    Partial("partial"),
+    Failed("failed");
+
+    companion object {
+        fun fromStorage(value: String): SpeechAnalysisStatus =
+            entries.firstOrNull { it.storageValue == value } ?: Failed
+    }
+}

+ 18 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/SpeechEmotion.kt

@@ -0,0 +1,18 @@
+package io.legado.app.domain.model.readaloud
+
+enum class SpeechEmotion(val storageValue: String) {
+    Neutral("neutral"),
+    Cheerful("cheerful"),
+    Sad("sad"),
+    Angry("angry"),
+    Fearful("fearful"),
+    Surprised("surprised"),
+    Disgusted("disgusted"),
+    Whispering("whispering"),
+    Calm("calm");
+
+    companion object {
+        fun fromStorage(value: String): SpeechEmotion =
+            entries.firstOrNull { it.storageValue.equals(value, ignoreCase = true) } ?: Neutral
+    }
+}

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

@@ -0,0 +1,46 @@
+package io.legado.app.domain.model.readaloud
+
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+
+object SpeechIdentity {
+
+    fun voiceId(engineType: String, engineId: String, speakerId: String): String =
+        "voice:${sha256("$engineType\u0000$engineId\u0000$speakerId").take(32)}"
+
+    fun chapterContentHash(paragraphs: List<CanonicalSpeechParagraph>): String = sha256(
+        paragraphs.joinToString("\u0001") {
+            "${it.index}\u0000${it.chapterPosition}\u0000${it.text}"
+        }
+    )
+
+    fun analysisId(
+        bookUrl: String,
+        chapterIndex: Int,
+        contentHash: String,
+        resolverVersion: String,
+    ): String = "speech-analysis:${sha256("$bookUrl\u0000$chapterIndex\u0000$contentHash\u0000$resolverVersion").take(32)}"
+
+    fun segmentId(
+        analysisId: String,
+        paragraphIndex: Int,
+        start: Int,
+        end: Int,
+    ): String = "speech-segment:${sha256("$analysisId\u0000$paragraphIndex\u0000$start\u0000$end").take(32)}"
+
+    fun characterRevision(characters: List<SpeakerCharacter>): String = sha256(
+        characters.sortedBy(SpeakerCharacter::id).joinToString("\u0001") { character ->
+            listOf(
+                character.id,
+                character.name,
+                character.aliases.sorted().joinToString("\u0002"),
+                character.role,
+                character.updatedAt.toString(),
+            ).joinToString("\u0000")
+        }
+    )
+
+    private fun sha256(value: String): String = MessageDigest.getInstance("SHA-256")
+        .digest(value.toByteArray(StandardCharsets.UTF_8))
+        .joinToString("") { byte -> "%02x".format(byte) }
+}

+ 48 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/SpeechVoiceRouter.kt

@@ -0,0 +1,48 @@
+package io.legado.app.domain.model.readaloud
+
+data class SpeechEngineRoute(
+    val engineType: String,
+    val engineId: String,
+    val speakerId: String = "",
+)
+
+data class RoutedSpeechVoice(
+    val voice: ReadAloudVoice?,
+    val usedFallback: Boolean,
+)
+
+/** Selects a voice that a concrete playback backend can execute. */
+object SpeechVoiceRouter {
+
+    fun route(
+        cue: ReadAloudPlaybackCue,
+        supportedEngineTypes: Set<String>,
+        defaultRoute: SpeechEngineRoute,
+    ): RoutedSpeechVoice {
+        val candidates = buildList {
+            add(cue.voice)
+            addAll(cue.fallbackVoices)
+        }.filterNotNull()
+            .filter { it.enabled && it.available && it.engineType in supportedEngineTypes }
+            .distinctBy(ReadAloudVoice::id)
+
+        val selected = candidates.firstOrNull()
+        if (selected != null) {
+            return RoutedSpeechVoice(
+                voice = selected,
+                usedFallback = selected.id != cue.voice?.id,
+            )
+        }
+        return RoutedSpeechVoice(
+            voice = ReadAloudVoice(
+                id = "runtime-default:${defaultRoute.engineType}:${defaultRoute.engineId}:${defaultRoute.speakerId}",
+                engineType = defaultRoute.engineType,
+                engineId = defaultRoute.engineId,
+                speakerId = defaultRoute.speakerId,
+                displayName = "",
+                managedBy = ReadAloudVoice.MANAGED_BY_USER,
+            ),
+            usedFallback = true,
+        )
+    }
+}

+ 6 - 0
app/src/main/java/io/legado/app/domain/model/readaloud/SystemTtsVoiceConfig.kt

@@ -0,0 +1,6 @@
+package io.legado.app.domain.model.readaloud
+
+data class SystemTtsVoiceConfig(
+    val speechRate: Float? = null,
+    val pitch: Float? = null,
+)

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

@@ -0,0 +1,33 @@
+package io.legado.app.domain.model.readaloud
+
+/** A configured synthesis engine. Native voices are discovered separately from user presets. */
+data class TtsEngineDescriptor(
+    val id: String,
+    val kind: TtsEngineKind,
+    val sourceId: String,
+    val displayName: String,
+    val providerName: String = "",
+    val supportsVoiceDiscovery: Boolean = false,
+)
+
+enum class TtsEngineKind {
+    System,
+    Cloud,
+    Http,
+}
+
+/** A voice supplied by an engine. This is not persisted until it becomes a [ReadAloudVoice] preset. */
+data class TtsNativeVoice(
+    val id: String,
+    val engineId: String,
+    val displayName: String,
+    val locale: String = "",
+    val gender: String = "",
+    val quality: Int? = null,
+    val latency: Int? = null,
+    val requiresNetwork: Boolean = false,
+    val styles: List<String> = emptyList(),
+    val roles: List<String> = emptyList(),
+    val supportedModels: List<String> = emptyList(),
+    val sampleRate: Int? = null,
+)

+ 6 - 0
app/src/main/java/io/legado/app/domain/model/settings/ReadAloudSettings.kt

@@ -6,6 +6,9 @@ data class ReadAloudSettings(
     val readAloudByMediaButton: Boolean = false,
     val pauseReadAloudWhilePhoneCalls: Boolean = false,
     val readAloudWakeLock: Boolean = false,
+    val showReadAloudCapsule: Boolean = true,
+    val capsuleOffsetX: Float = 0f,
+    val capsuleOffsetY: Float = 0f,
     val mediaButtonPerNext: Boolean = false,
     val readAloudByPage: Boolean = false,
     val systemMediaControlCompatibilityChange: Boolean = true,
@@ -13,4 +16,7 @@ data class ReadAloudSettings(
     val ttsTimer: Int = 0,
     val ttsFollowSys: Boolean = true,
     val ttsSpeechRate: Int = 5,
+    val speechAnalysisMode: String = "rule",
+    val useMultiSpeaker: Boolean = true,
+    val defaultInterface: String = "classic",
 )

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

@@ -202,7 +202,7 @@ class AiChatGenerationUseCase(
         val base = """
             You are a helpful AI assistant inside a reading app.
             Render answers in complete Markdown when structure helps.
-            Use local reading tools when the user asks about bookshelf books, current reading progress, chapters, bookmarks, reading statistics, or existing AI notes.
+            Use local reading tools when the user asks about bookshelf books, current reading progress, chapters, bookmarks, reading statistics, existing AI notes, character profiles, relationships, world-book entries, or outlines.
             For requests like summarizing, explaining, or continuing from the current chapter, use the local book and chapter tools before answering.
             If a tool says content is missing or unavailable, state that limitation clearly and do not invent book content.
             Save notes or summaries only when the user explicitly asks to save them.

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

@@ -106,7 +106,7 @@ class AiToolAwareGenerationUseCase(
 
         private val TOOL_CONTEXT_MESSAGE = AiMessage(
             role = AiMessageRole.SYSTEM,
-            content = "Read-only local book tools are available. Use them when needed to inspect the current book, list chapters, search cached chapter text by character or plot keyword, or read cached neighboring chapters for continuity. Use tools silently; the final response must still follow the original task output format.",
+            content = "Read-only local book tools are available. Use them when needed to inspect the current book, list chapters, search cached chapter text by character or plot keyword, read cached neighboring chapters for continuity, or look up saved character profiles, relationships, world-book entries, and outlines. Use tools silently; the final response must still follow the original task output format.",
         )
     }
 }

+ 95 - 0
app/src/main/java/io/legado/app/domain/usecase/AnalyzeChapterSpeechUseCase.kt

@@ -0,0 +1,95 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.ChapterSpeechGateway
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysis
+import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysisResult
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
+import io.legado.app.domain.model.readaloud.SpeechIdentity
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
+
+class AnalyzeChapterSpeechUseCase(
+    private val chapterSpeechGateway: ChapterSpeechGateway,
+) {
+
+    suspend operator fun invoke(
+        bookUrl: String,
+        chapterIndex: Int,
+        paragraphs: List<CanonicalSpeechParagraph>,
+        resolverVersion: String = RuleBasedSpeechSegmenter.VERSION,
+        now: Long = System.currentTimeMillis(),
+    ): ChapterSpeechAnalysisResult {
+        val contentHash = SpeechIdentity.chapterContentHash(paragraphs)
+        val cached = chapterSpeechGateway.getAnalysis(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            contentHash = contentHash,
+            resolverVersion = resolverVersion,
+        )
+        if (
+            cached != null &&
+            cached.status in setOf(SpeechAnalysisStatus.Success, SpeechAnalysisStatus.Partial)
+        ) {
+            return ChapterSpeechAnalysisResult(
+                analysis = cached,
+                segments = chapterSpeechGateway.getSegments(cached.id),
+                fromCache = true,
+            )
+        }
+
+        val analysisId = SpeechIdentity.analysisId(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            contentHash = contentHash,
+            resolverVersion = resolverVersion,
+        )
+        val drafts = RuleBasedSpeechSegmenter.segment(paragraphs)
+        val segments = drafts.map { draft ->
+            ChapterSpeechSegment(
+                id = SpeechIdentity.segmentId(
+                    analysisId = analysisId,
+                    paragraphIndex = draft.paragraphIndex,
+                    start = draft.start,
+                    end = draft.end,
+                ),
+                analysisId = analysisId,
+                bookUrl = bookUrl,
+                chapterIndex = chapterIndex,
+                paragraphIndex = draft.paragraphIndex,
+                start = draft.start,
+                end = draft.end,
+                chapterPosition = draft.chapterPosition,
+                text = draft.text,
+                roleType = draft.roleType,
+                emotion = draft.emotion,
+                confidence = draft.confidence,
+                source = draft.source,
+                createdAt = now,
+                updatedAt = now,
+            )
+        }
+        val status = if (segments.any { it.roleType.requiresSpeakerResolution }) {
+            SpeechAnalysisStatus.Partial
+        } else {
+            SpeechAnalysisStatus.Success
+        }
+        val analysis = ChapterSpeechAnalysis(
+            id = analysisId,
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            contentHash = contentHash,
+            resolverVersion = resolverVersion,
+            characterRevision = "",
+            status = status,
+            createdAt = cached?.createdAt ?: now,
+            updatedAt = now,
+        )
+        chapterSpeechGateway.saveAnalysis(analysis, segments)
+        return ChapterSpeechAnalysisResult(analysis, segments, fromCache = false)
+    }
+
+    private val SpeechRoleType.requiresSpeakerResolution: Boolean
+        get() = this == SpeechRoleType.Character || this == SpeechRoleType.Thought
+}

+ 123 - 0
app/src/main/java/io/legado/app/domain/usecase/BuildSpeechPlanUseCase.kt

@@ -0,0 +1,123 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.ReadAloudVoiceGateway
+import io.legado.app.domain.model.readaloud.BookVoiceBinding
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.CharacterPerformanceProfile
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+
+class BuildSpeechPlanUseCase(
+    private val voiceGateway: ReadAloudVoiceGateway,
+) {
+
+    suspend operator fun invoke(
+        bookUrl: String,
+        segments: List<ChapterSpeechSegment>,
+        preferredDefaultVoiceId: String? = null,
+        characterPerformances: Map<String, CharacterPerformanceProfile> = emptyMap(),
+        useMultiSpeaker: Boolean = true,
+    ): List<SpeechPlanItem> {
+        val voices = voiceGateway.getEnabledVoices()
+        val voicesById = voices.associateBy(ReadAloudVoice::id)
+        val bindings = voiceGateway.getBindings(bookUrl)
+            .associateBy { it.subjectType to it.subjectId }
+        val narrator = bindings.voice(
+            BookVoiceBinding.SUBJECT_NARRATOR,
+            BookVoiceBinding.SUBJECT_NARRATOR,
+            voicesById,
+        )
+        val unknown = bindings.voice(
+            BookVoiceBinding.SUBJECT_UNKNOWN,
+            BookVoiceBinding.SUBJECT_UNKNOWN,
+            voicesById,
+        )
+        val defaultVoice = preferredDefaultVoiceId?.let(voicesById::get)
+            ?: narrator
+
+        return segments.map { segment ->
+            val primary = if (!useMultiSpeaker) {
+                // Multi-speaker disabled — every segment uses the default voice
+                defaultVoice
+            } else {
+                val characterVoice = segment.characterId?.let { characterId ->
+                    bindings.voice(
+                        BookVoiceBinding.SUBJECT_CHARACTER,
+                        characterId,
+                        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(
+                        BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
+                        BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
+                        voicesById,
+                    )
+                    BookVoiceBinding.SUBJECT_FEMALE_LEAD,
+                    BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> bindings.voice(
+                        BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
+                        BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
+                        voicesById,
+                    )
+                    else -> null
+                }
+                when (segment.roleType) {
+                    SpeechRoleType.Character,
+                    SpeechRoleType.Thought -> characterVoice ?: roleVoice ?: genderFallback
+                        ?: unknown ?: narrator ?: defaultVoice
+                    SpeechRoleType.Unknown -> unknown ?: narrator ?: defaultVoice
+                    SpeechRoleType.Narrator -> narrator ?: defaultVoice
+                }
+            }
+            val fallbackVoices = if (!useMultiSpeaker) {
+                emptyList()
+            } 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(
+                                BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
+                                BookVoiceBinding.SUBJECT_UNKNOWN_MALE,
+                                voicesById,
+                            )
+                            BookVoiceBinding.SUBJECT_FEMALE_LEAD,
+                            BookVoiceBinding.SUBJECT_FEMALE_SUPPORTING -> bindings.voice(
+                                BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
+                                BookVoiceBinding.SUBJECT_UNKNOWN_FEMALE,
+                                voicesById,
+                            )
+                            else -> null
+                        })
+                        add(unknown)
+                    }
+                    add(narrator)
+                    add(defaultVoice)
+                }.filterNotNull()
+                    .distinctBy(ReadAloudVoice::id)
+                    .filterNot { it.id == primary?.id }
+            }
+            SpeechPlanItem(
+                segment = segment,
+                voice = primary,
+                fallbackVoices = fallbackVoices,
+                characterPerformance = segment.characterId?.let(characterPerformances::get),
+            )
+        }
+    }
+
+    private fun Map<Pair<String, String>, BookVoiceBinding>.voice(
+        subjectType: String,
+        subjectId: String,
+        voicesById: Map<String, ReadAloudVoice>,
+    ): ReadAloudVoice? = get(subjectType to subjectId)?.voiceId?.let(voicesById::get)
+}

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

@@ -52,7 +52,7 @@ class CleanSelectedTextUseCase(
             }
 
             val preset = resolvePreset() ?: error("No AI model configured")
-            val prompt = AiPromptTemplate.DEFAULT_CLEAN_SELECTION
+            val prompt = preset.promptTemplate.ifBlank { AiPromptTemplate.DEFAULT_CLEAN_SELECTION }
             val input = buildUserContent(
                 chapterTitle = chapterTitle,
                 selectedText = selectedText,

+ 72 - 0
app/src/main/java/io/legado/app/domain/usecase/PrepareChapterSpeechPlanUseCase.kt

@@ -0,0 +1,72 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
+import io.legado.app.help.readaloud.segment.RuleBasedSpeechSegmenter
+
+/**
+ * Builds the persisted speech plan used by a read-aloud session.
+ *
+ * Segmentation is pagination independent, while speaker resolution is refreshed when the
+ * character database changes. Keeping the orchestration here prevents Android services from
+ * knowing about the analysis cache or character repository.
+ */
+class PrepareChapterSpeechPlanUseCase(
+    private val analyzeChapterSpeech: AnalyzeChapterSpeechUseCase,
+    private val resolveLocalSpeakers: ResolveLocalSpeakersUseCase,
+    private val refineSpeechWithAi: RefineSpeechWithAiUseCase,
+    private val buildSpeechPlan: BuildSpeechPlanUseCase,
+) {
+
+    suspend operator fun invoke(
+        bookUrl: String,
+        chapterIndex: Int,
+        paragraphs: List<CanonicalSpeechParagraph>,
+        preferredDefaultVoiceId: String? = null,
+        analysisMode: SpeechAnalysisMode = SpeechAnalysisMode.Rule,
+        useMultiSpeaker: Boolean = true,
+    ): List<SpeechPlanItem> {
+        if (paragraphs.isEmpty()) return emptyList()
+        val requestedMode = analysisMode
+        val resolverVersion = if (requestedMode == SpeechAnalysisMode.Rule) {
+            RuleBasedSpeechSegmenter.VERSION
+        } else {
+            runCatching { refineSpeechWithAi.resolverVersion(bookUrl, requestedMode) }
+                .getOrDefault(RuleBasedSpeechSegmenter.VERSION)
+        }
+        val effectiveMode = if (resolverVersion == RuleBasedSpeechSegmenter.VERSION) {
+            SpeechAnalysisMode.Rule
+        } else {
+            requestedMode
+        }
+        val analysis = analyzeChapterSpeech(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            paragraphs = paragraphs,
+            resolverVersion = resolverVersion,
+        )
+        val locallyResolved = resolveLocalSpeakers(
+            analysisResult = analysis,
+            paragraphs = paragraphs,
+        )
+        val resolved = if (effectiveMode == SpeechAnalysisMode.Rule) {
+            locallyResolved
+        } else {
+            runCatching {
+                refineSpeechWithAi(
+                    analysisResult = locallyResolved,
+                    paragraphs = paragraphs,
+                    mode = effectiveMode,
+                )
+            }.getOrDefault(locallyResolved)
+        }
+        return buildSpeechPlan(
+            bookUrl = bookUrl,
+            segments = resolved.segments,
+            preferredDefaultVoiceId = preferredDefaultVoiceId,
+            characterPerformances = resolved.characterPerformances.associateBy { it.characterId },
+            useMultiSpeaker = useMultiSpeaker,
+        )
+    }
+}

+ 391 - 0
app/src/main/java/io/legado/app/domain/usecase/RefineSpeechWithAiUseCase.kt

@@ -0,0 +1,391 @@
+package io.legado.app.domain.usecase
+
+import com.google.gson.JsonObject
+import com.google.gson.JsonParser
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.domain.gateway.ChapterSpeechGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ChapterSpeechAnalysisResult
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
+import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
+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.utils.GSON
+import io.legado.app.utils.MD5Utils
+
+class RefineSpeechWithAiUseCase(
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiTextGateway: AiTextGateway,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+    private val chapterSpeechGateway: ChapterSpeechGateway,
+) {
+
+    suspend fun resolverVersion(bookUrl: String, mode: SpeechAnalysisMode): String {
+        if (mode == SpeechAnalysisMode.Rule) return RuleBasedSpeechSegmenter.VERSION
+        val preset = resolvePreset()
+        val profiles = activeProfiles(bookUrl)
+        val characterRevision = profiles
+            .sortedBy(BookCharacterProfile::id)
+            .joinToString("|") { "${it.id}:${it.updatedAt}" }
+        val promptHash = MD5Utils.md5Encode(systemPrompt(preset, mode))
+        return listOf(
+            RuleBasedSpeechSegmenter.VERSION,
+            VERSION,
+            mode.storageValue,
+            preset.model.id,
+            promptHash,
+            MD5Utils.md5Encode(characterRevision),
+        ).joinToString(":")
+    }
+
+    suspend operator fun invoke(
+        analysisResult: ChapterSpeechAnalysisResult,
+        paragraphs: List<CanonicalSpeechParagraph>,
+        mode: SpeechAnalysisMode,
+        now: Long = System.currentTimeMillis(),
+    ): ChapterSpeechAnalysisResult {
+        if (mode == SpeechAnalysisMode.Rule) return analysisResult
+        if (
+            mode == SpeechAnalysisMode.AiUnderstanding &&
+            analysisResult.fromCache &&
+            analysisResult.segments.all { it.userLocked || it.source == SpeechResolutionSource.Ai }
+        ) return analysisResult
+        val profiles = activeProfiles(analysisResult.analysis.bookUrl)
+        val preset = resolvePreset()
+        val refined = when (mode) {
+            SpeechAnalysisMode.Rule -> analysisResult.segments
+            SpeechAnalysisMode.RuleWithAi -> completeRuleSegments(
+                analysisResult = analysisResult,
+                profiles = profiles,
+                preset = preset,
+                now = now,
+            )
+            SpeechAnalysisMode.AiUnderstanding -> {
+                if (analysisResult.segments.any(ChapterSpeechSegment::userLocked)) {
+                    completeRuleSegments(analysisResult, profiles, preset, now)
+                } else {
+                    understandAtoms(analysisResult, paragraphs, profiles, preset, now)
+                }
+            }
+        }
+        val status = if (refined.any { segment ->
+                segment.characterId == null && segment.roleType in setOf(
+                    SpeechRoleType.Character,
+                    SpeechRoleType.Thought,
+                )
+            }) {
+            SpeechAnalysisStatus.Partial
+        } else {
+            SpeechAnalysisStatus.Success
+        }
+        val analysis = analysisResult.analysis.copy(status = status, error = "", updatedAt = now)
+        chapterSpeechGateway.saveAnalysis(analysis, refined)
+        return analysisResult.copy(
+            analysis = analysis,
+            segments = refined,
+            fromCache = false,
+        )
+    }
+
+    private suspend fun completeRuleSegments(
+        analysisResult: ChapterSpeechAnalysisResult,
+        profiles: List<BookCharacterProfile>,
+        preset: AiTaskPresetConfig,
+        now: Long,
+    ): List<ChapterSpeechSegment> {
+        val candidates = analysisResult.segments.filter { segment ->
+            !segment.userLocked && segment.source != SpeechResolutionSource.Ai && (
+                segment.confidence < HYBRID_CONFIDENCE_THRESHOLD ||
+                    segment.characterId == null && segment.roleType in setOf(
+                        SpeechRoleType.Character,
+                        SpeechRoleType.Thought,
+                    )
+                )
+        }
+        if (candidates.isEmpty()) return analysisResult.segments
+        val updates = linkedMapOf<String, AiSegmentDecision>()
+        candidates.chunkByTextLength(MAX_CHUNK_CHARS) { it.text }.forEach { chunk ->
+            val payload = mapOf(
+                "characters" to profiles.map { it.toPromptMap() },
+                "segments" to chunk.map { segment ->
+                    mapOf(
+                        "segmentId" to segment.id,
+                        "text" to segment.text,
+                        "roleType" to segment.roleType.storageValue,
+                        "characterId" to segment.characterId,
+                        "emotion" to segment.emotion,
+                        "confidence" to segment.confidence,
+                    )
+                },
+            )
+            parseSegmentDecisions(generate(preset, SpeechAnalysisMode.RuleWithAi, payload))
+                .forEach { decision ->
+                    require(decision.segmentId in chunk.map(ChapterSpeechSegment::id)) {
+                        "AI returned an unknown segmentId: ${decision.segmentId}"
+                    }
+                    require(updates.put(decision.segmentId, decision) == null) {
+                        "AI returned duplicate segmentId: ${decision.segmentId}"
+                    }
+                    require(decision.characterId == null || profiles.any { it.id == decision.characterId }) {
+                        "AI returned an unknown characterId: ${decision.characterId}"
+                    }
+                }
+            require(updates.keys.containsAll(chunk.map(ChapterSpeechSegment::id))) {
+                "AI did not return every requested segment"
+            }
+        }
+        val profilesById = profiles.associateBy(BookCharacterProfile::id)
+        return analysisResult.segments.map { segment ->
+            if (segment !in candidates) return@map segment
+            val decision = updates[segment.id]
+            val roleType = decision?.roleType ?: segment.roleType
+            val character = decision?.characterId?.let(profilesById::get)
+            segment.copy(
+                roleType = roleType,
+                characterId = if (roleType == SpeechRoleType.Narrator) null else character?.id,
+                characterName = if (roleType == SpeechRoleType.Narrator) "" else character?.name.orEmpty(),
+                emotion = decision?.emotion ?: segment.emotion,
+                confidence = decision?.confidence ?: segment.confidence,
+                source = SpeechResolutionSource.Ai,
+                updatedAt = now,
+            )
+        }
+    }
+
+    private suspend fun understandAtoms(
+        analysisResult: ChapterSpeechAnalysisResult,
+        paragraphs: List<CanonicalSpeechParagraph>,
+        profiles: List<BookCharacterProfile>,
+        preset: AiTaskPresetConfig,
+        now: Long,
+    ): List<ChapterSpeechSegment> {
+        val atoms = paragraphs.flatMap(AiSpeechAtomizer::atomize)
+        if (atoms.isEmpty()) return analysisResult.segments
+        val profilesById = profiles.associateBy(BookCharacterProfile::id)
+        val result = mutableListOf<ChapterSpeechSegment>()
+        atoms.chunkByTextLength(MAX_CHUNK_CHARS) { it.text }.forEach { chunk ->
+            val payload = mapOf(
+                "characters" to profiles.map { it.toPromptMap() },
+                "atoms" to chunk.map { atom ->
+                    mapOf("atomId" to atom.id, "text" to atom.text)
+                },
+            )
+            val groups = parseAtomGroups(generate(preset, SpeechAnalysisMode.AiUnderstanding, payload))
+            validateCoverage(chunk, groups)
+            require(groups.all { group ->
+                group.characterId == null || profilesById.containsKey(group.characterId)
+            }) { "AI returned an unknown characterId" }
+            val atomsById = chunk.associateBy(AiSpeechAtom::id)
+            groups.forEach { group ->
+                val groupedAtoms = group.atomIds.map(atomsById::getValue)
+                require(groupedAtoms.map(AiSpeechAtom::paragraphIndex).distinct().size == 1) {
+                    "AI cannot merge atoms across paragraphs"
+                }
+                val first = groupedAtoms.first()
+                val last = groupedAtoms.last()
+                val paragraph = paragraphs.first { it.index == first.paragraphIndex }
+                val character = group.characterId?.let(profilesById::get)
+                result += ChapterSpeechSegment(
+                    id = SpeechIdentity.segmentId(
+                        analysisId = analysisResult.analysis.id,
+                        paragraphIndex = first.paragraphIndex,
+                        start = first.start,
+                        end = last.end,
+                    ),
+                    analysisId = analysisResult.analysis.id,
+                    bookUrl = analysisResult.analysis.bookUrl,
+                    chapterIndex = analysisResult.analysis.chapterIndex,
+                    paragraphIndex = first.paragraphIndex,
+                    start = first.start,
+                    end = last.end,
+                    chapterPosition = paragraph.chapterPosition + first.start,
+                    text = paragraph.text.substring(first.start, last.end),
+                    roleType = group.roleType,
+                    characterId = if (group.roleType == SpeechRoleType.Narrator) null else character?.id,
+                    characterName = if (group.roleType == SpeechRoleType.Narrator) "" else character?.name.orEmpty(),
+                    emotion = group.emotion,
+                    confidence = group.confidence,
+                    source = SpeechResolutionSource.Ai,
+                    createdAt = now,
+                    updatedAt = now,
+                )
+            }
+        }
+        return result.sortedWith(compareBy(ChapterSpeechSegment::paragraphIndex, ChapterSpeechSegment::start))
+    }
+
+    private suspend fun generate(
+        preset: AiTaskPresetConfig,
+        mode: SpeechAnalysisMode,
+        payload: Any,
+    ): String = aiTextGateway.generate(
+        AiGenerateRequest(
+            model = preset.model,
+            messages = listOf(
+                AiMessage(AiMessageRole.SYSTEM, systemPrompt(preset, mode)),
+                AiMessage(AiMessageRole.USER, GSON.toJson(payload)),
+            ),
+            params = preset.params.copy(temperature = 0f),
+        )
+    ).getOrThrow().text
+
+    private suspend fun resolvePreset(): AiTaskPresetConfig =
+        aiProfileGateway.getTaskPreset(AiTaskType.ANALYZE_SPEECH)
+            ?: aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
+            ?: error("No AI model configured for speech analysis")
+
+    private suspend fun activeProfiles(bookUrl: String): List<BookCharacterProfile> =
+        bookKnowledgeGateway.getCharacterProfiles(bookUrl, 200)
+            .filter { it.status == BookCharacterProfile.STATUS_ACTIVE }
+
+    private fun systemPrompt(preset: AiTaskPresetConfig, mode: SpeechAnalysisMode): String {
+        val custom = preset.promptTemplate.takeIf {
+            preset.taskType == AiTaskType.ANALYZE_SPEECH && it.isNotBlank()
+        }
+        return buildString {
+            append(custom ?: DEFAULT_PROMPT)
+            append("\nReturn only one JSON object. Never rewrite text and never invent IDs.")
+            if (mode == SpeechAnalysisMode.RuleWithAi) {
+                append("\nReturn {\"segments\":[{\"segmentId\":string,\"roleType\":")
+                append("\"narrator|character|thought|unknown\",\"characterId\":string|null,")
+                append("\"emotion\":\"neutral|cheerful|sad|angry|fearful|surprised|disgusted|whispering|calm\",")
+                append("\"confidence\":number}]}. Return one decision for every input segment.")
+            } else {
+                append("\nGroup every atom exactly once and in input order. Groups cannot cross paragraphs.")
+                append("\nReturn {\"segments\":[{\"atomIds\":[string],\"roleType\":")
+                append("\"narrator|character|thought|unknown\",\"characterId\":string|null,")
+                append("\"emotion\":\"neutral|cheerful|sad|angry|fearful|surprised|disgusted|whispering|calm\",")
+                append("\"confidence\":number}]}.")
+            }
+        }
+    }
+
+    private fun parseSegmentDecisions(text: String): List<AiSegmentDecision> =
+        parseRoot(text).getAsJsonArray("segments").map { element ->
+            val item = element.asJsonObject
+            AiSegmentDecision(
+                segmentId = item.requiredString("segmentId"),
+                roleType = item.roleType(),
+                characterId = item.optionalString("characterId"),
+                emotion = item.emotion(),
+                confidence = item.confidence(),
+            )
+        }
+
+    private fun parseAtomGroups(text: String): List<AiAtomGroup> =
+        parseRoot(text).getAsJsonArray("segments").map { element ->
+            val item = element.asJsonObject
+            AiAtomGroup(
+                atomIds = item.getAsJsonArray("atomIds").map { it.asString },
+                roleType = item.roleType(),
+                characterId = item.optionalString("characterId"),
+                emotion = item.emotion(),
+                confidence = item.confidence(),
+            )
+        }
+
+    private fun parseRoot(text: String): JsonObject {
+        val start = text.indexOf('{')
+        val end = text.lastIndexOf('}')
+        require(start >= 0 && end > start) { "AI returned invalid JSON" }
+        return JsonParser.parseString(text.substring(start, end + 1)).asJsonObject
+    }
+
+    private fun JsonObject.roleType(): SpeechRoleType {
+        val value = requiredString("roleType")
+        require(value in SpeechRoleType.entries.map(SpeechRoleType::storageValue)) {
+            "AI returned invalid roleType: $value"
+        }
+        return SpeechRoleType.fromStorage(value)
+    }
+
+    private fun JsonObject.emotion(): String =
+        SpeechEmotion.fromStorage(optionalString("emotion").orEmpty()).storageValue
+
+    private fun JsonObject.confidence(): Float =
+        get("confidence")?.takeUnless { it.isJsonNull }?.asFloat?.coerceIn(0f, 1f) ?: 0f
+
+    private fun JsonObject.requiredString(name: String): String =
+        optionalString(name)?.takeIf(String::isNotBlank) ?: error("AI response misses $name")
+
+    private fun JsonObject.optionalString(name: String): String? =
+        get(name)?.takeUnless { it.isJsonNull }?.asString
+
+    private fun validateCoverage(atoms: List<AiSpeechAtom>, groups: List<AiAtomGroup>) {
+        require(groups.isNotEmpty()) { "AI returned no speech groups" }
+        val returned = groups.flatMap(AiAtomGroup::atomIds)
+        require(returned == atoms.map(AiSpeechAtom::id)) {
+            "AI atom coverage is incomplete, duplicated, or out of order"
+        }
+    }
+
+    private fun BookCharacterProfile.toPromptMap(): Map<String, Any?> = mapOf(
+        "characterId" to id,
+        "name" to name,
+        "aliasesJson" to aliasesJson,
+        "role" to role,
+        "personality" to personality,
+    )
+
+    private fun <T> List<T>.chunkByTextLength(
+        maxLength: Int,
+        text: (T) -> String,
+    ): List<List<T>> {
+        if (isEmpty()) return emptyList()
+        val result = mutableListOf<MutableList<T>>()
+        var current = mutableListOf<T>()
+        var length = 0
+        forEach { item ->
+            val itemLength = text(item).length
+            if (current.isNotEmpty() && length + itemLength > maxLength) {
+                result += current
+                current = mutableListOf()
+                length = 0
+            }
+            current += item
+            length += itemLength
+        }
+        if (current.isNotEmpty()) result += current
+        return result
+    }
+
+    private data class AiSegmentDecision(
+        val segmentId: String,
+        val roleType: SpeechRoleType,
+        val characterId: String?,
+        val emotion: String,
+        val confidence: Float,
+    )
+
+    private data class AiAtomGroup(
+        val atomIds: List<String>,
+        val roleType: SpeechRoleType,
+        val characterId: String?,
+        val emotion: String,
+        val confidence: Float,
+    )
+
+    companion object {
+        const val VERSION = "ai-speech-analysis-v1"
+        private const val MAX_CHUNK_CHARS = 6_000
+        private const val HYBRID_CONFIDENCE_THRESHOLD = 0.75f
+        private const val DEFAULT_PROMPT =
+            "Analyze fiction speech for text-to-speech. Distinguish narration, spoken dialogue, " +
+                "internal thought and unknown speech. Resolve speakers only from the supplied " +
+                "character IDs, infer emotion conservatively, and use null when uncertain."
+    }
+}

+ 102 - 0
app/src/main/java/io/legado/app/domain/usecase/ResolveLocalSpeakersUseCase.kt

@@ -0,0 +1,102 @@
+package io.legado.app.domain.usecase
+
+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.SpeakerCharacter
+import io.legado.app.domain.model.readaloud.SpeechAnalysisStatus
+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.resolve.LocalCharacterSpeakerResolver
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class ResolveLocalSpeakersUseCase(
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+    private val chapterSpeechGateway: ChapterSpeechGateway,
+) {
+
+    suspend operator fun invoke(
+        analysisResult: ChapterSpeechAnalysisResult,
+        paragraphs: List<CanonicalSpeechParagraph>,
+        now: Long = System.currentTimeMillis(),
+    ): ChapterSpeechAnalysisResult {
+        val profiles = withContext(Dispatchers.IO) {
+            bookKnowledgeGateway.getCharacterProfiles(analysisResult.analysis.bookUrl, 200)
+                .filter { it.status == BookCharacterProfile.STATUS_ACTIVE }
+        }
+        val characters = profiles.map { profile ->
+            SpeakerCharacter(
+                id = profile.id,
+                name = profile.name,
+                aliases = GSON.fromJsonArray<String>(profile.aliasesJson).getOrNull().orEmpty(),
+                role = profile.role,
+                updatedAt = profile.updatedAt,
+            )
+        }
+        val characterPerformances = profiles.map { profile ->
+            CharacterPerformanceProfile(
+                characterId = profile.id,
+                role = profile.role,
+                personality = profile.personality,
+                updatedAt = profile.updatedAt,
+            )
+        }
+        val characterRevision = buildString {
+            append(LocalCharacterSpeakerResolver.VERSION)
+            append(':')
+            append(SpeechIdentity.characterRevision(characters))
+        }
+        if (analysisResult.analysis.characterRevision == characterRevision) {
+            return analysisResult.copy(
+                fromCache = true,
+                characterPerformances = characterPerformances,
+            )
+        }
+
+        val resetSegments = analysisResult.segments.map { segment ->
+            if (segment.source == SpeechResolutionSource.Local && !segment.userLocked) {
+                segment.copy(
+                    characterId = null,
+                    characterName = "",
+                    confidence = minOf(segment.confidence, 0.72f),
+                    source = SpeechResolutionSource.Rule,
+                )
+            } else {
+                segment
+            }
+        }
+        val resolved = LocalCharacterSpeakerResolver.resolve(
+            paragraphs = paragraphs,
+            segments = resetSegments,
+            characters = characters,
+        )
+        val status = if (resolved.any { it.needsSpeakerResolution }) {
+            SpeechAnalysisStatus.Partial
+        } else {
+            SpeechAnalysisStatus.Success
+        }
+        val analysis = analysisResult.analysis.copy(
+            characterRevision = characterRevision,
+            status = status,
+            updatedAt = now,
+        )
+        chapterSpeechGateway.saveAnalysis(analysis, resolved)
+        return ChapterSpeechAnalysisResult(
+            analysis = analysis,
+            segments = resolved,
+            fromCache = false,
+            characterPerformances = characterPerformances,
+        )
+    }
+
+    private val io.legado.app.domain.model.readaloud.ChapterSpeechSegment.needsSpeakerResolution: Boolean
+        get() = characterId == null &&
+            (roleType == SpeechRoleType.Character || roleType == SpeechRoleType.Thought)
+}

+ 92 - 0
app/src/main/java/io/legado/app/domain/usecase/SyncReadAloudVoicesUseCase.kt

@@ -0,0 +1,92 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.domain.gateway.ReadAloudVoiceGateway
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.SpeechIdentity
+import io.legado.app.domain.model.readaloud.VoiceCatalogEntry
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+
+data class SyncReadAloudVoicesResult(
+    val inserted: Int,
+    val updated: Int,
+    val unavailable: Int,
+)
+
+class SyncReadAloudVoicesUseCase(
+    private val voiceGateway: ReadAloudVoiceGateway,
+) {
+
+    private val syncMutex = Mutex()
+
+    suspend operator fun invoke(
+        entries: List<VoiceCatalogEntry>,
+        managedSources: Set<String>,
+        now: Long = System.currentTimeMillis(),
+    ): SyncReadAloudVoicesResult = syncMutex.withLock {
+        val existing = voiceGateway.getVoices()
+        val existingById = existing.associateBy(ReadAloudVoice::id)
+        val incoming = entries
+            .distinctBy { Triple(it.engineType, it.engineId, it.speakerId) }
+            .map { entry -> entry to entry.stableId() }
+        val incomingIds = incoming.mapTo(mutableSetOf()) { it.second }
+        var inserted = 0
+        var updated = 0
+
+        incoming.forEach { (entry, id) ->
+            val old = existingById[id]
+            val metadataChanged = old == null ||
+                old.engineType != entry.engineType ||
+                old.engineId != entry.engineId ||
+                old.speakerId != entry.speakerId ||
+                old.displayName != entry.displayName ||
+                old.traitsJson != entry.traitsJson ||
+                old.emotionCatalogJson != entry.emotionCatalogJson ||
+                old.managedBy != entry.managedBy
+            val revision = when {
+                old == null -> entry.sourceRevision
+                metadataChanged -> maxOf(entry.sourceRevision, old.revision + 1)
+                else -> maxOf(entry.sourceRevision, old.revision)
+            }
+            val voice = ReadAloudVoice(
+                id = id,
+                engineType = entry.engineType,
+                engineId = entry.engineId,
+                speakerId = entry.speakerId,
+                displayName = entry.displayName,
+                traitsJson = entry.traitsJson,
+                emotionCatalogJson = entry.emotionCatalogJson,
+                managedBy = entry.managedBy,
+                enabled = old?.enabled ?: true,
+                available = true,
+                revision = revision,
+                createdAt = old?.createdAt ?: now,
+                updatedAt = if (old == null || metadataChanged || !old.available || revision != old.revision) {
+                    now
+                } else {
+                    old.updatedAt
+                },
+            )
+            if (old == null) {
+                inserted++
+                voiceGateway.upsertVoice(voice)
+            } else if (voice != old) {
+                updated++
+                voiceGateway.upsertVoice(voice)
+            }
+        }
+
+        var unavailable = 0
+        existing.asSequence()
+            .filter { it.managedBy in managedSources }
+            .filter { it.id !in incomingIds && it.available }
+            .forEach { voice ->
+                unavailable++
+                voiceGateway.upsertVoice(voice.copy(available = false, updatedAt = now))
+            }
+        SyncReadAloudVoicesResult(inserted, updated, unavailable)
+    }
+
+    private fun VoiceCatalogEntry.stableId(): String =
+        SpeechIdentity.voiceId(engineType, engineId, speakerId)
+}

+ 95 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/AlibabaCloudTtsProvider.kt

@@ -0,0 +1,95 @@
+package io.legado.app.help.readaloud.playback
+
+import android.util.Base64
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+
+internal class AlibabaCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.AlibabaCloud
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> = BUILT_IN_VOICES
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.apiKey.isNotBlank()) { "阿里云百炼 API Key 不能为空" }
+        require(engine.model.isNotBlank()) { "阿里云 TTS 模型不能为空" }
+        require(request.text.isNotBlank()) { "朗读文本不能为空" }
+        val body = JSONObject().apply {
+            put("model", engine.model)
+            put("input", JSONObject()
+                .put("text", request.text)
+                .put("voice", request.voiceId)
+                .put("language_type", request.locale.ifBlank { "Auto" }))
+            val emotionInstruction = request.style.takeIf(String::isNotBlank)
+            val instructions = listOfNotNull(
+                emotionInstruction,
+                request.instructions.takeIf(String::isNotBlank),
+            ).joinToString(";")
+            if (instructions.isNotBlank() && engine.model.contains("instruct", true)) {
+                getJSONObject("input").put("instructions", instructions)
+            }
+        }
+        val endpoint = engine.baseUrl.ifBlank { DEFAULT_ENDPOINT }.trimEnd('/').let { base ->
+            if (base.endsWith("/generation")) base
+            else "$base/services/aigc/multimodal-generation/generation"
+        }
+        val httpRequest = Request.Builder()
+            .url(endpoint)
+            .header("Authorization", "Bearer ${engine.apiKey}")
+            .header("X-DashScope-SSE", "enable")
+            .header("Content-Type", "application/json")
+            .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            response.requireCloudTtsSuccessful("阿里云百炼 TTS")
+            val pcm = ByteArrayOutputStream()
+            var audioUrl = ""
+            response.body.source().use { source ->
+                while (!source.exhausted()) {
+                    val line = source.readUtf8Line()?.trim().orEmpty()
+                    if (!line.startsWith("data:")) continue
+                    val json = runCatching { JSONObject(line.removePrefix("data:").trim()) }.getOrNull()
+                        ?: continue
+                    val audio = json.optJSONObject("output")?.optJSONObject("audio")
+                    val data = audio?.optString("data").orEmpty()
+                    if (data.isNotBlank()) pcm.write(Base64.decode(data, Base64.DEFAULT))
+                    audioUrl = audio?.optString("url").orEmpty().ifBlank { audioUrl }
+                }
+            }
+            val bytes = pcm.toByteArray().takeIf(ByteArray::isNotEmpty)
+                ?: audioUrl.takeIf(String::isNotBlank)?.let(::downloadAudio)
+                ?: error("阿里云百炼 TTS 没有返回音频数据")
+            val audio = if (pcm.size() > 0) pcm16MonoToWav(bytes, SAMPLE_RATE) else bytes
+            CloudTtsAudio(audio, if (pcm.size() > 0) "wav" else request.format, SAMPLE_RATE)
+        }
+    }
+
+    private fun downloadAudio(url: String): ByteArray {
+        val request = Request.Builder().url(url).get().build()
+        return cloudTtsHttpClient.newCall(request).execute().use { response ->
+            response.requireCloudTtsSuccessful("阿里云百炼音频下载").body.bytes()
+        }
+    }
+
+    private companion object {
+        const val DEFAULT_ENDPOINT = "https://dashscope.aliyuncs.com/api/v1"
+        const val SAMPLE_RATE = 24_000
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val BUILT_IN_VOICES = listOf(
+            CloudTtsVoiceDescriptor("Cherry", "Cherry", "Chinese", "Female", sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Serena", "Serena", "Chinese", "Female", sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Ethan", "Ethan", "Chinese", "Male", sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Chelsie", "Chelsie", "English", "Female", sampleRate = SAMPLE_RATE),
+        )
+    }
+}

+ 149 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/AwsPollyCloudTtsProvider.kt

@@ -0,0 +1,149 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.net.URI
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import java.security.MessageDigest
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+import java.util.TimeZone
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+
+internal class AwsPollyCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.AwsPolly
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> {
+        val engineName = engine.model.ifBlank { "neural" }
+        val result = mutableListOf<CloudTtsVoiceDescriptor>()
+        var nextToken: String? = null
+        do {
+            val query = buildList {
+                add("Engine=${encode(engineName)}")
+                add("IncludeAdditionalLanguageCodes=yes")
+                nextToken?.takeIf(String::isNotBlank)?.let { add("NextToken=${encode(it)}") }
+            }.joinToString("&")
+            val url = "${endpoint(engine)}/v1/voices?$query"
+            val request = signedRequest(engine, "GET", url, ByteArray(0)).get().build()
+            val json = cloudTtsHttpClient.newCall(request).execute().use { response ->
+                JSONObject(response.requireCloudTtsSuccessful("Amazon Polly").body.string())
+            }
+            val voices = json.getJSONArray("Voices")
+            repeat(voices.length()) { index ->
+                val voice = voices.getJSONObject(index)
+                val models = voice.optJSONArray("SupportedEngines")?.let { array ->
+                    List(array.length()) { array.getString(it) }
+                }.orEmpty()
+                result += CloudTtsVoiceDescriptor(
+                    id = voice.getString("Id"),
+                    displayName = voice.optString("Name", voice.getString("Id")),
+                    locale = voice.optString("LanguageCode"),
+                    gender = voice.optString("Gender"),
+                    supportedModels = models,
+                )
+            }
+            nextToken = json.optString("NextToken").takeIf(String::isNotBlank)
+        } while (nextToken != null)
+        return result.distinctBy(CloudTtsVoiceDescriptor::id)
+    }
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(request.text.isNotBlank()) { "朗读文本不能为空" }
+        val format = request.format.lowercase().let { if (it == "wav") "pcm" else it }
+        require(format in FORMATS) { "Amazon Polly 不支持音频格式:${request.format}" }
+        val body = JSONObject().apply {
+            put("Engine", engine.model.ifBlank { "neural" })
+            put("OutputFormat", format)
+            put("Text", request.text)
+            put("TextType", "text")
+            put("VoiceId", request.voiceId)
+            if (request.locale.isNotBlank()) put("LanguageCode", request.locale)
+            request.sampleRate?.let { put("SampleRate", it.toString()) }
+        }.toString().toByteArray()
+        val url = "${endpoint(engine)}/v1/speech"
+        val httpRequest = signedRequest(engine, "POST", url, body)
+            .post(body.toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            val bytes = response.requireCloudTtsSuccessful("Amazon Polly").body.bytes()
+            require(bytes.isNotEmpty()) { "Amazon Polly 没有返回音频数据" }
+            if (format == "pcm") {
+                val sampleRate = request.sampleRate ?: 24_000
+                CloudTtsAudio(pcm16MonoToWav(bytes, sampleRate), "wav", sampleRate)
+            } else CloudTtsAudio(bytes, format, request.sampleRate)
+        }
+    }
+
+    private fun signedRequest(
+        engine: CloudTtsEngine,
+        method: String,
+        url: String,
+        body: ByteArray,
+    ): Request.Builder {
+        require(engine.apiKey.isNotBlank()) { "AWS Access Key ID 不能为空" }
+        require(engine.secretKey.isNotBlank()) { "AWS Secret Access Key 不能为空" }
+        require(engine.region.isNotBlank()) { "AWS 区域不能为空" }
+        val now = Date()
+        val amzDate = UTC_DATE_TIME.format(now)
+        val date = UTC_DATE.format(now)
+        val uri = URI(url)
+        val payloadHash = body.sha256Hex()
+        val canonicalQuery = uri.rawQuery.orEmpty().split('&').filter(String::isNotBlank)
+            .sorted().joinToString("&")
+        val canonicalHeaders = "host:${uri.host}\nx-amz-content-sha256:$payloadHash\nx-amz-date:$amzDate\n"
+        val signedHeaders = "host;x-amz-content-sha256;x-amz-date"
+        val canonicalRequest = listOf(
+            method, uri.rawPath.ifBlank { "/" }, canonicalQuery,
+            canonicalHeaders, signedHeaders, payloadHash,
+        ).joinToString("\n")
+        val scope = "$date/${engine.region}/polly/aws4_request"
+        val stringToSign = "AWS4-HMAC-SHA256\n$amzDate\n$scope\n${canonicalRequest.toByteArray().sha256Hex()}"
+        val signingKey = hmac(hmac(hmac(hmac(
+            "AWS4${engine.secretKey}".toByteArray(), date), engine.region), "polly"), "aws4_request")
+        val signature = hmac(signingKey, stringToSign).toHex()
+        return Request.Builder()
+            .url(url)
+            .header("Host", uri.host)
+            .header("X-Amz-Date", amzDate)
+            .header("X-Amz-Content-Sha256", payloadHash)
+            .header("Authorization", "AWS4-HMAC-SHA256 Credential=${engine.apiKey}/$scope, SignedHeaders=$signedHeaders, Signature=$signature")
+    }
+
+    private fun endpoint(engine: CloudTtsEngine): String = engine.baseUrl.trim().trimEnd('/')
+        .ifBlank {
+            require(engine.region.isNotBlank()) { "AWS 区域不能为空" }
+            "https://polly.${engine.region}.amazonaws.com"
+        }
+
+    private fun hmac(key: ByteArray, value: String): ByteArray = Mac.getInstance("HmacSHA256").run {
+        init(SecretKeySpec(key, "HmacSHA256"))
+        doFinal(value.toByteArray())
+    }
+    private fun ByteArray.sha256Hex() = MessageDigest.getInstance("SHA-256").digest(this).toHex()
+    private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
+    private fun encode(value: String) = URLEncoder.encode(value, StandardCharsets.UTF_8.name())
+
+    private companion object {
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val FORMATS = setOf("mp3", "ogg_vorbis", "pcm")
+        val UTC_DATE_TIME = SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.US).apply {
+            timeZone = TimeZone.getTimeZone("UTC")
+        }
+        val UTC_DATE = SimpleDateFormat("yyyyMMdd", Locale.US).apply {
+            timeZone = TimeZone.getTimeZone("UTC")
+        }
+    }
+}

+ 96 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/AzureSpeechCloudTtsProvider.kt

@@ -0,0 +1,96 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+
+internal class AzureSpeechCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.AzureSpeech
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> {
+        require(engine.apiKey.isNotBlank()) { "Azure Speech Key 不能为空" }
+        val request = Request.Builder()
+            .url("${endpoint(engine)}/cognitiveservices/voices/list")
+            .header("Ocp-Apim-Subscription-Key", engine.apiKey)
+            .get()
+            .build()
+        return cloudTtsHttpClient.newCall(request).execute().use { response ->
+            val voices = JSONArray(response.requireCloudTtsSuccessful("Azure Speech").body.string())
+            buildList {
+                repeat(voices.length()) { index ->
+                    val voice = voices.getJSONObject(index)
+                    add(CloudTtsVoiceDescriptor(
+                        id = voice.getString("ShortName"),
+                        displayName = voice.optString("LocalName").ifBlank {
+                            voice.optString("DisplayName", voice.getString("ShortName"))
+                        },
+                        locale = voice.optString("Locale"),
+                        gender = voice.optString("Gender"),
+                        styles = voice.optJSONArray("StyleList").toStringList(),
+                        roles = voice.optJSONArray("RolePlayList").toStringList(),
+                        sampleRate = voice.optString("SampleRateHertz").toIntOrNull(),
+                    ))
+                }
+            }
+        }
+    }
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.apiKey.isNotBlank()) { "Azure Speech Key 不能为空" }
+        require(request.voiceId.isNotBlank()) { "Azure 音色不能为空" }
+        val locale = request.locale.ifBlank {
+            request.voiceId.split('-').take(2).joinToString("-").ifBlank { "zh-CN" }
+        }
+        val prosody = "<prosody rate=\"${request.speed}\" pitch=\"${request.pitch.toAzurePitch()}\" volume=\"${request.volume.toAzureVolume()}\">${request.text.escapeXml()}</prosody>"
+        val expressed = if (request.style.isNotBlank() || request.role.isNotBlank()) {
+            val style = request.style.ifBlank { "general" }.escapeXml()
+            val role = request.role.takeIf(String::isNotBlank)
+                ?.let { " role=\"${it.escapeXml()}\"" }.orEmpty()
+            "<mstts:express-as style=\"$style\"$role>$prosody</mstts:express-as>"
+        } else prosody
+        val ssml = "<speak version=\"1.0\" xmlns=\"http://www.w3.org/2001/10/synthesis\" xmlns:mstts=\"https://www.w3.org/2001/mstts\" xml:lang=\"${locale.escapeXml()}\"><voice name=\"${request.voiceId.escapeXml()}\">$expressed</voice></speak>"
+        val outputFormat = when (request.format.lowercase()) {
+            "wav" -> "riff-24khz-16bit-mono-pcm"
+            "ogg", "opus" -> "ogg-24khz-16bit-mono-opus"
+            else -> "audio-24khz-48kbitrate-mono-mp3"
+        }
+        val httpRequest = Request.Builder()
+            .url("${endpoint(engine)}/cognitiveservices/v1")
+            .header("Ocp-Apim-Subscription-Key", engine.apiKey)
+            .header("X-Microsoft-OutputFormat", outputFormat)
+            .header("User-Agent", "Legado")
+            .post(ssml.toRequestBody(SSML_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            val bytes = response.requireCloudTtsSuccessful("Azure Speech").body.bytes()
+            require(bytes.isNotEmpty()) { "Azure Speech 没有返回音频数据" }
+            CloudTtsAudio(bytes, request.format.lowercase())
+        }
+    }
+
+    private fun endpoint(engine: CloudTtsEngine): String = engine.baseUrl.trim().trimEnd('/')
+        .ifBlank {
+            require(engine.region.isNotBlank()) { "Azure 区域不能为空" }
+            "https://${engine.region}.tts.speech.microsoft.com"
+        }
+
+    private fun JSONArray?.toStringList(): List<String> = this?.let { array ->
+        List(array.length()) { index -> array.getString(index) }
+    }.orEmpty()
+
+    private fun Float.toAzurePitch() = "${((this - 1f) * 100).toInt().coerceIn(-100, 100)}%"
+    private fun Float.toAzureVolume() = "${((this - 1f) * 100).toInt().coerceIn(-100, 100)}%"
+
+    private companion object {
+        val SSML_MEDIA_TYPE = "application/ssml+xml".toMediaType()
+    }
+}

+ 49 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/CharacterPerformanceInstructionBuilder.kt

@@ -0,0 +1,49 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CharacterPerformanceProfile
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+
+object CharacterPerformanceInstructionBuilder {
+    const val VERSION = "character-performance-v1"
+
+    private val supportedProviders = setOf(
+        CloudTtsProviderType.OpenAiSpeech,
+        CloudTtsProviderType.GeminiTts,
+        CloudTtsProviderType.Mimo,
+        CloudTtsProviderType.AlibabaCloud,
+    )
+
+    fun build(
+        provider: CloudTtsProviderType,
+        profile: CharacterPerformanceProfile?,
+    ): String {
+        if (provider !in supportedProviders || profile == null) return ""
+        val role = profile.role.toRoleLabel()
+        val personality = profile.personality.toSafeTraitText()
+        if (role.isBlank() && personality.isBlank()) return ""
+        return buildString {
+            append("保持该角色的声音表演一致。")
+            if (role.isNotBlank()) append("角色定位:$role。")
+            if (personality.isNotBlank()) append("性格特征:$personality。")
+            append("这些内容仅用于控制表演,不要朗读,也不要修改台词。")
+        }
+    }
+
+    private fun String.toRoleLabel(): String = when (trim()) {
+        "male_lead" -> "男主角"
+        "female_lead" -> "女主角"
+        "male_supporting" -> "男性配角"
+        "female_supporting" -> "女性配角"
+        else -> ""
+    }
+
+    private fun String.toSafeTraitText(): String =
+        replace(CONTROL_CHARACTERS, " ")
+            .replace(WHITESPACE, " ")
+            .trim()
+            .take(MAX_PERSONALITY_LENGTH)
+
+    private const val MAX_PERSONALITY_LENGTH = 240
+    private val CONTROL_CHARACTERS = Regex("[\\p{Cc}\\p{Cf}]")
+    private val WHITESPACE = Regex("\\s+")
+}

+ 98 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsAudioSynthesizer.kt

@@ -0,0 +1,98 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceConfig
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import io.legado.app.domain.model.readaloud.CharacterPerformanceProfile
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.utils.GSON
+import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class CloudTtsAudioSynthesizer(
+    private val engineGateway: CloudTtsEngineGateway,
+) {
+    private val registry = CloudTtsProviderRegistry()
+
+    suspend fun fetchVoices(engineId: String): List<CloudTtsVoiceDescriptor> =
+        withContext(Dispatchers.IO) {
+            val engine = engineGateway.get(engineId) ?: error("云 TTS 引擎不存在")
+            require(engine.enabled) { "云 TTS 引擎已停用" }
+            registry.get(engine.provider).fetchVoices(engine)
+        }
+
+    suspend fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> =
+        withContext(Dispatchers.IO) {
+            registry.get(engine.provider).fetchVoices(engine)
+        }
+
+    suspend fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+        output: File,
+    ): Boolean = withContext(Dispatchers.IO) {
+        val audio = registry.get(engine.provider).synthesize(engine, request)
+        output.parentFile?.mkdirs()
+        output.writeBytes(audio.bytes)
+        output.length() > 0
+    }
+
+    suspend fun synthesize(
+        voice: ReadAloudVoice,
+        text: String,
+        output: File,
+        styleOverride: String = "",
+        characterPerformance: CharacterPerformanceProfile? = null,
+        roleType: SpeechRoleType = SpeechRoleType.Unknown,
+    ): Boolean = withContext(Dispatchers.IO) {
+        val engine = engineGateway.get(voice.engineId) ?: return@withContext false
+        if (!engine.enabled) return@withContext false
+        val config = runCatching {
+            GSON.fromJson(voice.traitsJson, CloudTtsVoiceConfig::class.java)
+        }.getOrNull() ?: CloudTtsVoiceConfig()
+        val supportedStyles = runCatching {
+            GSON.fromJson(voice.emotionCatalogJson, Array<String>::class.java).toSet()
+        }.getOrDefault(emptySet())
+        val emotionControl = if (config.automaticEmotion != false) {
+            CloudTtsEmotionMapper.map(engine.provider, styleOverride)
+        } else {
+            CloudTtsEmotionControl()
+        }
+        val mappedStyle = emotionControl.style.takeIf { it in supportedStyles }
+        val characterInstruction = if (config.characterPersonality != false) {
+            CharacterPerformanceInstructionBuilder.build(engine.provider, characterPerformance)
+        } else ""
+        val roleInstruction = if (config.thoughtPerformance != false) {
+            CloudTtsRoleInstructionMapper.map(engine.provider, roleType)
+        } else ""
+        val instructions = listOfNotNull(
+            config.instructions.takeIf(String::isNotBlank),
+            characterInstruction.takeIf(String::isNotBlank),
+            roleInstruction.takeIf(String::isNotBlank),
+            emotionControl.instruction.takeIf(String::isNotBlank),
+        ).joinToString(" ")
+        val audio = registry.get(engine.provider).synthesize(
+            engine = engine,
+            request = CloudTtsSynthesisRequest(
+                text = text,
+                voiceId = voice.speakerId,
+                locale = config.locale,
+                style = mappedStyle ?: config.style,
+                role = config.role,
+                instructions = instructions,
+                speed = config.speed,
+                pitch = config.pitch,
+                volume = config.volume,
+                format = config.format,
+                sampleRate = config.sampleRate,
+            ),
+        )
+        output.parentFile?.mkdirs()
+        output.writeBytes(audio.bytes)
+        output.length() > 0
+    }
+}

+ 93 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsEmotionMapper.kt

@@ -0,0 +1,93 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.SpeechEmotion
+
+object CloudTtsEmotionMapper {
+    const val VERSION = "emotion-control-v2"
+
+    fun map(provider: CloudTtsProviderType, emotionValue: String): CloudTtsEmotionControl {
+        val emotion = SpeechEmotion.fromStorage(emotionValue)
+        if (emotion == SpeechEmotion.Neutral) return CloudTtsEmotionControl()
+        return when (provider) {
+            CloudTtsProviderType.OpenAiSpeech,
+            CloudTtsProviderType.GeminiTts -> CloudTtsEmotionControl(
+                instruction = emotion.toNaturalLanguageInstruction(),
+            )
+            CloudTtsProviderType.Mimo -> CloudTtsEmotionControl(style = emotion.toMimo())
+            CloudTtsProviderType.AzureSpeech -> CloudTtsEmotionControl(style = emotion.toAzure())
+            CloudTtsProviderType.AlibabaCloud -> CloudTtsEmotionControl(
+                instruction = emotion.toAlibabaInstruction(),
+            )
+            CloudTtsProviderType.AwsPolly -> CloudTtsEmotionControl()
+            CloudTtsProviderType.Volcengine -> CloudTtsEmotionControl(
+                style = emotion.toVolcengine(),
+            )
+        }
+    }
+
+    private fun SpeechEmotion.toNaturalLanguageInstruction(): String = when (this) {
+        SpeechEmotion.Cheerful -> "Speak in a cheerful and lively tone."
+        SpeechEmotion.Sad -> "Speak in a sad and subdued tone."
+        SpeechEmotion.Angry -> "Speak with controlled anger and intensity."
+        SpeechEmotion.Fearful -> "Speak in a fearful and tense tone."
+        SpeechEmotion.Surprised -> "Speak with genuine surprise."
+        SpeechEmotion.Disgusted -> "Speak with clear disgust and displeasure."
+        SpeechEmotion.Whispering -> "Speak quietly, almost in a whisper."
+        SpeechEmotion.Calm -> "Speak in a calm and composed tone."
+        SpeechEmotion.Neutral -> ""
+    }
+
+    private fun SpeechEmotion.toMimo(): String = when (this) {
+        SpeechEmotion.Cheerful -> "开心"
+        SpeechEmotion.Sad -> "悲伤"
+        SpeechEmotion.Angry -> "愤怒"
+        SpeechEmotion.Fearful -> "恐惧"
+        SpeechEmotion.Surprised -> "惊讶"
+        SpeechEmotion.Disgusted -> "厌恶"
+        SpeechEmotion.Whispering -> "小声"
+        SpeechEmotion.Calm -> "平静"
+        SpeechEmotion.Neutral -> ""
+    }
+
+    private fun SpeechEmotion.toAzure(): String = when (this) {
+        SpeechEmotion.Cheerful -> "cheerful"
+        SpeechEmotion.Sad -> "sad"
+        SpeechEmotion.Angry -> "angry"
+        SpeechEmotion.Fearful -> "fearful"
+        SpeechEmotion.Surprised -> "excited"
+        SpeechEmotion.Disgusted -> "disgruntled"
+        SpeechEmotion.Whispering -> "whispering"
+        SpeechEmotion.Calm -> "calm"
+        SpeechEmotion.Neutral -> ""
+    }
+
+    private fun SpeechEmotion.toAlibabaInstruction(): String = when (this) {
+        SpeechEmotion.Cheerful -> "用开心、愉快的情绪朗读"
+        SpeechEmotion.Sad -> "用悲伤、低落的情绪朗读"
+        SpeechEmotion.Angry -> "用愤怒、强烈的情绪朗读"
+        SpeechEmotion.Fearful -> "用恐惧、紧张的情绪朗读"
+        SpeechEmotion.Surprised -> "用惊讶的情绪朗读"
+        SpeechEmotion.Disgusted -> "用厌恶、不满的情绪朗读"
+        SpeechEmotion.Whispering -> "压低声音,轻声朗读"
+        SpeechEmotion.Calm -> "用平静、从容的语气朗读"
+        SpeechEmotion.Neutral -> ""
+    }
+
+    private fun SpeechEmotion.toVolcengine(): String = when (this) {
+        SpeechEmotion.Cheerful -> "happy"
+        SpeechEmotion.Sad -> "sad"
+        SpeechEmotion.Angry -> "angry"
+        SpeechEmotion.Fearful -> "fear"
+        SpeechEmotion.Surprised -> "surprise"
+        SpeechEmotion.Disgusted -> "disgust"
+        SpeechEmotion.Whispering -> ""
+        SpeechEmotion.Calm -> "neutral"
+        SpeechEmotion.Neutral -> ""
+    }
+}
+
+data class CloudTtsEmotionControl(
+    val style: String = "",
+    val instruction: String = "",
+)

+ 73 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsProvider.kt

@@ -0,0 +1,73 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import io.legado.app.help.http.okHttpClient
+import okhttp3.OkHttpClient
+import okhttp3.Response
+import java.nio.ByteBuffer
+import java.nio.ByteOrder
+import java.util.concurrent.TimeUnit
+
+internal interface CloudTtsProvider {
+    val type: CloudTtsProviderType
+    fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor>
+    fun synthesize(engine: CloudTtsEngine, request: CloudTtsSynthesisRequest): CloudTtsAudio
+}
+
+internal class CloudTtsProviderRegistry(
+    providers: List<CloudTtsProvider> = listOf(
+        OpenAiCloudTtsProvider(),
+        GeminiCloudTtsProvider(),
+        MimoCloudTtsProvider(),
+        AzureSpeechCloudTtsProvider(),
+        AlibabaCloudTtsProvider(),
+        AwsPollyCloudTtsProvider(),
+        VolcengineCloudTtsProvider(),
+    ),
+) {
+    private val providersByType = providers.associateBy(CloudTtsProvider::type)
+
+    fun get(type: CloudTtsProviderType): CloudTtsProvider =
+        providersByType[type] ?: error("不支持的云 TTS 引擎:${type.storageValue}")
+}
+
+internal val cloudTtsHttpClient: OkHttpClient by lazy {
+    okHttpClient.newBuilder()
+        .readTimeout(120, TimeUnit.SECONDS)
+        .build()
+}
+
+internal fun Response.requireCloudTtsSuccessful(providerName: String): Response {
+    if (isSuccessful) return this
+    val detail = body.string().trim().take(2_000)
+    val suffix = detail.takeIf(String::isNotBlank)?.let { ":$it" }.orEmpty()
+    error("$providerName 请求失败:HTTP $code$suffix")
+}
+
+internal fun String.escapeXml(): String = buildString(length) {
+    this@escapeXml.forEach { char ->
+        append(when (char) {
+            '&' -> "&amp;"
+            '<' -> "&lt;"
+            '>' -> "&gt;"
+            '\"' -> "&quot;"
+            '\'' -> "&apos;"
+            else -> char
+        })
+    }
+}
+
+internal fun pcm16MonoToWav(pcm: ByteArray, sampleRate: Int): ByteArray {
+    val output = ByteBuffer.allocate(WAV_HEADER_SIZE + pcm.size).order(ByteOrder.LITTLE_ENDIAN)
+    output.put("RIFF".toByteArray()).putInt(36 + pcm.size).put("WAVE".toByteArray())
+    output.put("fmt ".toByteArray()).putInt(16).putShort(1.toShort()).putShort(1.toShort())
+    output.putInt(sampleRate).putInt(sampleRate * 2).putShort(2.toShort()).putShort(16.toShort())
+    output.put("data".toByteArray()).putInt(pcm.size).put(pcm)
+    return output.array()
+}
+
+private const val WAV_HEADER_SIZE = 44

+ 20 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/CloudTtsRoleInstructionMapper.kt

@@ -0,0 +1,20 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+
+object CloudTtsRoleInstructionMapper {
+    const val VERSION = "speech-role-v1"
+
+    fun map(provider: CloudTtsProviderType, roleType: SpeechRoleType): String {
+        if (roleType != SpeechRoleType.Thought) return ""
+        return when (provider) {
+            CloudTtsProviderType.OpenAiSpeech,
+            CloudTtsProviderType.GeminiTts,
+            CloudTtsProviderType.Mimo,
+            CloudTtsProviderType.AlibabaCloud ->
+                "Use an intimate internal-monologue delivery, restrained and slightly quieter than spoken dialogue."
+            else -> ""
+        }
+    }
+}

+ 94 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/GeminiCloudTtsProvider.kt

@@ -0,0 +1,94 @@
+package io.legado.app.help.readaloud.playback
+
+import android.util.Base64
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+
+internal class GeminiCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.GeminiTts
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> =
+        VOICES.map { CloudTtsVoiceDescriptor(it, it) }
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.apiKey.isNotBlank()) { "Gemini API Key 不能为空" }
+        require(engine.model.isNotBlank()) { "Gemini TTS 模型不能为空" }
+        val directions = listOfNotNull(
+            request.style.takeIf(String::isNotBlank),
+            request.instructions.takeIf(String::isNotBlank),
+        ).joinToString("\n")
+        val prompt = buildString {
+            appendLine("Synthesize the following transcript as speech.")
+            if (directions.isNotBlank()) {
+                appendLine("### DIRECTOR'S NOTES")
+                appendLine(directions)
+            }
+            appendLine("### TRANSCRIPT")
+            append(request.text)
+        }
+        val body = JSONObject().apply {
+            put("contents", JSONArray().put(JSONObject().put(
+                "parts",
+                JSONArray().put(JSONObject().put("text", prompt)),
+            )))
+            put("generationConfig", JSONObject()
+                .put("responseModalities", JSONArray().put("AUDIO"))
+                .put("speechConfig", JSONObject().put(
+                    "voiceConfig",
+                    JSONObject().put(
+                        "prebuiltVoiceConfig",
+                        JSONObject().put("voiceName", request.voiceId),
+                    ),
+                )))
+        }
+        val baseUrl = engine.baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/').removeSuffix("/openai")
+        val model = engine.model.removePrefix("models/")
+        val httpRequest = Request.Builder()
+            .url("$baseUrl/models/$model:generateContent")
+            .header("x-goog-api-key", engine.apiKey)
+            .header("Content-Type", "application/json")
+            .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            val json = JSONObject(response.requireCloudTtsSuccessful("Gemini TTS").body.string())
+            val inlineData = json.optJSONArray("candidates")?.optJSONObject(0)
+                ?.optJSONObject("content")?.optJSONArray("parts")?.optJSONObject(0)
+                ?.optJSONObject("inlineData") ?: error("Gemini TTS 没有返回音频数据")
+            val bytes = Base64.decode(inlineData.getString("data"), Base64.DEFAULT)
+            val mimeType = inlineData.optString("mimeType").lowercase()
+            if (mimeType in ENCODED_AUDIO_TYPES) {
+                CloudTtsAudio(bytes, mimeType.substringAfter('/'))
+            } else {
+                CloudTtsAudio(pcm16MonoToWav(bytes, SAMPLE_RATE), "wav", SAMPLE_RATE)
+            }
+        }
+    }
+
+    private companion object {
+        const val DEFAULT_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
+        const val SAMPLE_RATE = 24_000
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val ENCODED_AUDIO_TYPES = setOf(
+            "audio/wav", "audio/wave", "audio/x-wav", "audio/mpeg", "audio/mp3",
+            "audio/ogg", "audio/opus", "audio/flac",
+        )
+        val VOICES = listOf(
+            "Kore", "Puck", "Charon", "Fenrir", "Leda", "Orus", "Aoede", "Callirrhoe",
+            "Autonoe", "Enceladus", "Iapetus", "Umbriel", "Algieba", "Despina", "Erinome",
+            "Algenib", "Rasalgethi", "Laomedeia", "Achernar", "Alnilam", "Schedar", "Gacrux",
+            "Pulcherrima", "Achird", "Zubenelgenubi", "Vindemiatrix", "Sadachbia",
+            "Sadaltager", "Sulafat",
+        )
+    }
+}

+ 89 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/MimoCloudTtsProvider.kt

@@ -0,0 +1,89 @@
+package io.legado.app.help.readaloud.playback
+
+import android.util.Base64
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONArray
+import org.json.JSONObject
+import java.io.ByteArrayOutputStream
+
+internal class MimoCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.Mimo
+
+    override fun fetchVoices(engine: CloudTtsEngine) = BUILT_IN_VOICES
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.apiKey.isNotBlank()) { "MiMo API Key 不能为空" }
+        require(engine.model.isNotBlank()) { "MiMo 模型不能为空" }
+        require(request.text.isNotBlank()) { "朗读文本不能为空" }
+        val expression = request.style.takeIf(String::isNotBlank)?.let { "($it)" }.orEmpty()
+        val messages = JSONArray().apply {
+            request.instructions.takeIf(String::isNotBlank)?.let { instruction ->
+                put(JSONObject().put("role", "user").put("content", instruction))
+            }
+            put(JSONObject()
+                .put("role", "assistant")
+                .put("content", "$expression${request.text}"))
+        }
+        val body = JSONObject().apply {
+            put("model", engine.model)
+            put("messages", messages)
+            put("audio", JSONObject().put("format", "pcm16").put("voice", request.voiceId))
+            put("stream", true)
+        }
+        val baseUrl = engine.baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/')
+        val httpRequest = Request.Builder()
+            .url("$baseUrl/chat/completions")
+            .header("api-key", engine.apiKey)
+            .header("Content-Type", "application/json")
+            .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            response.requireCloudTtsSuccessful("MiMo TTS")
+            val pcm = ByteArrayOutputStream()
+            response.body.source().use { source ->
+                while (!source.exhausted()) {
+                    val line = source.readUtf8Line()?.trim().orEmpty()
+                    if (!line.startsWith("data:")) continue
+                    val payload = line.removePrefix("data:").trim()
+                    if (payload == "[DONE]") break
+                    val audio = runCatching {
+                        JSONObject(payload).optJSONArray("choices")?.optJSONObject(0)
+                            ?.optJSONObject("delta")?.optJSONObject("audio")?.optString("data")
+                    }.getOrNull()
+                    if (!audio.isNullOrBlank()) pcm.write(Base64.decode(audio, Base64.DEFAULT))
+                }
+            }
+            val bytes = pcm.toByteArray()
+            require(bytes.isNotEmpty()) { "MiMo TTS 没有返回音频数据" }
+            CloudTtsAudio(pcm16MonoToWav(bytes, SAMPLE_RATE), "wav", SAMPLE_RATE)
+        }
+    }
+
+    private companion object {
+        const val DEFAULT_BASE_URL = "https://api.xiaomimimo.com/v1"
+        const val SAMPLE_RATE = 24_000
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val STYLES = listOf("开心", "悲伤", "愤怒", "惊讶", "恐惧", "厌恶", "小声", "平静")
+        val BUILT_IN_VOICES = listOf(
+            CloudTtsVoiceDescriptor("mimo_default", "MiMo 默认", styles = STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("冰糖", "冰糖", "zh-CN", "Female", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("茉莉", "茉莉", "zh-CN", "Female", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("苏打", "苏打", "zh-CN", "Male", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("白桦", "白桦", "zh-CN", "Male", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Mia", "Mia", "en", "Female", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Chloe", "Chloe", "en", "Female", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Milo", "Milo", "en", "Male", STYLES, sampleRate = SAMPLE_RATE),
+            CloudTtsVoiceDescriptor("Dean", "Dean", "en", "Male", STYLES, sampleRate = SAMPLE_RATE),
+        )
+    }
+}

+ 73 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/OpenAiCloudTtsProvider.kt

@@ -0,0 +1,73 @@
+package io.legado.app.help.readaloud.playback
+
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+
+internal class OpenAiCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.OpenAiSpeech
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> =
+        VOICES.map { CloudTtsVoiceDescriptor(it, it.replaceFirstChar(Char::uppercase)) }
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.apiKey.isNotBlank()) { "OpenAI API Key 不能为空" }
+        require(engine.model.isNotBlank()) { "OpenAI TTS 模型不能为空" }
+        require(request.voiceId.isNotBlank()) { "OpenAI 音色不能为空" }
+        require(request.speed.isFinite() && request.speed in 0.25f..4f) {
+            "OpenAI TTS 速度必须在 0.25 到 4.0 之间"
+        }
+        val format = request.format.lowercase()
+        require(format in FORMATS) { "OpenAI TTS 不支持音频格式:$format" }
+        val instructions = listOfNotNull(
+            request.style.takeIf(String::isNotBlank),
+            request.instructions.takeIf(String::isNotBlank),
+        ).joinToString(" ")
+        val body = JSONObject().apply {
+            put("model", engine.model)
+            put("input", request.text)
+            if (request.voiceId.startsWith("voice_")) {
+                put("voice", JSONObject().put("id", request.voiceId))
+            } else {
+                put("voice", request.voiceId)
+            }
+            put("response_format", format)
+            put("speed", request.speed)
+            if (instructions.isNotBlank() && !engine.model.startsWith("tts-1")) {
+                put("instructions", instructions)
+            }
+        }
+        val baseUrl = engine.baseUrl.ifBlank { DEFAULT_BASE_URL }.trimEnd('/')
+        val endpoint = if (baseUrl.endsWith("/audio/speech")) baseUrl else "$baseUrl/audio/speech"
+        val httpRequest = Request.Builder()
+            .url(endpoint)
+            .header("Authorization", "Bearer ${engine.apiKey}")
+            .header("Content-Type", "application/json")
+            .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            val bytes = response.requireCloudTtsSuccessful("OpenAI TTS").body.bytes()
+            require(bytes.isNotEmpty()) { "OpenAI TTS 没有返回音频数据" }
+            CloudTtsAudio(bytes, format)
+        }
+    }
+
+    private companion object {
+        const val DEFAULT_BASE_URL = "https://api.openai.com/v1"
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val FORMATS = setOf("mp3", "opus", "aac", "flac", "wav", "pcm")
+        val VOICES = listOf(
+            "alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx",
+            "sage", "shimmer", "verse", "marin", "cedar",
+        )
+    }
+}

+ 102 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsFileSynthesizer.kt

@@ -0,0 +1,102 @@
+package io.legado.app.help.readaloud.playback
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import android.speech.tts.UtteranceProgressListener
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.sync.withLock
+import kotlinx.coroutines.withContext
+import java.io.File
+import java.util.UUID
+import kotlin.coroutines.resume
+
+/** Serializes Android TTS file synthesis and reuses the active engine between adjacent cues. */
+class SystemTtsFileSynthesizer(
+    context: Context,
+) {
+    private val appContext = context.applicationContext
+    private val mutex = Mutex()
+    private var tts: TextToSpeech? = null
+    private var activeEngine = ""
+
+    suspend fun synthesize(
+        engine: String,
+        voiceName: String,
+        text: String,
+        output: File,
+        speechRate: Float,
+        pitch: Float = 1f,
+    ): Boolean = mutex.withLock {
+        val instance = ensureEngine(engine) ?: return@withLock false
+        if (voiceName.isNotBlank()) {
+            val voice = instance.voices.orEmpty().firstOrNull { it.name == voiceName }
+                ?: return@withLock false
+            if (instance.setVoice(voice) == TextToSpeech.ERROR) return@withLock false
+        }
+        output.parentFile?.mkdirs()
+        if (output.exists()) output.delete()
+        val utteranceId = "legado-file-${UUID.randomUUID()}"
+        suspendCancellableCoroutine { continuation ->
+            instance.setOnUtteranceProgressListener(object : UtteranceProgressListener() {
+                override fun onStart(utteranceId: String?) = Unit
+
+                override fun onDone(completedId: String?) {
+                    if (completedId == utteranceId && continuation.isActive) {
+                        continuation.resume(output.exists() && output.length() > 0)
+                    }
+                }
+
+                override fun onError(failedId: String?) {
+                    if (failedId == utteranceId && continuation.isActive) continuation.resume(false)
+                }
+
+                override fun onError(failedId: String?, errorCode: Int) = onError(failedId)
+            })
+            if (instance.setSpeechRate(speechRate) == TextToSpeech.ERROR ||
+                instance.setPitch(pitch) == TextToSpeech.ERROR
+            ) {
+                continuation.resume(false)
+                return@suspendCancellableCoroutine
+            }
+            val result = instance.synthesizeToFile(text, null, output, utteranceId)
+            if (result == TextToSpeech.ERROR && continuation.isActive) continuation.resume(false)
+            continuation.invokeOnCancellation { instance.stop() }
+        }
+    }
+
+    suspend fun close() = withContext(Dispatchers.Main.immediate) {
+        tts?.stop()
+        tts?.shutdown()
+        tts = null
+        activeEngine = ""
+    }
+
+    private suspend fun ensureEngine(engine: String): TextToSpeech? {
+        if (tts != null && activeEngine == engine) return tts
+        close()
+        return withContext(Dispatchers.Main.immediate) {
+            suspendCancellableCoroutine { continuation ->
+                lateinit var instance: TextToSpeech
+                val listener = TextToSpeech.OnInitListener { status ->
+                    if (!continuation.isActive) return@OnInitListener
+                    if (status == TextToSpeech.SUCCESS) {
+                        tts = instance
+                        activeEngine = engine
+                        continuation.resume(instance)
+                    } else {
+                        instance.shutdown()
+                        continuation.resume(null)
+                    }
+                }
+                instance = if (engine.isBlank()) {
+                    TextToSpeech(appContext, listener)
+                } else {
+                    TextToSpeech(appContext, listener, engine)
+                }
+                continuation.invokeOnCancellation { instance.shutdown() }
+            }
+        }
+    }
+}

+ 88 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/SystemTtsVoiceCatalog.kt

@@ -0,0 +1,88 @@
+package io.legado.app.help.readaloud.playback
+
+import android.content.Context
+import android.speech.tts.TextToSpeech
+import io.legado.app.domain.model.readaloud.TtsEngineDescriptor
+import io.legado.app.domain.model.readaloud.TtsEngineKind
+import io.legado.app.domain.model.readaloud.TtsNativeVoice
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.suspendCancellableCoroutine
+import kotlinx.coroutines.withContext
+import kotlin.coroutines.resume
+
+class SystemTtsVoiceCatalog(context: Context) {
+
+    private val appContext = context.applicationContext
+
+    suspend fun getEngines(): List<TtsEngineDescriptor> = withTts("") { tts ->
+        tts.engines
+            .distinctBy { it.name }
+            .map { engine ->
+                TtsEngineDescriptor(
+                    id = engineId(engine.name),
+                    kind = TtsEngineKind.System,
+                    sourceId = engine.name,
+                    displayName = engine.label.ifBlank { engine.name },
+                    providerName = engine.name,
+                    supportsVoiceDiscovery = true,
+                )
+            }
+            .sortedBy { it.displayName.lowercase() }
+    } ?: emptyList()
+
+    suspend fun getVoices(enginePackage: String): List<TtsNativeVoice> =
+        withTts(enginePackage) { tts ->
+            tts.voices.orEmpty()
+                .map { voice ->
+                    TtsNativeVoice(
+                        id = voice.name,
+                        engineId = engineId(enginePackage),
+                        displayName = voice.name,
+                        locale = voice.locale.toLanguageTag(),
+                        quality = voice.quality,
+                        latency = voice.latency,
+                        requiresNetwork = voice.isNetworkConnectionRequired,
+                    )
+                }
+                .sortedWith(compareBy<TtsNativeVoice> { it.locale }.thenBy { it.displayName })
+        } ?: emptyList()
+
+    private suspend fun <T> withTts(
+        enginePackage: String,
+        block: (TextToSpeech) -> T,
+    ): T? = withContext(Dispatchers.Main.immediate) {
+        val tts = createTts(enginePackage) ?: return@withContext null
+        try {
+            block(tts)
+        } finally {
+            tts.stop()
+            tts.shutdown()
+        }
+    }
+
+    private suspend fun createTts(enginePackage: String): TextToSpeech? =
+        suspendCancellableCoroutine { continuation ->
+            var instance: TextToSpeech? = null
+            val listener = TextToSpeech.OnInitListener { status ->
+                val initialized = instance
+                if (!continuation.isActive) {
+                    initialized?.shutdown()
+                } else if (status == TextToSpeech.SUCCESS) {
+                    continuation.resume(initialized)
+                } else {
+                    initialized?.shutdown()
+                    continuation.resume(null)
+                }
+            }
+            instance = if (enginePackage.isBlank()) {
+                TextToSpeech(appContext, listener)
+            } else {
+                TextToSpeech(appContext, listener, enginePackage)
+            }
+            continuation.invokeOnCancellation { instance?.shutdown() }
+        }
+
+    companion object {
+        fun engineId(sourceId: String): String = "system:$sourceId"
+    }
+}

+ 86 - 0
app/src/main/java/io/legado/app/help/readaloud/playback/VolcengineCloudTtsProvider.kt

@@ -0,0 +1,86 @@
+package io.legado.app.help.readaloud.playback
+
+import android.util.Base64
+import io.legado.app.domain.model.readaloud.CloudTtsAudio
+import io.legado.app.domain.model.readaloud.CloudTtsEngine
+import io.legado.app.domain.model.readaloud.CloudTtsProviderType
+import io.legado.app.domain.model.readaloud.CloudTtsSynthesisRequest
+import io.legado.app.domain.model.readaloud.CloudTtsVoiceDescriptor
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import org.json.JSONObject
+import java.util.UUID
+
+internal class VolcengineCloudTtsProvider : CloudTtsProvider {
+    override val type = CloudTtsProviderType.Volcengine
+
+    override fun fetchVoices(engine: CloudTtsEngine): List<CloudTtsVoiceDescriptor> = BUILT_IN_VOICES
+
+    override fun synthesize(
+        engine: CloudTtsEngine,
+        request: CloudTtsSynthesisRequest,
+    ): CloudTtsAudio {
+        require(engine.appId.isNotBlank()) { "火山引擎 App ID 不能为空" }
+        require(engine.apiKey.isNotBlank()) { "火山引擎 Access Token 不能为空" }
+        require(request.speed.isFinite() && request.speed in 0.2f..3f) {
+            "火山引擎语速必须在 0.2 到 3.0 之间"
+        }
+        require(request.pitch.isFinite() && request.pitch in 0.1f..3f) {
+            "火山引擎音调必须在 0.1 到 3.0 之间"
+        }
+        require(request.volume.isFinite() && request.volume in 0.1f..3f) {
+            "火山引擎音量必须在 0.1 到 3.0 之间"
+        }
+        val format = request.format.lowercase()
+        require(format in FORMATS) { "火山引擎不支持音频格式:$format" }
+        val options = runCatching { JSONObject(engine.optionsJson) }.getOrElse { JSONObject() }
+        val body = JSONObject().apply {
+            put("app", JSONObject()
+                .put("appid", engine.appId)
+                .put("token", engine.apiKey)
+                .put("cluster", options.optString("cluster", "volcano_tts")))
+            put("user", JSONObject().put("uid", options.optString("uid", "legado")))
+            put("audio", JSONObject()
+                .put("voice_type", request.voiceId)
+                .put("encoding", format)
+                .put("speed_ratio", request.speed)
+                .put("volume_ratio", request.volume)
+                .put("pitch_ratio", request.pitch)
+                .apply { if (request.style.isNotBlank()) put("emotion", request.style) })
+            put("request", JSONObject()
+                .put("reqid", UUID.randomUUID().toString())
+                .put("text", request.text)
+                .put("text_type", "plain")
+                .put("operation", "query"))
+        }
+        val httpRequest = Request.Builder()
+            .url(engine.baseUrl.ifBlank { DEFAULT_ENDPOINT })
+            .header("Authorization", "Bearer;${engine.apiKey}")
+            .header("Content-Type", "application/json")
+            .post(body.toString().toRequestBody(JSON_MEDIA_TYPE))
+            .build()
+        return cloudTtsHttpClient.newCall(httpRequest).execute().use { response ->
+            val json = JSONObject(response.requireCloudTtsSuccessful("火山引擎 TTS").body.string())
+            val code = json.optInt("code")
+            require(code == 0 || code == 3000) {
+                "火山引擎 TTS 请求失败($code):${json.optString("message")}"
+            }
+            val data = json.optString("data")
+            require(data.isNotBlank()) { "火山引擎 TTS 没有返回音频数据" }
+            CloudTtsAudio(Base64.decode(data, Base64.DEFAULT), format)
+        }
+    }
+
+    private companion object {
+        const val DEFAULT_ENDPOINT = "https://openspeech.bytedance.com/api/v1/tts"
+        val JSON_MEDIA_TYPE = "application/json".toMediaType()
+        val FORMATS = setOf("mp3", "ogg_opus", "wav", "pcm")
+        val BUILT_IN_VOICES = listOf(
+            CloudTtsVoiceDescriptor("BV001_streaming", "通用女声", "zh-CN", "Female"),
+            CloudTtsVoiceDescriptor("BV002_streaming", "通用男声", "zh-CN", "Male"),
+            CloudTtsVoiceDescriptor("BV700_streaming", "灿灿", "zh-CN", "Female"),
+            CloudTtsVoiceDescriptor("BV701_streaming", "擎苍", "zh-CN", "Male"),
+        )
+    }
+}

+ 108 - 0
app/src/main/java/io/legado/app/help/readaloud/resolve/LocalCharacterSpeakerResolver.kt

@@ -0,0 +1,108 @@
+package io.legado.app.help.readaloud.resolve
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.ChapterSpeechSegment
+import io.legado.app.domain.model.readaloud.SpeakerCharacter
+import io.legado.app.domain.model.readaloud.SpeechResolutionSource
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+
+object LocalCharacterSpeakerResolver {
+
+    const val VERSION = "local-character-resolver-v1"
+
+    private const val CONTEXT_LENGTH = 64
+    private val speechVerb =
+        "(?:说道|说|问道|问|答道|答|喊道|喊|叫道|叫|喝道|笑道|低声道|沉声道|怒道|开口道)"
+    private val thoughtVerb = "(?:心想|心道|暗道|想道)"
+
+    fun resolve(
+        paragraphs: List<CanonicalSpeechParagraph>,
+        segments: List<ChapterSpeechSegment>,
+        characters: List<SpeakerCharacter>,
+    ): List<ChapterSpeechSegment> {
+        if (segments.isEmpty() || characters.isEmpty()) return segments
+        val paragraphsByIndex = paragraphs.associateBy(CanonicalSpeechParagraph::index)
+        val aliases = buildAliasCandidates(characters)
+        if (aliases.isEmpty()) return segments
+
+        return segments.map { segment ->
+            if (!segment.shouldResolve) return@map segment
+            val paragraph = paragraphsByIndex[segment.paragraphIndex] ?: return@map segment
+            val start = segment.start.coerceIn(0, paragraph.text.length)
+            val end = segment.end.coerceIn(start, paragraph.text.length)
+            val before = paragraph.text.substring(0, start).takeLast(CONTEXT_LENGTH)
+            val after = paragraph.text.substring(end).take(CONTEXT_LENGTH)
+            val matches = aliases.asSequence()
+                .filter { candidate ->
+                    candidate.matchesBefore(before, segment.roleType) ||
+                        candidate.matchesAfter(after)
+                }
+                .map(AliasCandidate::character)
+                .distinctBy(SpeakerCharacter::id)
+                .take(2)
+                .toList()
+            val character = matches.singleOrNull() ?: return@map segment
+            segment.copy(
+                characterId = character.id,
+                characterName = character.name,
+                confidence = maxOf(segment.confidence, 0.94f),
+                source = SpeechResolutionSource.Local,
+            )
+        }
+    }
+
+    private val ChapterSpeechSegment.shouldResolve: Boolean
+        get() = !userLocked &&
+            characterId == null &&
+            (roleType == SpeechRoleType.Character || roleType == SpeechRoleType.Thought)
+
+    private fun buildAliasCandidates(characters: List<SpeakerCharacter>): List<AliasCandidate> {
+        val ownersByAlias = linkedMapOf<String, MutableList<SpeakerCharacter>>()
+        characters.forEach { character ->
+            (listOf(character.name) + character.aliases)
+                .map { it.normalizeAlias() }
+                .filter { it.isNotBlank() }
+                .distinct()
+                .forEach { alias -> ownersByAlias.getOrPut(alias, ::mutableListOf) += character }
+        }
+        return ownersByAlias.mapNotNull { (alias, owners) ->
+            owners.distinctBy(SpeakerCharacter::id).singleOrNull()?.let { AliasCandidate(alias, it) }
+        }.sortedByDescending { it.alias.length }
+    }
+
+    private fun String.normalizeAlias(): String = trim()
+        .trim('“', '”', '‘', '’', '"', '\'', '《', '》', ',', ',', '。', ':', ':')
+        .replace(Regex("\\s+"), "")
+
+    private data class AliasCandidate(
+        val alias: String,
+        val character: SpeakerCharacter,
+    ) {
+        private val escapedAlias = Regex.escape(alias)
+        private val beforeSpeechRegex = Regex(
+            "(?<![\\p{L}\\p{N}_])$escapedAlias\\s*(?:$speechVerb|$thoughtVerb)?\\s*[::]\\s*$"
+        )
+        private val beforeVerbRegex = Regex(
+            "(?<![\\p{L}\\p{N}_])$escapedAlias\\s*(?:$speechVerb|$thoughtVerb)\\s*[,,\\s]*$"
+        )
+        private val afterRegex = Regex(
+            "^[,,。.!!??\\s]*(?<![\\p{L}\\p{N}_])$escapedAlias\\s*(?:$speechVerb|$thoughtVerb)"
+        )
+
+        fun matchesBefore(context: String, roleType: SpeechRoleType): Boolean {
+            val regex = if (roleType == SpeechRoleType.Thought) {
+                Regex(
+                    "(?<![\\p{L}\\p{N}_])$escapedAlias\\s*$thoughtVerb\\s*[,,::\\s]*$"
+                )
+            } else {
+                null
+            }
+            return regex?.containsMatchIn(context) == true ||
+                beforeSpeechRegex.containsMatchIn(context) ||
+                beforeVerbRegex.containsMatchIn(context)
+        }
+
+        fun matchesAfter(context: String): Boolean =
+            afterRegex.containsMatchIn(context)
+    }
+}

+ 65 - 0
app/src/main/java/io/legado/app/help/readaloud/segment/AiSpeechAtomizer.kt

@@ -0,0 +1,65 @@
+package io.legado.app.help.readaloud.segment
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+
+data class AiSpeechAtom(
+    val id: String,
+    val paragraphIndex: Int,
+    val start: Int,
+    val end: Int,
+    val text: String,
+)
+
+object AiSpeechAtomizer {
+    fun atomize(paragraph: CanonicalSpeechParagraph): List<AiSpeechAtom> {
+        if (paragraph.text.isEmpty()) return emptyList()
+        val boundaries = sortedSetOf(0, paragraph.text.length)
+        var straightDoubleOpen = false
+        var straightSingleOpen = false
+        var index = 0
+        while (index < paragraph.text.length) {
+            val char = paragraph.text[index]
+            when {
+                char == '"' -> {
+                    if (straightDoubleOpen) boundaries += index + 1 else boundaries += index
+                    straightDoubleOpen = !straightDoubleOpen
+                }
+                char == '\'' && !paragraph.text.isWordApostrophe(index) -> {
+                    if (straightSingleOpen) boundaries += index + 1 else boundaries += index
+                    straightSingleOpen = !straightSingleOpen
+                }
+                char in OPEN_QUOTES -> boundaries += index
+                char in CLOSE_QUOTES -> boundaries += index + 1
+            }
+            if (char in SENTENCE_ENDS) {
+                var end = index + 1
+                while (end < paragraph.text.length && paragraph.text[end] in ALL_CLOSE_QUOTES) {
+                    if (paragraph.text[end] == '"') straightDoubleOpen = false
+                    if (paragraph.text[end] == '\'') straightSingleOpen = false
+                    end++
+                }
+                boundaries += end
+                index = end - 1
+            }
+            index++
+        }
+        return boundaries.zipWithNext().mapNotNull { (start, end) ->
+            if (start >= end) null else AiSpeechAtom(
+                id = "p${paragraph.index}:$start:$end",
+                paragraphIndex = paragraph.index,
+                start = start,
+                end = end,
+                text = paragraph.text.substring(start, end),
+            )
+        }
+    }
+
+    private fun String.isWordApostrophe(index: Int): Boolean =
+        getOrNull(index - 1)?.isLetterOrDigit() == true &&
+            getOrNull(index + 1)?.isLetterOrDigit() == true
+
+    private val OPEN_QUOTES = setOf('“', '‘', '「', '『')
+    private val CLOSE_QUOTES = setOf('”', '’', '」', '』')
+    private val ALL_CLOSE_QUOTES = CLOSE_QUOTES + setOf('"', '\'')
+    private val SENTENCE_ENDS = setOf('。', '!', '?', '!', '?', ';', ';')
+}

+ 211 - 0
app/src/main/java/io/legado/app/help/readaloud/segment/RuleBasedSpeechSegmenter.kt

@@ -0,0 +1,211 @@
+package io.legado.app.help.readaloud.segment
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.domain.model.readaloud.SpeechSegmentDraft
+
+/**
+ * Performs deterministic, pagination-independent speech segmentation.
+ *
+ * Every non-empty input paragraph is fully covered by ordered, non-overlapping output ranges.
+ * Character identity resolution is intentionally left to a later stage.
+ */
+object RuleBasedSpeechSegmenter {
+
+    const val VERSION = "rule-segmenter-v2-emotion"
+
+    private val quotePairs = mapOf(
+        '“' to '”',
+        '‘' to '’',
+        '「' to '」',
+        '『' to '』',
+        '"' to '"',
+        '\'' to '\'',
+    )
+    private val sentencePunctuation = setOf('。', '!', '?', '…', '!', '?')
+    private val thoughtCueRegex = Regex("(?:心想|心道|暗道|想道)[,,::\\s]*$")
+    private val speechCueRegex = Regex(
+        "(?:说|说道|问|问道|答|答道|喊|喊道|叫|叫道|喝道|笑道|低声道|沉声道|怒道|开口道)$"
+    )
+
+    fun segment(paragraphs: List<CanonicalSpeechParagraph>): List<SpeechSegmentDraft> {
+        if (paragraphs.isEmpty()) return emptyList()
+        val result = mutableListOf<SpeechSegmentDraft>()
+        var openQuote: OpenQuote? = null
+
+        paragraphs.forEach { paragraph ->
+            val text = paragraph.text
+            if (text.isEmpty()) return@forEach
+            var cursor = 0
+
+            openQuote?.let { quote ->
+                val close = text.indexOf(quote.close)
+                if (close < 0) {
+                    result += paragraph.segment(
+                        0,
+                        text.length,
+                        quote.roleType,
+                        0.68f,
+                        quote.emotion,
+                    )
+                    return@forEach
+                }
+                result += paragraph.segment(0, close + 1, quote.roleType, 0.68f, quote.emotion)
+                cursor = close + 1
+                openQuote = null
+            }
+
+            while (cursor < text.length) {
+                val openIndex = text.indexOfNextQuote(cursor)
+                if (openIndex < 0) {
+                    addPlainSegments(result, paragraph, cursor, text.length)
+                    break
+                }
+                addPlainSegments(result, paragraph, cursor, openIndex)
+                val open = text[openIndex]
+                val closeChar = quotePairs.getValue(open)
+                val closeIndex = text.indexOf(closeChar, openIndex + 1)
+                val roleType = quoteRole(text, openIndex, closeIndex)
+                val context = text.substring((openIndex - 32).coerceAtLeast(0), openIndex)
+                val spokenText = text.substring(
+                    openIndex + 1,
+                    if (closeIndex < 0) text.length else closeIndex,
+                )
+                val emotion = if (roleType == SpeechRoleType.Narrator) {
+                    ""
+                } else {
+                    SpeechEmotionDetector.detect(spokenText, context).storageValue
+                }
+                if (closeIndex < 0) {
+                    result += paragraph.segment(
+                        openIndex,
+                        text.length,
+                        roleType,
+                        0.68f,
+                        emotion,
+                    )
+                    openQuote = OpenQuote(closeChar, roleType, emotion)
+                    break
+                }
+                result += paragraph.segment(
+                    openIndex,
+                    closeIndex + 1,
+                    roleType,
+                    0.72f,
+                    emotion,
+                )
+                cursor = closeIndex + 1
+            }
+        }
+
+        return mergeAdjacent(result)
+    }
+
+    private fun addPlainSegments(
+        output: MutableList<SpeechSegmentDraft>,
+        paragraph: CanonicalSpeechParagraph,
+        start: Int,
+        end: Int,
+    ) {
+        if (start >= end) return
+        val text = paragraph.text
+        val colon = (start until end).firstOrNull { text[it] == ':' || text[it] == ':' }
+        if (colon != null) {
+            val cue = text.substring(start, colon).trimEnd()
+            if (cue.length in 1..24 && speechCueRegex.containsMatchIn(cue)) {
+                output += paragraph.segment(start, colon + 1, SpeechRoleType.Narrator, 0.9f)
+                if (colon + 1 < end) {
+                    val emotion = SpeechEmotionDetector.detect(
+                        text.substring(colon + 1, end),
+                        cue,
+                    ).storageValue
+                    output += paragraph.segment(
+                        colon + 1,
+                        end,
+                        SpeechRoleType.Character,
+                        0.58f,
+                        emotion,
+                    )
+                }
+                return
+            }
+        }
+        output += paragraph.segment(start, end, SpeechRoleType.Narrator, 0.95f)
+    }
+
+    private fun quoteRole(text: String, openIndex: Int, closeIndex: Int): SpeechRoleType {
+        val contextBefore = text.substring((openIndex - 24).coerceAtLeast(0), openIndex)
+        if (thoughtCueRegex.containsMatchIn(contextBefore)) return SpeechRoleType.Thought
+        if (closeIndex < 0) return SpeechRoleType.Character
+        val inner = text.substring(openIndex + 1, closeIndex).trim()
+        return if (inner.length >= 6 || inner.any(sentencePunctuation::contains)) {
+            SpeechRoleType.Character
+        } else {
+            SpeechRoleType.Narrator
+        }
+    }
+
+    private fun String.indexOfNextQuote(start: Int): Int {
+        var index = start
+        while (index < length) {
+            val value = this[index]
+            if (value in quotePairs && !isWordApostrophe(index)) return index
+            index++
+        }
+        return -1
+    }
+
+    private fun String.isWordApostrophe(index: Int): Boolean {
+        if (this[index] != '\'') return false
+        return getOrNull(index - 1)?.isLetterOrDigit() == true &&
+            getOrNull(index + 1)?.isLetterOrDigit() == true
+    }
+
+    private fun CanonicalSpeechParagraph.segment(
+        start: Int,
+        end: Int,
+        roleType: SpeechRoleType,
+        confidence: Float,
+        emotion: String = "",
+    ) = SpeechSegmentDraft(
+        paragraphIndex = index,
+        start = start,
+        end = end,
+        chapterPosition = chapterPosition + start,
+        text = text.substring(start, end),
+        roleType = roleType,
+        confidence = confidence,
+        emotion = emotion,
+    )
+
+    private fun mergeAdjacent(segments: List<SpeechSegmentDraft>): List<SpeechSegmentDraft> {
+        if (segments.size < 2) return segments
+        val result = mutableListOf<SpeechSegmentDraft>()
+        segments.forEach { segment ->
+            val previous = result.lastOrNull()
+            if (
+                previous != null &&
+                previous.paragraphIndex == segment.paragraphIndex &&
+                previous.end == segment.start &&
+                previous.roleType == segment.roleType &&
+                previous.source == segment.source &&
+                previous.emotion == segment.emotion
+            ) {
+                result[result.lastIndex] = previous.copy(
+                    end = segment.end,
+                    text = previous.text + segment.text,
+                    confidence = minOf(previous.confidence, segment.confidence),
+                )
+            } else {
+                result += segment
+            }
+        }
+        return result
+    }
+
+    private data class OpenQuote(
+        val close: Char,
+        val roleType: SpeechRoleType,
+        val emotion: String,
+    )
+}

+ 30 - 0
app/src/main/java/io/legado/app/help/readaloud/segment/SpeechEmotionDetector.kt

@@ -0,0 +1,30 @@
+package io.legado.app.help.readaloud.segment
+
+import io.legado.app.domain.model.readaloud.SpeechEmotion
+
+/** Conservative deterministic emotion detection for explicit prose cues. */
+object SpeechEmotionDetector {
+    private val angry = Regex("怒道|怒吼|吼道|咆哮|暴喝|厉声|愤怒|恼怒|气急|混蛋|该死")
+    private val fearful = Regex("惊恐|恐惧|害怕|畏惧|颤声|颤抖|发抖|毛骨悚然|不寒而栗")
+    private val sad = Regex("哭道|哭喊|抽泣|哽咽|悲伤|悲痛|伤心|泪流|落泪|啜泣")
+    private val disgusted = Regex("厌恶|嫌恶|恶心|鄙夷|不屑|嗤之以鼻")
+    private val cheerful = Regex("笑道|笑着|大笑|轻笑|开心|高兴|欣喜|兴奋|欢呼|喜悦")
+    private val surprised = Regex("惊讶|震惊|惊呼|失声道|难以置信|怎么可能|竟然|居然")
+    private val whispering = Regex("低声|轻声|小声|耳语|喃喃|呢喃|悄声|压低声音")
+    private val calm = Regex("平静|淡淡地|淡然|从容|冷静|不紧不慢")
+
+    fun detect(text: String, context: String = ""): SpeechEmotion {
+        val sample = "$context$text"
+        return when {
+            angry.containsMatchIn(sample) -> SpeechEmotion.Angry
+            fearful.containsMatchIn(sample) -> SpeechEmotion.Fearful
+            sad.containsMatchIn(sample) -> SpeechEmotion.Sad
+            disgusted.containsMatchIn(sample) -> SpeechEmotion.Disgusted
+            cheerful.containsMatchIn(sample) -> SpeechEmotion.Cheerful
+            surprised.containsMatchIn(sample) -> SpeechEmotion.Surprised
+            whispering.containsMatchIn(sample) -> SpeechEmotion.Whispering
+            calm.containsMatchIn(sample) -> SpeechEmotion.Calm
+            else -> SpeechEmotion.Neutral
+        }
+    }
+}

+ 19 - 0
app/src/main/java/io/legado/app/help/readaloud/segment/TextChapterSpeechAdapter.kt

@@ -0,0 +1,19 @@
+package io.legado.app.help.readaloud.segment
+
+import io.legado.app.domain.model.readaloud.CanonicalSpeechParagraph
+import io.legado.app.ui.book.read.page.entities.TextChapter
+
+fun TextChapter.toCanonicalSpeechParagraphs(): List<CanonicalSpeechParagraph> {
+    if (!isCompleted) return emptyList()
+    return paragraphs
+        .asSequence()
+        .filter { it.textLines.isNotEmpty() }
+        .mapIndexed { fallbackIndex, paragraph ->
+            CanonicalSpeechParagraph(
+                index = (paragraph.realNum - 1).takeIf { it >= 0 } ?: fallbackIndex,
+                text = paragraph.text,
+                chapterPosition = paragraph.chapterPosition,
+            )
+        }
+        .toList()
+}

+ 58 - 0
app/src/main/java/io/legado/app/model/ReadAloud.kt

@@ -8,48 +8,106 @@ 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.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.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
 
 object ReadAloud {
     private var aloudClass: Class<*> = getReadAloudClass()
     val ttsEngine get() = ReadBook.book?.getTtsEngine() ?: ReadConfig.ttsEngine
     var httpTTS: HttpTTS? = null
+    var coordinatorDefaultEngineType: String = ReadAloudVoice.ENGINE_SYSTEM
+        private set
+    var coordinatorDefaultEngineId: String = ""
+        private set
 
     private fun getReadAloudClass(): Class<*> {
         val ttsEngine = ttsEngine
         if (ttsEngine.isNullOrBlank()) {
+            setSystemCoordinatorDefault(ttsEngine)
+            findCoordinatorHttpSeed()?.let {
+                httpTTS = it
+                return HttpReadAloudService::class.java
+            }
             return TTSReadAloudService::class.java
         }
         if (StringUtils.isNumeric(ttsEngine)) {
             httpTTS = appDb.httpTTSDao.get(ttsEngine.toLong())
             if (httpTTS != null) {
+                coordinatorDefaultEngineType = ReadAloudVoice.ENGINE_HTTP
+                coordinatorDefaultEngineId = ttsEngine
                 return HttpReadAloudService::class.java
             }
         }
+        setSystemCoordinatorDefault(ttsEngine)
+        findCoordinatorHttpSeed()?.let {
+            httpTTS = it
+            return HttpReadAloudService::class.java
+        }
         return TTSReadAloudService::class.java
     }
 
+    private fun setSystemCoordinatorDefault(serializedEngine: String?) {
+        coordinatorDefaultEngineType = ReadAloudVoice.ENGINE_SYSTEM
+        coordinatorDefaultEngineId = GSON.fromJsonObject<SelectItem<String>>(serializedEngine)
+            .getOrNull()?.value.orEmpty()
+    }
+
+    private fun findCoordinatorHttpSeed(): HttpTTS? {
+        if (!ReadConfig.useMultiSpeaker) return null
+        return runCatching {
+            val bookUrl = ReadBook.book?.bookUrl ?: return@runCatching null
+            val boundVoices = runBlocking {
+                val voiceIds = appDb.readAloudVoiceDao.getBindings(bookUrl)
+                    .mapTo(hashSetOf()) { it.voiceId }
+                appDb.readAloudVoiceDao.getVoices().filter {
+                    it.id in voiceIds && it.engineType in setOf(
+                        ReadAloudVoice.ENGINE_HTTP,
+                        ReadAloudVoice.ENGINE_CLOUD,
+                    ) &&
+                        it.enabled && it.available
+                }
+            }
+            if (boundVoices.isEmpty()) return@runCatching null
+            boundVoices.firstOrNull { it.engineType == ReadAloudVoice.ENGINE_HTTP }
+                ?.engineId?.toLongOrNull()?.let(appDb.httpTTSDao::get)
+                ?: appDb.httpTTSDao.all.firstOrNull()
+                ?: HttpTTS(id = Long.MIN_VALUE, name = "TTS coordinator")
+        }.getOrNull()
+    }
+
     fun upReadAloudClass() {
         stop(appCtx)
         aloudClass = getReadAloudClass()
     }
 
+    /** Re-evaluates the configured engine after the current service has stopped. */
+    fun refreshReadAloudClass() {
+        aloudClass = getReadAloudClass()
+    }
+
     fun play(
         context: Context,
         play: Boolean = true,
         pageIndex: Int = ReadBook.durPageIndex,
         startPos: Int = 0
     ) {
+        if (!BaseReadAloudService.isRun) {
+            aloudClass = getReadAloudClass()
+        }
         val intent = Intent(context, aloudClass)
         intent.action = IntentAction.play
         intent.putExtra("play", play)

+ 31 - 0
app/src/main/java/io/legado/app/model/ReadAloudSessionStore.kt

@@ -0,0 +1,31 @@
+package io.legado.app.model
+
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
+import io.legado.app.domain.model.readaloud.ReadAloudSessionState
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+
+/** Process-local source of truth for the active read-aloud session. */
+class ReadAloudSessionStore {
+
+    private val _state = MutableStateFlow(ReadAloudSessionState())
+    val state = _state.asStateFlow()
+
+    fun setStatus(status: ReadAloudSessionStatus) {
+        _state.update { it.copy(status = status) }
+    }
+
+    fun updatePlayback(playback: ReadAloudPlaybackInfo) {
+        _state.update { it.copy(playback = playback) }
+    }
+
+    fun updateTimer(minutes: Int) {
+        _state.update { it.copy(timerMinutes = minutes) }
+    }
+
+    fun stop() {
+        _state.value = ReadAloudSessionState()
+    }
+}

+ 181 - 19
app/src/main/java/io/legado/app/service/BaseReadAloudService.kt

@@ -36,6 +36,14 @@ import io.legado.app.constant.PreferKey
 import io.legado.app.constant.Status
 import io.legado.app.domain.model.PlaybackTimer
 import io.legado.app.help.MediaHelp
+import io.legado.app.domain.model.readaloud.SpeechPlanItem
+import io.legado.app.domain.model.readaloud.SpeechAnalysisMode
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackInfo
+import io.legado.app.domain.model.readaloud.ReadAloudSessionStatus
+import io.legado.app.domain.usecase.PrepareChapterSpeechPlanUseCase
+import io.legado.app.help.readaloud.segment.toCanonicalSpeechParagraphs
 import io.legado.app.help.config.AppConfig
 import io.legado.app.help.config.AppConfigStore
 import io.legado.app.ui.config.readConfig.ReadConfig
@@ -44,6 +52,7 @@ import io.legado.app.help.glide.ImageLoader
 import io.legado.app.lib.permission.Permissions
 import io.legado.app.lib.permission.PermissionsCompat
 import io.legado.app.model.ReadAloud
+import io.legado.app.model.ReadAloudSessionStore
 import io.legado.app.model.ReadBook
 import io.legado.app.receiver.MediaButtonReceiver
 import io.legado.app.ui.book.read.page.entities.TextChapter
@@ -62,6 +71,7 @@ import kotlinx.coroutines.flow.drop
 import kotlinx.coroutines.flow.merge
 import kotlinx.coroutines.isActive
 import kotlinx.coroutines.launch
+import org.koin.java.KoinJavaComponent.get
 import splitties.init.appCtx
 import splitties.systemservices.audioManager
 import splitties.systemservices.notificationManager
@@ -108,6 +118,10 @@ abstract class BaseReadAloudService : BaseService(),
 
     }
 
+    private val sessionStore: ReadAloudSessionStore by lazy {
+        get(ReadAloudSessionStore::class.java)
+    }
+
     private val useWakeLock = ReadConfig.readAloudWakeLock
     private val wakeLock by lazy {
         powerManager.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "legado:ReadAloudService")
@@ -132,6 +146,10 @@ abstract class BaseReadAloudService : BaseService(),
         ReadAloudPhoneStateListener()
     }
     internal var contentList = emptyList<String>()
+    /** Canonical, character-aware plan for the current chapter. Playback adoption is incremental. */
+    internal var speechPlan = emptyList<SpeechPlanItem>()
+    internal var playbackQueue = ReadAloudPlaybackQueue.Empty
+    internal var playbackCursor: ReadAloudPlaybackCursor? = null
     internal var nowSpeak: Int = 0
     internal var readAloudNumber: Int = 0
     internal var textChapter: TextChapter? = null
@@ -141,6 +159,8 @@ abstract class BaseReadAloudService : BaseService(),
     private var registeredPhoneStateListener = false
     private var dsJob: Job? = null
     private var upNotificationJob: Coroutine<*>? = null
+    private var prepareReadAloudJob: Coroutine<*>? = null
+    private var prepareReadAloudGeneration = 0L
     private var cover: Bitmap =
         BitmapFactory.decodeResource(appCtx.resources, R.drawable.ic_launcher)
     var pageChanged = false
@@ -148,6 +168,9 @@ abstract class BaseReadAloudService : BaseService(),
     var paragraphStartPos = 0
     var readAloudByPage = false
         private set
+    protected open val useSpeechPlaybackQueue: Boolean = false
+    protected val hasSpeechPlaybackQueue: Boolean
+        get() = useSpeechPlaybackQueue && !playbackQueue.isEmpty
 
     private val broadcastReceiver = object : BroadcastReceiver() {
         override fun onReceive(context: Context, intent: Intent) {
@@ -205,12 +228,15 @@ abstract class BaseReadAloudService : BaseService(),
     override fun onDestroy() {
         ReadBook.upReadTime()
         super.onDestroy()
+        prepareReadAloudGeneration++
+        prepareReadAloudJob?.cancel()
         if (useWakeLock) {
             wakeLock.release()
             wifiLock?.release()
         }
         isRun = false
         pause = true
+        sessionStore.stop()
         currentChapterIndex = -1
         currentProgress = 0
         abandonFocus()
@@ -254,20 +280,32 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun newReadAloud(play: Boolean, pageIndex: Int, startPos: Int) {
-        execute(executeContext = IO) {
-            this@BaseReadAloudService.pageIndex = pageIndex
-            textChapter = ReadBook.curTextChapter
-            val textChapter = textChapter ?: return@execute
-            if (!textChapter.isCompleted) {
+        val generation = ++prepareReadAloudGeneration
+        prepareReadAloudJob?.cancel()
+        prepareReadAloudJob = execute(executeContext = IO) {
+            val preparedChapter = ReadBook.curTextChapter ?: return@execute
+            if (!preparedChapter.isCompleted) {
                 return@execute
             }
-            readAloudNumber = textChapter.getReadLength(pageIndex) + startPos
-            readAloudByPage = ReadConfig.readAloudByPage
-            contentList = textChapter.getNeedReadAloud(0, readAloudByPage, 0)
+            val preparedReadAloudByPage = ReadConfig.readAloudByPage
+            var preparedReadAloudNumber = preparedChapter.getReadLength(pageIndex) + startPos
+            var preparedContentList = preparedChapter.getNeedReadAloud(0, preparedReadAloudByPage, 0)
                 .split("\n")
                 .filter { it.isNotEmpty() }
+            val preparedSpeechPlan = buildSpeechPlan(
+                bookUrl = ReadBook.book?.bookUrl.orEmpty(),
+                chapterIndex = ReadBook.durChapterIndex,
+                textChapter = preparedChapter,
+            )
+            if (generation != prepareReadAloudGeneration) return@execute
+            val preparedPlaybackQueue = runCatching {
+                ReadAloudPlaybackQueue.from(preparedSpeechPlan)
+            }.onFailure {
+                AppLog.put("创建多角色播放队列失败,使用原朗读方式\n${it.localizedMessage}", it)
+            }.getOrDefault(ReadAloudPlaybackQueue.Empty)
+            var preparedPlaybackCursor = preparedPlaybackQueue.cursorAt(preparedReadAloudNumber)
             var pos = startPos
-            val page = textChapter.getPage(pageIndex)!!
+            val page = preparedChapter.getPage(pageIndex) ?: return@execute
             if (pos > 0) {
                 for (paragraph in page.paragraphs) {
                     val tmp = pos - paragraph.length - 1
@@ -275,23 +313,49 @@ abstract class BaseReadAloudService : BaseService(),
                     pos = tmp
                 }
             }
-            nowSpeak = textChapter.getParagraphNum(readAloudNumber + 1, readAloudByPage) - 1
-            if (!readAloudByPage && startPos == 0 && !toLast) {
+            var preparedNowSpeak = preparedChapter.getParagraphNum(
+                preparedReadAloudNumber + 1,
+                preparedReadAloudByPage,
+            ) - 1
+            val moveToLast = toLast
+            if (!preparedReadAloudByPage && startPos == 0 && !moveToLast) {
                 pos = page.chapterPosition -
-                        textChapter.paragraphs[nowSpeak].chapterPosition
+                        preparedChapter.paragraphs[preparedNowSpeak].chapterPosition
             }
-            if (toLast) {
-                toLast = false
-                readAloudNumber = textChapter.getLastParagraphPosition()
-                nowSpeak = contentList.lastIndex
+            if (moveToLast) {
+                preparedReadAloudNumber = preparedChapter.getLastParagraphPosition()
+                preparedNowSpeak = preparedContentList.lastIndex
                 if (page.paragraphs.size == 1) {
                     pos = page.chapterPosition -
-                            textChapter.paragraphs[nowSpeak].chapterPosition
+                            preparedChapter.paragraphs[preparedNowSpeak].chapterPosition
                 }
             }
-            updateReadAloudProgressSnapshot(readAloudNumber + 1)
-            paragraphStartPos = pos
+            var preparedParagraphStartPos = pos
+            if (useSpeechPlaybackQueue && !preparedPlaybackQueue.isEmpty) {
+                preparedPlaybackQueue.cursorAt(preparedReadAloudNumber)?.let { cursor ->
+                    preparedPlaybackCursor = cursor
+                    preparedContentList = preparedPlaybackQueue.cues.map { it.text }
+                    preparedNowSpeak = cursor.cueIndex
+                    preparedParagraphStartPos = cursor.offset
+                    preparedReadAloudNumber = preparedPlaybackQueue.cues[cursor.cueIndex].chapterStart
+                }
+            }
+            if (generation != prepareReadAloudGeneration) return@execute
+            this@BaseReadAloudService.pageIndex = pageIndex
+            textChapter = preparedChapter
+            readAloudByPage = preparedReadAloudByPage
+            contentList = preparedContentList
+            speechPlan = preparedSpeechPlan
+            playbackQueue = preparedPlaybackQueue
+            playbackCursor = preparedPlaybackCursor
+            nowSpeak = preparedNowSpeak
+            readAloudNumber = preparedReadAloudNumber
+            paragraphStartPos = preparedParagraphStartPos
+            updateReadAloudProgressSnapshot(preparedReadAloudNumber + 1)
+            if (moveToLast) toLast = false
+            preparedPlaybackCursor?.takeIf { hasSpeechPlaybackQueue }?.let(::publishPlaybackInfo)
             launch(Main) {
+                if (generation != prepareReadAloudGeneration) return@launch
                 upMediaMetadata()
                 if (play) play() else pageChanged = true
             }
@@ -300,6 +364,27 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    protected suspend fun buildSpeechPlan(
+        bookUrl: String,
+        chapterIndex: Int,
+        textChapter: TextChapter,
+    ): List<SpeechPlanItem> {
+        if (bookUrl.isEmpty() || !ReadConfig.useMultiSpeaker) return emptyList()
+        val prepareSpeechPlan: PrepareChapterSpeechPlanUseCase =
+            get(PrepareChapterSpeechPlanUseCase::class.java)
+        return runCatching {
+            prepareSpeechPlan(
+                bookUrl = bookUrl,
+                chapterIndex = chapterIndex,
+                paragraphs = textChapter.toCanonicalSpeechParagraphs(),
+                analysisMode = SpeechAnalysisMode.fromStorage(ReadConfig.speechAnalysisMode),
+                useMultiSpeaker = ReadConfig.useMultiSpeaker,
+            )
+        }.onFailure {
+            AppLog.put("生成多角色朗读计划失败,使用原朗读方式\n${it.localizedMessage}", it)
+        }.getOrDefault(emptyList())
+    }
+
     @SuppressLint("WakelockTimeout")
     open fun play() {
         if (useWakeLock) {
@@ -311,6 +396,7 @@ abstract class BaseReadAloudService : BaseService(),
         needResumeOnAudioFocusGain = false
         needResumeOnCallStateIdle = false
         upReadAloudNotification()
+        sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         if (!ReadBook.isAutoSaveSessionRunning) {
             ReadBook.startReadSession()
@@ -332,6 +418,7 @@ abstract class BaseReadAloudService : BaseService(),
         }
         upReadAloudNotification()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PAUSED)
+        sessionStore.setStatus(ReadAloudSessionStatus.Paused)
         postEvent(EventBus.ALOUD_STATE, Status.PAUSE)
         ReadBook.uploadProgress()
         doDs()
@@ -353,6 +440,7 @@ abstract class BaseReadAloudService : BaseService(),
         needResumeOnCallStateIdle = false
         upReadAloudNotification()
         upMediaSessionPlaybackState(PlaybackStateCompat.STATE_PLAYING)
+        sessionStore.setStatus(ReadAloudSessionStatus.Playing)
         postEvent(EventBus.ALOUD_STATE, Status.PLAY)
         if (!ReadBook.isAutoSaveSessionRunning) {
             ReadBook.startReadSession()
@@ -363,6 +451,21 @@ abstract class BaseReadAloudService : BaseService(),
 
     fun upTtsProgress(progress: Int) {
         ReadBook.upReadTime()
+        val chapterPosition = progress.coerceAtLeast(0)
+        if (hasSpeechPlaybackQueue) {
+            playbackQueue.cursorAt(chapterPosition)?.let(::publishPlaybackInfo)
+        } else {
+            val chapterLength = textChapter?.paragraphs?.lastOrNull()?.let { paragraph ->
+                paragraph.chapterPosition + paragraph.text.length
+            } ?: chapterPosition
+            sessionStore.updatePlayback(
+                ReadAloudPlaybackInfo(
+                    chapterPosition = chapterPosition,
+                    chapterLength = chapterLength.coerceAtLeast(1),
+                    text = contentList.getOrNull(nowSpeak).orEmpty(),
+                )
+            )
+        }
         updateReadAloudProgressSnapshot(progress)
         postEvent(EventBus.TTS_PROGRESS, progress)
     }
@@ -409,6 +512,18 @@ abstract class BaseReadAloudService : BaseService(),
 
     private fun prevP() {
         ReadBook.upReadTime()
+        if (hasSpeechPlaybackQueue) {
+            val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
+            playbackQueue.previous(current)?.let { previous ->
+                playStop()
+                moveToPlaybackCursor(previous)
+                play()
+            } ?: run {
+                toLast = true
+                ReadBook.moveToPrevChapter(true)
+            }
+            return
+        }
         if (nowSpeak > 0) {
             playStop()
             do {
@@ -436,6 +551,15 @@ abstract class BaseReadAloudService : BaseService(),
     }
 
     private fun nextP() {
+        if (hasSpeechPlaybackQueue) {
+            val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
+            playbackQueue.next(current)?.let { next ->
+                playStop()
+                moveToPlaybackCursor(next)
+                play()
+            } ?: nextChapter()
+            return
+        }
         if (nowSpeak < contentList.size - 1) {
             playStop()
             readAloudNumber += contentList[nowSpeak].length.plus(1) - paragraphStartPos
@@ -461,6 +585,41 @@ abstract class BaseReadAloudService : BaseService(),
         }
     }
 
+    protected fun moveToPlaybackCursor(cursor: ReadAloudPlaybackCursor) {
+        val cue = playbackQueue.cues[cursor.cueIndex]
+        playbackCursor = cursor
+        nowSpeak = cursor.cueIndex
+        paragraphStartPos = cursor.offset
+        readAloudNumber = cue.chapterStart
+        publishPlaybackInfo(cursor)
+        textChapter?.let { chapter ->
+            val targetPosition = cue.chapterStart + cursor.offset
+            val targetPage = chapter.getPageIndexByCharIndex(targetPosition)
+            while (pageIndex < targetPage) {
+                pageIndex++
+                ReadBook.moveToNextPage()
+            }
+            while (pageIndex > targetPage) {
+                pageIndex--
+                ReadBook.moveToPrevPage()
+            }
+            upTtsProgress(targetPosition + 1)
+        }
+        upMediaMetadata(showContent = true)
+    }
+
+    private fun publishPlaybackInfo(cursor: ReadAloudPlaybackCursor) {
+        val cue = playbackQueue.cues.getOrNull(cursor.cueIndex) ?: return
+        sessionStore.updatePlayback(ReadAloudPlaybackInfo(
+            chapterPosition = cue.chapterStart + cursor.offset,
+            chapterLength = playbackQueue.cues.lastOrNull()?.chapterEnd ?: cue.chapterEnd,
+            text = cue.text,
+            engineName = cue.voice?.displayName.orEmpty(),
+            characterName = speechPlan.getOrNull(cursor.cueIndex)?.segment?.characterName.orEmpty(),
+            roleType = cue.roleType,
+        ))
+    }
+
     private fun setTimer(minute: Int) {
         timeMinute = minute
         doDs()
@@ -476,6 +635,7 @@ abstract class BaseReadAloudService : BaseService(),
      */
     @Synchronized
     private fun doDs() {
+        sessionStore.updateTimer(timeMinute)
         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
         upReadAloudNotification()
         dsJob?.cancel()
@@ -489,10 +649,12 @@ abstract class BaseReadAloudService : BaseService(),
                     timeMinute--
                     if (timeMinute == PlaybackTimer.MIN_MINUTES) {
                         ReadAloud.stop(this@BaseReadAloudService)
+                        sessionStore.updateTimer(timeMinute)
                         postEvent(EventBus.READ_ALOUD_DS, timeMinute)
                         break
                     }
                 }
+                sessionStore.updateTimer(timeMinute)
                 postEvent(EventBus.READ_ALOUD_DS, timeMinute)
                 upReadAloudNotification()
             }

+ 212 - 27
app/src/main/java/io/legado/app/service/HttpReadAloudService.kt

@@ -32,12 +32,25 @@ import io.legado.app.data.appDb
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.HttpTTS
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackQueue
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.SpeechEngineRoute
+import io.legado.app.domain.model.readaloud.SpeechRoleType
+import io.legado.app.domain.model.readaloud.SpeechVoiceRouter
+import io.legado.app.domain.model.readaloud.SystemTtsVoiceConfig
 import io.legado.app.exception.NoStackTraceException
 import io.legado.app.help.book.BookHelp
 import io.legado.app.help.book.ContentProcessor
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.help.exoplayer.InputStreamDataSource
 import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.readaloud.playback.SystemTtsFileSynthesizer
+import io.legado.app.help.readaloud.playback.CloudTtsAudioSynthesizer
+import io.legado.app.help.readaloud.playback.CloudTtsEmotionMapper
+import io.legado.app.help.readaloud.playback.CharacterPerformanceInstructionBuilder
+import io.legado.app.help.readaloud.playback.CloudTtsRoleInstructionMapper
+import io.legado.app.domain.gateway.CloudTtsEngineGateway
 import io.legado.app.model.ReadAloud
 import io.legado.app.model.ReadBook
 import io.legado.app.model.analyzeRule.AnalyzeUrl
@@ -45,6 +58,7 @@ import io.legado.app.ui.book.read.page.entities.TextChapter
 import io.legado.app.ui.book.read.page.provider.ChapterProvider
 import io.legado.app.ui.config.readConfig.ReadConfig
 import io.legado.app.utils.FileUtils
+import io.legado.app.utils.GSON
 import io.legado.app.utils.MD5Utils
 import io.legado.app.utils.printOnDebug
 import io.legado.app.utils.servicePendingIntent
@@ -63,6 +77,7 @@ import kotlinx.coroutines.sync.Mutex
 import kotlinx.coroutines.sync.withLock
 import okhttp3.Response
 import org.mozilla.javascript.WrappedException
+import org.koin.java.KoinJavaComponent.get
 import splitties.init.appCtx
 import java.io.File
 import java.io.InputStream
@@ -75,6 +90,14 @@ import java.net.SocketTimeoutException
 @SuppressLint("UnsafeOptInUsageError")
 class HttpReadAloudService : BaseReadAloudService(),
     Player.Listener {
+    override val useSpeechPlaybackQueue: Boolean = true
+
+    private data class PreDownloadChapter(
+        val textChapter: TextChapter,
+        val queue: ReadAloudPlaybackQueue,
+        val contentList: List<String>,
+    )
+
     private val exoPlayer: ExoPlayer by lazy {
         ExoPlayer.Builder(this).build()
     }
@@ -106,6 +129,10 @@ class HttpReadAloudService : BaseReadAloudService(),
     private var downloadErrorNo: Int = 0
     private var playErrorNo = 0
     private val downloadTaskActiveLock = Mutex()
+    private val systemTtsFileSynthesizer by lazy { SystemTtsFileSynthesizer(this) }
+    private val cloudTtsAudioSynthesizer by lazy {
+        CloudTtsAudioSynthesizer(get(CloudTtsEngineGateway::class.java))
+    }
 
     override fun onCreate() {
         super.onCreate()
@@ -118,6 +145,7 @@ class HttpReadAloudService : BaseReadAloudService(),
         exoPlayer.release()
         cache.release()
         Coroutine.async {
+            systemTtsFileSynthesizer.close()
             removeCacheFile()
         }
     }
@@ -131,7 +159,7 @@ class HttpReadAloudService : BaseReadAloudService(),
             ReadBook.readAloud()
         } else {
             super.play()
-            if (ReadConfig.streamReadAloudAudio) {
+            if (ReadConfig.streamReadAloudAudio && !hasFileSynthesisCue()) {
                 downloadAndPlayAudiosStream()
             } else {
                 downloadAndPlayAudios()
@@ -145,6 +173,11 @@ class HttpReadAloudService : BaseReadAloudService(),
     }
 
     private fun updateNextPos() {
+        if (!playbackQueue.isEmpty) {
+            val current = playbackCursor ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
+            playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: nextChapter()
+            return
+        }
         readAloudNumber += contentList[nowSpeak].length + 1 - paragraphStartPos
         paragraphStartPos = 0
         if (nowSpeak < contentList.lastIndex) {
@@ -170,18 +203,78 @@ class HttpReadAloudService : BaseReadAloudService(),
                         text = text.substring(paragraphStartPos)
                     }
                     // 计算文件名时,会自动调用修正后的 md5SpeakFileName
-                    val fileName = md5SpeakFileName(text)
+                    val routedVoice = voiceForCue(playbackQueue, index, httpTts)
+                    val cue = playbackQueue.cues.getOrNull(index)
+                    val cueEmotion = cue?.emotion.orEmpty()
+                    val characterPerformance = cue?.characterPerformance
+                    val cueRoleType = cue?.roleType ?: SpeechRoleType.Unknown
+                    val itemHttpTts = routedVoice.takeIf {
+                        it.engineType == ReadAloudVoice.ENGINE_HTTP
+                    }?.engineId?.toLongOrNull()?.let(appDb.httpTTSDao::get) ?: httpTts
+                    val sourceKey = when (routedVoice.engineType) {
+                        ReadAloudVoice.ENGINE_SYSTEM ->
+                            "system:${routedVoice.id}:${routedVoice.revision}:${routedVoice.engineId}:${routedVoice.speakerId}"
+                        ReadAloudVoice.ENGINE_CLOUD ->
+                            "cloud:${routedVoice.id}:${routedVoice.revision}:" +
+                                "${CloudTtsEmotionMapper.VERSION}:$cueEmotion:" +
+                                "${CharacterPerformanceInstructionBuilder.VERSION}:" +
+                                "${characterPerformance?.characterId.orEmpty()}:" +
+                                "${characterPerformance?.updatedAt.orZero()}:" +
+                                "${CloudTtsRoleInstructionMapper.VERSION}:" +
+                                cueRoleType.storageValue
+                        else -> itemHttpTts.url
+                    }
+                    val fileName = md5SpeakFileName(text, sourceKey = sourceKey)
                     val speakText = text.replace(AppPattern.notReadAloudRegex, "")
                     if (speakText.isEmpty()) {
                         AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$text")
                         createSilentSound(fileName)
                     } else if (!hasSpeakFile(fileName)) {
                         runCatching {
-                            val inputStream = getSpeakStream(httpTts, speakText)
-                            if (inputStream != null) {
-                                createSpeakFile(fileName, inputStream)
+                            if (routedVoice.engineType == ReadAloudVoice.ENGINE_SYSTEM) {
+                                val output = getSpeakFileAsMd5(fileName)
+                                val config = runCatching {
+                                    GSON.fromJson(
+                                        routedVoice.traitsJson,
+                                        SystemTtsVoiceConfig::class.java,
+                                    )
+                                }.getOrNull() ?: SystemTtsVoiceConfig()
+                                val globalRate = if (ReadConfig.ttsFollowSys) {
+                                    1f
+                                } else {
+                                    (ReadConfig.ttsSpeechRate + 5) / 10f
+                                }
+                                if (!systemTtsFileSynthesizer.synthesize(
+                                        routedVoice.engineId,
+                                        routedVoice.speakerId,
+                                        speakText,
+                                        output,
+                                        config.speechRate ?: globalRate,
+                                        config.pitch ?: 1f,
+                                    )
+                                ) {
+                                    createSilentSound(fileName)
+                                }
+                            } else if (routedVoice.engineType == ReadAloudVoice.ENGINE_CLOUD) {
+                                val output = getSpeakFileAsMd5(fileName)
+                                if (!cloudTtsAudioSynthesizer.synthesize(
+                                        routedVoice,
+                                        speakText,
+                                        output,
+                                        styleOverride = cueEmotion,
+                                        characterPerformance = characterPerformance,
+                                        roleType = cueRoleType,
+                                    )
+                                ) {
+                                    createSilentSound(fileName)
+                                }
                             } else {
-                                createSilentSound(fileName)
+                                val inputStream = getSpeakStream(itemHttpTts, speakText)
+                                if (inputStream != null) {
+                                    createSpeakFile(fileName, inputStream)
+                                } else {
+                                    createSilentSound(fileName)
+                                }
                             }
                         }.onFailure {
                             when (it) {
@@ -223,7 +316,7 @@ class HttpReadAloudService : BaseReadAloudService(),
     private suspend fun getPreDownloadChapter(
         book: Book,
         chapter: BookChapter,
-    ): Pair<TextChapter, List<String>>? {
+    ): PreDownloadChapter? {
         val content = BookHelp.getContent(book, chapter) ?: return null
         val contentProcessor = ContentProcessor.get(book.name, book.origin)
         val displayTitle = chapter.getDisplayTitle(
@@ -247,11 +340,21 @@ class HttpReadAloudService : BaseReadAloudService(),
         for (ignored in textChapter.layoutChannel) {
             currentCoroutineContext().ensureActive()
         }
-        val contentList = textChapter
-            .getNeedReadAloud(0, ReadConfig.readAloudByPage, 0)
-            .split("\n")
-            .filter { it.isNotEmpty() }
-        return textChapter to contentList
+        val plan = buildSpeechPlan(
+            bookUrl = book.bookUrl,
+            chapterIndex = chapter.index,
+            textChapter = textChapter,
+        )
+        val queue = runCatching { ReadAloudPlaybackQueue.from(plan) }
+            .getOrDefault(ReadAloudPlaybackQueue.Empty)
+        val contentList = if (!queue.isEmpty) {
+            queue.cues.map { it.text }
+        } else {
+            textChapter.getNeedReadAloud(0, ReadConfig.readAloudByPage, 0)
+                .split("\n")
+                .filter { it.isNotEmpty() }
+        }
+        return PreDownloadChapter(textChapter, queue, contentList)
     }
 
     private suspend fun preDownloadAudios(httpTts: HttpTTS) {
@@ -266,20 +369,28 @@ class HttpReadAloudService : BaseReadAloudService(),
                 val targetIndex = currentIdx + i
                 val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
                 
-                val (textChapter, contentList) =
-                    getPreDownloadChapter(book, chapter) ?: continue
+                val prepared = getPreDownloadChapter(book, chapter) ?: continue
 
-                contentList.forEach { content ->
+                prepared.contentList.forEachIndexed { index, content ->
                     currentCoroutineContext().ensureActive()
-                    
-                    val fileName = md5SpeakFileName(content, textChapter)
+                    val routedVoice = voiceForCue(prepared.queue, index, httpTts)
+                    if (routedVoice.engineType != ReadAloudVoice.ENGINE_HTTP) {
+                        return@forEachIndexed
+                    }
+                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                        ?.let(appDb.httpTTSDao::get) ?: httpTts
+                    val fileName = md5SpeakFileName(
+                        content,
+                        prepared.textChapter,
+                        itemHttpTts,
+                    )
                     
                     val speakText = content.replace(AppPattern.notReadAloudRegex, "")
                     if (speakText.isEmpty()) {
                         createSilentSound(fileName)
                     } else if (!hasSpeakFile(fileName)) {
                         runCatching {
-                            val inputStream = getSpeakStream(httpTts, speakText)
+                            val inputStream = getSpeakStream(itemHttpTts, speakText)
                             if (inputStream != null) {
                                 createSpeakFile(fileName, inputStream)
                             } else {
@@ -318,8 +429,9 @@ class HttpReadAloudService : BaseReadAloudService(),
                     if (speakText.isEmpty()) {
                         AppLog.put("阅读段落内容为空,使用无声音频代替。\n朗读文本:$speakText")
                     }
-                    val fileName = md5SpeakFileName(text)
-                    val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
+                    val itemHttpTts = httpTtsForCue(index, httpTts)
+                    val fileName = md5SpeakFileName(text, httpTts = itemHttpTts)
+                    val dataSourceFactory = createDataSourceFactory(itemHttpTts, speakText)
                     val downloader = createDownloader(dataSourceFactory, fileName)
                     downloaderChannel.send(downloader)
                     val mediaSource = createMediaSource(dataSourceFactory, fileName)
@@ -364,15 +476,24 @@ class HttpReadAloudService : BaseReadAloudService(),
                 val targetIndex = currentIdx + i
                 val chapter = appDb.bookChapterDao.getChapter(book.bookUrl, targetIndex) ?: break
                 
-                val (textChapter, contentList) =
-                    getPreDownloadChapter(book, chapter) ?: continue
+                val prepared = getPreDownloadChapter(book, chapter) ?: continue
                 
-                contentList.forEach { content ->
+                prepared.contentList.forEachIndexed { index, content ->
                     currentCoroutineContext().ensureActive()
-                    val fileName = md5SpeakFileName(content, textChapter)
+                    val routedVoice = voiceForCue(prepared.queue, index, httpTts)
+                    if (routedVoice.engineType != ReadAloudVoice.ENGINE_HTTP) {
+                        return@forEachIndexed
+                    }
+                    val itemHttpTts = routedVoice.engineId.toLongOrNull()
+                        ?.let(appDb.httpTTSDao::get) ?: httpTts
+                    val fileName = md5SpeakFileName(
+                        content,
+                        prepared.textChapter,
+                        itemHttpTts,
+                    )
                     
                     val speakText = content.replace(AppPattern.notReadAloudRegex, "")
-                    val dataSourceFactory = createDataSourceFactory(httpTts, speakText)
+                    val dataSourceFactory = createDataSourceFactory(itemHttpTts, speakText)
                     val downloader = createDownloader(dataSourceFactory, fileName)
                     downloaderChannel.send(downloader)
                 }
@@ -506,10 +627,74 @@ class HttpReadAloudService : BaseReadAloudService(),
     /**
      * 生成音频文件名
      */
-    private fun md5SpeakFileName(content: String, textChapter: TextChapter? = this.textChapter): String {
+    private fun md5SpeakFileName(
+        content: String,
+        textChapter: TextChapter? = this.textChapter,
+        httpTts: HttpTTS? = ReadAloud.httpTTS,
+        sourceKey: String = httpTts?.url.orEmpty(),
+    ): String {
         val titleToUse = textChapter?.chapter?.title ?: ""
         return MD5Utils.md5Encode16(titleToUse) + "_" +
-                MD5Utils.md5Encode16("${ReadAloud.httpTTS?.url}-|-$speechRate-|-$content")
+                MD5Utils.md5Encode16("$sourceKey-|-$speechRate-|-$content")
+    }
+
+    private fun Long?.orZero(): Long = this ?: 0L
+
+    private fun hasFileSynthesisCue(): Boolean = playbackQueue.cues.indices.any { index ->
+        voiceForCue(playbackQueue, index, ReadAloud.httpTTS ?: return@any false).engineType in
+            setOf(
+                ReadAloudVoice.ENGINE_SYSTEM,
+                ReadAloudVoice.ENGINE_CLOUD,
+            )
+    }
+
+    private fun voiceForCue(
+        queue: ReadAloudPlaybackQueue,
+        index: Int,
+        default: HttpTTS,
+    ): ReadAloudVoice {
+        val cue = queue.cues.getOrNull(index)
+            ?: return ReadAloudVoice(
+                id = "runtime-http:${default.id}",
+                engineType = ReadAloudVoice.ENGINE_HTTP,
+                engineId = default.id.toString(),
+                speakerId = "",
+                displayName = default.name,
+            )
+        return SpeechVoiceRouter.route(
+            cue = cue,
+            supportedEngineTypes = setOf(
+                ReadAloudVoice.ENGINE_HTTP,
+                ReadAloudVoice.ENGINE_SYSTEM,
+                ReadAloudVoice.ENGINE_CLOUD,
+            ),
+            defaultRoute = SpeechEngineRoute(
+                engineType = ReadAloud.coordinatorDefaultEngineType,
+                engineId = ReadAloud.coordinatorDefaultEngineId,
+            ),
+        ).voice!!
+    }
+
+    private fun httpTtsForCue(index: Int, default: HttpTTS): HttpTTS {
+        return httpTtsForCue(playbackQueue, index, default)
+    }
+
+    private fun httpTtsForCue(
+        queue: ReadAloudPlaybackQueue,
+        index: Int,
+        default: HttpTTS,
+    ): HttpTTS {
+        val cue = queue.cues.getOrNull(index) ?: return default
+        val routed = SpeechVoiceRouter.route(
+            cue = cue,
+            supportedEngineTypes = setOf(ReadAloudVoice.ENGINE_HTTP),
+            defaultRoute = SpeechEngineRoute(
+                engineType = ReadAloudVoice.ENGINE_HTTP,
+                engineId = default.id.toString(),
+            ),
+        ).voice ?: return default
+        val id = routed.engineId.toLongOrNull() ?: return default
+        return appDb.httpTTSDao.get(id) ?: default
     }
 
     private fun createSilentSound(fileName: String) {

+ 107 - 13
app/src/main/java/io/legado/app/service/TTSReadAloudService.kt

@@ -11,6 +11,11 @@ import io.legado.app.constant.AppPattern
 import androidx.lifecycle.lifecycleScope
 import io.legado.app.domain.gateway.ReadAloudSettingsGateway
 import io.legado.app.exception.NoStackTraceException
+import io.legado.app.domain.model.readaloud.ReadAloudPlaybackCursor
+import io.legado.app.domain.model.readaloud.ReadAloudVoice
+import io.legado.app.domain.model.readaloud.SpeechEngineRoute
+import io.legado.app.domain.model.readaloud.SpeechVoiceRouter
+import io.legado.app.domain.model.readaloud.SystemTtsVoiceConfig
 import io.legado.app.help.MediaHelp
 import io.legado.app.help.coroutine.Coroutine
 import io.legado.app.lib.dialogs.SelectItem
@@ -31,7 +36,9 @@ import org.koin.core.component.inject
 /**
  * 本地朗读
  */
-class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener, KoinComponent {
+class TTSReadAloudService : BaseReadAloudService(), KoinComponent {
+
+    override val useSpeechPlaybackQueue: Boolean = true
 
     private val readAloudSettingsGateway: ReadAloudSettingsGateway by inject()
     @Volatile
@@ -44,6 +51,10 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
     private var utteranceStartPos = 0
     private var utteranceStartReadAloudNumber = 0
     private var needParagraphInterval = false // 是否需要进行段落间隔延迟
+    private var activeEngine = ""
+    private var activeVoiceName = ""
+    private var defaultVoiceName = ""
+    private var initGeneration = 0
     private val TAG = "TTSReadAloudService"
 
     override fun onCreate() {
@@ -63,14 +74,17 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
     }
 
     @Synchronized
-    private fun initTts() {
+    private fun initTts(engineOverride: String? = null) {
         ttsInitFinish = false
-        val engine = GSON.fromJsonObject<SelectItem<String>>(ReadAloud.ttsEngine).getOrNull()?.value
+        val engine = engineOverride
+            ?: GSON.fromJsonObject<SelectItem<String>>(ReadAloud.ttsEngine).getOrNull()?.value
+        activeEngine = engine.orEmpty()
+        val generation = ++initGeneration
         LogUtils.d(TAG, "initTts engine:$engine")
         textToSpeech = if (engine.isNullOrBlank()) {
-            TextToSpeech(this, this)
+            TextToSpeech(this) { status -> onTtsInitialized(status, generation) }
         } else {
-            TextToSpeech(this, this, engine)
+            TextToSpeech(this, { status -> onTtsInitialized(status, generation) }, engine)
         }
         upSpeechRate()
     }
@@ -83,12 +97,18 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
         }
         textToSpeech = null
         ttsInitFinish = false
+        activeVoiceName = ""
+        defaultVoiceName = ""
+        initGeneration++
     }
 
-    override fun onInit(status: Int) {
+    private fun onTtsInitialized(status: Int, generation: Int) {
+        if (generation != initGeneration) return
         if (status == TextToSpeech.SUCCESS) {
             textToSpeech?.let {
                 it.setOnUtteranceProgressListener(ttsUtteranceListener)
+                defaultVoiceName = it.defaultVoice?.name.orEmpty()
+                activeVoiceName = defaultVoiceName
                 ttsInitFinish = true
                 play()
             }
@@ -99,6 +119,17 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
 
     @Synchronized
     override fun play() {
+        if (hasSpeechPlaybackQueue) {
+            val route = systemVoiceForCurrentCue()
+            val requiredEngine = route.engineId
+            if (requiredEngine != activeEngine || textToSpeech == null) {
+                clearTTS()
+                initTts(requiredEngine)
+                return
+            }
+            applyVoice(route.speakerId)
+            applyPreset(route)
+        }
         if (!ttsInitFinish) return
         if (!requestFocus()) return
         if (contentList.isEmpty()) {
@@ -118,7 +149,7 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
             val interval = ReadConfig.ttsParagraphInterval.toLong()
             AppLog.putDebug("TTS_PLAY: nowSpeak=$nowSpeak, isDelay=$isDelay, interval=$interval")
             
-            if (interval > 0) {
+            if (hasSpeechPlaybackQueue || interval > 0) {
                 // 段落间隔模式:单段播放
                 if (isDelay) {
                     AppLog.putDebug("TTS开始延迟: $interval 毫秒")
@@ -206,6 +237,55 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
         }
     }
 
+    private fun systemVoiceForCurrentCue(): ReadAloudVoice {
+        val configured = GSON.fromJsonObject<SelectItem<String>>(ReadAloud.ttsEngine)
+            .getOrNull()?.value.orEmpty()
+        val fallback = ReadAloudVoice(
+            id = "runtime-system:$configured",
+            engineType = ReadAloudVoice.ENGINE_SYSTEM,
+            engineId = configured,
+            speakerId = "",
+            displayName = configured,
+        )
+        val cue = playbackQueue.cues.getOrNull(nowSpeak) ?: return fallback
+        return SpeechVoiceRouter.route(
+            cue = cue,
+            supportedEngineTypes = setOf(ReadAloudVoice.ENGINE_SYSTEM),
+            defaultRoute = SpeechEngineRoute(ReadAloudVoice.ENGINE_SYSTEM, configured),
+        ).voice ?: fallback
+    }
+
+    private fun applyVoice(voiceName: String) {
+        val requestedName = voiceName.ifBlank { defaultVoiceName }
+        if (requestedName == activeVoiceName) return
+        val tts = textToSpeech ?: return
+        val voice = tts.voices.orEmpty().firstOrNull { it.name == requestedName }
+        if (voice == null) {
+            AppLog.putDebug("系统 TTS 音色不可用: $requestedName")
+            return
+        }
+        if (tts.setVoice(voice) == TextToSpeech.SUCCESS) {
+            activeVoiceName = requestedName
+        } else {
+            AppLog.putDebug("系统 TTS 音色切换失败: $requestedName")
+        }
+    }
+
+    private fun applyPreset(voice: ReadAloudVoice) {
+        val config = runCatching {
+            GSON.fromJson(voice.traitsJson, SystemTtsVoiceConfig::class.java)
+        }.getOrNull() ?: SystemTtsVoiceConfig()
+        val globalRate = if (ReadConfig.ttsFollowSys) {
+            1f
+        } else {
+            (ReadConfig.ttsSpeechRate + 5) / 10f
+        }
+        textToSpeech?.apply {
+            setSpeechRate(config.speechRate ?: globalRate)
+            setPitch(config.pitch ?: 1f)
+        }
+    }
+
     override fun playStop() {
         textToSpeech?.runCatching {
             stop()
@@ -257,6 +337,7 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
         private val TAG = "TTSUtteranceListener"
 
         override fun onStart(s: String) {
+            if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onStart nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$s")
             utteranceStartPos = paragraphStartPos
             utteranceStartReadAloudNumber = readAloudNumber
@@ -276,16 +357,18 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
         }
 
         override fun onDone(s: String) {
+            if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onDone utteranceId:$s")
             nextParagraph()
-            if (!pause && ReadConfig.ttsParagraphInterval > 0) {
-                needParagraphInterval = true
+            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+                needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
         override fun onRangeStart(utteranceId: String?, start: Int, end: Int, frame: Int) {
             super.onRangeStart(utteranceId, start, end, frame)
+            if (!isCurrentUtterance(utteranceId)) return
             paragraphStartPos = utteranceStartPos + start
             readAloudNumber = currentRangePosition(utteranceStartReadAloudNumber, start)
             updateReadAloudProgressSnapshot(readAloudNumber + 1)
@@ -298,18 +381,25 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
         }
 
         override fun onError(utteranceId: String?, errorCode: Int) {
+            if (!isCurrentUtterance(utteranceId)) return
             LogUtils.d(
                 TAG,
                 "onError nowSpeak:$nowSpeak pageIndex:$pageIndex utteranceId:$utteranceId errorCode:$errorCode"
             )
             nextParagraph()
-            if (!pause && ReadConfig.ttsParagraphInterval > 0) {
-                needParagraphInterval = true
+            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+                needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
         private fun nextParagraph() {
+            if (hasSpeechPlaybackQueue) {
+                val current = playbackCursor
+                    ?: ReadAloudPlaybackCursor(nowSpeak, paragraphStartPos)
+                playbackQueue.next(current)?.let(::moveToPlaybackCursor) ?: nextChapter()
+                return
+            }
             //跳过全标点段落
             do {
                 readAloudNumber = nextParagraphPosition(
@@ -328,14 +418,18 @@ class TTSReadAloudService : BaseReadAloudService(), TextToSpeech.OnInitListener,
 
         @Deprecated("Deprecated in Java")
         override fun onError(s: String) {
+            if (!isCurrentUtterance(s)) return
             LogUtils.d(TAG, "onError nowSpeak:$nowSpeak pageIndex:$pageIndex s:$s")
             nextParagraph()
-            if (!pause && ReadConfig.ttsParagraphInterval > 0) {
-                needParagraphInterval = true
+            if (!pause && (hasSpeechPlaybackQueue || ReadConfig.ttsParagraphInterval > 0)) {
+                needParagraphInterval = ReadConfig.ttsParagraphInterval > 0
                 play()
             }
         }
 
+        private fun isCurrentUtterance(utteranceId: String?): Boolean =
+            !hasSpeechPlaybackQueue || utteranceId == AppConst.APP_TAG + nowSpeak
+
     }
 
     override fun aloudServicePendingIntent(actionStr: String): PendingIntent? {

+ 22 - 1
app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt

@@ -56,6 +56,7 @@ import androidx.compose.material3.ModalDrawerSheet
 import androidx.compose.material3.ModalNavigationDrawer
 import androidx.compose.material3.SnackbarHost
 import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.SnackbarResult
 import androidx.compose.material3.Surface
 import androidx.compose.material3.rememberDrawerState
 import androidx.compose.runtime.Composable
@@ -145,6 +146,9 @@ fun AiChatScreen(
     var topOverlayHeightPx by remember { mutableIntStateOf(0) }
     var bottomOverlayHeightPx by remember { mutableIntStateOf(0) }
     var initiallyPositionedConversationId by remember { mutableStateOf<String?>(null) }
+    var errorDialogMessage by rememberSaveable { mutableStateOf<String?>(null) }
+    val snackbarActionLabel = stringResource(R.string.details)
+    val errorDialogTitle = stringResource(R.string.error_details)
     val currentConversation = state.conversations.firstOrNull {
         it.id == state.currentConversationId
     } ?: state.conversations.firstOrNull { it.isSelected }
@@ -188,7 +192,15 @@ fun AiChatScreen(
     LaunchedEffect(Unit) {
         effects.collectLatest { effect ->
             when (effect) {
-                is AiChatEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+                is AiChatEffect.ShowMessage -> {
+                    val result = snackbarHostState.showSnackbar(
+                        message = effect.message,
+                        actionLabel = snackbarActionLabel
+                    )
+                    if (result == SnackbarResult.ActionPerformed) {
+                        errorDialogMessage = effect.message
+                    }
+                }
             }
         }
     }
@@ -562,6 +574,15 @@ fun AiChatScreen(
             }
         }
     }
+
+    AppAlertDialog(
+        show = errorDialogMessage != null,
+        onDismissRequest = { errorDialogMessage = null },
+        title = errorDialogTitle,
+        text = errorDialogMessage,
+        confirmText = stringResource(R.string.ok),
+        onConfirm = { errorDialogMessage = null }
+    )
 }
 
 @Composable

+ 21 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoActivity.kt

@@ -53,6 +53,27 @@ class BookInfoActivity : BaseComposeActivity(), VariableDialog.Callback {
                     MainActivity.createExploreShowIntent(this, title, sourceUrl, exploreUrl)
                 )
             },
+            onOpenCharacterDetail = { bookUrl, characterId ->
+                startActivity(
+                    MainActivity.createBookCharacterDetailIntent(
+                        this,
+                        bookUrl,
+                        characterId
+                    )
+                )
+            },
+            onOpenCharacterNetwork = { bookUrl ->
+                startActivity(MainActivity.createBookCharacterNetworkIntent(this, bookUrl))
+            },
+            onOpenCharacterList = { bookUrl ->
+                startActivity(MainActivity.createBookCharacterListIntent(this, bookUrl))
+            },
+            onOpenKnowledgeList = { bookUrl ->
+                startActivity(MainActivity.createBookKnowledgeListIntent(this, bookUrl))
+            },
+            onOpenEventList = { bookUrl ->
+                startActivity(MainActivity.createBookEventListIntent(this, bookUrl))
+            },
         )
     }
 

+ 57 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoContract.kt

@@ -33,6 +33,9 @@ data class BookInfoUiState(
     val inBookshelf: Boolean = false,
     val bookSource: BookInfoSourceUi? = null,
     val relatedBooks: ImmutableList<RelatedBooksUi> = persistentListOf(),
+    val characters: ImmutableList<BookInfoCharacterUi> = persistentListOf(),
+    val knowledgeEntries: ImmutableList<BookInfoKnowledgeUi> = persistentListOf(),
+    val recentEvents: ImmutableList<BookInfoEventUi> = persistentListOf(),
     val isTocLoading: Boolean = false,
     val isBusy: Boolean = false,
     val deleteAlertEnabled: Boolean = true,
@@ -79,6 +82,33 @@ data class BookInfoSourceUi(
     val hasCustomButton: Boolean,
 )
 
+@Stable
+data class BookInfoCharacterUi(
+    val id: String,
+    val name: String,
+    val avatarUri: String?,
+    val role: String,
+    val tags: String,
+    val summary: String,
+)
+
+@Stable
+data class BookInfoKnowledgeUi(
+    val id: String,
+    val type: String,
+    val title: String,
+    val summary: String,
+)
+
+@Stable
+data class BookInfoEventUi(
+    val id: String,
+    val chapterTitle: String,
+    val eventTimeText: String,
+    val content: String,
+    val characterName: String,
+)
+
 sealed interface BookInfoSheet {
     data object None : BookInfoSheet
     data object CoverPicker : BookInfoSheet
@@ -174,6 +204,12 @@ sealed interface BookInfoIntent {
 
     data class RelatedBookClick(val book: SearchBook) : BookInfoIntent
     data class RelatedBooksMore(val title: String, val url: String) : BookInfoIntent
+    data class CharacterClick(val characterId: String) : BookInfoIntent
+    data object AddCharacterClick : BookInfoIntent
+    data object CharacterNetworkClick : BookInfoIntent
+    data object CharacterListClick : BookInfoIntent
+    data object KnowledgeListClick : BookInfoIntent
+    data object EventListClick : BookInfoIntent
     data class SetDefaultBookTreeUri(val value: String) : BookInfoIntent
 }
 
@@ -221,6 +257,27 @@ sealed interface BookInfoEffect {
         val sourceUrl: String,
         val exploreUrl: String?,
     ) : BookInfoEffect
+
+    data class OpenCharacterDetail(
+        val bookUrl: String,
+        val characterId: String?,
+    ) : BookInfoEffect
+
+    data class OpenCharacterNetwork(
+        val bookUrl: String,
+    ) : BookInfoEffect
+
+    data class OpenCharacterList(
+        val bookUrl: String,
+    ) : BookInfoEffect
+
+    data class OpenKnowledgeList(
+        val bookUrl: String,
+    ) : BookInfoEffect
+
+    data class OpenEventList(
+        val bookUrl: String,
+    ) : BookInfoEffect
 }
 
 sealed interface BookInfoCallbackAction {

+ 25 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoRouteScreen.kt

@@ -56,6 +56,11 @@ fun BookInfoRouteScreen(
     onOpenReader: (bookUrl: String, inBookshelf: Boolean, chapterChanged: Boolean) -> Unit = { _, _, _ -> },
     onNavigateToBookInfo: (name: String?, author: String?, bookUrl: String, origin: String?, coverPath: String?) -> Unit = { _, _, _, _, _ -> },
     onNavigateToExploreShow: (title: String?, sourceUrl: String, exploreUrl: String?) -> Unit = { _, _, _ -> },
+    onOpenCharacterDetail: (bookUrl: String, characterId: String?) -> Unit = { _, _ -> },
+    onOpenCharacterNetwork: (bookUrl: String) -> Unit = {},
+    onOpenCharacterList: (bookUrl: String) -> Unit = {},
+    onOpenKnowledgeList: (bookUrl: String) -> Unit = {},
+    onOpenEventList: (bookUrl: String) -> Unit = {},
     sharedTransitionScope: SharedTransitionScope? = null,
     animatedVisibilityScope: AnimatedVisibilityScope? = null,
     sharedCoverKey: String? = null,
@@ -205,6 +210,26 @@ fun BookInfoRouteScreen(
                 is BookInfoEffect.NavigateToExploreShow -> {
                     onNavigateToExploreShow(effect.title, effect.sourceUrl, effect.exploreUrl)
                 }
+
+                is BookInfoEffect.OpenCharacterDetail -> {
+                    onOpenCharacterDetail(effect.bookUrl, effect.characterId)
+                }
+
+                is BookInfoEffect.OpenCharacterNetwork -> {
+                    onOpenCharacterNetwork(effect.bookUrl)
+                }
+
+                is BookInfoEffect.OpenKnowledgeList -> {
+                    onOpenKnowledgeList(effect.bookUrl)
+                }
+
+                is BookInfoEffect.OpenCharacterList -> {
+                    onOpenCharacterList(effect.bookUrl)
+                }
+
+                is BookInfoEffect.OpenEventList -> {
+                    onOpenEventList(effect.bookUrl)
+                }
             }
         }
     }

+ 342 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoScreen.kt

@@ -23,11 +23,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
 import androidx.compose.foundation.layout.height
 import androidx.compose.foundation.layout.heightIn
 import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.layout.width
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
 import androidx.compose.foundation.lazy.itemsIndexed
 import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CircleShape
 import androidx.compose.material.icons.Icons
 import androidx.compose.material.icons.automirrored.filled.ArrowForward
 import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
@@ -35,7 +38,9 @@ import androidx.compose.material.icons.filled.Book
 import androidx.compose.material.icons.filled.BookmarkAdd
 import androidx.compose.material.icons.filled.Code
 import androidx.compose.material.icons.filled.Edit
+import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Person
 import androidx.compose.material.icons.filled.Share
 import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material.icons.outlined.Book
@@ -56,6 +61,7 @@ import androidx.compose.runtime.setValue
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.draw.blur
+import androidx.compose.ui.draw.clip
 import androidx.compose.ui.graphics.Brush
 import androidx.compose.ui.graphics.Color
 import androidx.compose.ui.graphics.lerp
@@ -71,6 +77,7 @@ import androidx.compose.ui.semantics.semantics
 import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.unit.dp
 import coil.ImageLoader
+import coil.compose.AsyncImage
 import coil.size.Size
 import io.legado.app.R
 import io.legado.app.constant.BookType
@@ -315,6 +322,30 @@ private fun BookInfoScreenContent(
                                     onSourceClick = { onIntent(BookInfoIntent.ChangeSourceClick) },
                                     onReadRecordClick = { onIntent(BookInfoIntent.ReadRecordClick) },
                                 )
+                                if (
+                                    state.characters.isNotEmpty() ||
+                                    state.knowledgeEntries.isNotEmpty() ||
+                                    state.recentEvents.isNotEmpty()
+                                ) {
+                                    BookInfoCharacters(
+                                        characters = state.characters,
+                                        onCharacterClick = {
+                                            onIntent(BookInfoIntent.CharacterClick(it))
+                                        },
+                                        onNetworkClick = {
+                                            onIntent(BookInfoIntent.CharacterNetworkClick)
+                                        },
+                                        onViewAllClick = {
+                                            onIntent(BookInfoIntent.CharacterListClick)
+                                        },
+                                        onKnowledgeClick = {
+                                            onIntent(BookInfoIntent.KnowledgeListClick)
+                                        },
+                                        onEventsClick = {
+                                            onIntent(BookInfoIntent.EventListClick)
+                                        },
+                                    )
+                                }
                                 state.relatedBooks.forEach { module ->
                                     RelatedBooksBanner(
                                         title = module.title,
@@ -1349,3 +1380,314 @@ private fun RelatedBooksBanner(
         )
     }
 }
+
+@Composable
+private fun BookInfoCharacters(
+    characters: ImmutableList<BookInfoCharacterUi>,
+    onCharacterClick: (String) -> Unit,
+    onNetworkClick: () -> Unit,
+    onViewAllClick: () -> Unit,
+    onKnowledgeClick: () -> Unit,
+    onEventsClick: () -> Unit,
+) {
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(LegadoTheme.colorScheme.surface)
+            .padding(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppText(
+                text = stringResource(R.string.book_info_knowledge),
+                style = LegadoTheme.typography.titleMedium,
+                fontWeight = FontWeight.Bold,
+                modifier = Modifier.weight(1f),
+            )
+            SmallTonalButton(
+                onClick = onViewAllClick,
+                icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
+                contentDescription = stringResource(R.string.book_characters),
+            )
+            Spacer(modifier = Modifier.width(4.dp))
+            SmallTonalButton(
+                onClick = onNetworkClick,
+                icon = Icons.Default.Group,
+                contentDescription = stringResource(R.string.character_network),
+            )
+            Spacer(modifier = Modifier.width(4.dp))
+            SmallTonalButton(
+                onClick = onKnowledgeClick,
+                icon = Icons.Default.Book,
+                contentDescription = stringResource(R.string.book_knowledge),
+            )
+            Spacer(modifier = Modifier.width(4.dp))
+            SmallTonalButton(
+                onClick = onEventsClick,
+                icon = Icons.Default.Timeline,
+                contentDescription = stringResource(R.string.plot_events),
+            )
+        }
+        LazyRow(
+            modifier = Modifier.fillMaxWidth(),
+            contentPadding = PaddingValues(horizontal = 16.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = characters,
+                key = { it.id },
+            ) { character ->
+                CharacterEntryCard(
+                    character = character,
+                    onClick = { onCharacterClick(character.id) },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterEntryCard(
+    character: BookInfoCharacterUi,
+    onClick: () -> Unit,
+) {
+    val avatarLoadFailed = remember(character.avatarUri) { mutableStateOf(false) }
+    val roleDisplayName = when (character.role) {
+        io.legado.app.data.entities.BookCharacterProfile.ROLE_MALE_LEAD -> stringResource(R.string.role_male_lead)
+        io.legado.app.data.entities.BookCharacterProfile.ROLE_FEMALE_LEAD -> stringResource(R.string.role_female_lead)
+        io.legado.app.data.entities.BookCharacterProfile.ROLE_MALE_SUPPORTING -> stringResource(R.string.role_male_supporting)
+        io.legado.app.data.entities.BookCharacterProfile.ROLE_FEMALE_SUPPORTING -> stringResource(R.string.role_female_supporting)
+        else -> ""
+    }
+
+    GlassCard(
+        modifier = Modifier.width(160.dp),
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(10.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                Box(
+                    modifier = Modifier
+                        .size(36.dp)
+                        .clip(CircleShape)
+                        .background(LegadoTheme.colorScheme.surfaceContainerHighest),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    if (!character.avatarUri.isNullOrBlank() && !avatarLoadFailed.value) {
+                        AsyncImage(
+                            model = character.avatarUri,
+                            contentDescription = null,
+                            modifier = Modifier
+                                .fillMaxSize()
+                                .clip(CircleShape),
+                            contentScale = ContentScale.Crop,
+                            onError = { avatarLoadFailed.value = true },
+                        )
+                    } else {
+                        AppIcon(
+                            imageVector = Icons.Default.Person,
+                            contentDescription = null,
+                            modifier = Modifier.size(18.dp),
+                            tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                        )
+                    }
+                }
+                Column(
+                    modifier = Modifier.weight(1f),
+                    verticalArrangement = Arrangement.spacedBy(1.dp),
+                ) {
+                    AnimatedTextLine(
+                        text = character.name,
+                        style = LegadoTheme.typography.labelLarge,
+                        maxLines = 1,
+                    )
+                    if (roleDisplayName.isNotBlank()) {
+                        AnimatedTextLine(
+                            text = roleDisplayName,
+                            style = LegadoTheme.typography.labelSmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            maxLines = 1,
+                        )
+                    }
+                }
+            }
+            if (character.tags.isNotBlank()) {
+                AnimatedTextLine(
+                    text = character.tags,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun BookInfoKnowledge(
+    entries: ImmutableList<BookInfoKnowledgeUi>,
+    onViewAllClick: () -> Unit,
+) {
+    if (entries.isEmpty()) return
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(LegadoTheme.colorScheme.surface)
+            .padding(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppText(
+                text = stringResource(R.string.book_knowledge),
+                style = LegadoTheme.typography.titleMedium,
+                fontWeight = FontWeight.Bold,
+                modifier = Modifier.weight(1f),
+            )
+            SmallTonalButton(
+                onClick = onViewAllClick,
+                icon = Icons.AutoMirrored.Filled.ArrowForward,
+                contentDescription = stringResource(R.string.view_all),
+            )
+        }
+        LazyRow(
+            modifier = Modifier.fillMaxWidth(),
+            contentPadding = PaddingValues(horizontal = 16.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = entries,
+                key = { it.id },
+            ) { entry ->
+                KnowledgeInfoCard(
+                    title = entry.title,
+                    summary = entry.summary,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun KnowledgeInfoCard(
+    title: String,
+    summary: String,
+) {
+    GlassCard(
+        modifier = Modifier.width(140.dp),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AnimatedTextLine(
+                text = title,
+                style = LegadoTheme.typography.titleSmall,
+                maxLines = 1,
+            )
+            AnimatedTextLine(
+                text = summary.ifBlank { stringResource(R.string.knowledge_content) },
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 2,
+            )
+        }
+    }
+}
+
+@Composable
+private fun BookInfoEvents(
+    events: ImmutableList<BookInfoEventUi>,
+    onViewAllClick: () -> Unit,
+) {
+    if (events.isEmpty()) return
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .background(LegadoTheme.colorScheme.surface)
+            .padding(vertical = 8.dp),
+        verticalArrangement = Arrangement.spacedBy(8.dp)
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 16.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AppText(
+                text = stringResource(R.string.plot_events),
+                style = LegadoTheme.typography.titleMedium,
+                fontWeight = FontWeight.Bold,
+                modifier = Modifier.weight(1f),
+            )
+            SmallTonalButton(
+                onClick = onViewAllClick,
+                icon = Icons.AutoMirrored.Filled.ArrowForward,
+                contentDescription = stringResource(R.string.view_all),
+            )
+        }
+        LazyRow(
+            modifier = Modifier.fillMaxWidth(),
+            contentPadding = PaddingValues(horizontal = 16.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            items(
+                items = events,
+                key = { it.id },
+            ) { event ->
+                EventInfoCard(
+                    title = listOfNotNull(
+                        event.characterName.takeIf { it.isNotBlank() },
+                        event.chapterTitle.takeIf { it.isNotBlank() },
+                    ).joinToString(" · ").ifBlank { stringResource(R.string.event_detail) },
+                    content = event.content,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun EventInfoCard(
+    title: String,
+    content: String,
+) {
+    GlassCard(
+        modifier = Modifier.width(160.dp),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AnimatedTextLine(
+                text = title,
+                style = LegadoTheme.typography.titleSmall,
+                maxLines = 1,
+            )
+            AnimatedTextLine(
+                text = content.ifBlank { stringResource(R.string.event_content) },
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 2,
+            )
+        }
+    }
+}

+ 153 - 0
app/src/main/java/io/legado/app/ui/book/info/BookInfoViewModel.kt

@@ -21,6 +21,7 @@ import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.SearchBook
 import io.legado.app.data.entities.readRecord.ReadRecordTimelineDay
+import io.legado.app.domain.gateway.BookKnowledgeGateway
 import io.legado.app.data.repository.BookGroupRepository
 import io.legado.app.data.repository.ReadRecordRepository
 import io.legado.app.data.repository.RemoteBookRepository
@@ -97,6 +98,7 @@ class BookInfoViewModel(
     private val clearBookCacheUseCase: ClearBookCacheUseCase,
     private val bookGroupRepository: BookGroupRepository,
     private val imageLoader: ImageLoader,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
     private val themeSettingsGateway: ThemeSettingsGateway,
     private val coverSettingsGateway: CoverSettingsGateway,
     private val otherSettingsGateway: OtherSettingsGateway,
@@ -179,6 +181,7 @@ class BookInfoViewModel(
     private var tocLoadFailed = false
     private var currentWebFiles: List<BookInfoWebFile> = emptyList()
     private var currentRelatedBooks: List<RelatedBooksUi> = emptyList()
+    private var currentCharacters: List<BookInfoCharacterUi> = emptyList()
     private var currentHighlightedTags: List<HighlightedTag> = emptyList()
     private var currentKindLabels: List<String> = emptyList()
     private var currentGroupNames: String? = null
@@ -196,6 +199,7 @@ class BookInfoViewModel(
     private var changeSourceCoroutine: Coroutine<*>? = null
     private var readRecordObserveJob: Job? = null
     private var relatedBooksLoadJob: Job? = null
+    private var characterLoadJob: Job? = null
 
     fun initData(intent: Intent) {
         initData(
@@ -233,6 +237,7 @@ class BookInfoViewModel(
         currentChapterList = emptyList()
         currentWebFiles = emptyList()
         currentRelatedBooks = emptyList()
+        currentCharacters = emptyList()
         currentKindLabels = emptyList()
         currentGroupNames = null
         currentHasCustomGroup = false
@@ -241,6 +246,7 @@ class BookInfoViewModel(
         chapterChanged = false
         clearReadRecordObserve()
         relatedBooksLoadJob?.cancel()
+        characterLoadJob?.cancel()
         syncUiState()
         execute {
             val dbBook = appDb.bookDao.getBook(bookUrl)
@@ -365,6 +371,12 @@ class BookInfoViewModel(
 
             is BookInfoIntent.RelatedBookClick -> onRelatedBookClick(intent.book)
             is BookInfoIntent.RelatedBooksMore -> onRelatedBooksMore(intent.title, intent.url)
+            is BookInfoIntent.CharacterClick -> openCharacterDetail(intent.characterId)
+            BookInfoIntent.AddCharacterClick -> openCharacterDetail(null)
+            BookInfoIntent.CharacterNetworkClick -> openCharacterNetwork()
+            BookInfoIntent.CharacterListClick -> openCharacterList()
+            BookInfoIntent.KnowledgeListClick -> openKnowledgeList()
+            BookInfoIntent.EventListClick -> openEventList()
             is BookInfoIntent.SetDefaultBookTreeUri -> viewModelScope.launch {
                 otherSettingsGateway.update(OtherSettingsUpdate.DefaultBookTreeUri(intent.value))
             }
@@ -458,6 +470,9 @@ class BookInfoViewModel(
                 inBookshelf = nextInBookshelf
                 syncUiState()
             }
+            loadBookCharacters(bookUrl)
+            loadBookKnowledge(bookUrl)
+            loadBookEvents(bookUrl)
         }
     }
 
@@ -861,6 +876,7 @@ class BookInfoViewModel(
             }
             currentChapterList = toc
             currentRelatedBooks = emptyList()
+            currentCharacters = emptyList()
             currentGroupNames = null
             currentHasCustomGroup = false
             currentKindLabels = emptyList()
@@ -876,11 +892,15 @@ class BookInfoViewModel(
         tocLoadFailed = false
         currentWebFiles = emptyList()
         currentRelatedBooks = emptyList()
+        currentCharacters = emptyList()
         currentKindLabels = emptyList()
         currentGroupNames = null
         currentHasCustomGroup = false
         bookSource = source
         syncUiState(isTocLoading = false)
+        loadBookCharacters(book.bookUrl)
+        loadBookKnowledge(book.bookUrl)
+        loadBookEvents(book.bookUrl)
         refreshMeta(book)
         upCoverByRule(book)
         if (book.tocUrl.isEmpty() && !book.isLocal) {
@@ -1417,6 +1437,7 @@ class BookInfoViewModel(
                 tocLoadFailed = tocLoadFailed,
                 webFiles = currentWebFiles,
                 relatedBooks = currentRelatedBooks.toImmutableList(),
+                characters = currentCharacters.toImmutableList(),
                 highlightedTags = currentHighlightedTags,
                 kindLabels = currentKindLabels,
                 groupNames = currentGroupNames,
@@ -1455,6 +1476,138 @@ class BookInfoViewModel(
         )
     }
 
+    private fun openCharacterDetail(characterId: String?) {
+        val bookUrl = currentBook?.bookUrl ?: return
+        emitEffect(BookInfoEffect.OpenCharacterDetail(bookUrl, characterId))
+    }
+
+    private fun openCharacterNetwork() {
+        val bookUrl = currentBook?.bookUrl ?: return
+        emitEffect(BookInfoEffect.OpenCharacterNetwork(bookUrl))
+    }
+
+    private fun openKnowledgeList() {
+        val bookUrl = currentBook?.bookUrl ?: return
+        emitEffect(BookInfoEffect.OpenKnowledgeList(bookUrl))
+    }
+
+    private fun openCharacterList() {
+        val bookUrl = currentBook?.bookUrl ?: return
+        emitEffect(BookInfoEffect.OpenCharacterList(bookUrl))
+    }
+
+    private fun openEventList() {
+        val bookUrl = currentBook?.bookUrl ?: return
+        emitEffect(BookInfoEffect.OpenEventList(bookUrl))
+    }
+
+    private var knowledgeLoadJob: Job? = null
+    private var eventLoadJob: Job? = null
+    private var currentKnowledgeEntries: List<BookInfoKnowledgeUi> = emptyList()
+    private var currentRecentEvents: List<BookInfoEventUi> = emptyList()
+
+    private fun loadBookCharacters(bookUrl: String) {
+        characterLoadJob?.cancel()
+        characterLoadJob = viewModelScope.launch {
+            val roleOrder = mapOf(
+                io.legado.app.data.entities.BookCharacterProfile.ROLE_MALE_LEAD to 0,
+                io.legado.app.data.entities.BookCharacterProfile.ROLE_FEMALE_LEAD to 1,
+                io.legado.app.data.entities.BookCharacterProfile.ROLE_MALE_SUPPORTING to 2,
+                io.legado.app.data.entities.BookCharacterProfile.ROLE_FEMALE_SUPPORTING to 3,
+            )
+            val characters = try {
+                withContext(IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(bookUrl, limit = 50)
+                }.sortedBy { roleOrder[it.role] ?: 99 }
+                    .map {
+                        val tags = GSON.fromJsonArray<String>(it.tagsJson).getOrNull().orEmpty()
+                        BookInfoCharacterUi(
+                            id = it.id,
+                            name = it.name,
+                            avatarUri = it.avatarUri,
+                            role = it.role,
+                            tags = tags.joinToString(" | "),
+                            summary = it.summary,
+                        )
+                    }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                emptyList()
+            }
+            if (currentBook?.bookUrl != bookUrl) return@launch
+            currentCharacters = characters
+            _uiState.update {
+                it.copy(characters = currentCharacters.toImmutableList())
+            }
+        }
+    }
+
+    private fun loadBookKnowledge(bookUrl: String) {
+        knowledgeLoadJob?.cancel()
+        knowledgeLoadJob = viewModelScope.launch {
+            val entries = try {
+                withContext(IO) {
+                    bookKnowledgeGateway.searchKnowledgeEntries(bookUrl, "", null, null, 10)
+                }.map {
+                    BookInfoKnowledgeUi(
+                        id = it.id,
+                        type = it.type,
+                        title = it.title,
+                        summary = it.content.take(80),
+                    )
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                emptyList()
+            }
+            if (currentBook?.bookUrl != bookUrl) return@launch
+            currentKnowledgeEntries = entries
+            _uiState.update {
+                it.copy(knowledgeEntries = currentKnowledgeEntries.toImmutableList())
+            }
+        }
+    }
+
+    private fun loadBookEvents(bookUrl: String) {
+        eventLoadJob?.cancel()
+        eventLoadJob = viewModelScope.launch {
+            val events = try {
+                withContext(IO) {
+                    bookKnowledgeGateway.getCharacterEvents(bookUrl, null, null, 10)
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                emptyList()
+            }
+            if (currentBook?.bookUrl != bookUrl) return@launch
+            val profiles = try {
+                withContext(IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(bookUrl, 200)
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                emptyList()
+            }
+            val nameMap = profiles.associate { it.id to it.name }
+            currentRecentEvents = events.map { event ->
+                BookInfoEventUi(
+                    id = event.id,
+                    chapterTitle = event.chapterTitle,
+                    eventTimeText = event.eventTimeText,
+                    content = event.content.take(80),
+                    characterName = nameMap[event.characterId].orEmpty(),
+                )
+            }
+            _uiState.update {
+                it.copy(recentEvents = currentRecentEvents.toImmutableList())
+            }
+        }
+    }
+
     private fun scheduleRelatedBooksLoad(
         book: Book,
         source: BookSource,

+ 14 - 1
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditActivity.kt

@@ -7,6 +7,7 @@ import androidx.compose.runtime.Composable
 import io.legado.app.ui.theme.AppTheme
 import io.legado.app.base.BaseComposeActivity
 import io.legado.app.ui.book.changecover.ChangeCoverDialog
+import io.legado.app.ui.main.MainActivity
 
 class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack {
 
@@ -24,7 +25,19 @@ class BookInfoEditActivity : BaseComposeActivity(), ChangeCoverDialog.CallBack {
                             setResult(RESULT_OK)
                             finish()
                         }
-                    }
+                    },
+                    onOpenCharacterList = { bookUrl ->
+                        startActivity(MainActivity.createBookCharacterListIntent(this, bookUrl))
+                    },
+                    onOpenCharacterNetwork = { bookUrl ->
+                        startActivity(MainActivity.createBookCharacterNetworkIntent(this, bookUrl))
+                    },
+                    onOpenKnowledgeList = { bookUrl ->
+                        startActivity(MainActivity.createBookKnowledgeListIntent(this, bookUrl))
+                    },
+                    onOpenEventList = { bookUrl ->
+                        startActivity(MainActivity.createBookEventListIntent(this, bookUrl))
+                    },
                 )
             }
         }

+ 83 - 4
app/src/main/java/io/legado/app/ui/book/info/edit/BookInfoEditScreen.kt

@@ -22,11 +22,15 @@ import androidx.compose.foundation.text.input.rememberTextFieldState
 import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
 import androidx.compose.foundation.verticalScroll
 import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.outlined.FormatListBulleted
+import androidx.compose.material.icons.filled.Book
+import androidx.compose.material.icons.filled.Group
 import androidx.compose.material.icons.filled.Add
 import androidx.compose.material.icons.filled.FolderOpen
 import androidx.compose.material.icons.filled.ImageSearch
 import androidx.compose.material.icons.filled.Replay
 import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Timeline
 import androidx.compose.material3.ExperimentalMaterial3Api
 import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
 import androidx.compose.material3.ExposedDropdownMenuAnchorType
@@ -80,7 +84,11 @@ import sh.calvin.reorderable.rememberReorderableLazyListState
 fun BookInfoEditScreen(
     viewModel: BookInfoEditViewModel,
     onBack: () -> Unit,
-    onSave: () -> Unit
+    onSave: () -> Unit,
+    onOpenCharacterList: (String) -> Unit,
+    onOpenCharacterNetwork: (String) -> Unit,
+    onOpenKnowledgeList: (String) -> Unit,
+    onOpenEventList: (String) -> Unit,
 ) {
     val uiState by viewModel.uiState.collectAsStateWithLifecycle()
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
@@ -114,7 +122,11 @@ fun BookInfoEditScreen(
                         .imePadding()
                         .verticalScroll(rememberScrollState()),
                     uiState = uiState,
-                    viewModel = viewModel
+                    viewModel = viewModel,
+                    onOpenCharacterList = onOpenCharacterList,
+                    onOpenCharacterNetwork = onOpenCharacterNetwork,
+                    onOpenKnowledgeList = onOpenKnowledgeList,
+                    onOpenEventList = onOpenEventList,
                 )
             }
         }
@@ -126,7 +138,11 @@ fun BookInfoEditScreen(
 fun BookInfoEditContent(
     modifier: Modifier = Modifier,
     uiState: BookInfoEditUiState,
-    viewModel: BookInfoEditViewModel
+    viewModel: BookInfoEditViewModel,
+    onOpenCharacterList: (String) -> Unit,
+    onOpenCharacterNetwork: (String) -> Unit,
+    onOpenKnowledgeList: (String) -> Unit,
+    onOpenEventList: (String) -> Unit,
 ) {
     val context = LocalContext.current
 
@@ -193,6 +209,14 @@ fun BookInfoEditContent(
             }
         }
         Spacer(modifier = Modifier.height(16.dp))
+        BookKnowledgeEditCard(
+            bookUrl = uiState.book?.bookUrl.orEmpty(),
+            onOpenCharacterList = onOpenCharacterList,
+            onOpenCharacterNetwork = onOpenCharacterNetwork,
+            onOpenKnowledgeList = onOpenKnowledgeList,
+            onOpenEventList = onOpenEventList,
+        )
+        Spacer(modifier = Modifier.height(16.dp))
         SwitchSettingItem(
             title = stringResource(R.string.fixed_book_type),
             description = stringResource(R.string.fixed_book_type_summary),
@@ -258,6 +282,61 @@ fun BookInfoEditContent(
     }
 }
 
+@Composable
+private fun BookKnowledgeEditCard(
+    bookUrl: String,
+    onOpenCharacterList: (String) -> Unit,
+    onOpenCharacterNetwork: (String) -> Unit,
+    onOpenKnowledgeList: (String) -> Unit,
+    onOpenEventList: (String) -> Unit,
+) {
+    NormalCard(modifier = Modifier.fillMaxWidth()) {
+        Column(
+            modifier = Modifier.padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(12.dp),
+        ) {
+            AppText(
+                text = stringResource(R.string.book_info_knowledge),
+                style = LegadoTheme.typography.titleMedium,
+            )
+            LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                item {
+                    MediumOutlinedButton(
+                        onClick = { onOpenCharacterList(bookUrl) },
+                        enabled = bookUrl.isNotEmpty(),
+                        icon = Icons.AutoMirrored.Outlined.FormatListBulleted,
+                        text = stringResource(R.string.book_characters),
+                    )
+                }
+                item {
+                    MediumOutlinedButton(
+                        onClick = { onOpenCharacterNetwork(bookUrl) },
+                        enabled = bookUrl.isNotEmpty(),
+                        icon = Icons.Default.Group,
+                        text = stringResource(R.string.character_network),
+                    )
+                }
+                item {
+                    MediumOutlinedButton(
+                        onClick = { onOpenKnowledgeList(bookUrl) },
+                        enabled = bookUrl.isNotEmpty(),
+                        icon = Icons.Default.Book,
+                        text = stringResource(R.string.book_knowledge),
+                    )
+                }
+                item {
+                    MediumOutlinedButton(
+                        onClick = { onOpenEventList(bookUrl) },
+                        enabled = bookUrl.isNotEmpty(),
+                        icon = Icons.Default.Timeline,
+                        text = stringResource(R.string.plot_events),
+                    )
+                }
+            }
+        }
+    }
+}
+
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun BookTypeDropdown(
@@ -413,7 +492,7 @@ fun KindEditor(
     if (editingIndex != null) {
         val isAdding = editingIndex == -1
         AppAlertDialog(
-            show = true,
+            show = editingIndex != null,
             onDismissRequest = { editingIndex = null },
             title = if (isAdding) {
                 stringResource(R.string.add_tag)

+ 443 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailScreen.kt

@@ -0,0 +1,443 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+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.Person
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+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.icon.AppIcon
+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.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
+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.toastOnUi
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookCharacterDetailScreen(
+    state: CharacterDetailUiState,
+    onIntent: (CharacterDetailIntent) -> Unit,
+    effects: Flow<CharacterDetailEffect>,
+    onBack: () -> Unit,
+    onPickAvatar: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is CharacterDetailEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.character_detail),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(CharacterDetailIntent.Save) },
+                        imageVector = Icons.Default.Save,
+                        contentDescription = stringResource(R.string.save),
+                    )
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            CharacterDetailContent(
+                state = state,
+                onIntent = onIntent,
+                onPickAvatar = onPickAvatar,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
+                    .consumeWindowInsets(paddingValues)
+                    .imePadding()
+                    .verticalScroll(rememberScrollState()),
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun CharacterDetailContent(
+    state: CharacterDetailUiState,
+    onIntent: (CharacterDetailIntent) -> Unit,
+    onPickAvatar: () -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    var showProfileDialog by remember { mutableStateOf(false) }
+    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) }
+
+    Column(
+        modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        CharacterHeader(
+            name = state.name,
+            aliasesText = state.aliasesText,
+            avatarUri = state.avatarUri,
+            roleDisplayName = state.role,
+            tags = state.tags,
+            onPickAvatar = onPickAvatar,
+            onInfoClick = {
+                dialogName = state.name
+                dialogAliases = state.aliasesText
+                dialogRole = state.role
+                showProfileDialog = true
+            },
+        )
+        KnowledgeFlowTagEditor(
+            title = stringResource(R.string.character_tags),
+            tags = state.tags,
+            onAddTag = { onIntent(CharacterDetailIntent.AddTag(it)) },
+            onRemoveTag = { onIntent(CharacterDetailIntent.RemoveTag(it)) },
+            showCloseIcon = true,
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.character_personality),
+            value = state.personality,
+            onValueChange = { onIntent(CharacterDetailIntent.SetPersonality(it)) },
+            multiline = true,
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.character_summary),
+            value = state.summary,
+            onValueChange = { onIntent(CharacterDetailIntent.SetSummary(it)) },
+            multiline = true,
+        )
+        CharacterReadonlySection(
+            title = stringResource(R.string.character_events),
+            emptyText = stringResource(R.string.character_events_empty),
+            isEmpty = state.events.isEmpty(),
+        ) {
+            state.events.forEach { event ->
+                InfoCard(
+                    title = event.title,
+                    body = event.content,
+                )
+            }
+        }
+        CharacterReadonlySection(
+            title = stringResource(R.string.character_relations),
+            emptyText = stringResource(R.string.character_relations_empty),
+            isEmpty = state.relations.isEmpty(),
+        ) {
+            state.relations.forEach { relation ->
+                InfoCard(
+                    title = "${relation.fromName} - ${relation.toName}".trim(' ', '-'),
+                    body = listOf(relation.relationType, relation.attitude, relation.summary)
+                        .filter { it.isNotBlank() }
+                        .joinToString("\n"),
+                )
+            }
+        }
+    }
+
+    AppAlertDialog(
+        show = showProfileDialog,
+        onDismissRequest = { showProfileDialog = false },
+        title = stringResource(R.string.character_detail),
+        confirmText = stringResource(R.string.apply),
+        onConfirm = {
+            onIntent(CharacterDetailIntent.SetName(dialogName))
+            onIntent(CharacterDetailIntent.SetAliasesText(dialogAliases))
+            onIntent(CharacterDetailIntent.SetRole(dialogRole))
+            showProfileDialog = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showProfileDialog = false },
+        content = {
+            Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+                AppTextField(
+                    value = dialogName,
+                    onValueChange = { dialogName = it },
+                    label = stringResource(R.string.character_name),
+                    modifier = Modifier.fillMaxWidth(),
+                    singleLine = true,
+                )
+                AppTextField(
+                    value = dialogAliases,
+                    onValueChange = { dialogAliases = it },
+                    label = stringResource(R.string.character_aliases),
+                    modifier = Modifier.fillMaxWidth(),
+                    singleLine = true,
+                )
+                ProfileRoleDropdown(
+                    selectedRole = dialogRole,
+                    onRoleSelected = { dialogRole = it },
+                )
+            }
+        },
+    )
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun ProfileRoleDropdown(
+    selectedRole: String,
+    onRoleSelected: (String) -> Unit,
+) {
+    var showDropdown by remember { mutableStateOf(false) }
+    val roleDisplayNames = mapOf(
+        BookCharacterProfile.ROLE_MALE_LEAD to stringResource(R.string.role_male_lead),
+        BookCharacterProfile.ROLE_FEMALE_LEAD to stringResource(R.string.role_female_lead),
+        BookCharacterProfile.ROLE_MALE_SUPPORTING to stringResource(R.string.role_male_supporting),
+        BookCharacterProfile.ROLE_FEMALE_SUPPORTING to stringResource(R.string.role_female_supporting),
+    )
+    val currentDisplay = roleDisplayNames[selectedRole] ?: "—"
+
+    AppText(
+        text = stringResource(R.string.character_role),
+        style = LegadoTheme.typography.labelMedium,
+        color = LegadoTheme.colorScheme.onSurfaceVariant,
+    )
+    Box {
+        GlassCard(
+            onClick = { showDropdown = true },
+            containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        ) {
+            AnimatedTextLine(
+                text = currentDisplay,
+                style = LegadoTheme.typography.bodyMedium,
+                modifier = Modifier.padding(12.dp),
+            )
+        }
+        RoundDropdownMenu(
+            expanded = showDropdown,
+            onDismissRequest = { showDropdown = false },
+        ) {
+            BookCharacterProfile.ALL_ROLES.forEach { role ->
+                RoundDropdownMenuItem(
+                    text = roleDisplayNames[role] ?: role,
+                    onClick = {
+                        onRoleSelected(if (selectedRole == role) "" else role)
+                        showDropdown = false
+                    },
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterHeader(
+    name: String,
+    aliasesText: String,
+    avatarUri: String,
+    roleDisplayName: String,
+    tags: ImmutableList<String>,
+    onPickAvatar: () -> Unit,
+    onInfoClick: () -> Unit,
+) {
+    val avatarLoadFailed = remember(avatarUri) { mutableStateOf(false) }
+    val resolvedRole = when (roleDisplayName) {
+        BookCharacterProfile.ROLE_MALE_LEAD -> stringResource(R.string.role_male_lead)
+        BookCharacterProfile.ROLE_FEMALE_LEAD -> stringResource(R.string.role_female_lead)
+        BookCharacterProfile.ROLE_MALE_SUPPORTING -> stringResource(R.string.role_male_supporting)
+        BookCharacterProfile.ROLE_FEMALE_SUPPORTING -> stringResource(R.string.role_female_supporting)
+        else -> ""
+    }
+
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = Arrangement.spacedBy(12.dp),
+        verticalAlignment = Alignment.CenterVertically,
+    ) {
+        GlassCard(
+            onClick = onPickAvatar,
+            cornerRadius = 64.dp,
+            containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+        ) {
+            Box(
+                modifier = Modifier
+                    .size(72.dp),
+                contentAlignment = Alignment.Center,
+            ) {
+                if (avatarUri.isNotBlank() && !avatarLoadFailed.value) {
+                    AsyncImage(
+                        model = avatarUri,
+                        contentDescription = null,
+                        modifier = Modifier
+                            .fillMaxSize(),
+                        contentScale = ContentScale.Crop,
+                        onError = { avatarLoadFailed.value = true },
+                    )
+                } else {
+                    AppIcon(
+                        imageVector = Icons.Default.Person,
+                        contentDescription = null,
+                        modifier = Modifier.size(36.dp),
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+        }
+
+        Column(
+            modifier = Modifier
+                .weight(1f)
+                .clip(RoundedCornerShape(12.dp))
+                .clickable(onClick = onInfoClick)
+                .padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            Row(
+                verticalAlignment = Alignment.CenterVertically,
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                AnimatedTextLine(
+                    text = name.ifBlank { "—" },
+                    style = LegadoTheme.typography.titleMedium,
+                    modifier = Modifier.weight(1f, fill = false),
+                )
+                if (resolvedRole.isNotBlank()) {
+                    TextCard(text = resolvedRole)
+                }
+            }
+            if (aliasesText.isNotBlank()) {
+                AnimatedTextLine(
+                    text = aliasesText,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 2,
+                )
+            } else {
+                AnimatedTextLine(
+                    text = stringResource(R.string.character_no_aliases),
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 2,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterReadonlySection(
+    title: String,
+    emptyText: String,
+    isEmpty: Boolean,
+    content: @Composable ColumnScope.() -> Unit,
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        AppText(
+            text = title,
+            style = LegadoTheme.typography.titleMedium,
+        )
+        if (isEmpty) {
+            GlassCard {
+                Box(
+                    modifier = Modifier
+                        .padding(vertical = 16.dp)
+                        .fillMaxWidth(),
+                    contentAlignment = Alignment.Center
+                ) {
+                    EmptyMessage(
+                        message = emptyText
+                    )
+                }
+            }
+        } else {
+            content()
+        }
+    }
+}
+
+@Composable
+private fun InfoCard(
+    title: String,
+    body: String,
+) {
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AnimatedTextLine(
+                text = title.ifBlank { stringResource(R.string.character_unknown) },
+                style = LegadoTheme.typography.bodyMedium,
+                overflow = TextOverflow.Ellipsis,
+            )
+            AnimatedTextLine(
+                text = body.ifBlank { stringResource(R.string.character_no_summary) },
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                overflow = TextOverflow.Ellipsis,
+                maxLines = 4,
+            )
+        }
+    }
+}

+ 232 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterDetailViewModel.kt

@@ -0,0 +1,232 @@
+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.BookCharacterProfile
+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
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import java.util.UUID
+
+class BookCharacterDetailViewModel(
+    bookUrl: String,
+    characterId: String?,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        CharacterDetailUiState(
+            bookUrl = bookUrl,
+            characterId = characterId,
+            isLoading = true,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<CharacterDetailEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var currentProfile: BookCharacterProfile? = null
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: CharacterDetailIntent) {
+        when (intent) {
+            is CharacterDetailIntent.SetName -> _uiState.update { it.copy(name = intent.value) }
+            is CharacterDetailIntent.SetAliasesText -> _uiState.update { it.copy(aliasesText = intent.value) }
+            is CharacterDetailIntent.SetAvatarUri -> _uiState.update { it.copy(avatarUri = intent.value) }
+            is CharacterDetailIntent.SetTagInput -> _uiState.update { it.copy(tagInput = intent.value) }
+            is CharacterDetailIntent.AddTag -> {
+                val tag = intent.tag.trim()
+                if (tag.isNotBlank()) {
+                    _uiState.update {
+                        it.copy(
+                            tags = (it.tags + tag).distinct().toImmutableList(),
+                            tagInput = "",
+                        )
+                    }
+                }
+            }
+
+            is CharacterDetailIntent.RemoveTag -> _uiState.update {
+                it.copy(tags = it.tags.filterIndexed { index, _ -> index != intent.index }
+                    .toImmutableList())
+            }
+
+            is CharacterDetailIntent.SetRole -> _uiState.update { it.copy(role = 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()
+        }
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val profile = state.characterId?.let {
+                    withContext(Dispatchers.IO) {
+                        bookKnowledgeGateway.getCharacterProfile(state.bookUrl, it)
+                    }
+                }
+                currentProfile = profile
+                val events = profile?.let {
+                    withContext(Dispatchers.IO) {
+                        bookKnowledgeGateway.getCharacterEvents(state.bookUrl, it.id, null, 30)
+                    }
+                }.orEmpty()
+                val relations = profile?.let {
+                    withContext(Dispatchers.IO) {
+                        bookKnowledgeGateway.getCharacterRelations(state.bookUrl, it.id, 80)
+                    }
+                }.orEmpty()
+                val profiles = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(state.bookUrl, 80)
+                }
+                val nameMap = profiles.associate { it.id to it.name }
+
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        characterId = profile?.id ?: state.characterId,
+                        name = profile?.name.orEmpty(),
+                        aliasesText = profile?.aliasesJson.toAliasesText(),
+                        avatarUri = profile?.avatarUri.orEmpty(),
+                        tags = profile?.tagsJson.toTagList(),
+                        role = profile?.role.orEmpty(),
+                        personality = profile?.personality.orEmpty(),
+                        summary = profile?.summary.orEmpty(),
+                        events = events.map { event ->
+                            CharacterEventUi(
+                                id = event.id,
+                                title = listOfNotNull(
+                                    event.chapterTitle.takeIf { title -> title.isNotBlank() },
+                                    event.eventTimeText.takeIf { time -> time.isNotBlank() },
+                                ).joinToString(" · ")
+                                    .ifBlank { appCtx.getString(R.string.character_events) },
+                                content = event.content,
+                            )
+                        }.toImmutableList(),
+                        relations = relations.map { relation ->
+                            CharacterRelationUi(
+                                id = relation.id,
+                                fromName = nameMap[relation.fromCharacterId].orEmpty(),
+                                toName = nameMap[relation.toCharacterId].orEmpty(),
+                                relationType = relation.relationType,
+                                summary = relation.summary,
+                                attitude = relation.attitude,
+                            )
+                        }.toImmutableList(),
+                    )
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    CharacterDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.character_load_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun save() {
+        val state = _uiState.value
+        val name = state.name.trim()
+        if (name.isBlank()) {
+            _effects.tryEmit(CharacterDetailEffect.ShowToast(appCtx.getString(R.string.character_name_empty)))
+            return
+        }
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            try {
+                val now = System.currentTimeMillis()
+                val existing = currentProfile
+                val profile = BookCharacterProfile(
+                    id = existing?.id ?: state.characterId ?: UUID.randomUUID().toString(),
+                    bookUrl = state.bookUrl,
+                    name = name,
+                    aliasesJson = state.aliasesText.toAliasesJson(),
+                    avatarUri = state.avatarUri.trim().takeIf { it.isNotBlank() },
+                    tagsJson = state.tags.toTagsJson(),
+                    role = state.role,
+                    personality = state.personality.trim(),
+                    summary = state.summary.trim(),
+                    status = existing?.status ?: BookCharacterProfile.STATUS_ACTIVE,
+                    source = BookCharacterProfile.SOURCE_USER,
+                    confidence = existing?.confidence ?: 1f,
+                    schemaVersion = existing?.schemaVersion ?: 1,
+                    createdAt = existing?.createdAt ?: now,
+                    updatedAt = now,
+                )
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.upsertCharacterProfile(profile)
+                }
+                currentProfile = profile
+                _uiState.update {
+                    it.copy(
+                        characterId = profile.id,
+                        isSaving = false,
+                    )
+                }
+                _effects.tryEmit(CharacterDetailEffect.ShowToast(appCtx.getString(R.string.character_saved)))
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(
+                    CharacterDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.character_save_failed)
+                    )
+                )
+            }
+        }
+    }
+}
+
+private fun String?.toTagList(): ImmutableList<String> {
+    return GSON.fromJsonArray<String>(this)
+        .getOrNull()
+        .orEmpty()
+        .distinct()
+        .toImmutableList()
+}
+
+private fun ImmutableList<String>.toTagsJson(): String {
+    return GSON.toJson(this.toList())
+}
+
+private fun String?.toAliasesText(): String {
+    return GSON.fromJsonArray<String>(this)
+        .getOrNull()
+        .orEmpty()
+        .joinToString(", ")
+}
+
+private fun String.toAliasesJson(): String {
+    return GSON.toJson(
+        split(",", ",", "\n")
+            .map { it.trim() }
+            .filter { it.isNotBlank() }
+            .distinct()
+    )
+}

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

@@ -0,0 +1,36 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class CharacterListUiState(
+    val bookUrl: String = "",
+    val isLoading: Boolean = false,
+    val roleFilter: String = "",
+    val characters: ImmutableList<CharacterListItemUi> = persistentListOf(),
+)
+
+@Stable
+data class CharacterListItemUi(
+    val id: String,
+    val name: String,
+    val avatarUri: String?,
+    val role: String,
+    val summary: String,
+    val tags: String,
+)
+
+sealed interface CharacterListIntent {
+    data class SetRoleFilter(val role: String) : CharacterListIntent
+    data class OpenCharacter(val characterId: String) : CharacterListIntent
+    data object AddCharacter : CharacterListIntent
+}
+
+sealed interface CharacterListEffect {
+    data class OpenCharacterDetail(val bookUrl: String, val characterId: String?) :
+        CharacterListEffect
+
+    data class ShowToast(val message: String) : CharacterListEffect
+}

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

@@ -0,0 +1,275 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+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.Person
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+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.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.card.GlassCard
+import io.legado.app.ui.widget.components.card.TextCard
+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.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@Composable
+fun BookCharacterListScreen(
+    state: CharacterListUiState,
+    onIntent: (CharacterListIntent) -> Unit,
+    effects: Flow<CharacterListEffect>,
+    onBack: () -> Unit,
+    onOpenDetail: (characterId: String?) -> Unit,
+    onRefresh: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+
+    val roleFilterLabels = remember {
+        listOf(
+            "" to R.string.knowledge_type_all,
+            BookCharacterProfile.ROLE_MALE_LEAD to R.string.role_male_lead,
+            BookCharacterProfile.ROLE_FEMALE_LEAD to R.string.role_female_lead,
+            BookCharacterProfile.ROLE_MALE_SUPPORTING to R.string.role_male_supporting,
+            BookCharacterProfile.ROLE_FEMALE_SUPPORTING to R.string.role_female_supporting,
+        )
+    }
+    val tabTitles = roleFilterLabels.map { stringResource(it.second) }
+    val selectedTabIndex =
+        roleFilterLabels.indexOfFirst { it.first == state.roleFilter }.coerceAtLeast(0)
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is CharacterListEffect.OpenCharacterDetail -> onOpenDetail(effect.characterId)
+                is CharacterListEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    DisposableEffect(lifecycleOwner, onRefresh) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                onRefresh()
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.book_characters),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                scrollBehavior = scrollBehavior,
+                bottomContent = {
+                    AppTabRow(
+                        tabTitles = tabTitles,
+                        selectedTabIndex = selectedTabIndex,
+                        onTabSelected = { index ->
+                            onIntent(CharacterListIntent.SetRoleFilter(roleFilterLabels[index].first))
+                        }
+                    )
+                }
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(CharacterListIntent.AddCharacter) },
+                icon = Icons.Default.Add,
+                tooltipText = stringResource(R.string.add_character),
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading && state.characters.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            CharacterListContent(
+                state = state,
+                onIntent = onIntent,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .imePadding(),
+                paddingValues = paddingValues
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun CharacterListContent(
+    state: CharacterListUiState,
+    onIntent: (CharacterListIntent) -> Unit,
+    modifier: Modifier = Modifier,
+    paddingValues: PaddingValues
+) {
+    LazyColumn(
+        modifier = modifier,
+        contentPadding = adaptiveContentPadding(
+            top = paddingValues.calculateTopPadding(),
+            bottom = 120.dp
+        ),
+        verticalArrangement = Arrangement.spacedBy(8.dp),
+    ) {
+        if (state.characters.isEmpty()) {
+            item {
+                Box(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .padding(top = 64.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    AnimatedTextLine(
+                        text = stringResource(R.string.character_empty_hint),
+                        style = LegadoTheme.typography.bodyMedium,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+        }
+        items(
+            items = state.characters,
+            key = { it.id },
+        ) { character ->
+            CharacterListItem(
+                character = character,
+                onClick = { onIntent(CharacterListIntent.OpenCharacter(character.id)) },
+            )
+        }
+    }
+}
+
+@Composable
+private fun CharacterListItem(
+    character: CharacterListItemUi,
+    onClick: () -> Unit,
+) {
+    val avatarLoadFailed = remember(character.avatarUri) { mutableStateOf(false) }
+
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Row(
+            modifier = Modifier.padding(12.dp),
+            horizontalArrangement = Arrangement.spacedBy(12.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            Box(
+                modifier = Modifier
+                    .size(48.dp)
+                    .clip(CircleShape)
+                    .background(LegadoTheme.colorScheme.surfaceContainerHighest),
+                contentAlignment = Alignment.Center,
+            ) {
+                if (!character.avatarUri.isNullOrBlank() && !avatarLoadFailed.value) {
+                    AsyncImage(
+                        model = character.avatarUri,
+                        contentDescription = null,
+                        modifier = Modifier
+                            .fillMaxSize()
+                            .clip(CircleShape),
+                        contentScale = ContentScale.Crop,
+                        onError = { avatarLoadFailed.value = true },
+                    )
+                } else {
+                    AppIcon(
+                        imageVector = Icons.Default.Person,
+                        contentDescription = null,
+                        modifier = Modifier.size(24.dp),
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+            Column(
+                modifier = Modifier.weight(1f),
+                verticalArrangement = Arrangement.spacedBy(2.dp),
+            ) {
+                Row(
+                    verticalAlignment = Alignment.CenterVertically,
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                ) {
+                    AnimatedTextLine(
+                        text = character.name,
+                        style = LegadoTheme.typography.titleSmall,
+                    )
+                    if (character.role.isNotBlank()) {
+                        val roleLabel = when (character.role) {
+                            BookCharacterProfile.ROLE_MALE_LEAD -> stringResource(R.string.role_male_lead)
+                            BookCharacterProfile.ROLE_FEMALE_LEAD -> stringResource(R.string.role_female_lead)
+                            BookCharacterProfile.ROLE_MALE_SUPPORTING -> stringResource(R.string.role_male_supporting)
+                            BookCharacterProfile.ROLE_FEMALE_SUPPORTING -> stringResource(R.string.role_female_supporting)
+                            else -> character.role
+                        }
+                        TextCard(text = roleLabel)
+                    }
+                }
+                if (character.summary.isNotBlank()) {
+                    AnimatedTextLine(
+                        text = character.summary,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 2,
+                    )
+                }
+            }
+        }
+    }
+}

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

@@ -0,0 +1,119 @@
+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.BookCharacterProfile
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import io.legado.app.utils.GSON
+import io.legado.app.utils.fromJsonArray
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+private val ROLE_ORDER = mapOf(
+    BookCharacterProfile.ROLE_MALE_LEAD to 0,
+    BookCharacterProfile.ROLE_FEMALE_LEAD to 1,
+    BookCharacterProfile.ROLE_MALE_SUPPORTING to 2,
+    BookCharacterProfile.ROLE_FEMALE_SUPPORTING to 3,
+)
+
+class BookCharacterListViewModel(
+    bookUrl: String,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        CharacterListUiState(bookUrl = bookUrl, isLoading = true)
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<CharacterListEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var allProfiles: List<BookCharacterProfile> = emptyList()
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: CharacterListIntent) {
+        when (intent) {
+            is CharacterListIntent.SetRoleFilter -> {
+                _uiState.update { it.copy(roleFilter = intent.role) }
+                applyFilter()
+            }
+
+            is CharacterListIntent.OpenCharacter -> {
+                _effects.tryEmit(
+                    CharacterListEffect.OpenCharacterDetail(
+                        _uiState.value.bookUrl,
+                        intent.characterId
+                    )
+                )
+            }
+
+            CharacterListIntent.AddCharacter -> {
+                _effects.tryEmit(
+                    CharacterListEffect.OpenCharacterDetail(
+                        _uiState.value.bookUrl,
+                        null
+                    )
+                )
+            }
+        }
+    }
+
+    fun refresh() {
+        load()
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                allProfiles = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(state.bookUrl, 200)
+                }
+                applyFilter()
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    CharacterListEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.load_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun applyFilter() {
+        val filter = _uiState.value.roleFilter
+        val characters = allProfiles
+            .filter { filter.isEmpty() || it.role == filter }
+            .sortedBy { ROLE_ORDER[it.role] ?: 99 }
+            .map { p ->
+                val tags = GSON.fromJsonArray<String>(p.tagsJson).getOrNull().orEmpty()
+                CharacterListItemUi(
+                    id = p.id,
+                    name = p.name,
+                    avatarUri = p.avatarUri,
+                    role = p.role,
+                    summary = p.summary,
+                    tags = tags.joinToString(", "),
+                )
+            }.toImmutableList()
+        _uiState.update { it.copy(isLoading = false, characters = characters) }
+    }
+}

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

@@ -0,0 +1,401 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.LazyRow
+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.Person
+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.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import coil.compose.AsyncImage
+import io.legado.app.R
+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.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallTonalButton
+import io.legado.app.ui.widget.components.card.GlassCard
+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.text.AnimatedTextLine
+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.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookCharacterNetworkScreen(
+    state: CharacterNetworkUiState,
+    onIntent: (CharacterNetworkIntent) -> Unit,
+    effects: Flow<CharacterNetworkEffect>,
+    onBack: () -> Unit,
+    onOpenCharacterDetail: (String) -> Unit,
+    onRefresh: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is CharacterNetworkEffect.OpenCharacterDetail -> onOpenCharacterDetail(effect.characterId)
+                is CharacterNetworkEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    DisposableEffect(lifecycleOwner, onRefresh) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                onRefresh()
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose {
+            lifecycleOwner.lifecycle.removeObserver(observer)
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.character_network),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading && state.characters.isEmpty() && state.relations.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            CharacterNetworkContent(
+                state = state,
+                onIntent = onIntent,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .imePadding(),
+                paddingValues = paddingValues,
+            )
+        }
+    }
+}
+
+@Composable
+private fun CharacterNetworkContent(
+    state: CharacterNetworkUiState,
+    onIntent: (CharacterNetworkIntent) -> Unit,
+    modifier: Modifier = Modifier,
+    paddingValues: PaddingValues,
+) {
+    LazyColumn(
+        modifier = modifier,
+        contentPadding = adaptiveContentPadding(
+            top = paddingValues.calculateTopPadding(),
+            bottom = 120.dp,
+        ),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        item {
+            CharacterNodeStrip(
+                characters = state.characters,
+                onOpenCharacter = {
+                    onIntent(CharacterNetworkIntent.OpenCharacter(it))
+                },
+            )
+        }
+        item {
+            AddRelationCard(
+                state = state,
+                onIntent = onIntent,
+            )
+        }
+        item {
+            AppText(
+                text = stringResource(R.string.character_relations),
+                style = LegadoTheme.typography.titleMedium,
+            )
+        }
+        if (state.relations.isEmpty()) {
+            item {
+                AnimatedTextLine(
+                    text = stringResource(R.string.character_relations_empty),
+                    style = LegadoTheme.typography.bodyMedium,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                )
+            }
+        } else {
+            items(
+                items = state.relations,
+                key = { it.id },
+            ) { relation ->
+                RelationEditorCard(
+                    relation = relation,
+                    isSaving = state.isSaving,
+                    onIntent = onIntent,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterNodeStrip(
+    characters: List<CharacterNodeUi>,
+    onOpenCharacter: (String) -> Unit,
+) {
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        AppText(
+            text = stringResource(R.string.book_characters),
+            style = LegadoTheme.typography.titleMedium,
+        )
+        if (characters.isEmpty()) {
+            AnimatedTextLine(
+                text = stringResource(R.string.character_empty_hint),
+                style = LegadoTheme.typography.bodyMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+        } else {
+            LazyRow(
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                items(
+                    items = characters,
+                    key = { it.id },
+                ) { character ->
+                    CharacterNodeChip(
+                        character = character,
+                        onClick = { onOpenCharacter(character.id) },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun CharacterNodeChip(
+    character: CharacterNodeUi,
+    onClick: () -> Unit,
+) {
+    GlassCard(
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Row(
+            modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp),
+            horizontalArrangement = Arrangement.spacedBy(8.dp),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            CharacterAvatar(
+                avatarUri = character.avatarUri,
+                modifier = Modifier.size(32.dp),
+            )
+            AnimatedTextLine(
+                text = character.name,
+                style = LegadoTheme.typography.bodyMedium,
+                maxLines = 1,
+            )
+        }
+    }
+}
+
+@Composable
+private fun AddRelationCard(
+    state: CharacterNetworkUiState,
+    onIntent: (CharacterNetworkIntent) -> Unit,
+) {
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            AppText(
+                text = stringResource(R.string.add_character_relation),
+                style = LegadoTheme.typography.titleSmall,
+            )
+            Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+                AppTextField(
+                    value = state.newFromName,
+                    onValueChange = { onIntent(CharacterNetworkIntent.SetNewFromName(it)) },
+                    label = stringResource(R.string.relation_from),
+                    modifier = Modifier.weight(1f),
+                    singleLine = true,
+                )
+                AppTextField(
+                    value = state.newToName,
+                    onValueChange = { onIntent(CharacterNetworkIntent.SetNewToName(it)) },
+                    label = stringResource(R.string.relation_to),
+                    modifier = Modifier.weight(1f),
+                    singleLine = true,
+                )
+            }
+            AppTextField(
+                value = state.newRelationType,
+                onValueChange = { onIntent(CharacterNetworkIntent.SetNewRelationType(it)) },
+                label = stringResource(R.string.relation_type),
+                modifier = Modifier.fillMaxWidth(),
+                singleLine = true,
+            )
+            AppTextField(
+                value = state.newAttitude,
+                onValueChange = { onIntent(CharacterNetworkIntent.SetNewAttitude(it)) },
+                label = stringResource(R.string.relation_attitude),
+                modifier = Modifier.fillMaxWidth(),
+                singleLine = true,
+            )
+            AppTextField(
+                value = state.newSummary,
+                onValueChange = { onIntent(CharacterNetworkIntent.SetNewSummary(it)) },
+                label = stringResource(R.string.relation_summary),
+                modifier = Modifier.fillMaxWidth(),
+                minLines = 2,
+                maxLines = 5,
+            )
+            MediumTonalButton(
+                onClick = { onIntent(CharacterNetworkIntent.AddRelation) },
+                enabled = !state.isSaving,
+                icon = Icons.Default.Add,
+                text = stringResource(R.string.add_character_relation),
+            )
+        }
+    }
+}
+
+@Composable
+private fun RelationEditorCard(
+    relation: CharacterRelationEditorUi,
+    isSaving: Boolean,
+    onIntent: (CharacterNetworkIntent) -> Unit,
+) {
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(8.dp),
+        ) {
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.spacedBy(8.dp),
+                verticalAlignment = Alignment.CenterVertically,
+            ) {
+                AnimatedTextLine(
+                    text = "${relation.fromName} - ${relation.toName}",
+                    style = LegadoTheme.typography.titleSmall,
+                    modifier = Modifier.weight(1f),
+                    maxLines = 1,
+                )
+                SmallTonalButton(
+                    onClick = { onIntent(CharacterNetworkIntent.SaveRelation(relation.id)) },
+                    enabled = !isSaving,
+                    icon = Icons.Default.Save,
+                    contentDescription = stringResource(R.string.save),
+                )
+            }
+            AppTextField(
+                value = relation.relationType,
+                onValueChange = {
+                    onIntent(CharacterNetworkIntent.SetRelationType(relation.id, it))
+                },
+                label = stringResource(R.string.relation_type),
+                modifier = Modifier.fillMaxWidth(),
+                singleLine = true,
+            )
+            AppTextField(
+                value = relation.attitude,
+                onValueChange = {
+                    onIntent(CharacterNetworkIntent.SetRelationAttitude(relation.id, it))
+                },
+                label = stringResource(R.string.relation_attitude),
+                modifier = Modifier.fillMaxWidth(),
+                singleLine = true,
+            )
+            AppTextField(
+                value = relation.summary,
+                onValueChange = {
+                    onIntent(CharacterNetworkIntent.SetRelationSummary(relation.id, it))
+                },
+                label = stringResource(R.string.relation_summary),
+                modifier = Modifier.fillMaxWidth(),
+                minLines = 2,
+                maxLines = 5,
+            )
+        }
+    }
+}
+
+@Composable
+private fun CharacterAvatar(
+    avatarUri: String?,
+    modifier: Modifier = Modifier,
+) {
+    val avatarLoadFailed = remember(avatarUri) { mutableStateOf(false) }
+
+    Box(
+        modifier = modifier
+            .clip(CircleShape)
+            .background(LegadoTheme.colorScheme.surfaceContainerHighest),
+        contentAlignment = Alignment.Center,
+    ) {
+        if (!avatarUri.isNullOrBlank() && !avatarLoadFailed.value) {
+            AsyncImage(
+                model = avatarUri,
+                contentDescription = null,
+                modifier = Modifier.fillMaxSize(),
+                contentScale = ContentScale.Crop,
+                onError = { avatarLoadFailed.value = true },
+            )
+        } else {
+            AppIcon(Icons.Default.Person, null)
+        }
+    }
+}

+ 263 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookCharacterNetworkViewModel.kt

@@ -0,0 +1,263 @@
+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.BookCharacterProfile
+import io.legado.app.data.entities.BookCharacterRelation
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+import java.util.UUID
+
+class BookCharacterNetworkViewModel(
+    bookUrl: String,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        CharacterNetworkUiState(
+            bookUrl = bookUrl,
+            isLoading = true,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<CharacterNetworkEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var profileMap: Map<String, BookCharacterProfile> = emptyMap()
+    private var relationMap: Map<String, BookCharacterRelation> = emptyMap()
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: CharacterNetworkIntent) {
+        when (intent) {
+            is CharacterNetworkIntent.OpenCharacter -> {
+                _effects.tryEmit(
+                    CharacterNetworkEffect.OpenCharacterDetail(
+                        bookUrl = _uiState.value.bookUrl,
+                        characterId = intent.characterId,
+                    )
+                )
+            }
+
+            is CharacterNetworkIntent.SetNewFromName -> _uiState.update { it.copy(newFromName = intent.value) }
+            is CharacterNetworkIntent.SetNewToName -> _uiState.update { it.copy(newToName = intent.value) }
+            is CharacterNetworkIntent.SetNewRelationType -> _uiState.update {
+                it.copy(
+                    newRelationType = intent.value
+                )
+            }
+
+            is CharacterNetworkIntent.SetNewSummary -> _uiState.update { it.copy(newSummary = intent.value) }
+            is CharacterNetworkIntent.SetNewAttitude -> _uiState.update { it.copy(newAttitude = intent.value) }
+            is CharacterNetworkIntent.SetRelationType -> updateRelation(intent.relationId) {
+                it.copy(relationType = intent.value)
+            }
+
+            is CharacterNetworkIntent.SetRelationSummary -> updateRelation(intent.relationId) {
+                it.copy(summary = intent.value)
+            }
+
+            is CharacterNetworkIntent.SetRelationAttitude -> updateRelation(intent.relationId) {
+                it.copy(attitude = intent.value)
+            }
+
+            is CharacterNetworkIntent.SaveRelation -> saveRelation(intent.relationId)
+            CharacterNetworkIntent.AddRelation -> addRelation()
+        }
+    }
+
+    fun refresh() {
+        load()
+    }
+
+    private fun load() {
+        val bookUrl = _uiState.value.bookUrl
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val profiles = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(bookUrl, 120)
+                }
+                val relations = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getBookCharacterRelations(bookUrl, 200)
+                }
+                profileMap = profiles.associateBy { it.id }
+                relationMap = relations.associateBy { it.id }
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        characters = profiles.map { profile ->
+                            CharacterNodeUi(
+                                id = profile.id,
+                                name = profile.name,
+                                avatarUri = profile.avatarUri,
+                                summary = profile.summary,
+                            )
+                        }.toImmutableList(),
+                        relations = relations.map { relation ->
+                            relation.toEditorUi()
+                        }.toImmutableList(),
+                    )
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    CharacterNetworkEffect.ShowToast(
+                        e.localizedMessage
+                            ?: appCtx.getString(R.string.relation_network_load_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun updateRelation(
+        relationId: String,
+        transform: (CharacterRelationEditorUi) -> CharacterRelationEditorUi,
+    ) {
+        _uiState.update { state ->
+            state.copy(
+                relations = state.relations.map {
+                    if (it.id == relationId) transform(it) else it
+                }.toImmutableList()
+            )
+        }
+    }
+
+    private fun addRelation() {
+        val state = _uiState.value
+        val fromName = state.newFromName.trim()
+        val toName = state.newToName.trim()
+        if (fromName.isBlank() || toName.isBlank()) {
+            _effects.tryEmit(CharacterNetworkEffect.ShowToast(appCtx.getString(R.string.relation_fill_both)))
+            return
+        }
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            try {
+                val fromProfile = findOrCreateProfile(fromName)
+                val toProfile = findOrCreateProfile(toName)
+                val now = System.currentTimeMillis()
+                val relation = BookCharacterRelation(
+                    id = UUID.randomUUID().toString(),
+                    bookUrl = state.bookUrl,
+                    fromCharacterId = fromProfile.id,
+                    toCharacterId = toProfile.id,
+                    relationType = state.newRelationType.trim(),
+                    summary = state.newSummary.trim(),
+                    attitude = state.newAttitude.trim(),
+                    source = BookCharacterProfile.SOURCE_USER,
+                    createdAt = now,
+                    updatedAt = now,
+                )
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.upsertCharacterRelation(relation)
+                }
+                _uiState.update {
+                    it.copy(
+                        isSaving = false,
+                        newFromName = "",
+                        newToName = "",
+                        newRelationType = "",
+                        newSummary = "",
+                        newAttitude = "",
+                    )
+                }
+                _effects.tryEmit(CharacterNetworkEffect.ShowToast(appCtx.getString(R.string.relation_added)))
+                load()
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(
+                    CharacterNetworkEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.relation_add_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun saveRelation(relationId: String) {
+        val state = _uiState.value
+        val editor = state.relations.firstOrNull { it.id == relationId } ?: return
+        val existing = relationMap[relationId] ?: return
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            try {
+                val relation = existing.copy(
+                    relationType = editor.relationType.trim(),
+                    summary = editor.summary.trim(),
+                    attitude = editor.attitude.trim(),
+                    source = BookCharacterProfile.SOURCE_USER,
+                    updatedAt = System.currentTimeMillis(),
+                )
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.upsertCharacterRelation(relation)
+                }
+                relationMap = relationMap + (relation.id to relation)
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(CharacterNetworkEffect.ShowToast(appCtx.getString(R.string.relation_saved)))
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(
+                    CharacterNetworkEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.relation_save_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private suspend fun findOrCreateProfile(name: String): BookCharacterProfile {
+        val bookUrl = _uiState.value.bookUrl
+        profileMap.values.firstOrNull { it.name == name }?.let { return it }
+        bookKnowledgeGateway.getCharacterProfile(bookUrl, name)?.let {
+            profileMap = profileMap + (it.id to it)
+            return it
+        }
+        val now = System.currentTimeMillis()
+        val profile = BookCharacterProfile(
+            id = UUID.randomUUID().toString(),
+            bookUrl = bookUrl,
+            name = name,
+            source = BookCharacterProfile.SOURCE_USER,
+            createdAt = now,
+            updatedAt = now,
+        )
+        bookKnowledgeGateway.upsertCharacterProfile(profile)
+        profileMap = profileMap + (profile.id to profile)
+        return profile
+    }
+
+    private fun BookCharacterRelation.toEditorUi(): CharacterRelationEditorUi {
+        return CharacterRelationEditorUi(
+            id = id,
+            fromCharacterId = fromCharacterId,
+            toCharacterId = toCharacterId,
+            fromName = profileMap[fromCharacterId]?.name.orEmpty(),
+            toName = profileMap[toCharacterId]?.name.orEmpty(),
+            relationType = relationType,
+            summary = summary,
+            attitude = attitude,
+        )
+    }
+}

+ 128 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailScreen.kt

@@ -0,0 +1,128 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+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.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+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.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+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
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookEventDetailScreen(
+    state: EventDetailUiState,
+    onIntent: (EventDetailIntent) -> Unit,
+    effects: Flow<EventDetailEffect>,
+    onBack: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is EventDetailEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.event_detail),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(EventDetailIntent.Save) },
+                        imageVector = Icons.Default.Save,
+                        contentDescription = stringResource(R.string.save),
+                    )
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            EventDetailContent(
+                state = state,
+                onIntent = onIntent,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
+                    .consumeWindowInsets(paddingValues)
+                    .imePadding()
+                    .verticalScroll(rememberScrollState()),
+            )
+        }
+    }
+}
+
+@Composable
+private fun EventDetailContent(
+    state: EventDetailUiState,
+    onIntent: (EventDetailIntent) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.event_character_name),
+            value = state.characterName,
+            onValueChange = { onIntent(EventDetailIntent.SetCharacterName(it)) },
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.event_chapter_title),
+            value = state.chapterTitle,
+            onValueChange = { onIntent(EventDetailIntent.SetChapterTitle(it)) },
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.event_time_text),
+            value = state.eventTimeText,
+            onValueChange = { onIntent(EventDetailIntent.SetEventTimeText(it)) },
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.event_content),
+            value = state.content,
+            onValueChange = { onIntent(EventDetailIntent.SetContent(it)) },
+            multiline = true,
+        )
+    }
+}

+ 175 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventDetailViewModel.kt

@@ -0,0 +1,175 @@
+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.BookCharacterEvent
+import io.legado.app.data.entities.BookCharacterProfile
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+import java.util.UUID
+
+class BookEventDetailViewModel(
+    bookUrl: String,
+    eventId: String?,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        EventDetailUiState(
+            bookUrl = bookUrl,
+            eventId = eventId,
+            isLoading = eventId != null,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<EventDetailEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var currentEvent: BookCharacterEvent? = null
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: EventDetailIntent) {
+        when (intent) {
+            is EventDetailIntent.SetCharacterName -> _uiState.update { it.copy(characterName = intent.value) }
+            is EventDetailIntent.SetChapterTitle -> _uiState.update { it.copy(chapterTitle = intent.value) }
+            is EventDetailIntent.SetEventTimeText -> _uiState.update { it.copy(eventTimeText = intent.value) }
+            is EventDetailIntent.SetContent -> _uiState.update { it.copy(content = intent.value) }
+            is EventDetailIntent.SetImportance -> _uiState.update { it.copy(importance = intent.value) }
+            EventDetailIntent.Save -> save()
+        }
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        val eventId = state.eventId ?: return
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val events = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterEvents(
+                        bookUrl = state.bookUrl,
+                        characterId = null,
+                        maxChapterIndex = null,
+                        limit = 200,
+                    )
+                }
+                val event = events.find { it.id == eventId }
+                currentEvent = event
+                if (event != null) {
+                    val profiles = withContext(Dispatchers.IO) {
+                        bookKnowledgeGateway.getCharacterProfiles(state.bookUrl, 200)
+                    }
+                    val charName = profiles.find { it.id == event.characterId }?.name.orEmpty()
+                    _uiState.update {
+                        it.copy(
+                            isLoading = false,
+                            characterName = charName,
+                            chapterTitle = event.chapterTitle,
+                            eventTimeText = event.eventTimeText,
+                            content = event.content,
+                            importance = event.importance,
+                        )
+                    }
+                } else {
+                    _uiState.update { it.copy(isLoading = false) }
+                    _effects.tryEmit(EventDetailEffect.ShowToast(appCtx.getString(R.string.event_not_found)))
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    EventDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(
+                            R.string.load_failed
+                        )
+                    )
+                )
+            }
+        }
+    }
+
+    private fun save() {
+        val state = _uiState.value
+        val content = state.content.trim()
+        if (content.isBlank()) {
+            _effects.tryEmit(EventDetailEffect.ShowToast(appCtx.getString(R.string.event_content_empty)))
+            return
+        }
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            try {
+                val charName = state.characterName.trim()
+                val character = if (charName.isNotBlank()) {
+                    bookKnowledgeGateway.getCharacterProfile(state.bookUrl, charName)
+                        ?: withContext(Dispatchers.IO) {
+                            val now = System.currentTimeMillis()
+                            val profile = io.legado.app.data.entities.BookCharacterProfile(
+                                id = UUID.randomUUID().toString(),
+                                bookUrl = state.bookUrl,
+                                name = charName,
+                                source = io.legado.app.data.entities.BookCharacterProfile.SOURCE_USER,
+                                createdAt = now,
+                                updatedAt = now,
+                            )
+                            bookKnowledgeGateway.upsertCharacterProfile(profile)
+                            profile
+                        }
+                } else null
+
+                val now = System.currentTimeMillis()
+                val existing = currentEvent
+                val event = BookCharacterEvent(
+                    id = existing?.id ?: state.eventId ?: UUID.randomUUID().toString(),
+                    bookUrl = state.bookUrl,
+                    characterId = character?.id.orEmpty(),
+                    chapterTitle = state.chapterTitle.trim(),
+                    eventTimeText = state.eventTimeText.trim(),
+                    content = content,
+                    importance = state.importance,
+                    source = existing?.source ?: BookCharacterProfile.SOURCE_USER,
+                    confidence = existing?.confidence ?: 1f,
+                    schemaVersion = existing?.schemaVersion ?: 1,
+                    createdAt = existing?.createdAt ?: now,
+                    updatedAt = now,
+                )
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.upsertCharacterEvent(event)
+                }
+                currentEvent = event
+                _uiState.update {
+                    it.copy(
+                        eventId = event.id,
+                        isSaving = false,
+                    )
+                }
+                _effects.tryEmit(EventDetailEffect.ShowToast(appCtx.getString(R.string.knowledge_saved)))
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(
+                    EventDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(
+                            R.string.save_failed
+                        )
+                    )
+                )
+            }
+        }
+    }
+}

+ 58 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListContract.kt

@@ -0,0 +1,58 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class EventListUiState(
+    val bookUrl: String = "",
+    val isLoading: Boolean = false,
+    val entries: ImmutableList<EventListItemUi> = persistentListOf(),
+)
+
+@Stable
+data class EventListItemUi(
+    val id: String,
+    val chapterTitle: String,
+    val eventTimeText: String,
+    val content: String,
+    val importance: Int,
+    val characterName: String,
+)
+
+sealed interface EventListIntent {
+    data class OpenEvent(val eventId: String) : EventListIntent
+    data object AddEvent : EventListIntent
+}
+
+sealed interface EventListEffect {
+    data class OpenEventDetail(val bookUrl: String, val eventId: String?) : EventListEffect
+    data class ShowToast(val message: String) : EventListEffect
+}
+
+@Stable
+data class EventDetailUiState(
+    val bookUrl: String = "",
+    val eventId: String? = null,
+    val isLoading: Boolean = false,
+    val isSaving: Boolean = false,
+    val characterName: String = "",
+    val chapterTitle: String = "",
+    val eventTimeText: String = "",
+    val content: String = "",
+    val importance: Int = 0,
+)
+
+sealed interface EventDetailIntent {
+    data class SetCharacterName(val value: String) : EventDetailIntent
+    data class SetChapterTitle(val value: String) : EventDetailIntent
+    data class SetEventTimeText(val value: String) : EventDetailIntent
+    data class SetContent(val value: String) : EventDetailIntent
+    data class SetImportance(val value: Int) : EventDetailIntent
+    data object Save : EventDetailIntent
+}
+
+sealed interface EventDetailEffect {
+    data class ShowToast(val message: String) : EventDetailEffect
+}

+ 175 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListScreen.kt

@@ -0,0 +1,175 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import io.legado.app.R
+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.card.GlassCard
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+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.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookEventListScreen(
+    state: EventListUiState,
+    onIntent: (EventListIntent) -> Unit,
+    effects: Flow<EventListEffect>,
+    onBack: () -> Unit,
+    onOpenDetail: (eventId: String?) -> Unit,
+    onRefresh: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is EventListEffect.OpenEventDetail -> onOpenDetail(effect.eventId)
+                is EventListEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    DisposableEffect(lifecycleOwner, onRefresh) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                onRefresh()
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.event_list),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(EventListIntent.AddEvent) },
+                icon = Icons.Default.Add,
+                tooltipText = stringResource(R.string.add_event),
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading && state.entries.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            LazyColumn(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .imePadding(),
+                contentPadding = adaptiveContentPadding(
+                    top = paddingValues.calculateTopPadding(),
+                    bottom = 120.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                if (state.entries.isEmpty()) {
+                    item {
+                        Box(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(top = 64.dp),
+                            contentAlignment = Alignment.Center,
+                        ) {
+                            AnimatedTextLine(
+                                text = stringResource(R.string.event_empty_hint),
+                                style = LegadoTheme.typography.bodyMedium,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            )
+                        }
+                    }
+                }
+                items(
+                    items = state.entries,
+                    key = { it.id },
+                ) { entry ->
+                    EventEntryCard(
+                        entry = entry,
+                        onClick = { onIntent(EventListIntent.OpenEvent(entry.id)) },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun EventEntryCard(
+    entry: EventListItemUi,
+    onClick: () -> Unit,
+) {
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            val header = listOfNotNull(
+                entry.characterName.takeIf { it.isNotBlank() },
+                entry.chapterTitle.takeIf { it.isNotBlank() },
+                entry.eventTimeText.takeIf { it.isNotBlank() },
+            ).joinToString(" · ").ifBlank { stringResource(R.string.event_detail) }
+            AnimatedTextLine(
+                text = header,
+                style = LegadoTheme.typography.titleSmall,
+            )
+            AnimatedTextLine(
+                text = entry.content.ifBlank { stringResource(R.string.event_content) },
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 2,
+            )
+        }
+    }
+}

+ 98 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookEventListViewModel.kt

@@ -0,0 +1,98 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.R
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+class BookEventListViewModel(
+    bookUrl: String,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        EventListUiState(bookUrl = bookUrl, isLoading = true)
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<EventListEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: EventListIntent) {
+        when (intent) {
+            is EventListIntent.OpenEvent -> {
+                val bookUrl = _uiState.value.bookUrl
+                _effects.tryEmit(EventListEffect.OpenEventDetail(bookUrl, intent.eventId))
+            }
+
+            EventListIntent.AddEvent -> {
+                val bookUrl = _uiState.value.bookUrl
+                _effects.tryEmit(EventListEffect.OpenEventDetail(bookUrl, null))
+            }
+        }
+    }
+
+    fun refresh() {
+        load()
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val events = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterEvents(
+                        bookUrl = state.bookUrl,
+                        characterId = null,
+                        maxChapterIndex = null,
+                        limit = 100,
+                    )
+                }
+                val profiles = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.getCharacterProfiles(state.bookUrl, 200)
+                }
+                val nameMap = profiles.associate { it.id to it.name }
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        entries = events.map { event ->
+                            EventListItemUi(
+                                id = event.id,
+                                chapterTitle = event.chapterTitle,
+                                eventTimeText = event.eventTimeText,
+                                content = event.content,
+                                importance = event.importance,
+                                characterName = nameMap[event.characterId].orEmpty(),
+                            )
+                        }.toImmutableList(),
+                    )
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    EventListEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.event_list_load_failed)
+                    )
+                )
+            }
+        }
+    }
+}

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

@@ -0,0 +1,116 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class CharacterDetailUiState(
+    val bookUrl: String = "",
+    val characterId: String? = null,
+    val isLoading: Boolean = false,
+    val isSaving: Boolean = false,
+    val name: String = "",
+    val aliasesText: String = "",
+    val avatarUri: String = "",
+    val tags: ImmutableList<String> = persistentListOf(),
+    val tagInput: String = "",
+    val role: String = "",
+    val personality: String = "",
+    val summary: String = "",
+    val events: ImmutableList<CharacterEventUi> = persistentListOf(),
+    val relations: ImmutableList<CharacterRelationUi> = persistentListOf(),
+)
+
+@Stable
+data class CharacterEventUi(
+    val id: String,
+    val title: String,
+    val content: String,
+)
+
+@Stable
+data class CharacterRelationUi(
+    val id: String,
+    val fromName: String,
+    val toName: String,
+    val relationType: String,
+    val summary: String,
+    val attitude: String,
+)
+
+sealed interface CharacterDetailIntent {
+    data class SetName(val value: String) : CharacterDetailIntent
+    data class SetAliasesText(val value: String) : CharacterDetailIntent
+    data class SetAvatarUri(val value: String) : CharacterDetailIntent
+    data class SetTagInput(val value: String) : CharacterDetailIntent
+    data class AddTag(val tag: String) : CharacterDetailIntent
+    data class RemoveTag(val index: Int) : CharacterDetailIntent
+    data class SetRole(val value: String) : CharacterDetailIntent
+    data class SetPersonality(val value: String) : CharacterDetailIntent
+    data class SetSummary(val value: String) : CharacterDetailIntent
+    data object Save : CharacterDetailIntent
+}
+
+sealed interface CharacterDetailEffect {
+    data class ShowToast(val message: String) : CharacterDetailEffect
+}
+
+@Stable
+data class CharacterNetworkUiState(
+    val bookUrl: String = "",
+    val isLoading: Boolean = false,
+    val isSaving: Boolean = false,
+    val characters: ImmutableList<CharacterNodeUi> = persistentListOf(),
+    val relations: ImmutableList<CharacterRelationEditorUi> = persistentListOf(),
+    val newFromName: String = "",
+    val newToName: String = "",
+    val newRelationType: String = "",
+    val newSummary: String = "",
+    val newAttitude: String = "",
+)
+
+@Stable
+data class CharacterNodeUi(
+    val id: String,
+    val name: String,
+    val avatarUri: String?,
+    val summary: String,
+)
+
+@Stable
+data class CharacterRelationEditorUi(
+    val id: String,
+    val fromCharacterId: String,
+    val toCharacterId: String,
+    val fromName: String,
+    val toName: String,
+    val relationType: String,
+    val summary: String,
+    val attitude: String,
+)
+
+sealed interface CharacterNetworkIntent {
+    data class OpenCharacter(val characterId: String) : CharacterNetworkIntent
+    data class SetNewFromName(val value: String) : CharacterNetworkIntent
+    data class SetNewToName(val value: String) : CharacterNetworkIntent
+    data class SetNewRelationType(val value: String) : CharacterNetworkIntent
+    data class SetNewSummary(val value: String) : CharacterNetworkIntent
+    data class SetNewAttitude(val value: String) : CharacterNetworkIntent
+    data class SetRelationType(val relationId: String, val value: String) : CharacterNetworkIntent
+    data class SetRelationSummary(val relationId: String, val value: String) :
+        CharacterNetworkIntent
+
+    data class SetRelationAttitude(val relationId: String, val value: String) :
+        CharacterNetworkIntent
+
+    data class SaveRelation(val relationId: String) : CharacterNetworkIntent
+    data object AddRelation : CharacterNetworkIntent
+}
+
+sealed interface CharacterNetworkEffect {
+    data class OpenCharacterDetail(val bookUrl: String, val characterId: String) :
+        CharacterNetworkEffect
+
+    data class ShowToast(val message: String) : CharacterNetworkEffect
+}

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

@@ -0,0 +1,208 @@
+package io.legado.app.ui.book.knowledge
+
+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.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.imePadding
+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.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+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.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppScaffold
+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
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
+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.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+private val KNOWLEDGE_TYPE_LABELS = mapOf(
+    "world_rule" to R.string.knowledge_type_world_rule,
+    "location" to R.string.knowledge_type_location,
+    "faction" to R.string.knowledge_type_faction,
+    "object" to R.string.knowledge_type_object,
+    "terminology" to R.string.knowledge_type_terminology,
+    "timeline" to R.string.knowledge_type_timeline,
+    "style" to R.string.knowledge_type_style,
+    "theme" to R.string.knowledge_type_theme,
+)
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookKnowledgeDetailScreen(
+    state: KnowledgeDetailUiState,
+    onIntent: (KnowledgeDetailIntent) -> Unit,
+    effects: Flow<KnowledgeDetailEffect>,
+    onBack: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is KnowledgeDetailEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.knowledge_detail),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                actions = {
+                    TopBarActionButton(
+                        onClick = { onIntent(KnowledgeDetailIntent.Save) },
+                        imageVector = Icons.Default.Save,
+                        contentDescription = stringResource(R.string.save),
+                    )
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            KnowledgeDetailContent(
+                state = state,
+                onIntent = onIntent,
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
+                    .consumeWindowInsets(paddingValues)
+                    .imePadding()
+                    .verticalScroll(rememberScrollState()),
+            )
+        }
+    }
+}
+
+@Composable
+private fun KnowledgeDetailContent(
+    state: KnowledgeDetailUiState,
+    onIntent: (KnowledgeDetailIntent) -> Unit,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier.padding(horizontal = 16.dp, vertical = 12.dp),
+        verticalArrangement = Arrangement.spacedBy(12.dp),
+    ) {
+        KnowledgeTypeSelector(
+            selectedType = state.type,
+            onTypeSelected = { onIntent(KnowledgeDetailIntent.SetType(it)) },
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.knowledge_title),
+            value = state.title,
+            onValueChange = { onIntent(KnowledgeDetailIntent.SetTitle(it)) },
+        )
+        KnowledgeFlowTagEditor(
+            title = stringResource(R.string.knowledge_keywords),
+            tags = state.keywords,
+            onAddTag = { onIntent(KnowledgeDetailIntent.AddKeyword(it)) },
+            onRemoveTag = { onIntent(KnowledgeDetailIntent.RemoveKeyword(it)) },
+        )
+        KnowledgeEditFieldCard(
+            label = stringResource(R.string.knowledge_content),
+            value = state.content,
+            onValueChange = { onIntent(KnowledgeDetailIntent.SetContent(it)) },
+            multiline = true,
+        )
+        Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
+            KnowledgeEditFieldCard(
+                label = stringResource(R.string.knowledge_scope_start),
+                value = state.scopeStartChapter,
+                onValueChange = { onIntent(KnowledgeDetailIntent.SetScopeStart(it)) },
+                modifier = Modifier.weight(1f),
+            )
+            KnowledgeEditFieldCard(
+                label = stringResource(R.string.knowledge_scope_end),
+                value = state.scopeEndChapter,
+                onValueChange = { onIntent(KnowledgeDetailIntent.SetScopeEnd(it)) },
+                modifier = Modifier.weight(1f),
+            )
+        }
+    }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun KnowledgeTypeSelector(
+    selectedType: String,
+    onTypeSelected: (String) -> Unit,
+) {
+    var showDropdown by remember { mutableStateOf(false) }
+    val currentLabel = KNOWLEDGE_TYPE_LABELS[selectedType]?.let { stringResource(it) } ?: "—"
+
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        AppText(
+            text = stringResource(R.string.knowledge_type),
+            style = LegadoTheme.typography.titleMedium,
+        )
+        Box {
+            GlassCard(
+                onClick = { showDropdown = true },
+                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+            ) {
+                AnimatedTextLine(
+                    text = currentLabel,
+                    style = LegadoTheme.typography.bodyMedium,
+                    modifier = Modifier.padding(16.dp),
+                )
+            }
+            RoundDropdownMenu(
+                expanded = showDropdown,
+                onDismissRequest = { showDropdown = false },
+            ) {
+                KNOWLEDGE_TYPE_LABELS.forEach { (type, labelRes) ->
+                    RoundDropdownMenuItem(
+                        text = stringResource(labelRes),
+                        onClick = {
+                            onTypeSelected(type)
+                            showDropdown = false
+                        },
+                    )
+                }
+            }
+        }
+    }
+}

+ 190 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeDetailViewModel.kt

@@ -0,0 +1,190 @@
+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.BookCharacterProfile
+import io.legado.app.data.entities.BookKnowledgeEntry
+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
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+import java.util.UUID
+
+class BookKnowledgeDetailViewModel(
+    bookUrl: String,
+    entryId: String?,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        KnowledgeDetailUiState(
+            bookUrl = bookUrl,
+            entryId = entryId,
+            isLoading = entryId != null,
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<KnowledgeDetailEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    private var currentEntry: BookKnowledgeEntry? = null
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: KnowledgeDetailIntent) {
+        when (intent) {
+            is KnowledgeDetailIntent.SetType -> _uiState.update { it.copy(type = intent.value) }
+            is KnowledgeDetailIntent.SetTitle -> _uiState.update { it.copy(title = intent.value) }
+            is KnowledgeDetailIntent.SetContent -> _uiState.update { it.copy(content = intent.value) }
+            is KnowledgeDetailIntent.SetKeywordInput -> _uiState.update { it.copy(keywordInput = intent.value) }
+            is KnowledgeDetailIntent.AddKeyword -> {
+                val kw = intent.keyword.trim()
+                if (kw.isNotBlank()) {
+                    _uiState.update {
+                        it.copy(
+                            keywords = (it.keywords + kw).distinct().toImmutableList(),
+                            keywordInput = "",
+                        )
+                    }
+                }
+            }
+
+            is KnowledgeDetailIntent.RemoveKeyword -> _uiState.update {
+                it.copy(keywords = it.keywords.filterIndexed { index, _ -> index != intent.index }
+                    .toImmutableList())
+            }
+
+            is KnowledgeDetailIntent.SetScopeStart -> _uiState.update { it.copy(scopeStartChapter = intent.value) }
+            is KnowledgeDetailIntent.SetScopeEnd -> _uiState.update { it.copy(scopeEndChapter = intent.value) }
+            is KnowledgeDetailIntent.SetPriority -> _uiState.update { it.copy(priority = intent.value) }
+            KnowledgeDetailIntent.Save -> save()
+        }
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        val entryId = state.entryId ?: return
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val entries = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.searchKnowledgeEntries(
+                        bookUrl = state.bookUrl,
+                        query = "",
+                        type = null,
+                        chapterIndex = null,
+                        limit = 200,
+                    )
+                }
+                val entry = entries.find { it.id == entryId }
+                currentEntry = entry
+                if (entry != null) {
+                    _uiState.update {
+                        it.copy(
+                            isLoading = false,
+                            type = entry.type,
+                            title = entry.title,
+                            content = entry.content,
+                            keywords = entry.keywordsJson.toKeywordList(),
+                            scopeStartChapter = entry.scopeStartChapter?.toString().orEmpty(),
+                            scopeEndChapter = entry.scopeEndChapter?.toString().orEmpty(),
+                            priority = entry.priority,
+                        )
+                    }
+                } else {
+                    _uiState.update { it.copy(isLoading = false) }
+                    _effects.tryEmit(KnowledgeDetailEffect.ShowToast(appCtx.getString(R.string.knowledge_not_found)))
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    KnowledgeDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.load_failed)
+                    )
+                )
+            }
+        }
+    }
+
+    private fun save() {
+        val state = _uiState.value
+        val title = state.title.trim()
+        if (title.isBlank()) {
+            _effects.tryEmit(KnowledgeDetailEffect.ShowToast(appCtx.getString(R.string.knowledge_title_empty)))
+            return
+        }
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            try {
+                val now = System.currentTimeMillis()
+                val existing = currentEntry
+                val entry = BookKnowledgeEntry(
+                    id = existing?.id ?: state.entryId ?: UUID.randomUUID().toString(),
+                    bookUrl = state.bookUrl,
+                    type = state.type,
+                    title = title,
+                    keywordsJson = state.keywords.toKeywordsJson(),
+                    content = state.content.trim(),
+                    scopeStartChapter = state.scopeStartChapter.toIntOrNull(),
+                    scopeEndChapter = state.scopeEndChapter.toIntOrNull(),
+                    priority = state.priority,
+                    source = existing?.source ?: BookCharacterProfile.SOURCE_USER,
+                    confidence = existing?.confidence ?: 1f,
+                    schemaVersion = existing?.schemaVersion ?: 1,
+                    createdAt = existing?.createdAt ?: now,
+                    updatedAt = now,
+                )
+                withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.upsertKnowledgeEntry(entry)
+                }
+                currentEntry = entry
+                _uiState.update {
+                    it.copy(
+                        entryId = entry.id,
+                        isSaving = false,
+                    )
+                }
+                _effects.tryEmit(KnowledgeDetailEffect.ShowToast(appCtx.getString(R.string.knowledge_saved)))
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isSaving = false) }
+                _effects.tryEmit(
+                    KnowledgeDetailEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.save_failed)
+                    )
+                )
+            }
+        }
+    }
+}
+
+private fun String?.toKeywordList(): ImmutableList<String> {
+    return GSON.fromJsonArray<String>(this)
+        .getOrNull()
+        .orEmpty()
+        .distinct()
+        .toImmutableList()
+}
+
+private fun ImmutableList<String>.toKeywordsJson(): String {
+    return GSON.toJson(this.toList())
+}

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

@@ -0,0 +1,66 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class KnowledgeListUiState(
+    val bookUrl: String = "",
+    val isLoading: Boolean = false,
+    val typeFilter: String? = null,
+    val entries: ImmutableList<KnowledgeEntryUi> = persistentListOf(),
+)
+
+@Stable
+data class KnowledgeEntryUi(
+    val id: String,
+    val type: String,
+    val title: String,
+    val summary: String,
+    val source: String,
+)
+
+sealed interface KnowledgeListIntent {
+    data class SetTypeFilter(val type: String?) : KnowledgeListIntent
+    data class OpenEntry(val entryId: String) : KnowledgeListIntent
+    data object AddEntry : KnowledgeListIntent
+}
+
+sealed interface KnowledgeListEffect {
+    data class OpenKnowledgeDetail(val bookUrl: String, val entryId: String?) : KnowledgeListEffect
+    data class ShowToast(val message: String) : KnowledgeListEffect
+}
+
+@Stable
+data class KnowledgeDetailUiState(
+    val bookUrl: String = "",
+    val entryId: String? = null,
+    val isLoading: Boolean = false,
+    val isSaving: Boolean = false,
+    val type: String = "world_rule",
+    val title: String = "",
+    val content: String = "",
+    val keywords: ImmutableList<String> = persistentListOf(),
+    val keywordInput: String = "",
+    val scopeStartChapter: String = "",
+    val scopeEndChapter: String = "",
+    val priority: Int = 0,
+)
+
+sealed interface KnowledgeDetailIntent {
+    data class SetType(val value: String) : KnowledgeDetailIntent
+    data class SetTitle(val value: String) : KnowledgeDetailIntent
+    data class SetContent(val value: String) : KnowledgeDetailIntent
+    data class SetKeywordInput(val value: String) : KnowledgeDetailIntent
+    data class AddKeyword(val keyword: String) : KnowledgeDetailIntent
+    data class RemoveKeyword(val index: Int) : KnowledgeDetailIntent
+    data class SetScopeStart(val value: String) : KnowledgeDetailIntent
+    data class SetScopeEnd(val value: String) : KnowledgeDetailIntent
+    data class SetPriority(val value: Int) : KnowledgeDetailIntent
+    data object Save : KnowledgeDetailIntent
+}
+
+sealed interface KnowledgeDetailEffect {
+    data class ShowToast(val message: String) : KnowledgeDetailEffect
+}

+ 170 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListScreen.kt

@@ -0,0 +1,170 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import io.legado.app.R
+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.card.GlassCard
+import io.legado.app.ui.widget.components.progressIndicator.AppCircularProgressIndicator
+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.TopBarNavigationButton
+import io.legado.app.utils.toastOnUi
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun BookKnowledgeListScreen(
+    state: KnowledgeListUiState,
+    onIntent: (KnowledgeListIntent) -> Unit,
+    effects: Flow<KnowledgeListEffect>,
+    onBack: () -> Unit,
+    onOpenDetail: (entryId: String?) -> Unit,
+    onRefresh: () -> Unit,
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val context = LocalContext.current
+    val lifecycleOwner = LocalLifecycleOwner.current
+
+    LaunchedEffect(effects) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is KnowledgeListEffect.OpenKnowledgeDetail -> onOpenDetail(effect.entryId)
+                is KnowledgeListEffect.ShowToast -> context.toastOnUi(effect.message)
+            }
+        }
+    }
+
+    DisposableEffect(lifecycleOwner, onRefresh) {
+        val observer = LifecycleEventObserver { _, event ->
+            if (event == Lifecycle.Event.ON_RESUME) {
+                onRefresh()
+            }
+        }
+        lifecycleOwner.lifecycle.addObserver(observer)
+        onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.knowledge_list),
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBack)
+                },
+                scrollBehavior = scrollBehavior,
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(KnowledgeListIntent.AddEntry) },
+                icon = Icons.Default.Add,
+                tooltipText = stringResource(R.string.add_knowledge_entry),
+            )
+        },
+    ) { paddingValues ->
+        if (state.isLoading && state.entries.isEmpty()) {
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues),
+                contentAlignment = Alignment.Center,
+            ) {
+                AppCircularProgressIndicator()
+            }
+        } else {
+            LazyColumn(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .imePadding(),
+                contentPadding = adaptiveContentPadding(
+                    top = paddingValues.calculateTopPadding(),
+                    bottom = 120.dp,
+                ),
+                verticalArrangement = Arrangement.spacedBy(8.dp),
+            ) {
+                if (state.entries.isEmpty()) {
+                    item {
+                        Box(
+                            modifier = Modifier
+                                .fillMaxWidth()
+                                .padding(top = 64.dp),
+                            contentAlignment = Alignment.Center,
+                        ) {
+                            AnimatedTextLine(
+                                text = stringResource(R.string.knowledge_empty_hint),
+                                style = LegadoTheme.typography.bodyMedium,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                            )
+                        }
+                    }
+                }
+                items(
+                    items = state.entries,
+                    key = { it.id },
+                ) { entry ->
+                    KnowledgeEntryCard(
+                        entry = entry,
+                        onClick = { onIntent(KnowledgeListIntent.OpenEntry(entry.id)) },
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun KnowledgeEntryCard(
+    entry: KnowledgeEntryUi,
+    onClick: () -> Unit,
+) {
+    GlassCard(
+        modifier = Modifier.fillMaxWidth(),
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(12.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp),
+        ) {
+            AnimatedTextLine(
+                text = entry.title.ifBlank { stringResource(R.string.knowledge_title) },
+                style = LegadoTheme.typography.titleSmall,
+            )
+            AnimatedTextLine(
+                text = entry.summary.ifBlank { stringResource(R.string.knowledge_content) },
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                maxLines = 2,
+            )
+        }
+    }
+}

+ 100 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/BookKnowledgeListViewModel.kt

@@ -0,0 +1,100 @@
+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.BookKnowledgeEntry
+import io.legado.app.domain.gateway.BookKnowledgeGateway
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import splitties.init.appCtx
+
+class BookKnowledgeListViewModel(
+    bookUrl: String,
+    private val bookKnowledgeGateway: BookKnowledgeGateway,
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        KnowledgeListUiState(bookUrl = bookUrl, isLoading = true)
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<KnowledgeListEffect>(extraBufferCapacity = 8)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        load()
+    }
+
+    fun onIntent(intent: KnowledgeListIntent) {
+        when (intent) {
+            is KnowledgeListIntent.SetTypeFilter -> {
+                _uiState.update { it.copy(typeFilter = intent.type) }
+                load()
+            }
+
+            is KnowledgeListIntent.OpenEntry -> {
+                val bookUrl = _uiState.value.bookUrl
+                _effects.tryEmit(KnowledgeListEffect.OpenKnowledgeDetail(bookUrl, intent.entryId))
+            }
+
+            KnowledgeListIntent.AddEntry -> {
+                val bookUrl = _uiState.value.bookUrl
+                _effects.tryEmit(KnowledgeListEffect.OpenKnowledgeDetail(bookUrl, null))
+            }
+        }
+    }
+
+    fun refresh() {
+        load()
+    }
+
+    private fun load() {
+        val state = _uiState.value
+        viewModelScope.launch {
+            _uiState.update { it.copy(isLoading = true) }
+            try {
+                val entries = withContext(Dispatchers.IO) {
+                    bookKnowledgeGateway.searchKnowledgeEntries(
+                        bookUrl = state.bookUrl,
+                        query = "",
+                        type = state.typeFilter,
+                        chapterIndex = null,
+                        limit = 50,
+                    )
+                }
+                _uiState.update {
+                    it.copy(
+                        isLoading = false,
+                        entries = entries.map { entry ->
+                            KnowledgeEntryUi(
+                                id = entry.id,
+                                type = entry.type,
+                                title = entry.title,
+                                summary = entry.content.take(100),
+                                source = entry.source,
+                            )
+                        }.toImmutableList(),
+                    )
+                }
+            } catch (e: CancellationException) {
+                throw e
+            } catch (e: Throwable) {
+                _uiState.update { it.copy(isLoading = false) }
+                _effects.tryEmit(
+                    KnowledgeListEffect.ShowToast(
+                        e.localizedMessage ?: appCtx.getString(R.string.knowledge_list_load_failed)
+                    )
+                )
+            }
+        }
+    }
+}

+ 168 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarCropDialog.kt

@@ -0,0 +1,168 @@
+package io.legado.app.ui.book.knowledge
+
+import android.net.Uri
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.gestures.detectTransformGestures
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableFloatStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.layout.ScaleFactor
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.input.pointer.pointerInput
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.IntOffset
+import androidx.compose.ui.unit.dp
+import coil.compose.AsyncImage
+import io.legado.app.R
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import kotlin.math.roundToInt
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun CharacterAvatarCropDialog(
+    sourceUri: Uri?,
+    show: Boolean = sourceUri != null,
+    onDismissRequest: () -> Unit,
+    onConfirm: (CharacterAvatarCrop) -> Unit,
+) {
+    if (sourceUri == null) return
+
+    var zoom by remember(sourceUri) { mutableFloatStateOf(1f) }
+    var offsetX by remember(sourceUri) { mutableFloatStateOf(0f) }
+    var offsetY by remember(sourceUri) { mutableFloatStateOf(0f) }
+    var viewportSize by remember(sourceUri) { mutableFloatStateOf(1f) }
+    var imageWidth by remember(sourceUri) { mutableFloatStateOf(1f) }
+    var imageHeight by remember(sourceUri) { mutableFloatStateOf(1f) }
+
+    AppAlertDialog(
+        show = show,
+        onDismissRequest = onDismissRequest,
+        title = stringResource(R.string.character_avatar_crop),
+        confirmText = stringResource(R.string.apply),
+        onConfirm = {
+            onConfirm(
+                CharacterAvatarCrop(
+                    zoom = zoom,
+                    offsetX = offsetX,
+                    offsetY = offsetY,
+                    viewportSize = viewportSize,
+                )
+            )
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = onDismissRequest,
+        content = {
+            Column(
+                verticalArrangement = Arrangement.spacedBy(12.dp),
+            ) {
+                BoxWithConstraints(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .height(280.dp),
+                    contentAlignment = Alignment.Center,
+                ) {
+                    val density = LocalDensity.current
+                    val cropSize = minOf(maxWidth, 280.dp)
+                    val sizePx = with(density) { cropSize.toPx() }
+                    viewportSize = sizePx
+                    val baseScale = maxOf(sizePx / imageWidth, sizePx / imageHeight)
+                    val maxOffsetX =
+                        ((imageWidth * baseScale * zoom) - sizePx).coerceAtLeast(0f) / 2f
+                    val maxOffsetY =
+                        ((imageHeight * baseScale * zoom) - sizePx).coerceAtLeast(0f) / 2f
+                    val cropContentScale = remember(zoom) {
+                        object : ContentScale {
+                            override fun computeScaleFactor(
+                                srcSize: Size,
+                                dstSize: Size
+                            ): ScaleFactor {
+                                val scale = maxOf(
+                                    dstSize.width / srcSize.width,
+                                    dstSize.height / srcSize.height,
+                                ) * zoom
+                                return ScaleFactor(scale, scale)
+                            }
+                        }
+                    }
+                    val cropAlignment = remember(offsetX, offsetY) {
+                        Alignment { size, space, _ ->
+                            IntOffset(
+                                x = ((space.width - size.width) / 2f + offsetX).roundToInt(),
+                                y = ((space.height - size.height) / 2f + offsetY).roundToInt(),
+                            )
+                        }
+                    }
+
+                    Box(
+                        modifier = Modifier
+                            .size(cropSize)
+                            .clip(CircleShape)
+                            .background(LegadoTheme.colorScheme.surfaceContainerHighest)
+                            .pointerInput(sourceUri, imageWidth, imageHeight, sizePx) {
+                                detectTransformGestures { _, pan, gestureZoom, _ ->
+                                    val newZoom = (zoom * gestureZoom).coerceIn(1f, 3f)
+                                    val newBaseScale =
+                                        maxOf(sizePx / imageWidth, sizePx / imageHeight)
+                                    val newMaxOffsetX =
+                                        ((imageWidth * newBaseScale * newZoom) - sizePx)
+                                            .coerceAtLeast(0f) / 2f
+                                    val newMaxOffsetY =
+                                        ((imageHeight * newBaseScale * newZoom) - sizePx)
+                                            .coerceAtLeast(0f) / 2f
+                                    zoom = newZoom
+                                    offsetX =
+                                        (offsetX + pan.x).coerceIn(-newMaxOffsetX, newMaxOffsetX)
+                                    offsetY =
+                                        (offsetY + pan.y).coerceIn(-newMaxOffsetY, newMaxOffsetY)
+                                }
+                            },
+                        contentAlignment = Alignment.Center,
+                    ) {
+                        AsyncImage(
+                            model = sourceUri,
+                            contentDescription = null,
+                            modifier = Modifier.size(cropSize),
+                            contentScale = cropContentScale,
+                            alignment = cropAlignment,
+                            onSuccess = {
+                                val intrinsicSize = it.painter.intrinsicSize
+                                if (
+                                    intrinsicSize.width.isFinite() &&
+                                    intrinsicSize.height.isFinite() &&
+                                    intrinsicSize.width > 0f &&
+                                    intrinsicSize.height > 0f
+                                ) {
+                                    imageWidth = intrinsicSize.width
+                                    imageHeight = intrinsicSize.height
+                                }
+                            },
+                        )
+                        Box(
+                            modifier = Modifier
+                                .size(cropSize)
+                                .border(2.dp, LegadoTheme.colorScheme.primary, CircleShape),
+                        )
+                    }
+                }
+            }
+        },
+    )
+}

+ 103 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/CharacterAvatarImage.kt

@@ -0,0 +1,103 @@
+package io.legado.app.ui.book.knowledge
+
+import android.content.Context
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.graphics.Canvas
+import android.graphics.Rect
+import android.graphics.RectF
+import android.net.Uri
+import androidx.core.net.toUri
+import java.io.File
+import java.util.UUID
+import kotlin.math.ceil
+import kotlin.math.max
+
+data class CharacterAvatarCrop(
+    val zoom: Float,
+    val offsetX: Float,
+    val offsetY: Float,
+    val viewportSize: Float,
+)
+
+fun saveCharacterAvatar(
+    context: Context,
+    sourceUri: Uri,
+    crop: CharacterAvatarCrop,
+    outputSize: Int = 512,
+): String {
+    val bitmap = decodeCharacterAvatarBitmap(context, sourceUri)
+    val viewportSize = crop.viewportSize.coerceAtLeast(1f)
+    val baseScale = max(
+        viewportSize / bitmap.width.toFloat(),
+        viewportSize / bitmap.height.toFloat(),
+    )
+    val totalScale = baseScale * crop.zoom.coerceAtLeast(1f)
+    val imageLeft = (viewportSize - bitmap.width * totalScale) / 2f + crop.offsetX
+    val imageTop = (viewportSize - bitmap.height * totalScale) / 2f + crop.offsetY
+    val sourceRect = RectF(
+        (0f - imageLeft) / totalScale,
+        (0f - imageTop) / totalScale,
+        (viewportSize - imageLeft) / totalScale,
+        (viewportSize - imageTop) / totalScale,
+    )
+    sourceRect.offset(
+        sourceRect.left.coerceIn(0f, bitmap.width - sourceRect.width()) - sourceRect.left,
+        sourceRect.top.coerceIn(0f, bitmap.height - sourceRect.height()) - sourceRect.top,
+    )
+
+    val output = Bitmap.createBitmap(outputSize, outputSize, Bitmap.Config.ARGB_8888)
+    Canvas(output).drawBitmap(
+        bitmap,
+        Rect(
+            sourceRect.left.toInt(),
+            sourceRect.top.toInt(),
+            sourceRect.right.toInt(),
+            sourceRect.bottom.toInt(),
+        ),
+        RectF(0f, 0f, outputSize.toFloat(), outputSize.toFloat()),
+        null,
+    )
+
+    val dir = File(context.filesDir, "character_avatars").apply { mkdirs() }
+    val file = File(dir, "${UUID.randomUUID()}.jpg")
+    file.outputStream().use { stream ->
+        output.compress(Bitmap.CompressFormat.JPEG, 92, stream)
+    }
+    if (output !== bitmap) {
+        output.recycle()
+    }
+    bitmap.recycle()
+    return file.toUri().toString()
+}
+
+fun deleteCharacterAvatar(context: Context, avatarUri: String?) {
+    if (avatarUri.isNullOrBlank()) return
+    val avatarDir = File(context.filesDir, "character_avatars").canonicalFile
+    val file = runCatching {
+        File(Uri.parse(avatarUri).path.orEmpty()).canonicalFile
+    }.getOrNull() ?: return
+    if (file.parentFile == avatarDir) {
+        file.delete()
+    }
+}
+
+fun decodeCharacterAvatarBitmap(
+    context: Context,
+    uri: Uri,
+    maxSideSize: Int = 2048,
+): Bitmap {
+    val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
+    context.contentResolver.openInputStream(uri)?.use {
+        BitmapFactory.decodeStream(it, null, bounds)
+    }
+    val maxSide = max(bounds.outWidth, bounds.outHeight).coerceAtLeast(1)
+    val sampleSize = ceil(maxSide / maxSideSize.toFloat()).toInt().coerceAtLeast(1)
+    val options = BitmapFactory.Options().apply {
+        inSampleSize = sampleSize
+        inPreferredConfig = Bitmap.Config.ARGB_8888
+    }
+    return context.contentResolver.openInputStream(uri)?.use {
+        BitmapFactory.decodeStream(it, null, options)
+    } ?: error("Failed to decode avatar image")
+}

+ 240 - 0
app/src/main/java/io/legado/app/ui/book/knowledge/KnowledgeSharedComponents.kt

@@ -0,0 +1,240 @@
+package io.legado.app.ui.book.knowledge
+
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+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.AppTextField
+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.icon.AppIcon
+import io.legado.app.ui.widget.components.text.AnimatedTextLine
+import io.legado.app.ui.widget.components.text.AppText
+import kotlinx.collections.immutable.ImmutableList
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun KnowledgeEditFieldCard(
+    label: String,
+    value: String,
+    onValueChange: (String) -> Unit,
+    multiline: Boolean = false,
+    modifier: Modifier = Modifier,
+) {
+    var showDialog by remember { mutableStateOf(false) }
+    var dialogValue by remember(value) { mutableStateOf(value) }
+
+    GlassCard(
+        onClick = {
+            dialogValue = value
+            showDialog = true
+        },
+        modifier = modifier,
+        containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+    ) {
+        Column(
+            modifier = Modifier.padding(16.dp),
+            verticalArrangement = Arrangement.spacedBy(4.dp)
+        ) {
+            AppText(
+                text = label,
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+            )
+            AnimatedTextLine(
+                text = value.ifBlank { "—" },
+                style = LegadoTheme.typography.bodyMedium,
+                color = if (value.isBlank()) LegadoTheme.colorScheme.onSurfaceVariant else LegadoTheme.colorScheme.onSurface,
+            )
+        }
+    }
+
+    if (showDialog) {
+        AppAlertDialog(
+            show = showDialog,
+            onDismissRequest = { showDialog = false },
+            title = label,
+            confirmText = stringResource(R.string.apply),
+            onConfirm = {
+                onValueChange(dialogValue)
+                showDialog = false
+            },
+            dismissText = stringResource(R.string.cancel),
+            onDismiss = { showDialog = false },
+            content = {
+                AppTextField(
+                    value = dialogValue,
+                    onValueChange = { dialogValue = it },
+                    modifier = Modifier.fillMaxWidth(),
+                    singleLine = !multiline,
+                    minLines = if (multiline) 4 else 1,
+                    maxLines = if (multiline) 12 else 1,
+                )
+            },
+        )
+    }
+}
+
+@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun KnowledgeFlowTagEditor(
+    title: String,
+    tags: ImmutableList<String>,
+    onAddTag: (String) -> Unit,
+    onRemoveTag: (Int) -> Unit,
+    showCloseIcon: Boolean = true,
+) {
+    var showAddDialog by remember { mutableStateOf(false) }
+    var newTagValue by remember { mutableStateOf("") }
+    var showRemoveDialog by remember { mutableStateOf(false) }
+    var removeTagIndex by remember { mutableStateOf(-1) }
+    var removeTagName by remember { mutableStateOf("") }
+
+    Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+        AppText(
+            text = title,
+            style = LegadoTheme.typography.titleMedium,
+        )
+        FlowRow(
+            horizontalArrangement = Arrangement.spacedBy(6.dp),
+            verticalArrangement = Arrangement.spacedBy(6.dp),
+            itemVerticalAlignment = Alignment.CenterVertically,
+        ) {
+            tags.forEachIndexed { index, tag ->
+                TagChip(
+                    text = tag,
+                    showCloseIcon = showCloseIcon,
+                    onClick = {
+                        if (showCloseIcon) {
+                            removeTagIndex = index
+                            removeTagName = tag
+                            showRemoveDialog = true
+                        } else {
+                            onRemoveTag(index)
+                        }
+                    },
+                )
+            }
+            GlassCard(
+                onClick = {
+                    newTagValue = ""
+                    showAddDialog = true
+                },
+                containerColor = LegadoTheme.colorScheme.surfaceContainerLow,
+            ) {
+                Box(
+                    modifier = Modifier.padding(
+                        start = 10.dp,
+                        end = 10.dp,
+                        top = 6.dp,
+                        bottom = 6.dp,
+                    )
+                ) {
+                    AppIcon(
+                        imageVector = Icons.Default.Add,
+                        contentDescription = stringResource(R.string.add),
+                        modifier = Modifier.size(14.dp),
+                        tint = LegadoTheme.colorScheme.onSurfaceVariant,
+                    )
+                }
+            }
+        }
+    }
+
+    AppAlertDialog(
+        show = showAddDialog,
+        onDismissRequest = { showAddDialog = false },
+        title = title,
+        confirmText = stringResource(R.string.apply),
+        onConfirm = {
+            if (newTagValue.isNotBlank()) {
+                onAddTag(newTagValue)
+            }
+            showAddDialog = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showAddDialog = false },
+        content = {
+            AppTextField(
+                value = newTagValue,
+                onValueChange = { newTagValue = it },
+                modifier = Modifier.fillMaxWidth(),
+                singleLine = true,
+            )
+        },
+    )
+
+    AppAlertDialog(
+        show = showRemoveDialog,
+        onDismissRequest = { showRemoveDialog = false },
+        title = title,
+        text = removeTagName,
+        confirmText = stringResource(R.string.delete),
+        onConfirm = {
+            onRemoveTag(removeTagIndex)
+            showRemoveDialog = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showRemoveDialog = false },
+    )
+
+}
+
+@Composable
+fun TagChip(
+    text: String,
+    showCloseIcon: Boolean = false,
+    onClick: () -> Unit,
+) {
+    GlassCard(
+        onClick = onClick,
+        containerColor = LegadoTheme.colorScheme.secondaryContainer,
+    ) {
+        Row(
+            modifier = Modifier.padding(
+                start = 10.dp,
+                end = 10.dp,
+                top = 6.dp,
+                bottom = 6.dp,
+            ),
+            verticalAlignment = Alignment.CenterVertically,
+        ) {
+            AnimatedTextLine(
+                text = text,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSecondaryContainer,
+            )
+            if (showCloseIcon) {
+                Spacer(modifier = Modifier.width(2.dp))
+                AppIcon(
+                    imageVector = Icons.Default.Close,
+                    contentDescription = null,
+                    modifier = Modifier.size(12.dp),
+                    tint = LegadoTheme.colorScheme.onSecondaryContainer,
+                )
+            }
+        }
+    }
+}

Некоторые файлы не были показаны из-за большого количества измененных файлов