HapeLee 4 месяцев назад
Родитель
Сommit
990fc78033
74 измененных файлов с 12727 добавлено и 525 удалено
  1. 4 1
      .claude/settings.local.json
  2. 1 1
      app/build.gradle.kts
  3. 3011 0
      app/schemas/io.legado.app.data.AppDatabase/92.json
  4. 21 3
      app/src/main/java/io/legado/app/data/AppDatabase.kt
  5. 0 1
      app/src/main/java/io/legado/app/data/DatabaseMigrations.kt
  6. 61 0
      app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt
  7. 79 0
      app/src/main/java/io/legado/app/data/dao/AiChatDao.kt
  8. 33 0
      app/src/main/java/io/legado/app/data/dao/AiMemoryDao.kt
  9. 72 0
      app/src/main/java/io/legado/app/data/dao/AiProfileDao.kt
  10. 36 0
      app/src/main/java/io/legado/app/data/entities/AiArtifact.kt
  11. 15 0
      app/src/main/java/io/legado/app/data/entities/AiChatConversation.kt
  12. 24 0
      app/src/main/java/io/legado/app/data/entities/AiChatMessage.kt
  13. 21 0
      app/src/main/java/io/legado/app/data/entities/AiMemory.kt
  14. 25 0
      app/src/main/java/io/legado/app/data/entities/AiModelProfile.kt
  15. 32 0
      app/src/main/java/io/legado/app/data/entities/AiProviderProfile.kt
  16. 25 0
      app/src/main/java/io/legado/app/data/entities/AiTaskPreset.kt
  17. 37 0
      app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt
  18. 130 0
      app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt
  19. 48 0
      app/src/main/java/io/legado/app/data/repository/AiMemoryRepository.kt
  20. 351 0
      app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt
  21. 46 0
      app/src/main/java/io/legado/app/data/repository/AiTextRepositoryImpl.kt
  22. 520 0
      app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt
  23. 0 391
      app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt
  24. 29 0
      app/src/main/java/io/legado/app/data/repository/ai/AiProtocolHandler.kt
  25. 15 0
      app/src/main/java/io/legado/app/data/repository/ai/AiProviderRegistry.kt
  26. 86 0
      app/src/main/java/io/legado/app/data/repository/ai/AiRetryUtils.kt
  27. 76 0
      app/src/main/java/io/legado/app/data/repository/ai/AiSseUtils.kt
  28. 425 0
      app/src/main/java/io/legado/app/data/repository/ai/AnthropicHandler.kt
  29. 329 0
      app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt
  30. 351 0
      app/src/main/java/io/legado/app/data/repository/ai/OpenAiResponsesHandler.kt
  31. 5 1
      app/src/main/java/io/legado/app/di/appDatabaseModule.kt
  32. 43 3
      app/src/main/java/io/legado/app/di/appModule.kt
  33. 17 0
      app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt
  34. 33 0
      app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt
  35. 15 0
      app/src/main/java/io/legado/app/domain/gateway/AiMemoryGateway.kt
  36. 26 0
      app/src/main/java/io/legado/app/domain/gateway/AiProfileGateway.kt
  37. 25 0
      app/src/main/java/io/legado/app/domain/gateway/AiTextGateway.kt
  38. 11 0
      app/src/main/java/io/legado/app/domain/gateway/AiToolGateway.kt
  39. 0 21
      app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt
  40. 177 0
      app/src/main/java/io/legado/app/domain/model/AiMessageParts.kt
  41. 113 0
      app/src/main/java/io/legado/app/domain/model/AiModelRegistry.kt
  42. 307 0
      app/src/main/java/io/legado/app/domain/model/AiModels.kt
  43. 3 2
      app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt
  44. 369 0
      app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt
  45. 107 0
      app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt
  46. 296 25
      app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt
  47. 10 6
      app/src/main/java/io/legado/app/model/translation/TranslationManager.kt
  48. 3 21
      app/src/main/java/io/legado/app/ui/about/AboutSheets.kt
  49. 78 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt
  50. 57 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiChatMessageParts.kt
  51. 1102 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt
  52. 766 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt
  53. 250 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiReasoningCard.kt
  54. 205 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt
  55. 267 0
      app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingStepsCard.kt
  56. 5 0
      app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt
  57. 51 0
      app/src/main/java/io/legado/app/ui/config/ai/AiConfigContract.kt
  58. 197 0
      app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt
  59. 107 0
      app/src/main/java/io/legado/app/ui/config/ai/AiConfigViewModel.kt
  60. 45 0
      app/src/main/java/io/legado/app/ui/config/ai/AiModelEditContract.kt
  61. 220 0
      app/src/main/java/io/legado/app/ui/config/ai/AiModelEditScreen.kt
  62. 159 0
      app/src/main/java/io/legado/app/ui/config/ai/AiModelEditViewModel.kt
  63. 89 0
      app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditContract.kt
  64. 303 0
      app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditScreen.kt
  65. 283 0
      app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditViewModel.kt
  66. 11 1
      app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt
  67. 9 45
      app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt
  68. 60 1
      app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt
  69. 19 0
      app/src/main/java/io/legado/app/ui/main/MainNavKey.kt
  70. 17 0
      app/src/main/java/io/legado/app/ui/main/MainNavigator.kt
  71. 782 0
      app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt
  72. 90 0
      app/src/main/res/values-zh-rCN/strings.xml
  73. 90 0
      app/src/main/res/values/strings.xml
  74. 2 2
      gradle/libs.versions.toml

+ 4 - 1
.claude/settings.local.json

@@ -114,7 +114,10 @@
       "Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")",
       "PowerShell(grep *)",
       "Bash(powershell -Command \"./gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30\")",
-      "Bash(git revert *)"
+      "Bash(git revert *)",
+      "Bash(xargs grep -l \"ImeLazyListAutoScroller\")",
+      "Bash(xargs grep -l \"MarkdownBlock\")",
+      "Bash(./gradlew.bat assembleAppDebug)"
     ]
   }
 }

+ 1 - 1
app/build.gradle.kts

@@ -207,6 +207,7 @@ dependencies {
     implementation(libs.jsoup)
     implementation(libs.json.path)
     implementation(libs.jsoupxpath)
+    implementation(libs.intellij.markdown)
     implementation(project(":modules:book"))
     implementation(project(":modules:rhino"))
     implementation(libs.okhttp)
@@ -233,7 +234,6 @@ dependencies {
     implementation(libs.markwon.image.glide)
     implementation(libs.markwon.ext.tables)
     implementation(libs.markwon.html)
-    implementation(libs.markdown.renderer.m3)
     implementation(libs.quick.chinese.transfer.core)
     implementation(libs.hutool.crypto)
     //noinspection GradleDependency

+ 3011 - 0
app/schemas/io.legado.app.data.AppDatabase/92.json

@@ -0,0 +1,3011 @@
+{
+  "formatVersion": 1,
+  "database": {
+    "version": 92,
+    "identityHash": "43e0a02ed8063766bf739654b904fd27",
+    "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`)"
+          }
+        ]
+      }
+    ],
+    "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, '43e0a02ed8063766bf739654b904fd27')"
+    ]
+  }
+}

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

@@ -12,6 +12,10 @@ import io.legado.app.data.dao.BookDao
 import io.legado.app.data.dao.BookGroupDao
 import io.legado.app.data.dao.BookSourceDao
 import io.legado.app.data.dao.BookmarkDao
+import io.legado.app.data.dao.AiArtifactDao
+import io.legado.app.data.dao.AiChatDao
+import io.legado.app.data.dao.AiMemoryDao
+import io.legado.app.data.dao.AiProfileDao
 import io.legado.app.data.dao.CacheDao
 import io.legado.app.data.dao.CookieDao
 import io.legado.app.data.dao.DictRuleDao
@@ -38,6 +42,13 @@ import io.legado.app.data.entities.BookGroup
 import io.legado.app.data.entities.BookSource
 import io.legado.app.data.entities.BookSourcePart
 import io.legado.app.data.entities.Bookmark
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.data.entities.AiChatConversation
+import io.legado.app.data.entities.AiChatMessage
+import io.legado.app.data.entities.AiMemory
+import io.legado.app.data.entities.AiModelProfile
+import io.legado.app.data.entities.AiProviderProfile
+import io.legado.app.data.entities.AiTaskPreset
 import io.legado.app.data.entities.Cache
 import io.legado.app.data.entities.Cookie
 import io.legado.app.data.entities.DictRule
@@ -75,7 +86,7 @@ val appDb by lazy {
 }
 
 @Database(
-    version = 91,
+    version = 92,
     exportSchema = true,
     entities = [Book::class, BookGroup::class, BookSource::class, BookChapter::class,
         ReplaceRule::class, SearchBook::class, SearchKeyword::class, Cookie::class,
@@ -84,7 +95,9 @@ val appDb by lazy {
         RssStar::class, TxtTocRule::class, ReadRecord::class, HttpTTS::class, Cache::class,
         RuleSub::class, DictRule::class, KeyboardAssist::class, Server::class,
         SearchContentHistory::class, HomepageModule::class, HomepageCustomSet::class,
-        HighlightRule::class],
+        HighlightRule::class, AiProviderProfile::class, AiModelProfile::class,
+        AiTaskPreset::class, AiArtifact::class, AiChatConversation::class,
+        AiChatMessage::class, AiMemory::class],
     views = [BookSourcePart::class],
     autoMigrations = [
         AutoMigration(from = 43, to = 44),
@@ -134,7 +147,8 @@ val appDb by lazy {
         AutoMigration(from = 87, to = 88),
         AutoMigration(from = 88, to = 89),
         AutoMigration(from = 89, to = 90),
-        AutoMigration(from = 90, to = 91)
+        AutoMigration(from = 90, to = 91),
+        AutoMigration(from = 91, to = 92)
     ]
 )
 abstract class AppDatabase : RoomDatabase() {
@@ -164,6 +178,10 @@ abstract class AppDatabase : RoomDatabase() {
     abstract val homepageModuleDao: HomepageModuleDao
     abstract val homepageCustomSetDao: HomepageCustomSetDao
     abstract val highlightRuleDao: HighlightRuleDao
+    abstract val aiProfileDao: AiProfileDao
+    abstract val aiArtifactDao: AiArtifactDao
+    abstract val aiChatDao: AiChatDao
+    abstract val aiMemoryDao: AiMemoryDao
 
     companion object {
 

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

@@ -451,7 +451,6 @@ object DatabaseMigrations {
         }
     }
 
-
     @Suppress("ClassName")
     class Migration_54_55 : AutoMigrationSpec {
 

+ 61 - 0
app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt

@@ -0,0 +1,61 @@
+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.AiArtifact
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AiArtifactDao {
+
+    @Query("select * from ai_artifacts where bookUrl = :bookUrl and taskType = :taskType order by chapterIndex, updatedAt desc")
+    fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>>
+
+    @Query(
+        """
+        select * from ai_artifacts
+        where bookUrl = :bookUrl
+          and (:chapterIndex is null or chapterIndex = :chapterIndex)
+          and taskType = :taskType
+          and contentHash = :contentHash
+          and promptHash = :promptHash
+          and modelProfileId = :modelProfileId
+          and status = ${AiArtifact.STATUS_SUCCESS}
+        order by updatedAt desc
+        limit 1
+        """
+    )
+    suspend fun getCachedArtifact(
+        bookUrl: String,
+        chapterIndex: Int?,
+        taskType: String,
+        contentHash: String,
+        promptHash: String,
+        modelProfileId: String
+    ): AiArtifact?
+
+    @Query(
+        """
+        select * from ai_artifacts
+        where (:bookUrl is null or bookUrl = :bookUrl)
+          and (:taskType is null or taskType = :taskType)
+          and (:chapterIndex is null or chapterIndex = :chapterIndex)
+        order by updatedAt desc
+        limit :limit
+        """
+    )
+    suspend fun queryArtifacts(
+        bookUrl: String?,
+        taskType: String?,
+        chapterIndex: Int?,
+        limit: Int
+    ): List<AiArtifact>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(artifact: AiArtifact)
+
+    @Query("delete from ai_artifacts where bookUrl = :bookUrl and taskType = :taskType")
+    suspend fun deleteBookArtifacts(bookUrl: String, taskType: String)
+}

+ 79 - 0
app/src/main/java/io/legado/app/data/dao/AiChatDao.kt

@@ -0,0 +1,79 @@
+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.AiChatConversation
+import io.legado.app.data.entities.AiChatMessage
+import kotlinx.coroutines.flow.Flow
+
+data class BranchCount(
+    val parentMessageId: String,
+    val cnt: Int
+)
+
+@Dao
+interface AiChatDao {
+
+    @Query("SELECT * FROM ai_chat_conversations ORDER BY updatedAt DESC")
+    fun observeConversations(): Flow<List<AiChatConversation>>
+
+    @Query("SELECT * FROM ai_chat_messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
+    fun observeMessages(conversationId: String): Flow<List<AiChatMessage>>
+
+    /** Observe only the selected branch path for display */
+    @Query("SELECT * FROM ai_chat_messages WHERE conversationId = :conversationId AND isSelected = 1 ORDER BY createdAt ASC")
+    fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>>
+
+    /** Count branches for a given parent message (for regenerate UI) */
+    @Query("SELECT COUNT(*) FROM ai_chat_messages WHERE parentMessageId = :parentMessageId")
+    suspend fun countBranches(parentMessageId: String): Int
+
+    /** Get all branches for a given parent message */
+    @Query("SELECT * FROM ai_chat_messages WHERE parentMessageId = :parentMessageId ORDER BY branchIndex ASC")
+    suspend fun getBranches(parentMessageId: String): List<AiChatMessage>
+
+    /** Get branch counts grouped by parentMessageId for a conversation */
+    @Query("SELECT parentMessageId, COUNT(*) as cnt FROM ai_chat_messages WHERE conversationId = :conversationId AND parentMessageId IS NOT NULL GROUP BY parentMessageId")
+    suspend fun getBranchCounts(conversationId: String): List<BranchCount>
+
+    /** Mark all messages after a given point in the conversation as unselected */
+    @Query("UPDATE ai_chat_messages SET isSelected = 0 WHERE conversationId = :conversationId AND createdAt > :afterTimestamp AND role = 'assistant'")
+    suspend fun deselectAssistantAfter(conversationId: String, afterTimestamp: Long)
+
+    /** Select a specific branch by id */
+    @Query("UPDATE ai_chat_messages SET isSelected = 1 WHERE id = :messageId")
+    suspend fun selectBranch(messageId: String)
+
+    /** Deselect all messages for a conversation */
+    @Query("UPDATE ai_chat_messages SET isSelected = 0 WHERE conversationId = :conversationId")
+    suspend fun deselectAll(conversationId: String)
+
+    @Query("SELECT * FROM ai_chat_conversations WHERE id = :id")
+    suspend fun getConversation(id: String): AiChatConversation?
+
+    @Query("SELECT * FROM ai_chat_messages WHERE id = :id")
+    suspend fun getMessage(id: String): AiChatMessage?
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertConversation(conversation: AiChatConversation)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertMessage(message: AiChatMessage)
+
+    @Query("UPDATE ai_chat_conversations SET title = :title, updatedAt = :updatedAt WHERE id = :conversationId")
+    suspend fun updateConversationTitle(conversationId: String, title: String, updatedAt: Long)
+
+    @Query(
+        """
+        UPDATE ai_chat_conversations
+        SET reasoningLevel = :reasoningLevel, updatedAt = :updatedAt
+        WHERE id = :conversationId
+        """
+    )
+    suspend fun updateConversationReasoningLevel(conversationId: String, reasoningLevel: String, updatedAt: Long)
+
+    @Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
+    suspend fun touchConversation(conversationId: String, updatedAt: Long)
+}

+ 33 - 0
app/src/main/java/io/legado/app/data/dao/AiMemoryDao.kt

@@ -0,0 +1,33 @@
+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.AiMemory
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AiMemoryDao {
+
+    @Query("SELECT * FROM ai_memory WHERE conversationId = :conversationId ORDER BY updatedAt DESC")
+    fun observeByConversation(conversationId: String): Flow<List<AiMemory>>
+
+    @Query("SELECT * FROM ai_memory WHERE conversationId = '' ORDER BY updatedAt DESC")
+    fun observeGlobal(): Flow<List<AiMemory>>
+
+    @Query("SELECT * FROM ai_memory WHERE conversationId = :conversationId")
+    suspend fun getByConversation(conversationId: String): List<AiMemory>
+
+    @Query("SELECT * FROM ai_memory WHERE conversationId = ''")
+    suspend fun getGlobal(): List<AiMemory>
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun upsert(memory: AiMemory)
+
+    @Query("DELETE FROM ai_memory WHERE conversationId = :conversationId AND `key` = :key")
+    suspend fun delete(conversationId: String, key: String)
+
+    @Query("DELETE FROM ai_memory WHERE conversationId = :conversationId")
+    suspend fun deleteAllForConversation(conversationId: String)
+}

+ 72 - 0
app/src/main/java/io/legado/app/data/dao/AiProfileDao.kt

@@ -0,0 +1,72 @@
+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 androidx.room.Update
+import io.legado.app.data.entities.AiModelProfile
+import io.legado.app.data.entities.AiProviderProfile
+import io.legado.app.data.entities.AiTaskPreset
+import kotlinx.coroutines.flow.Flow
+
+@Dao
+interface AiProfileDao {
+
+    @Query("select * from ai_provider_profiles order by createdAt")
+    fun observeProviders(): Flow<List<AiProviderProfile>>
+
+    @Query("select * from ai_model_profiles order by sortNumber, createdAt")
+    fun observeModels(): Flow<List<AiModelProfile>>
+
+    @Query("select * from ai_task_presets order by taskType, sortNumber, createdAt")
+    fun observePresets(): Flow<List<AiTaskPreset>>
+
+    @Query("select * from ai_provider_profiles where id = :id")
+    suspend fun getProvider(id: String): AiProviderProfile?
+
+    @Query("select * from ai_model_profiles where id = :id")
+    suspend fun getModel(id: String): AiModelProfile?
+
+    @Query("select * from ai_model_profiles where providerId = :providerId order by sortNumber, createdAt")
+    suspend fun getModelsByProvider(providerId: String): List<AiModelProfile>
+
+    @Query("select * from ai_task_presets where id = :id")
+    suspend fun getPreset(id: String): AiTaskPreset?
+
+    @Query("select * from ai_task_presets where taskType = :taskType and enabled = 1 order by isDefault desc, sortNumber, createdAt limit 1")
+    suspend fun getDefaultPreset(taskType: String): AiTaskPreset?
+
+    @Query("select count(*) from ai_provider_profiles")
+    suspend fun countProviders(): Int
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertProvider(provider: AiProviderProfile)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertModel(model: AiModelProfile)
+
+    @Insert(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun insertPreset(preset: AiTaskPreset)
+
+    @Update(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun updateProvider(provider: AiProviderProfile)
+
+    @Update(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun updateModel(model: AiModelProfile)
+
+    @Update(onConflict = OnConflictStrategy.REPLACE)
+    suspend fun updatePreset(preset: AiTaskPreset)
+
+    @Transaction
+    suspend fun upsertProfile(
+        provider: AiProviderProfile,
+        model: AiModelProfile,
+        preset: AiTaskPreset
+    ) {
+        insertProvider(provider)
+        insertModel(model)
+        insertPreset(preset)
+    }
+}

+ 36 - 0
app/src/main/java/io/legado/app/data/entities/AiArtifact.kt

@@ -0,0 +1,36 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "ai_artifacts",
+    indices = [
+        Index(value = ["bookUrl", "chapterIndex", "taskType"]),
+        Index(value = ["contentHash", "promptHash", "modelProfileId"])
+    ]
+)
+data class AiArtifact(
+    @PrimaryKey
+    val id: String,
+    val taskType: String,
+    val bookUrl: String,
+    val chapterIndex: Int? = null,
+    val contentHash: String,
+    val promptHash: String,
+    val modelProfileId: String,
+    val status: Int = STATUS_PENDING,
+    val output: String? = null,
+    val errorMessage: String? = null,
+    val schemaVersion: Int = 1,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis()
+) {
+    companion object {
+        const val STATUS_PENDING = 0
+        const val STATUS_RUNNING = 1
+        const val STATUS_SUCCESS = 2
+        const val STATUS_FAILED = 3
+    }
+}

+ 15 - 0
app/src/main/java/io/legado/app/data/entities/AiChatConversation.kt

@@ -0,0 +1,15 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "ai_chat_conversations")
+data class AiChatConversation(
+    @PrimaryKey
+    val id: String,
+    val title: String,
+    val reasoningLevel: String = "auto",
+    val modelProfileId: String? = null,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis()
+)

+ 24 - 0
app/src/main/java/io/legado/app/data/entities/AiChatMessage.kt

@@ -0,0 +1,24 @@
+package io.legado.app.data.entities
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "ai_chat_messages",
+    indices = [Index(value = ["conversationId", "createdAt"])]
+)
+data class AiChatMessage(
+    @PrimaryKey
+    val id: String,
+    val conversationId: String,
+    val role: String,
+    val partsJson: String,
+    val createdAt: Long = System.currentTimeMillis(),
+    val branchIndex: Int = 0,
+    val isSelected: Boolean = true,
+    val parentMessageId: String? = null,
+    @ColumnInfo(defaultValue = "0")
+    val thinkingDuration: Int = 0
+)

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

@@ -0,0 +1,21 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+
+/**
+ * Long-term memory entries for AI conversations.
+ * Stored as key-value pairs scoped to a conversation (or global when conversationId is null).
+ * Injected into the system prompt so the model can reference past context.
+ */
+@Entity(
+    tableName = "ai_memory",
+    primaryKeys = ["conversationId", "key"],
+    indices = [Index(value = ["conversationId"])]
+)
+data class AiMemory(
+    val conversationId: String,  // "" for global memories
+    val key: String,
+    val value: String,
+    val updatedAt: Long = System.currentTimeMillis()
+)

+ 25 - 0
app/src/main/java/io/legado/app/data/entities/AiModelProfile.kt

@@ -0,0 +1,25 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "ai_model_profiles",
+    indices = [Index(value = ["providerId"])]
+)
+data class AiModelProfile(
+    @PrimaryKey
+    val id: String,
+    val providerId: String,
+    val displayName: String,
+    val modelId: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val capabilities: String = "",
+    val defaultParamsJson: String? = null,
+    val enabled: Boolean = true,
+    val sortNumber: Int = 0,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis()
+)

+ 32 - 0
app/src/main/java/io/legado/app/data/entities/AiProviderProfile.kt

@@ -0,0 +1,32 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+@Entity(tableName = "ai_provider_profiles")
+data class AiProviderProfile(
+    @PrimaryKey
+    val id: String,
+    val name: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelsUrl: String? = null,
+    val apiKey: String = "",
+    val authType: String = AUTH_TYPE_BEARER,
+    val secretRef: String? = null,
+    val headersJson: String? = null,
+    val chatPath: String? = null,
+    val responsesPath: String? = null,
+    val messagesPath: String? = null,
+    val modelsPath: String? = null,
+    val customHeadersJson: String? = null,
+    val enabled: Boolean = true,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis()
+) {
+    companion object {
+        const val AUTH_TYPE_NONE = "none"
+        const val AUTH_TYPE_BEARER = "bearer"
+        const val AUTH_TYPE_HEADER = "header"
+    }
+}

+ 25 - 0
app/src/main/java/io/legado/app/data/entities/AiTaskPreset.kt

@@ -0,0 +1,25 @@
+package io.legado.app.data.entities
+
+import androidx.room.Entity
+import androidx.room.Index
+import androidx.room.PrimaryKey
+
+@Entity(
+    tableName = "ai_task_presets",
+    indices = [Index(value = ["taskType"]), Index(value = ["modelProfileId"])]
+)
+data class AiTaskPreset(
+    @PrimaryKey
+    val id: String,
+    val taskType: String,
+    val name: String,
+    val modelProfileId: String,
+    val promptTemplate: String,
+    val paramsJson: String? = null,
+    val chunkPolicyJson: String? = null,
+    val enabled: Boolean = true,
+    val isDefault: Boolean = false,
+    val sortNumber: Int = 0,
+    val createdAt: Long = System.currentTimeMillis(),
+    val updatedAt: Long = System.currentTimeMillis()
+)

+ 37 - 0
app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt

@@ -0,0 +1,37 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.AiArtifactDao
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.domain.gateway.AiArtifactGateway
+import kotlinx.coroutines.flow.Flow
+
+class AiArtifactRepository(
+    private val aiArtifactDao: AiArtifactDao
+) : AiArtifactGateway {
+
+    override fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>> {
+        return aiArtifactDao.observeBookArtifacts(bookUrl, taskType)
+    }
+
+    override suspend fun getCachedArtifact(
+        bookUrl: String,
+        chapterIndex: Int?,
+        taskType: String,
+        contentHash: String,
+        promptHash: String,
+        modelProfileId: String
+    ): AiArtifact? {
+        return aiArtifactDao.getCachedArtifact(
+            bookUrl = bookUrl,
+            chapterIndex = chapterIndex,
+            taskType = taskType,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = modelProfileId
+        )
+    }
+
+    override suspend fun upsertArtifact(artifact: AiArtifact) {
+        aiArtifactDao.upsert(artifact)
+    }
+}

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

@@ -0,0 +1,130 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.AiChatDao
+import io.legado.app.data.entities.AiChatConversation
+import io.legado.app.data.entities.AiChatMessage
+import io.legado.app.domain.gateway.AiChatGateway
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.domain.model.AiMessagePartJson
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import java.util.UUID
+
+class AiChatRepository(
+    private val aiChatDao: AiChatDao
+) : AiChatGateway {
+
+    override fun observeConversations(): Flow<List<AiChatConversation>> = aiChatDao.observeConversations()
+
+    override fun observeMessages(conversationId: String): Flow<List<AiChatMessage>> =
+        aiChatDao.observeMessages(conversationId)
+
+    override fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>> =
+        aiChatDao.observeSelectedMessages(conversationId)
+
+    override suspend fun getConversation(id: String): AiChatConversation? = withContext(Dispatchers.IO) {
+        aiChatDao.getConversation(id)
+    }
+
+    override suspend fun createConversation(title: String): AiChatConversation = withContext(Dispatchers.IO) {
+        val now = System.currentTimeMillis()
+        AiChatConversation(
+            id = newId("chat"),
+            title = title,
+            createdAt = now,
+            updatedAt = now
+        ).also { aiChatDao.insertConversation(it) }
+    }
+
+    override suspend fun saveMessage(
+        conversationId: String,
+        role: String,
+        parts: List<AiMessagePart>,
+        parentMessageId: String?,
+        thinkingDuration: Int
+    ): AiChatMessage = withContext(Dispatchers.IO) {
+        val now = System.currentTimeMillis()
+        AiChatMessage(
+            id = newId("message"),
+            conversationId = conversationId,
+            role = role,
+            partsJson = AiMessagePartJson.encode(parts),
+            createdAt = now,
+            branchIndex = 0,
+            isSelected = true,
+            parentMessageId = parentMessageId,
+            thinkingDuration = thinkingDuration
+        ).also {
+            aiChatDao.insertMessage(it)
+            aiChatDao.touchConversation(conversationId, now)
+        }
+    }
+
+    override suspend fun saveRegeneratedMessage(
+        conversationId: String,
+        role: String,
+        parts: List<AiMessagePart>,
+        parentMessageId: String,
+        thinkingDuration: Int
+    ): AiChatMessage = withContext(Dispatchers.IO) {
+        val branchCount = aiChatDao.countBranches(parentMessageId)
+        val now = System.currentTimeMillis()
+        // Deselect existing branches for this parent
+        val siblings = aiChatDao.getBranches(parentMessageId)
+        siblings.forEach { sibling ->
+            aiChatDao.insertMessage(sibling.copy(isSelected = false))
+        }
+        AiChatMessage(
+            id = newId("message"),
+            conversationId = conversationId,
+            role = role,
+            partsJson = AiMessagePartJson.encode(parts),
+            createdAt = now,
+            branchIndex = branchCount,
+            isSelected = true,
+            parentMessageId = parentMessageId,
+            thinkingDuration = thinkingDuration
+        ).also {
+            aiChatDao.insertMessage(it)
+            aiChatDao.touchConversation(conversationId, now)
+        }
+    }
+
+    override suspend fun selectBranch(messageId: String) = withContext(Dispatchers.IO) {
+        val message = aiChatDao.getMessage(messageId) ?: return@withContext
+        val parentId = message.parentMessageId ?: return@withContext
+        // Deselect siblings
+        val siblings = aiChatDao.getBranches(parentId)
+        siblings.forEach { sibling ->
+            aiChatDao.insertMessage(sibling.copy(isSelected = false))
+        }
+        // Select this branch
+        aiChatDao.selectBranch(messageId)
+    }
+
+    override suspend fun getBranches(parentMessageId: String): List<AiChatMessage> =
+        withContext(Dispatchers.IO) {
+            aiChatDao.getBranches(parentMessageId)
+        }
+
+    override suspend fun getBranchCounts(conversationId: String): Map<String, Int> =
+        withContext(Dispatchers.IO) {
+            aiChatDao.getBranchCounts(conversationId).associate { it.parentMessageId to it.cnt }
+        }
+
+    override suspend fun updateConversationTitle(conversationId: String, title: String) = withContext(Dispatchers.IO) {
+        aiChatDao.updateConversationTitle(conversationId, title, System.currentTimeMillis())
+    }
+
+    override suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String) =
+        withContext(Dispatchers.IO) {
+            aiChatDao.updateConversationReasoningLevel(
+                conversationId = conversationId,
+                reasoningLevel = reasoningLevel,
+                updatedAt = System.currentTimeMillis()
+            )
+        }
+
+    private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
+}

+ 48 - 0
app/src/main/java/io/legado/app/data/repository/AiMemoryRepository.kt

@@ -0,0 +1,48 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.AiMemoryDao
+import io.legado.app.data.entities.AiMemory
+import io.legado.app.domain.gateway.AiMemoryGateway
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+
+class AiMemoryRepository(
+    private val aiMemoryDao: AiMemoryDao
+) : AiMemoryGateway {
+
+    override fun observeByConversation(conversationId: String): Flow<List<AiMemory>> =
+        aiMemoryDao.observeByConversation(conversationId)
+
+    override fun observeGlobal(): Flow<List<AiMemory>> = aiMemoryDao.observeGlobal()
+
+    override suspend fun getByConversation(conversationId: String): List<AiMemory> =
+        withContext(Dispatchers.IO) { aiMemoryDao.getByConversation(conversationId) }
+
+    override suspend fun getGlobal(): List<AiMemory> =
+        withContext(Dispatchers.IO) { aiMemoryDao.getGlobal() }
+
+    override suspend fun getForPrompt(conversationId: String): List<AiMemory> =
+        withContext(Dispatchers.IO) {
+            val global = aiMemoryDao.getGlobal()
+            val scoped = if (conversationId.isNotBlank()) {
+                aiMemoryDao.getByConversation(conversationId)
+            } else {
+                emptyList()
+            }
+            global + scoped
+        }
+
+    override suspend fun upsert(memory: AiMemory) = withContext(Dispatchers.IO) {
+        aiMemoryDao.upsert(memory.copy(updatedAt = System.currentTimeMillis()))
+    }
+
+    override suspend fun delete(conversationId: String, key: String) = withContext(Dispatchers.IO) {
+        aiMemoryDao.delete(conversationId, key)
+    }
+
+    override suspend fun deleteAllForConversation(conversationId: String) =
+        withContext(Dispatchers.IO) {
+            aiMemoryDao.deleteAllForConversation(conversationId)
+        }
+}

+ 351 - 0
app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt

@@ -0,0 +1,351 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.dao.AiProfileDao
+import io.legado.app.data.entities.AiModelProfile
+import io.legado.app.data.entities.AiProviderProfile
+import io.legado.app.data.entities.AiTaskPreset
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelDraft
+import io.legado.app.domain.model.AiModelConfig
+import io.legado.app.domain.model.AiModelRegistry
+import io.legado.app.domain.model.AiProfileDraft
+import io.legado.app.domain.model.AiPromptTemplate
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiProviderDraft
+import io.legado.app.domain.model.AiTaskRuntimeOptions
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.withContext
+import java.util.UUID
+
+class AiProfileRepository(
+    private val aiProfileDao: AiProfileDao
+) : AiProfileGateway {
+
+    override fun observeProviders(): Flow<List<AiProviderProfile>> = aiProfileDao.observeProviders()
+
+    override fun observeModels(): Flow<List<AiModelProfile>> = aiProfileDao.observeModels()
+
+    override fun observePresets(): Flow<List<AiTaskPreset>> = aiProfileDao.observePresets()
+
+    override suspend fun getProvider(id: String): AiProviderProfile? = withContext(Dispatchers.IO) {
+        aiProfileDao.getProvider(id)
+    }
+
+    override suspend fun getModel(id: String): AiModelProfile? = withContext(Dispatchers.IO) {
+        aiProfileDao.getModel(id)
+    }
+
+    override suspend fun getTaskPreset(taskType: String): AiTaskPresetConfig? = withContext(Dispatchers.IO) {
+        aiProfileDao.getDefaultPreset(taskType)?.toConfig()
+    }
+
+    override suspend fun getProviderApiKey(providerId: String): String = withContext(Dispatchers.IO) {
+        aiProfileDao.getProvider(providerId)
+            ?.apiKey
+            .orEmpty()
+    }
+
+    override suspend fun saveProvider(draft: AiProviderDraft): AiProviderProfile = withContext(Dispatchers.IO) {
+        require(draft.providerName.isNotBlank()) { "Provider name is required" }
+        require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
+
+        val providerId = draft.providerId?.takeIf { it.isNotBlank() } ?: newId("provider")
+        val existingProvider = aiProfileDao.getProvider(providerId)
+        val apiKey = draft.apiKey.ifBlank { existingProvider?.apiKey.orEmpty() }
+        val now = System.currentTimeMillis()
+        val provider = AiProviderProfile(
+            id = providerId,
+            name = draft.providerName,
+            protocol = draft.protocol,
+            baseUrl = draft.baseUrl,
+            modelsUrl = draft.modelsUrl?.takeIf { it.isNotBlank() },
+            apiKey = apiKey,
+            authType = existingProvider?.authType ?: AiProviderProfile.AUTH_TYPE_BEARER,
+            secretRef = existingProvider?.secretRef,
+            headersJson = existingProvider?.headersJson,
+            chatPath = existingProvider?.chatPath,
+            responsesPath = existingProvider?.responsesPath,
+            messagesPath = existingProvider?.messagesPath,
+            modelsPath = existingProvider?.modelsPath,
+            customHeadersJson = existingProvider?.customHeadersJson,
+            enabled = existingProvider?.enabled ?: true,
+            createdAt = existingProvider?.createdAt ?: now,
+            updatedAt = now
+        )
+        aiProfileDao.insertProvider(provider)
+        provider
+    }
+
+    override suspend fun saveModel(draft: AiModelDraft): AiModelProfile = withContext(Dispatchers.IO) {
+        require(draft.providerId.isNotBlank()) { "Provider is required" }
+        require(draft.modelId.isNotBlank()) { "Model is required" }
+
+        val existingProvider = aiProfileDao.getProvider(draft.providerId)
+        require(existingProvider != null) { "Provider is required" }
+        val modelProfileId = draft.modelProfileId?.takeIf { it.isNotBlank() } ?: stableModelId(
+            providerId = draft.providerId,
+            modelId = draft.modelId
+        )
+        val existingModel = aiProfileDao.getModel(modelProfileId)
+        val now = System.currentTimeMillis()
+        val params = AiGenerationParams(temperature = draft.temperature)
+        val model = AiModelProfile(
+            id = modelProfileId,
+            providerId = draft.providerId,
+            displayName = draft.modelName.ifBlank { draft.modelId },
+            modelId = draft.modelId,
+            contextWindow = draft.contextWindow,
+            maxOutputTokens = draft.maxOutputTokens,
+            capabilities = existingModel?.capabilities.orEmpty(),
+            defaultParamsJson = GSON.toJson(params),
+            enabled = existingModel?.enabled ?: true,
+            sortNumber = existingModel?.sortNumber ?: 0,
+            createdAt = existingModel?.createdAt ?: now,
+            updatedAt = now
+        )
+        aiProfileDao.insertModel(model)
+        model
+    }
+
+    override suspend fun importProviderModels(
+        providerId: String,
+        models: List<AiAvailableModel>
+    ): List<AiModelProfile> = withContext(Dispatchers.IO) {
+        require(aiProfileDao.getProvider(providerId) != null) { "Provider is required" }
+        val now = System.currentTimeMillis()
+        models.distinctBy { it.id }.map { availableModel ->
+            val modelProfileId = stableModelId(providerId, availableModel.id)
+            val existingModel = aiProfileDao.getModel(modelProfileId)
+            AiModelProfile(
+                id = modelProfileId,
+                providerId = providerId,
+                displayName = availableModel.name.ifBlank { availableModel.id },
+                modelId = availableModel.id,
+                contextWindow = availableModel.contextWindow.takeIf { it > 0 } ?: existingModel?.contextWindow ?: 0,
+                maxOutputTokens = availableModel.maxOutputTokens.takeIf { it > 0 } ?: existingModel?.maxOutputTokens ?: 0,
+                capabilities = existingModel?.capabilities?.takeIf { it.isNotBlank() }
+                    ?: AiModelRegistry.inferCapabilities(availableModel.id).joinToString(","),
+                defaultParamsJson = existingModel?.defaultParamsJson ?: GSON.toJson(AiGenerationParams()),
+                enabled = existingModel?.enabled ?: true,
+                sortNumber = existingModel?.sortNumber ?: 0,
+                createdAt = existingModel?.createdAt ?: now,
+                updatedAt = now
+            ).also { aiProfileDao.insertModel(it) }
+        }
+    }
+
+    override suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig = withContext(Dispatchers.IO) {
+        val model = aiProfileDao.getModel(modelProfileId) ?: error("Model is required")
+        val params = parseParams(model.defaultParamsJson)
+        saveDefaultPresets(modelProfileId, params)
+        aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)?.toConfig()
+            ?: error("Failed to save default model")
+    }
+
+    override suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig = withContext(Dispatchers.IO) {
+        require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
+        require(draft.modelId.isNotBlank()) { "Model is required" }
+
+        val providerId = draft.providerId?.takeIf { it.isNotBlank() } ?: newId("provider")
+        val existingProvider = aiProfileDao.getProvider(providerId)
+        val modelProfileId = draft.modelProfileId?.takeIf { it.isNotBlank() } ?: newId("model")
+        val existingModel = aiProfileDao.getModel(modelProfileId)
+        val apiKey = draft.apiKey.ifBlank { existingProvider?.apiKey.orEmpty() }
+        require(apiKey.isNotBlank()) { "API key is required" }
+        val now = System.currentTimeMillis()
+        val params = AiGenerationParams(temperature = draft.temperature)
+        val existingTranslatePreset = aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)
+        val translationRuntimeOptions = existingTranslatePreset
+            ?.chunkPolicyJson
+            ?.let { parseRuntimeOptions(it) }
+            ?: AiTaskRuntimeOptions(
+                targetLanguage = draft.translationTargetLanguage,
+                maxInputChars = draft.maxInputChars,
+                concurrentRequests = draft.concurrentRequests,
+                retryCount = draft.retryCount
+            )
+
+        aiProfileDao.insertProvider(
+            AiProviderProfile(
+                id = providerId,
+                name = draft.providerName.ifBlank { "Default AI Provider" },
+                protocol = draft.protocol,
+                baseUrl = draft.baseUrl,
+                modelsUrl = existingProvider?.modelsUrl,
+                apiKey = apiKey,
+                secretRef = existingProvider?.secretRef,
+                headersJson = existingProvider?.headersJson,
+                chatPath = existingProvider?.chatPath,
+                responsesPath = existingProvider?.responsesPath,
+                messagesPath = existingProvider?.messagesPath,
+                modelsPath = existingProvider?.modelsPath,
+                customHeadersJson = existingProvider?.customHeadersJson,
+                createdAt = existingProvider?.createdAt ?: now,
+                updatedAt = now
+            )
+        )
+        aiProfileDao.insertModel(
+            AiModelProfile(
+                id = modelProfileId,
+                providerId = providerId,
+                displayName = draft.modelName.ifBlank { draft.modelId },
+                modelId = draft.modelId,
+                contextWindow = draft.contextWindow,
+                maxOutputTokens = draft.maxOutputTokens,
+                capabilities = existingModel?.capabilities.orEmpty(),
+                defaultParamsJson = GSON.toJson(params),
+                createdAt = existingModel?.createdAt ?: now,
+                updatedAt = now
+            )
+        )
+        saveDefaultPresets(modelProfileId, params, translationRuntimeOptions)
+        aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)?.toConfig()
+            ?: error("Failed to save AI profile")
+    }
+
+    private suspend fun saveDefaultPresets(
+        modelProfileId: String,
+        params: AiGenerationParams,
+        translationRuntimeOptions: AiTaskRuntimeOptions? = null
+    ) {
+        val now = System.currentTimeMillis()
+        val existingTranslatePreset = aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)
+        val runtimeOptions = translationRuntimeOptions
+            ?: existingTranslatePreset
+                ?.chunkPolicyJson
+                ?.let { parseRuntimeOptions(it) }
+            ?: AiTaskRuntimeOptions()
+        aiProfileDao.insertPreset(
+            AiTaskPreset(
+                id = DEFAULT_TRANSLATE_PRESET_ID,
+                taskType = AiTaskType.TRANSLATE_CHAPTER,
+                name = "Default Translation",
+                modelProfileId = modelProfileId,
+                promptTemplate = TranslationConstants.DEFAULT_PROMPT,
+                paramsJson = GSON.toJson(params),
+                chunkPolicyJson = GSON.toJson(runtimeOptions),
+                isDefault = true,
+                createdAt = existingTranslatePreset?.createdAt ?: now,
+                updatedAt = now
+            )
+        )
+        val existingSummaryPreset = aiProfileDao.getPreset(DEFAULT_SUMMARY_PRESET_ID)
+        aiProfileDao.insertPreset(
+            AiTaskPreset(
+                id = DEFAULT_SUMMARY_PRESET_ID,
+                taskType = AiTaskType.SUMMARIZE_CHAPTER,
+                name = "Default Chapter Summary",
+                modelProfileId = modelProfileId,
+                promptTemplate = AiPromptTemplate.DEFAULT_CHAPTER_SUMMARY,
+                paramsJson = GSON.toJson(params.copy(maxOutputTokens = 1200)),
+                isDefault = true,
+                createdAt = existingSummaryPreset?.createdAt ?: now,
+                updatedAt = now
+            )
+        )
+        val existingChatPreset = aiProfileDao.getPreset(DEFAULT_CHAT_PRESET_ID)
+        aiProfileDao.insertPreset(
+            AiTaskPreset(
+                id = DEFAULT_CHAT_PRESET_ID,
+                taskType = AiTaskType.CHAT,
+                name = "Default Chat",
+                modelProfileId = modelProfileId,
+                promptTemplate = "You are a helpful AI assistant.",
+                paramsJson = GSON.toJson(params),
+                isDefault = true,
+                createdAt = existingChatPreset?.createdAt ?: now,
+                updatedAt = now
+            )
+        )
+    }
+
+    private suspend fun AiTaskPreset.toConfig(): AiTaskPresetConfig? {
+        val model = aiProfileDao.getModel(modelProfileId) ?: return null
+        val provider = aiProfileDao.getProvider(model.providerId) ?: return null
+        return AiTaskPresetConfig(
+            id = id,
+            taskType = taskType,
+            name = name,
+            model = model.toConfig(provider),
+            promptTemplate = promptTemplate,
+            params = parseParams(paramsJson),
+            runtimeOptions = parseRuntimeOptions(chunkPolicyJson)
+        )
+    }
+
+    private fun AiModelProfile.toConfig(provider: AiProviderProfile): AiModelConfig {
+        return AiModelConfig(
+            id = id,
+            provider = AiProviderConfig(
+                id = provider.id,
+                name = provider.name,
+                protocol = provider.protocol,
+                baseUrl = provider.baseUrl,
+                apiKey = provider.apiKey,
+                modelsUrl = provider.modelsUrl,
+                headers = parseHeaders(provider.headersJson),
+                chatPath = provider.chatPath ?: "/chat/completions",
+                responsesPath = provider.responsesPath ?: "/responses",
+                messagesPath = provider.messagesPath ?: "/v1/messages",
+                modelsPath = provider.modelsPath,
+                customHeaders = parseHeaders(provider.customHeadersJson)
+            ),
+            displayName = displayName,
+            modelId = modelId,
+            contextWindow = contextWindow,
+            maxOutputTokens = maxOutputTokens,
+            capabilities = capabilities.split(',')
+                .map { it.trim() }
+                .filter { it.isNotEmpty() }
+                .toSet(),
+            defaultParams = parseParams(defaultParamsJson)
+        )
+    }
+
+    private fun parseParams(json: String?): AiGenerationParams {
+        if (json.isNullOrBlank()) return AiGenerationParams()
+        return runCatching {
+            GSON.fromJson(json, AiGenerationParams::class.java)
+        }.getOrDefault(AiGenerationParams())
+    }
+
+    private fun parseRuntimeOptions(json: String?): AiTaskRuntimeOptions {
+        if (json.isNullOrBlank()) return AiTaskRuntimeOptions()
+        return runCatching {
+            GSON.fromJson(json, AiTaskRuntimeOptions::class.java)
+        }.getOrDefault(AiTaskRuntimeOptions())
+    }
+
+    private fun parseHeaders(json: String?): Map<String, String> {
+        if (json.isNullOrBlank()) return emptyMap()
+        return runCatching {
+            @Suppress("UNCHECKED_CAST")
+            GSON.fromJson(json, Map::class.java)
+                .mapKeys { it.key.toString() }
+                .mapValues { it.value.toString() }
+        }.getOrDefault(emptyMap())
+    }
+
+    private companion object {
+        const val DEFAULT_TRANSLATE_PRESET_ID = "default_translate_chapter"
+        const val DEFAULT_SUMMARY_PRESET_ID = "default_summarize_chapter"
+        const val DEFAULT_CHAT_PRESET_ID = "default_chat"
+
+        fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
+
+        fun stableModelId(providerId: String, modelId: String): String {
+            val uuid = UUID.nameUUIDFromBytes("$providerId:$modelId".toByteArray())
+                .toString()
+                .replace("-", "")
+            return "model_$uuid"
+        }
+    }
+}

+ 46 - 0
app/src/main/java/io/legado/app/data/repository/AiTextRepositoryImpl.kt

@@ -0,0 +1,46 @@
+package io.legado.app.data.repository
+
+import io.legado.app.data.repository.ai.AiProviderRegistry
+import io.legado.app.data.repository.ai.AnthropicHandler
+import io.legado.app.data.repository.ai.OpenAiChatHandler
+import io.legado.app.data.repository.ai.OpenAiResponsesHandler
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiProviderConfig
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.withContext
+
+class AiTextRepositoryImpl : AiTextGateway {
+
+    private val registry = AiProviderRegistry(
+        handlers = listOf(
+            OpenAiChatHandler(),
+            OpenAiResponsesHandler(),
+            AnthropicHandler()
+        )
+    )
+
+    override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
+        withContext(Dispatchers.IO) {
+            runCatching {
+                registry.handlerFor(request.model.provider.protocol).generate(request)
+            }.mapCatching { it.getOrThrow() }
+        }
+
+    override fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent> = flow {
+        registry.handlerFor(request.model.provider.protocol).stream(request) { emit(it) }
+    }.flowOn(Dispatchers.IO)
+
+    override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
+        withContext(Dispatchers.IO) {
+            runCatching {
+                registry.handlerFor(provider.protocol).fetchModels(provider).getOrThrow()
+            }
+        }
+}

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

@@ -0,0 +1,520 @@
+package io.legado.app.data.repository
+
+import com.google.gson.JsonObject
+import io.legado.app.data.dao.AiArtifactDao
+import io.legado.app.data.dao.BookChapterDao
+import io.legado.app.data.dao.BookDao
+import io.legado.app.data.dao.BookmarkDao
+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.domain.gateway.AiMemoryGateway
+import io.legado.app.domain.gateway.AiToolGateway
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.AiToolDefinition
+import io.legado.app.domain.model.AiToolResult
+import io.legado.app.help.book.BookHelp
+import io.legado.app.help.book.ContentProcessor
+import io.legado.app.utils.GSON
+import io.legado.app.utils.MD5Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class AiToolRepository(
+    private val bookDao: BookDao,
+    private val bookChapterDao: BookChapterDao,
+    private val bookmarkDao: BookmarkDao,
+    private val readRecordDao: ReadRecordDao,
+    private val aiArtifactDao: AiArtifactDao,
+    private val aiMemoryGateway: AiMemoryGateway
+) : AiToolGateway {
+
+    override fun availableTools(): List<AiToolDefinition> = tools
+
+    override fun requiresConfirmation(toolName: String): Boolean {
+        return toolName in confirmationRequiredTools
+    }
+
+    override suspend fun execute(call: AiToolCall): AiToolResult = withContext(Dispatchers.IO) {
+        val args = call.arguments.toJsonObject()
+        val content = when (call.name) {
+            TOOL_SEARCH_BOOKS -> searchBooks(args)
+            TOOL_GET_BOOK_DETAIL -> getBookDetail(args)
+            TOOL_LIST_BOOK_CHAPTERS -> listBookChapters(args)
+            TOOL_GET_CHAPTER_CONTENT -> getChapterContent(args)
+            TOOL_SEARCH_BOOKMARKS -> searchBookmarks(args)
+            TOOL_GET_READING_STATS -> getReadingStats(args)
+            TOOL_GET_AI_ARTIFACTS -> getAiArtifacts(args)
+            TOOL_SAVE_AI_ARTIFACT -> saveAiArtifact(args)
+            TOOL_SAVE_MEMORY -> saveMemory(args)
+            TOOL_RECALL_MEMORY -> recallMemory(args)
+            TOOL_DELETE_MEMORY -> deleteMemory(args)
+            else -> """{"error":"Unknown tool: ${call.name}"}"""
+        }
+        AiToolResult(
+            callId = call.id,
+            name = call.name,
+            content = content
+        )
+    }
+
+    private fun searchBooks(args: JsonObject): String {
+        val query = args.string("query").orEmpty().trim()
+        val limit = args.int("limit", 8).coerceIn(1, 20)
+        val books = bookDao.all
+            .asSequence()
+            .filter { book ->
+                query.isBlank() ||
+                    book.name.contains(query, ignoreCase = true) ||
+                    book.author.contains(query, ignoreCase = true) ||
+                    book.originName.contains(query, ignoreCase = true)
+            }
+            .sortedByDescending { it.durChapterTime }
+            .take(limit)
+            .map { it.toSummaryMap() }
+            .toList()
+        return GSON.toJson(mapOf("books" to books))
+    }
+
+    private fun getBookDetail(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        return GSON.toJson(
+            book.toSummaryMap() + mapOf(
+                "bookUrl" to book.bookUrl,
+                "kind" to book.kind,
+                "intro" to book.getDisplayIntro(),
+                "remark" to book.remark,
+                "latestChapterTitle" to book.latestChapterTitle,
+                "lastCheckCount" to book.lastCheckCount,
+                "canUpdate" to book.canUpdate
+            )
+        )
+    }
+
+    private fun listBookChapters(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val query = args.string("query").orEmpty().trim()
+        val start = args.int("start", 0).coerceAtLeast(0)
+        val limit = args.int("limit", 20).coerceIn(1, 80)
+        val chapters = if (query.isBlank()) {
+            bookChapterDao.getChapterList(book.bookUrl, start, start + limit - 1)
+        } else {
+            bookChapterDao.search(book.bookUrl, query).drop(start).take(limit)
+        }
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "chapters" to chapters.map {
+                    mapOf(
+                        "index" to it.index,
+                        "title" to it.title,
+                        "isVolume" to it.isVolume,
+                        "wordCount" to it.wordCount,
+                        "tag" to it.tag
+                    )
+                }
+            )
+        )
+    }
+
+    private fun getChapterContent(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val chapterIndex = args.int("chapterIndex", book.durChapterIndex).coerceAtLeast(0)
+        val maxChars = args.int("maxChars", 6000).coerceIn(500, 12000)
+        val chapter = bookChapterDao.getChapter(book.bookUrl, chapterIndex)
+            ?: return """{"error":"Chapter not found"}"""
+        val rawContent = BookHelp.getContent(book, chapter)
+            ?: return """{"error":"Chapter content is not cached locally"}"""
+        val content = ContentProcessor.get(book.name, book.origin)
+            .getContent(book, chapter, rawContent, includeTitle = false)
+            .toString()
+            .take(maxChars)
+        return GSON.toJson(
+            mapOf(
+                "book" to book.toIdentityMap(),
+                "chapter" to mapOf("index" to chapter.index, "title" to chapter.title),
+                "truncated" to (content.length < rawContent.length),
+                "content" to content
+            )
+        )
+    }
+
+    private fun searchBookmarks(args: JsonObject): String {
+        val query = args.string("query").orEmpty().trim()
+        val limit = args.int("limit", 10).coerceIn(1, 30)
+        val bookName = args.string("bookName")?.trim().orEmpty()
+        val bookAuthor = args.string("bookAuthor")?.trim().orEmpty()
+        val bookmarks = bookmarkDao.all
+            .asSequence()
+            .filter { bookmark ->
+                (bookName.isBlank() || bookmark.bookName.equals(bookName, ignoreCase = true)) &&
+                    (bookAuthor.isBlank() || bookmark.bookAuthor.equals(bookAuthor, ignoreCase = true)) &&
+                    (query.isBlank() ||
+                        bookmark.bookName.contains(query, ignoreCase = true) ||
+                        bookmark.bookAuthor.contains(query, ignoreCase = true) ||
+                        bookmark.chapterName.contains(query, ignoreCase = true) ||
+                        bookmark.content.contains(query, ignoreCase = true) ||
+                        bookmark.bookText.contains(query, ignoreCase = true))
+            }
+            .sortedWith(compareBy({ it.bookName }, { it.chapterIndex }, { it.chapterPos }))
+            .take(limit)
+            .map {
+                mapOf(
+                    "bookName" to it.bookName,
+                    "bookAuthor" to it.bookAuthor,
+                    "chapterIndex" to it.chapterIndex,
+                    "chapterName" to it.chapterName,
+                    "chapterPos" to it.chapterPos,
+                    "note" to it.content,
+                    "text" to it.bookText.take(500),
+                    "time" to it.time
+                )
+            }
+            .toList()
+        return GSON.toJson(mapOf("bookmarks" to bookmarks))
+    }
+
+    private fun getReadingStats(args: JsonObject): String {
+        val query = args.string("query").orEmpty().trim()
+        val date = args.string("date")?.trim().orEmpty()
+        val limit = args.int("limit", 10).coerceIn(1, 30)
+        val records = readRecordDao.all
+            .asSequence()
+            .filter {
+                query.isBlank() ||
+                    it.bookName.contains(query, ignoreCase = true) ||
+                    it.bookAuthor.contains(query, ignoreCase = true)
+            }
+            .sortedByDescending { it.lastRead }
+            .take(limit)
+            .map {
+                mapOf(
+                    "bookName" to it.bookName,
+                    "bookAuthor" to it.bookAuthor,
+                    "readTimeMillis" to it.readTime,
+                    "lastReadTime" to it.lastRead
+                )
+            }
+            .toList()
+        val dailyDetails = readRecordDao.allDetail
+            .asSequence()
+            .filter {
+                (date.isBlank() || it.date == date) &&
+                    (query.isBlank() ||
+                        it.bookName.contains(query, ignoreCase = true) ||
+                        it.bookAuthor.contains(query, ignoreCase = true))
+            }
+            .sortedWith(compareByDescending<io.legado.app.data.entities.readRecord.ReadRecordDetail> { it.date }
+                .thenByDescending { it.lastReadTime })
+            .take(limit)
+            .map {
+                mapOf(
+                    "date" to it.date,
+                    "bookName" to it.bookName,
+                    "bookAuthor" to it.bookAuthor,
+                    "readTimeMillis" to it.readTime,
+                    "readWords" to it.readWords,
+                    "firstReadTime" to it.firstReadTime,
+                    "lastReadTime" to it.lastReadTime
+                )
+            }
+            .toList()
+        return GSON.toJson(
+            mapOf(
+                "totalReadTimeMillis" to readRecordDao.all.sumOf { it.readTime },
+                "recentRecords" to records,
+                "dailyDetails" to dailyDetails
+            )
+        )
+    }
+
+    private suspend fun getAiArtifacts(args: JsonObject): String {
+        val book = resolveBook(args)
+        val taskType = args.string("taskType")?.trim()?.takeIf { it.isNotBlank() }
+        val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
+        val limit = args.int("limit", 8).coerceIn(1, 30)
+        val artifacts = aiArtifactDao.queryArtifacts(
+            bookUrl = book?.bookUrl,
+            taskType = taskType,
+            chapterIndex = chapterIndex,
+            limit = limit
+        ).map {
+            mapOf(
+                "id" to it.id,
+                "bookUrl" to it.bookUrl,
+                "chapterIndex" to it.chapterIndex,
+                "taskType" to it.taskType,
+                "status" to it.status,
+                "modelProfileId" to it.modelProfileId,
+                "updatedAt" to it.updatedAt,
+                "output" to it.output.orEmpty().take(4000),
+                "errorMessage" to it.errorMessage,
+                "truncated" to (it.output.orEmpty().length > 4000)
+            )
+        }
+        return GSON.toJson(
+            mapOf(
+                "book" to book?.toIdentityMap(),
+                "artifacts" to artifacts
+            )
+        )
+    }
+
+    private suspend fun saveAiArtifact(args: JsonObject): String {
+        val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
+        val output = args.string("output")?.trim().orEmpty()
+        if (output.isBlank()) return """{"error":"output is required"}"""
+        val taskType = args.string("taskType")?.trim()?.takeIf { it.isNotBlank() } ?: "ai_note"
+        val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
+        val now = System.currentTimeMillis()
+        val contentHash = MD5Utils.md5Encode(output)
+        val promptHash = MD5Utils.md5Encode("tool:$TOOL_SAVE_AI_ARTIFACT:$taskType")
+        val artifact = AiArtifact(
+            id = "tool_${book.bookUrl}_${chapterIndex ?: "book"}_${taskType}_${contentHash}",
+            taskType = taskType,
+            bookUrl = book.bookUrl,
+            chapterIndex = chapterIndex,
+            contentHash = contentHash,
+            promptHash = promptHash,
+            modelProfileId = "tool",
+            status = AiArtifact.STATUS_SUCCESS,
+            output = output,
+            createdAt = now,
+            updatedAt = now
+        )
+        aiArtifactDao.upsert(artifact)
+        return GSON.toJson(
+            mapOf(
+                "saved" to true,
+                "artifactId" to artifact.id,
+                "book" to book.toIdentityMap(),
+                "taskType" to taskType,
+                "chapterIndex" to chapterIndex,
+                "updatedAt" to now
+            )
+        )
+    }
+
+    private suspend fun saveMemory(args: JsonObject): String {
+        val key = args.string("key")?.trim().orEmpty()
+        if (key.isBlank()) return """{"error":"key is required"}"""
+        val value = args.string("value")?.trim().orEmpty()
+        if (value.isBlank()) return """{"error":"value is required"}"""
+        val conversationId = args.string("conversationId")?.trim().orEmpty()
+        aiMemoryGateway.upsert(
+            AiMemory(
+                conversationId = conversationId,
+                key = key,
+                value = value
+            )
+        )
+        return GSON.toJson(mapOf("saved" to true, "key" to key, "scope" to if (conversationId.isBlank()) "global" else "conversation"))
+    }
+
+    private suspend fun recallMemory(args: JsonObject): String {
+        val conversationId = args.string("conversationId")?.trim().orEmpty()
+        val memories = aiMemoryGateway.getForPrompt(conversationId)
+        return GSON.toJson(
+            mapOf(
+                "memories" to memories.map { mapOf("key" to it.key, "value" to it.value, "scope" to if (it.conversationId.isBlank()) "global" else "conversation") }
+            )
+        )
+    }
+
+    private suspend fun deleteMemory(args: JsonObject): String {
+        val key = args.string("key")?.trim().orEmpty()
+        if (key.isBlank()) return """{"error":"key is required"}"""
+        val conversationId = args.string("conversationId")?.trim().orEmpty()
+        aiMemoryGateway.delete(conversationId, key)
+        return GSON.toJson(mapOf("deleted" to true, "key" to key))
+    }
+
+    private fun resolveBook(args: JsonObject): Book? {
+        args.string("bookUrl")?.takeIf { it.isNotBlank() }?.let { url ->
+            bookDao.getBook(url)?.let { return it }
+        }
+        val name = args.string("bookName")?.trim().orEmpty()
+        val author = args.string("bookAuthor")?.trim().orEmpty()
+        if (name.isNotBlank() && author.isNotBlank()) {
+            bookDao.getBook(name, author)?.let { return it }
+        }
+        if (name.isNotBlank()) {
+            return bookDao.findByName(name).firstOrNull()
+        }
+        return bookDao.lastReadBook
+    }
+
+    private fun Book.toIdentityMap(): Map<String, Any?> {
+        return mapOf(
+            "bookUrl" to bookUrl,
+            "name" to name,
+            "author" to author
+        )
+    }
+
+    private fun Book.toSummaryMap(): Map<String, Any?> {
+        return toIdentityMap() + mapOf(
+            "originName" to originName,
+            "currentChapterIndex" to durChapterIndex,
+            "currentChapterTitle" to durChapterTitle,
+            "totalChapterNum" to totalChapterNum,
+            "wordCount" to wordCount,
+            "lastReadTime" to durChapterTime
+        )
+    }
+
+    private fun JsonObject.string(name: String): String? {
+        return get(name)?.takeIf { !it.isJsonNull }?.asString
+    }
+
+    private fun JsonObject.int(name: String, defaultValue: Int): Int {
+        return runCatching { get(name)?.takeIf { !it.isJsonNull }?.asInt }.getOrNull() ?: defaultValue
+    }
+
+    private fun String.toJsonObject(): JsonObject {
+        return runCatching { GSON.fromJson(this, JsonObject::class.java) }.getOrNull() ?: JsonObject()
+    }
+
+    companion object {
+        const val TOOL_SEARCH_BOOKS = "search_books"
+        const val TOOL_GET_BOOK_DETAIL = "get_book_detail"
+        const val TOOL_LIST_BOOK_CHAPTERS = "list_book_chapters"
+        const val TOOL_GET_CHAPTER_CONTENT = "get_chapter_content"
+        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_SAVE_AI_ARTIFACT = "save_ai_artifact"
+        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 tools = listOf(
+            AiToolDefinition(
+                name = TOOL_SEARCH_BOOKS,
+                description = "Search the local bookshelf by book title, author, or source name.",
+                inputSchema = objectSchema(
+                    "query" to stringSchema("Search keyword. Leave empty to list recently read books."),
+                    "limit" to intSchema("Maximum number of books to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_BOOK_DETAIL,
+                description = "Get metadata and reading progress for one book. If no identifier is given, use the last read book.",
+                inputSchema = objectSchema(
+                    "bookUrl" to stringSchema("Exact book URL/id from search_books."),
+                    "bookName" to stringSchema("Book title."),
+                    "bookAuthor" to stringSchema("Book author.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_LIST_BOOK_CHAPTERS,
+                description = "List chapter metadata for a local bookshelf book.",
+                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("Optional chapter title keyword."),
+                    "start" to intSchema("Zero-based offset for returned chapters."),
+                    "limit" to intSchema("Maximum number of chapters to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_CHAPTER_CONTENT,
+                description = "Read cached text content for a chapter. This never downloads network content.",
+                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("Zero-based chapter index. Defaults to current reading chapter."),
+                    "maxChars" to intSchema("Maximum characters to return, capped by the app.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_SEARCH_BOOKMARKS,
+                description = "Search local bookmarks and notes across the bookshelf or within one book.",
+                inputSchema = objectSchema(
+                    "query" to stringSchema("Keyword for bookmark text, note, book, or chapter."),
+                    "bookName" to stringSchema("Optional book title filter."),
+                    "bookAuthor" to stringSchema("Optional book author filter."),
+                    "limit" to intSchema("Maximum number of bookmarks to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_READING_STATS,
+                description = "Get local reading statistics, recent read books, and daily read records.",
+                inputSchema = objectSchema(
+                    "query" to stringSchema("Optional book title or author filter."),
+                    "date" to stringSchema("Optional date filter for daily records, format YYYY-MM-DD."),
+                    "limit" to intSchema("Maximum number of records to return.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_GET_AI_ARTIFACTS,
+                description = "Read existing AI artifacts such as chapter summaries for a book or 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."),
+                    "taskType" to stringSchema("Optional artifact task type, such as summarize_chapter."),
+                    "limit" to intSchema("Maximum number of artifacts 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.",
+                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."),
+                    "taskType" to stringSchema("Artifact task type, such as ai_note or summarize_chapter."),
+                    "output" to stringSchema("The note or summary content to save.")
+                )
+            ),
+            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.",
+                inputSchema = objectSchema(
+                    "key" to stringSchema("Short label for the memory, e.g. 'favorite_genre', 'reading_goal'."),
+                    "value" to stringSchema("The fact or preference to remember."),
+                    "conversationId" to stringSchema("Leave empty for global memory across all conversations, or pass current conversation id for scoped memory.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_RECALL_MEMORY,
+                description = "Recall saved memories about the user.",
+                inputSchema = objectSchema(
+                    "conversationId" to stringSchema("Leave empty to recall global memories, or pass current conversation id for scoped memories.")
+                )
+            ),
+            AiToolDefinition(
+                name = TOOL_DELETE_MEMORY,
+                description = "Delete a saved memory entry.",
+                inputSchema = objectSchema(
+                    "key" to stringSchema("The memory key to delete."),
+                    "conversationId" to stringSchema("Leave empty for global scope, or pass conversation id for scoped memory.")
+                )
+            )
+        )
+
+        private fun objectSchema(vararg properties: Pair<String, Map<String, Any?>>): Map<String, Any?> {
+            return mapOf(
+                "type" to "object",
+                "properties" to properties.toMap(),
+                "additionalProperties" to false
+            )
+        }
+
+        private fun stringSchema(description: String): Map<String, Any?> {
+            return mapOf("type" to "string", "description" to description)
+        }
+
+        private fun intSchema(description: String): Map<String, Any?> {
+            return mapOf("type" to "integer", "description" to description)
+        }
+    }
+}

+ 0 - 391
app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt

@@ -1,391 +0,0 @@
-package io.legado.app.data.repository
-
-import androidx.annotation.Keep
-import io.legado.app.domain.gateway.LlmGateway
-import io.legado.app.domain.model.DictPair
-import io.legado.app.domain.model.RetryReason
-import io.legado.app.domain.model.TranslationConstants
-import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
-import io.legado.app.help.http.addHeaders
-import io.legado.app.help.http.newCallStrResponse
-import io.legado.app.help.http.okHttpClient
-import io.legado.app.help.http.postJson
-import io.legado.app.utils.GSON
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-
-class LlmTranslateRepositoryImpl : LlmGateway {
-
-    /**
-     * Get display name for a language code, e.g., "zh" -> "简体中文"
-     */
-    private fun getLanguageDisplayName(code: String): String {
-        return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code
-    }
-
-    override suspend fun translate(
-        text: String,
-        targetLanguage: String,
-        provider: String,
-        baseUrl: String,
-        apiKey: String,
-        model: String,
-        prompt: String,
-        temperature: Float,
-        dictionaries: List<DictPair>,
-        onUpdate: ((List<DictPair>) -> Unit)?,
-        retryReason: RetryReason?
-    ): Result<String> = withContext(Dispatchers.IO) {
-        // 如果目标语言是英语,且文本已经是英文(英文字符及标点占比超过80%),跳过翻译直接返回原文
-        if (targetLanguage == "en" && isMostlyEnglish(text)) {
-            return@withContext Result.success(text)
-        }
-
-        // 如果目标语言是其他语言(如中文),且文本已经是该语言,跳过翻译直接返回原文
-        if (targetLanguage == "zh" && isMostlyChinese(text)) {
-            return@withContext Result.success(text)
-        }
-
-        try {
-            when (provider) {
-                TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(text, targetLanguage)
-                TranslationConstants.PROVIDER_OPENAI -> translateWithOpenAI(
-                    text,
-                    targetLanguage,
-                    baseUrl,
-                    apiKey,
-                    model,
-                    prompt,
-                    temperature,
-                    dictionaries,
-                    onUpdate,
-                    retryReason
-                )
-
-                else -> Result.failure(IllegalArgumentException("Unknown provider: $provider"))
-            }
-        } catch (e: Exception) {
-            Result.failure(e)
-        }
-    }
-
-    /**
-     * 判断文本是否大部分为英文(英文字母及标点符号占比超过80%)
-     */
-    private fun isMostlyEnglish(text: String): Boolean {
-        if (text.isEmpty()) return false
-        val englishChars =
-            text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}–—…" }
-        return englishChars.toDouble() / text.length > 0.8
-    }
-
-    /**
-     * 判断文本是否大部分为中文(中文及中文标点占比超过80%)
-     */
-    private fun isMostlyChinese(text: String): Boolean {
-        if (text.isEmpty()) return false
-        val chinesePunctuation = "。,!?;:“”‘’()【】《》——…"
-        val chineseChars = text.count {
-            it in '一'..'鿿' || it in chinesePunctuation
-        }
-        return chineseChars.toDouble() / text.length > 0.8
-    }
-
-    /**
-     * 如果翻译结果是中文,删除其中英文含量超过80%的段落(LLM可能生成中英对照内容)
-     */
-    private fun filterHighEnglishParagraphs(text: String): String {
-        val paragraphs = text.split("\n")
-        val filtered = paragraphs.filter { paragraph ->
-            !isMostlyEnglish(paragraph)
-        }
-        return filtered.joinToString("\n")
-    }
-
-    private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result<String> {
-        val encodedText = java.net.URLEncoder.encode(text, "UTF-8")
-        val url =
-            "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText"
-        val response = okHttpClient.newCallStrResponse {
-            url(url)
-        }
-        return if (response.isSuccessful()) {
-            try {
-                val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
-                val translatedText = json?.sentences?.mapNotNull { it.trans }?.joinToString("") ?: ""
-                if (translatedText.isNotEmpty()) {
-                    Result.success(translatedText)
-                } else {
-                    Result.failure(Exception("Empty translation result"))
-                }
-            } catch (e: Exception) {
-                Result.failure(e)
-            }
-        } else {
-            Result.failure(Exception("HTTP ${response.code()}: ${response.message()}"))
-        }
-    }
-
-    private suspend fun translateWithOpenAI(
-        text: String,
-        targetLanguage: String,
-        baseUrl: String,
-        apiKey: String,
-        model: String,
-        prompt: String,
-        temperature: Float,
-        dictionaries: List<DictPair>,
-        onUpdate: ((List<DictPair>) -> Unit)?,
-        retryReason: RetryReason?
-    ): Result<String> {
-        if (baseUrl.isBlank() || apiKey.isBlank() || model.isBlank()) {
-            return Result.failure(IllegalArgumentException("OpenAI configuration incomplete: baseUrl, apiKey, and model are required"))
-        }
-
-        // Build dictionary instruction for consistent terminology
-        val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
-
-        val systemPrompt =
-            buildSystemPrompt(prompt, targetLanguage, dictionaryInstruction, OUTPUT_FORMAT)
-        val requestBody = mapOf(
-            "model" to model,
-            "messages" to listOf(
-                mapOf("role" to "system", "content" to systemPrompt),
-                mapOf("role" to "user", "content" to "Translate the following text:\n\n$text")
-            ),
-            "temperature" to temperature.coerceIn(
-                TranslationConstants.MIN_TEMPERATURE,
-                TranslationConstants.MAX_TEMPERATURE
-            )
-        )
-        val jsonBody = GSON.toJson(requestBody)
-        val fullUrl = baseUrl.trimEnd('/') + "/v1/chat/completions"
-        val response = okHttpClient.newCallStrResponse {
-            url(fullUrl)
-            postJson(jsonBody)
-            addHeaders(
-                mapOf(
-                    "Authorization" to "Bearer $apiKey",
-                    "Content-Type" to "application/json"
-                )
-            )
-        }
-        return if (response.isSuccessful()) {
-            try {
-                val json = GSON.fromJson(response.body, OpenAIResponse::class.java)
-                val rawContent = json?.choices?.firstOrNull()?.message?.content
-                if (rawContent != null) {
-                    // Parse the output with [dictionary] and [result] sections
-                    val parseResult = parseLlmOutput(rawContent, dictionaries)
-
-                    // Report extracted pairs for dictionary update (max 10)
-                    if (parseResult.extractedPairs.isNotEmpty()) {
-                        val limitedPairs = parseResult.extractedPairs.take(10)
-                        onUpdate?.invoke(limitedPairs)
-                    }
-
-                    // Always return the result, even if dictionary parsing failed
-                    val finalText = if (targetLanguage == "zh") {
-                        filterHighEnglishParagraphs(parseResult.translatedText)
-                    } else {
-                        parseResult.translatedText
-                    }
-                    Result.success(finalText)
-                } else {
-                    Result.failure(Exception("Empty translation result"))
-                }
-            } catch (e: Exception) {
-                Result.failure(e)
-            }
-        } else {
-            val errorMsg = "HTTP ${response.code()}: ${response.message()}"
-            Result.failure(Exception(errorMsg))
-        }
-    }
-
-    /**
-     * Result of parsing LLM output
-     */
-    data class ParseOutputResult(
-        val translatedText: String,
-        val extractedPairs: List<DictPair>
-    )
-
-    /**
-     * Parse LLM output that should contain [dictionary] and [result] sections.
-     * Fallback: if parsing fails or format is wrong, still return the result part.
-     * Don't use regex for parsing.
-     */
-    private fun parseLlmOutput(
-        rawOutput: String,
-        existingDictionaries: List<DictPair>
-    ): ParseOutputResult {
-        val existingOriginals = existingDictionaries.map { it.original }.toSet()
-
-        var dictionarySection: String? = null
-        var resultSection: String? = null
-
-        val lines = rawOutput.split('\n')
-        var currentSection: String? = null
-
-        for (line in lines) {
-            val trimmedLine = line.trim()
-
-            // Detect section header
-            when {
-                trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> {
-                    currentSection = "dictionary"
-                }
-
-                trimmedLine.startsWith("[result]", ignoreCase = true) -> {
-                    currentSection = "result"
-                }
-
-                currentSection == "dictionary" -> {
-                    dictionarySection = (dictionarySection ?: "") + line + "\n"
-                }
-
-                currentSection == "result" -> {
-                    resultSection = (resultSection ?: "") + line + "\n"
-                }
-            }
-        }
-
-        // Extract pairs from dictionary section
-        val extractedPairs = mutableListOf<DictPair>()
-        if (dictionarySection != null) {
-            extractedPairs.addAll(parseDictionarySection(dictionarySection, existingOriginals))
-        }
-
-        // Determine translated text
-        val translatedText = when {
-            resultSection != null -> resultSection.trim()
-            else -> rawOutput.trim() // ultimate fallback: return raw output
-        }
-
-        return ParseOutputResult(translatedText, extractedPairs)
-    }
-
-    /**
-     * Parse dictionary section line by line without regex.
-     * Format: "Original -> Translation" or "Original: Translation"
-     */
-    private fun parseDictionarySection(
-        section: String,
-        existingOriginals: Set<String>
-    ): List<DictPair> {
-        val pairs = mutableListOf<DictPair>()
-        val lines = section.split('\n')
-
-        for (line in lines) {
-            val trimmedLine = line.trim()
-            if (trimmedLine.isEmpty()) continue
-
-            // Skip section headers
-            if (trimmedLine.startsWith("[") || trimmedLine.startsWith(
-                    "dictionary",
-                    ignoreCase = true
-                )
-            ) {
-                continue
-            }
-
-            // Try to find separator: "->" or ":"
-            val separator = when {
-                trimmedLine.contains(" -> ") -> " -> "
-                trimmedLine.contains(" ->") -> " ->"
-                trimmedLine.contains("-> ") -> "-> "
-                trimmedLine.contains("->") -> "->"
-                trimmedLine.contains(" : ") -> " : "
-                trimmedLine.contains(": ") -> ": "
-                trimmedLine.contains(" :") -> " :"
-                trimmedLine.contains(":") -> ":"
-                else -> null
-            }
-
-            if (separator != null) {
-                val parts = trimmedLine.split(separator, limit = 2)
-                if (parts.size == 2) {
-                    val original = parts[0].trim()
-                    val translation = parts[1].trim()
-
-                    // Skip if already in existing dictionary
-                    if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
-                        pairs.add(DictPair(original, translation))
-                        // Max 10 pairs
-                        if (pairs.size >= 10) break
-                    }
-                }
-            }
-        }
-
-        return pairs
-    }
-
-    /**
-     * Build a dictionary instruction string for consistent terminology.
-     */
-    private fun buildDictionaryInstruction(dictionaries: List<DictPair>): String {
-        if (dictionaries.isEmpty()) return ""
-
-        val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" }
-        return """
-
-Terminology Dictionary (use these exact translations):
-$terms
-"""
-    }
-
-    /**
-     * Build the full system prompt with dictionary instructions.
-     */
-    private fun buildSystemPrompt(
-        prompt: String,
-        targetLanguage: String,
-        dictionaryInstruction: String,
-        outputFormat: String,
-    ): String {
-        return buildString {
-            append(prompt)
-            append("\n Target language: ").append(getLanguageDisplayName(targetLanguage))
-            if (dictionaryInstruction.isNotEmpty()) {
-                append(dictionaryInstruction)
-            }
-            append("\n ").append(outputFormat)
-        }
-    }
-}
-
-@Keep
-data class GoogleTranslateResponse(
-    val sentences: List<GoogleSentence>,
-    val src: String?,
-    val spell: GoogleSpell?
-)
-
-@Keep
-data class GoogleSentence(
-    val trans: String?,
-    val orig: String?,
-    val backend: Int?
-)
-
-@Keep
-data class GoogleSpell(
-    val spell: String?
-)
-
-@Keep
-data class OpenAIResponse(
-    val choices: List<OpenAIChoice>
-)
-
-@Keep
-data class OpenAIChoice(
-    val message: OpenAIMessage
-)
-
-@Keep
-data class OpenAIMessage(
-    val content: String
-)

+ 29 - 0
app/src/main/java/io/legado/app/data/repository/ai/AiProtocolHandler.kt

@@ -0,0 +1,29 @@
+package io.legado.app.data.repository.ai
+
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiProviderConfig
+
+/**
+ * Protocol-specific handler for AI text generation.
+ * Each implementation handles one or more protocol types (e.g. OpenAI Chat, Anthropic Messages).
+ */
+interface AiProtocolHandler {
+
+    /** Protocol identifiers this handler supports (e.g. "openai_chat_completions"). */
+    val protocols: Set<String>
+
+    /** Single-shot text generation. */
+    suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse>
+
+    /** Streaming text generation via SSE. */
+    suspend fun stream(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    )
+
+    /** Fetch available models from the provider. */
+    suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>>
+}

+ 15 - 0
app/src/main/java/io/legado/app/data/repository/ai/AiProviderRegistry.kt

@@ -0,0 +1,15 @@
+package io.legado.app.data.repository.ai
+
+/**
+ * Registry that maps protocol identifiers to their [AiProtocolHandler] implementations.
+ */
+class AiProviderRegistry(handlers: List<AiProtocolHandler>) {
+
+    private val handlerMap: Map<String, AiProtocolHandler> =
+        handlers.flatMap { handler -> handler.protocols.map { it to handler } }.toMap()
+
+    fun handlerFor(protocol: String): AiProtocolHandler {
+        return handlerMap[protocol]
+            ?: error("Unsupported AI protocol: $protocol")
+    }
+}

+ 86 - 0
app/src/main/java/io/legado/app/data/repository/ai/AiRetryUtils.kt

@@ -0,0 +1,86 @@
+package io.legado.app.data.repository.ai
+
+import kotlinx.coroutines.delay
+import kotlin.math.min
+import kotlin.random.Random
+
+/**
+ * Round-robin key rotator for providers with multiple API keys.
+ * Keys are comma-separated in the provider's apiKey field.
+ */
+internal class KeyRotator(rawKey: String) {
+
+    private val keys: List<String> = rawKey
+        .split(",")
+        .map { it.trim() }
+        .filter { it.isNotBlank() }
+
+    private var index = 0
+
+    val currentKey: String
+        get() = keys[index % keys.size]
+
+    val hasMultipleKeys: Boolean
+        get() = keys.size > 1
+
+    /** Advance to the next key. Returns the new current key. */
+    fun rotate(): String {
+        if (keys.size > 1) {
+            index = (index + 1) % keys.size
+        }
+        return currentKey
+    }
+}
+
+/**
+ * Retry a block with exponential backoff + jitter.
+ * Retries on [retryableStatusCodes] (default: 429, 502, 503).
+ * If [keyRotator] is provided and has multiple keys, rotates key on each retry.
+ *
+ * @param maxAttempts Total attempts (1 = no retry, 2 = one retry, etc.)
+ * @param baseDelayMs Base delay in milliseconds
+ * @param maxDelayMs Maximum delay cap
+ * @param onRetry Called before each retry with (attempt, delayMs, exception)
+ */
+internal suspend fun <T> retryWithBackoff(
+    maxAttempts: Int = 3,
+    baseDelayMs: Long = 1_000,
+    maxDelayMs: Long = 30_000,
+    retryableStatusCodes: Set<Int> = setOf(429, 502, 503),
+    keyRotator: KeyRotator? = null,
+    onRetry: (suspend (attempt: Int, delayMs: Long, error: Exception) -> Unit)? = null,
+    block: suspend () -> T
+): T {
+    var lastException: Exception? = null
+    for (attempt in 1..maxAttempts) {
+        try {
+            return block()
+        } catch (e: Exception) {
+            lastException = e
+            if (attempt >= maxAttempts) break
+            if (!isRetryable(e, retryableStatusCodes)) break
+
+            // Rotate key if available
+            if (keyRotator != null && keyRotator.hasMultipleKeys) {
+                keyRotator.rotate()
+            }
+
+            // Exponential backoff with jitter
+            val exponentialDelay = baseDelayMs * (1L shl (attempt - 1))
+            val cappedDelay = min(exponentialDelay, maxDelayMs)
+            val jitter = Random.nextLong(0, cappedDelay / 4 + 1)
+            val totalDelay = cappedDelay + jitter
+
+            onRetry?.invoke(attempt, totalDelay, e)
+            delay(totalDelay)
+        }
+    }
+    throw lastException ?: Exception("Retry failed after $maxAttempts attempts")
+}
+
+private fun isRetryable(e: Exception, retryableStatusCodes: Set<Int>): Boolean {
+    val message = e.message ?: return false
+    return retryableStatusCodes.any { code ->
+        message.contains("HTTP $code") || message.contains("$code:")
+    }
+}

+ 76 - 0
app/src/main/java/io/legado/app/data/repository/ai/AiSseUtils.kt

@@ -0,0 +1,76 @@
+package io.legado.app.data.repository.ai
+
+import com.google.gson.JsonElement
+import com.google.gson.JsonObject
+import io.legado.app.domain.model.AiCapability
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.utils.GSON
+import okhttp3.Response
+
+/**
+ * Shared SSE parsing utilities used by all protocol handlers.
+ */
+internal suspend fun Response.readSseData(onData: suspend (String) -> Unit) {
+    val source = body.source()
+    val dataLines = mutableListOf<String>()
+    try {
+        while (!source.exhausted()) {
+            val line = source.readUtf8Line() ?: break
+            when {
+                line.isEmpty() -> {
+                    if (dataLines.isNotEmpty()) {
+                        val data = dataLines.joinToString("\n").trim()
+                        dataLines.clear()
+                        if (data == "[DONE]") break
+                        if (data.isNotEmpty()) onData(data)
+                    }
+                }
+                line.startsWith("data:") -> {
+                    dataLines += line.removePrefix("data:").trimStart()
+                }
+            }
+        }
+        if (dataLines.isNotEmpty()) {
+            val data = dataLines.joinToString("\n").trim()
+            if (data != "[DONE]" && data.isNotEmpty()) onData(data)
+        }
+    } finally {
+        source.close()
+    }
+}
+
+internal fun String.toJsonObject(): JsonObject? {
+    return runCatching {
+        GSON.fromJson(this, JsonObject::class.java)
+    }.getOrNull()
+}
+
+internal fun JsonObject.getString(name: String): String? {
+    return get(name)?.takeIf { !it.isJsonNull }?.asString
+}
+
+internal fun JsonObject.extractApiErrorMessage(): String? {
+    val error = get("error")?.asJsonObjectOrNull() ?: return null
+    return error.getString("message") ?: error.getString("code") ?: "AI provider returned an error"
+}
+
+internal fun JsonElement.asJsonObjectOrNull(): JsonObject? {
+    return if (isJsonObject) asJsonObject else null
+}
+
+internal fun JsonElement.asJsonArrayOrNull() = if (isJsonArray) asJsonArray else null
+
+/**
+ * Check if the model supports reasoning capability.
+ */
+internal fun hasReasoningCapability(capabilities: Set<String>): Boolean {
+    return AiCapability.REASONING in capabilities
+}
+
+/**
+ * Map AiReasoningLevel to OpenAI-compatible reasoning_effort value.
+ * OpenAI doesn't accept "none" — remap to "low".
+ */
+internal fun AiReasoningLevel.toOpenAiEffort(): String {
+    return if (effort == "none") "low" else effort
+}

+ 425 - 0
app/src/main/java/io/legado/app/data/repository/ai/AnthropicHandler.kt

@@ -0,0 +1,425 @@
+package io.legado.app.data.repository.ai
+
+import androidx.annotation.Keep
+import com.google.gson.JsonObject
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiCapability
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.AiToolDefinition
+import io.legado.app.help.http.addHeaders
+import io.legado.app.help.http.await
+import io.legado.app.help.http.newCallResponse
+import io.legado.app.help.http.newCallStrResponse
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.postJson
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class AnthropicHandler : AiProtocolHandler {
+
+    override val protocols = setOf(AiProtocol.ANTHROPIC_MESSAGES)
+
+    override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
+        withContext(Dispatchers.IO) {
+            runCatching { generateInternal(request) }
+        }
+
+    override suspend fun stream(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        streamInternal(request, emitEvent)
+    }
+
+    override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
+        withContext(Dispatchers.IO) {
+            runCatching { fetchModelsInternal(provider) }
+        }
+
+    private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "Anthropic configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val systemPrompt = request.messages
+            .filter { it.role == AiMessageRole.SYSTEM }
+            .joinToString("\n\n") { it.content }
+            .takeIf { it.isNotBlank() }
+        val messages = request.messages
+            .filter { it.role != AiMessageRole.SYSTEM }
+            .toAnthropicMessages()
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "messages" to messages,
+            "max_tokens" to (request.params.maxOutputTokens ?: request.model.maxOutputTokens.takeIf { it > 0 } ?: 2048)
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toAnthropicTools() }
+        systemPrompt?.let { body["system"] = it }
+        // Reasoning config — suppress temperature when thinking is enabled
+        val reasoningLevel = request.params.reasoningLevel
+        if (hasReasoningCapability(request.model.capabilities)) {
+            val thinking = buildMap<String, Any> {
+                put("type", if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "adaptive")
+                if (reasoningLevel != AiReasoningLevel.OFF) {
+                    put("display", "summarized")
+                }
+            }
+            body["thinking"] = thinking
+            if (reasoningLevel != AiReasoningLevel.OFF && reasoningLevel != AiReasoningLevel.AUTO) {
+                body["output_config"] = mapOf("effort" to reasoningLevel.effort)
+            }
+        } else {
+            request.params.temperature?.let { body["temperature"] = it }
+        }
+        request.params.topP?.let { body["top_p"] = it }
+
+        return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(provider.baseUrl + provider.messagesPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "x-api-key" to keyRotator.currentKey,
+                        "anthropic-version" to "2023-06-01",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val json = GSON.fromJson(response.body, AnthropicMessageResponse::class.java)
+            val text = json?.content
+                ?.mapNotNull { it.text?.takeIf { text -> text.isNotBlank() } }
+                ?.joinToString("")
+            if (text.isNullOrBlank()) {
+                throw Exception("Empty AI response")
+            } else {
+                AiGenerateResponse(text = text, rawBody = response.body)
+            }
+        }
+    }
+
+    private suspend fun streamInternal(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "Anthropic configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val systemPrompt = request.messages
+            .filter { it.role == AiMessageRole.SYSTEM }
+            .joinToString("\n\n") { it.content }
+            .takeIf { it.isNotBlank() }
+        val messages = request.messages
+            .filter { it.role != AiMessageRole.SYSTEM }
+            .toAnthropicMessages()
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "messages" to messages,
+            "max_tokens" to (request.params.maxOutputTokens ?: request.model.maxOutputTokens.takeIf { it > 0 } ?: 2048),
+            "stream" to true
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toAnthropicTools() }
+        systemPrompt?.let { body["system"] = it }
+        // Reasoning config — suppress temperature when thinking is enabled
+        val reasoningLevel = request.params.reasoningLevel
+        if (hasReasoningCapability(request.model.capabilities)) {
+            val thinking = buildMap<String, Any> {
+                put("type", if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "adaptive")
+                if (reasoningLevel != AiReasoningLevel.OFF) {
+                    put("display", "summarized")
+                }
+            }
+            body["thinking"] = thinking
+            if (reasoningLevel != AiReasoningLevel.OFF && reasoningLevel != AiReasoningLevel.AUTO) {
+                body["output_config"] = mapOf("effort" to reasoningLevel.effort)
+            }
+        } else {
+            request.params.temperature?.let { body["temperature"] = it }
+        }
+        request.params.topP?.let { body["top_p"] = it }
+
+        val keyRotator = KeyRotator(provider.apiKey)
+        val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            okHttpClient.newCallResponse {
+                url(provider.baseUrl + provider.messagesPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "x-api-key" to keyRotator.currentKey,
+                        "anthropic-version" to "2023-06-01",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }.also {
+                if (!it.isSuccessful) {
+                    throw Exception("HTTP ${it.code}: ${it.message}")
+                }
+            }
+        }
+        val toolBlocks = mutableMapOf<Int, AnthropicToolBlock>()
+        try {
+            response.readSseData { data ->
+                val event = runCatching {
+                    GSON.fromJson(data, AnthropicStreamEvent::class.java)
+                }.getOrElse {
+                    throw Exception("Invalid Anthropic stream event", it)
+                } ?: return@readSseData
+                when (event.type) {
+                    "error" -> {
+                        throw Exception(event.error?.message ?: event.error?.type ?: "Anthropic stream error")
+                    }
+                    "content_block_start" -> {
+                        val index = event.index ?: return@readSseData
+                        val block = event.content_block ?: return@readSseData
+                        if (block.type == "tool_use") {
+                            toolBlocks[index] = AnthropicToolBlock(
+                                id = block.id ?: "anthropic_tool_$index",
+                                name = block.name.orEmpty()
+                            )
+                            emitEvent(
+                                AiStreamEvent.ToolCallDelta(
+                                    id = block.id ?: "anthropic_tool_$index",
+                                    index = index,
+                                    name = block.name,
+                                    argumentsDelta = null,
+                                    rawType = "tool_use"
+                                )
+                            )
+                        }
+                    }
+                    "content_block_delta" -> {
+                        val delta = event.delta ?: return@readSseData
+                        when (delta.type) {
+                            "text_delta", null -> {
+                                delta.text?.takeIf { it.isNotEmpty() }?.let {
+                                    emitEvent(AiStreamEvent.Content(it))
+                                }
+                                delta.thinking?.takeIf { it.isNotEmpty() }?.let {
+                                    emitEvent(AiStreamEvent.Reasoning(it))
+                                }
+                                delta.partial_json?.takeIf { it.isNotEmpty() }?.let {
+                                    val block = event.index?.let { index -> toolBlocks[index] }
+                                    emitEvent(
+                                        AiStreamEvent.ToolCallDelta(
+                                            id = block?.id ?: event.index?.let { index -> "anthropic_tool_$index" },
+                                            index = event.index,
+                                            name = block?.name,
+                                            argumentsDelta = it,
+                                            rawType = "input_json_delta"
+                                        )
+                                    )
+                                }
+                            }
+                            "thinking_delta" -> {
+                                delta.thinking?.takeIf { it.isNotEmpty() }?.let {
+                                    emitEvent(AiStreamEvent.Reasoning(it))
+                                }
+                            }
+                            "input_json_delta" -> {
+                                delta.partial_json?.takeIf { it.isNotEmpty() }?.let {
+                                    val block = event.index?.let { index -> toolBlocks[index] }
+                                    emitEvent(
+                                        AiStreamEvent.ToolCallDelta(
+                                            id = block?.id ?: event.index?.let { index -> "anthropic_tool_$index" },
+                                            index = event.index,
+                                            name = block?.name,
+                                            argumentsDelta = it,
+                                            rawType = "input_json_delta"
+                                        )
+                                    )
+                                }
+                            }
+                        }
+                    }
+                    "content_block_stop" -> {
+                        event.index?.let { toolBlocks.remove(it) }
+                    }
+                }
+            }
+        } finally {
+            response.close()
+        }
+    }
+
+    private suspend fun fetchModelsInternal(provider: AiProviderConfig): List<AiAvailableModel> {
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
+            "Anthropic configuration incomplete: baseUrl and apiKey are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
+            ?: provider.modelsUrl
+            ?: (provider.baseUrl + "/v1/models")
+        return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(modelsUrl)
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "x-api-key" to keyRotator.currentKey,
+                        "anthropic-version" to "2023-06-01",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val json = GSON.fromJson(response.body, AnthropicModelsResponse::class.java)
+            json?.data.toAvailableModels()
+        }
+    }
+}
+
+// ---- Message & tool format converters ----
+
+internal fun List<AiMessage>.toAnthropicMessages(): List<Map<String, Any?>> {
+    return map { message ->
+        when {
+            message.role == AiMessageRole.TOOL -> mapOf(
+                "role" to "user",
+                "content" to listOf(
+                    mapOf(
+                        "type" to "tool_result",
+                        "tool_use_id" to message.toolCallId,
+                        "content" to message.content
+                    )
+                )
+            )
+            message.toolCalls.isNotEmpty() -> {
+                val content = buildList {
+                    if (message.content.isNotBlank()) {
+                        add(mapOf("type" to "text", "text" to message.content))
+                    }
+                    message.toolCalls.forEach {
+                        add(
+                            mapOf(
+                                "type" to "tool_use",
+                                "id" to it.id,
+                                "name" to it.name,
+                                "input" to (it.arguments.toJsonObject() ?: JsonObject())
+                            )
+                        )
+                    }
+                }
+                mapOf("role" to "assistant", "content" to content)
+            }
+            else -> mapOf(
+                "role" to if (message.role == AiMessageRole.ASSISTANT) "assistant" else "user",
+                "content" to message.content
+            )
+        }
+    }
+}
+
+internal fun List<AiToolDefinition>.toAnthropicTools(): List<Map<String, Any?>> {
+    return map {
+        mapOf(
+            "name" to it.name,
+            "description" to it.description,
+            "input_schema" to it.inputSchema
+        )
+    }
+}
+
+private fun List<AnthropicModelItem>?.toAvailableModels(): List<AiAvailableModel> {
+    return orEmpty()
+        .mapNotNull { item ->
+            val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            AiAvailableModel(
+                id = id,
+                name = item.display_name?.takeIf { it.isNotBlank() }
+                    ?: item.displayName?.takeIf { it.isNotBlank() }
+                    ?: item.name?.takeIf { it.isNotBlank() }
+                    ?: id,
+                contextWindow = item.context_window ?: item.contextWindow ?: 0,
+                maxOutputTokens = item.max_tokens
+                    ?: item.maxTokens
+                    ?: item.max_output_tokens
+                    ?: item.maxOutputTokens
+                    ?: 0
+            )
+        }
+        .distinctBy { it.id }
+        .sortedBy { it.name.lowercase() }
+}
+
+// ---- Data classes ----
+
+@Keep
+internal data class AnthropicMessageResponse(
+    val content: List<AnthropicMessageContent>?
+)
+
+@Keep
+internal data class AnthropicMessageContent(
+    val type: String?,
+    val text: String?
+)
+
+@Keep
+internal data class AnthropicModelsResponse(
+    val data: List<AnthropicModelItem>?
+)
+
+@Keep
+internal data class AnthropicModelItem(
+    val id: String?,
+    val name: String?,
+    val display_name: String?,
+    val displayName: String?,
+    val context_window: Int?,
+    val contextWindow: Int?,
+    val max_tokens: Int?,
+    val maxTokens: Int?,
+    val max_output_tokens: Int?,
+    val maxOutputTokens: Int?
+)
+
+@Keep
+internal data class AnthropicStreamEvent(
+    val type: String?,
+    val index: Int?,
+    val content_block: AnthropicContentBlock?,
+    val error: AnthropicStreamError?,
+    val delta: AnthropicStreamDelta?
+)
+
+@Keep
+internal data class AnthropicStreamDelta(
+    val type: String?,
+    val text: String?,
+    val thinking: String?,
+    val partial_json: String?,
+    val signature: String?
+)
+
+@Keep
+internal data class AnthropicContentBlock(
+    val type: String?,
+    val id: String?,
+    val name: String?,
+    val text: String?
+)
+
+@Keep
+internal data class AnthropicStreamError(
+    val type: String?,
+    val message: String?
+)
+
+internal data class AnthropicToolBlock(
+    val id: String,
+    val name: String
+)

+ 329 - 0
app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt

@@ -0,0 +1,329 @@
+package io.legado.app.data.repository.ai
+
+import androidx.annotation.Keep
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiCapability
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.AiToolDefinition
+import io.legado.app.help.http.addHeaders
+import io.legado.app.help.http.await
+import io.legado.app.help.http.newCallResponse
+import io.legado.app.help.http.newCallStrResponse
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.postJson
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.Response
+
+class OpenAiChatHandler : AiProtocolHandler {
+
+    override val protocols = setOf(AiProtocol.OPENAI_CHAT_COMPLETIONS)
+
+    override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
+        withContext(Dispatchers.IO) {
+            runCatching { generateInternal(request) }
+        }
+
+    override suspend fun stream(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        streamInternal(request, emitEvent)
+    }
+
+    override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
+        withContext(Dispatchers.IO) {
+            runCatching { fetchModelsInternal(provider) }
+        }
+
+    private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "OpenAI-compatible configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val params = request.params
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "messages" to request.messages.toOpenAiChatMessages()
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiChatTools() }
+        params.temperature?.let { body["temperature"] = it }
+        params.maxOutputTokens?.let { body["max_tokens"] = it }
+        params.topP?.let { body["top_p"] = it }
+        if (hasReasoningCapability(request.model.capabilities) && params.reasoningLevel != AiReasoningLevel.AUTO) {
+            body["reasoning_effort"] = params.reasoningLevel.toOpenAiEffort()
+        }
+
+        return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(provider.baseUrl + provider.chatPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val json = GSON.fromJson(response.body, OpenAiChatResponse::class.java)
+            val text = json?.choices?.firstOrNull()?.message?.content
+            if (text.isNullOrBlank()) {
+                throw Exception("Empty AI response")
+            } else {
+                AiGenerateResponse(text = text, rawBody = response.body)
+            }
+        }
+    }
+
+    private suspend fun streamInternal(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "OpenAI-compatible configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val params = request.params
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "messages" to request.messages.toOpenAiChatMessages(),
+            "stream" to true
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiChatTools() }
+        params.temperature?.let { body["temperature"] = it }
+        params.maxOutputTokens?.let { body["max_tokens"] = it }
+        params.topP?.let { body["top_p"] = it }
+        if (hasReasoningCapability(request.model.capabilities) && params.reasoningLevel != AiReasoningLevel.AUTO) {
+            body["reasoning_effort"] = params.reasoningLevel.toOpenAiEffort()
+        }
+
+        // For streaming, we retry before establishing the SSE connection.
+        // Once streaming starts, errors are not retried (partial output would be confusing).
+        val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            okHttpClient.newCallResponse {
+                url(provider.baseUrl + provider.chatPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }.also {
+                if (!it.isSuccessful) {
+                    throw Exception("HTTP ${it.code}: ${it.message}")
+                }
+            }
+        }
+        try {
+            response.readSseData { data ->
+                val root = data.toJsonObject()
+                root?.extractApiErrorMessage()?.let { throw Exception(it) }
+                runCatching {
+                    val chunk = GSON.fromJson(data, OpenAiChatStreamChunk::class.java)
+                    val delta = chunk?.choices?.firstOrNull()?.delta
+                    val reasoning = delta?.reasoning_content ?: delta?.reasoning
+                    if (!reasoning.isNullOrEmpty()) {
+                        emitEvent(AiStreamEvent.Reasoning(reasoning))
+                    }
+                    val content = delta?.content
+                    if (!content.isNullOrEmpty()) {
+                        emitEvent(AiStreamEvent.Content(content))
+                    }
+                    delta?.tool_calls?.forEach { toolCall ->
+                        emitEvent(
+                            AiStreamEvent.ToolCallDelta(
+                                id = toolCall.id,
+                                index = toolCall.index,
+                                name = toolCall.function?.name,
+                                argumentsDelta = toolCall.function?.arguments,
+                                rawType = toolCall.type ?: "tool_call"
+                            )
+                        )
+                    }
+                }.getOrElse {
+                    throw Exception("Invalid OpenAI chat stream chunk", it)
+                }
+            }
+        } finally {
+            response.close()
+        }
+    }
+
+    private suspend fun fetchModelsInternal(provider: AiProviderConfig): List<AiAvailableModel> {
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
+            "AI provider configuration incomplete: baseUrl and apiKey are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
+            ?: provider.modelsUrl
+            ?: (provider.baseUrl + "/models")
+        return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(modelsUrl)
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val json = GSON.fromJson(response.body, OpenAiModelsResponse::class.java)
+            json?.data.toAvailableModels()
+        }
+    }
+}
+
+// ---- Message & tool format converters ----
+
+internal fun List<AiMessage>.toOpenAiChatMessages(): List<Map<String, Any?>> {
+    return mapNotNull { message ->
+        when {
+            message.role == AiMessageRole.TOOL -> mapOf(
+                "role" to "tool",
+                "tool_call_id" to message.toolCallId,
+                "content" to message.content
+            )
+            message.toolCalls.isNotEmpty() -> {
+                buildMap {
+                    put("role", "assistant")
+                    put("content", message.content.takeIf { it.isNotBlank() })
+                    put(
+                        "tool_calls",
+                        message.toolCalls.map {
+                            mapOf(
+                                "id" to it.id,
+                                "type" to "function",
+                                "function" to mapOf(
+                                    "name" to it.name,
+                                    "arguments" to it.arguments
+                                )
+                            )
+                        }
+                    )
+                }
+            }
+            else -> mapOf("role" to message.role, "content" to message.content)
+        }
+    }
+}
+
+internal fun List<AiToolDefinition>.toOpenAiChatTools(): List<Map<String, Any?>> {
+    return map {
+        mapOf(
+            "type" to "function",
+            "function" to mapOf(
+                "name" to it.name,
+                "description" to it.description,
+                "parameters" to it.inputSchema
+            )
+        )
+    }
+}
+
+private fun List<OpenAiModelItem>?.toAvailableModels(): List<AiAvailableModel> {
+    return orEmpty()
+        .mapNotNull { item ->
+            val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            AiAvailableModel(
+                id = id,
+                name = item.display_name?.takeIf { it.isNotBlank() }
+                    ?: item.displayName?.takeIf { it.isNotBlank() }
+                    ?: item.name?.takeIf { it.isNotBlank() }
+                    ?: id,
+                contextWindow = item.context_window ?: item.contextWindow ?: 0,
+                maxOutputTokens = item.max_tokens
+                    ?: item.maxTokens
+                    ?: item.max_output_tokens
+                    ?: item.maxOutputTokens
+                    ?: 0
+            )
+        }
+        .distinctBy { it.id }
+        .sortedBy { it.name.lowercase() }
+}
+
+// ---- Data classes ----
+
+@Keep
+internal data class OpenAiChatResponse(
+    val choices: List<OpenAiChatChoice>?
+)
+
+@Keep
+internal data class OpenAiChatChoice(
+    val message: OpenAiChatMessage?
+)
+
+@Keep
+internal data class OpenAiChatMessage(
+    val content: String?
+)
+
+@Keep
+internal data class OpenAiModelsResponse(
+    val data: List<OpenAiModelItem>?
+)
+
+@Keep
+internal data class OpenAiModelItem(
+    val id: String?,
+    val name: String?,
+    val display_name: String?,
+    val displayName: String?,
+    val context_window: Int?,
+    val contextWindow: Int?,
+    val max_tokens: Int?,
+    val maxTokens: Int?,
+    val max_output_tokens: Int?,
+    val maxOutputTokens: Int?
+)
+
+@Keep
+internal data class OpenAiChatStreamChunk(
+    val choices: List<OpenAiChatStreamChoice>?
+)
+
+@Keep
+internal data class OpenAiChatStreamChoice(
+    val delta: OpenAiChatStreamDelta?
+)
+
+@Keep
+internal data class OpenAiChatStreamDelta(
+    val content: String?,
+    val reasoning_content: String?,
+    val reasoning: String?,
+    val tool_calls: List<OpenAiChatToolCall>?
+)
+
+@Keep
+internal data class OpenAiChatToolCall(
+    val index: Int?,
+    val id: String?,
+    val type: String?,
+    val function: OpenAiChatToolCallFunction?
+)
+
+@Keep
+internal data class OpenAiChatToolCallFunction(
+    val name: String?,
+    val arguments: String?
+)

+ 351 - 0
app/src/main/java/io/legado/app/data/repository/ai/OpenAiResponsesHandler.kt

@@ -0,0 +1,351 @@
+package io.legado.app.data.repository.ai
+
+import com.google.gson.JsonObject
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiCapability
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.AiToolDefinition
+import io.legado.app.help.http.addHeaders
+import io.legado.app.help.http.await
+import io.legado.app.help.http.newCallResponse
+import io.legado.app.help.http.newCallStrResponse
+import io.legado.app.help.http.okHttpClient
+import io.legado.app.help.http.postJson
+import io.legado.app.utils.GSON
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class OpenAiResponsesHandler : AiProtocolHandler {
+
+    override val protocols = setOf(AiProtocol.OPENAI_RESPONSES)
+
+    override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
+        withContext(Dispatchers.IO) {
+            runCatching { generateInternal(request) }
+        }
+
+    override suspend fun stream(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        streamInternal(request, emitEvent)
+    }
+
+    override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
+        withContext(Dispatchers.IO) {
+            runCatching { fetchOpenAiCompatibleModels(provider) }
+        }
+
+    private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "OpenAI Responses configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val params = request.params
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "input" to request.messages.toOpenAiResponsesInput()
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiResponsesTools() }
+        params.temperature?.let { body["temperature"] = it }
+        params.maxOutputTokens?.let { body["max_output_tokens"] = it }
+        params.topP?.let { body["top_p"] = it }
+        if (hasReasoningCapability(request.model.capabilities)) {
+            body["reasoning"] = buildMap<String, Any> {
+                put("summary", "auto")
+                if (params.reasoningLevel != AiReasoningLevel.AUTO) {
+                    put("effort", params.reasoningLevel.effort)
+                }
+            }
+        }
+
+        return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(provider.baseUrl + provider.responsesPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val root = GSON.fromJson(response.body, JsonObject::class.java)
+            val text = root?.getString("output_text") ?: root.extractResponsesOutputText()
+            if (text.isNullOrBlank()) {
+                throw Exception("Empty AI response")
+            } else {
+                AiGenerateResponse(text = text, rawBody = response.body)
+            }
+        }
+    }
+
+    private suspend fun streamInternal(
+        request: AiGenerateRequest,
+        emitEvent: suspend (AiStreamEvent) -> Unit
+    ) {
+        val provider = request.model.provider
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
+            "OpenAI Responses configuration incomplete: baseUrl, apiKey, and model are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val params = request.params
+        val body = mutableMapOf<String, Any?>(
+            "model" to request.model.modelId,
+            "input" to request.messages.toOpenAiResponsesInput(),
+            "stream" to true
+        )
+        request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiResponsesTools() }
+        params.temperature?.let { body["temperature"] = it }
+        params.maxOutputTokens?.let { body["max_output_tokens"] = it }
+        params.topP?.let { body["top_p"] = it }
+        if (hasReasoningCapability(request.model.capabilities)) {
+            body["reasoning"] = buildMap<String, Any> {
+                put("summary", "auto")
+                if (params.reasoningLevel != AiReasoningLevel.AUTO) {
+                    put("effort", params.reasoningLevel.effort)
+                }
+            }
+        }
+
+        val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
+            okHttpClient.newCallResponse {
+                url(provider.baseUrl + provider.responsesPath)
+                postJson(GSON.toJson(body))
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }.also {
+                if (!it.isSuccessful) {
+                    throw Exception("HTTP ${it.code}: ${it.message}")
+                }
+            }
+        }
+        try {
+            response.readSseData { data ->
+                val root = data.toJsonObject() ?: throw Exception("Invalid OpenAI Responses stream chunk")
+                root.extractApiErrorMessage()?.let { throw Exception(it) }
+                when (root.getString("type")) {
+                    "response.output_text.delta",
+                    "response.refusal.delta" -> {
+                        root.getString("delta")?.takeIf { it.isNotEmpty() }?.let {
+                            emitEvent(AiStreamEvent.Content(it))
+                        }
+                    }
+                    "response.reasoning_summary_text.delta",
+                    "response.reasoning_text.delta" -> {
+                        root.getString("delta")?.takeIf { it.isNotEmpty() }?.let {
+                            emitEvent(AiStreamEvent.Reasoning(it))
+                        }
+                    }
+                    "response.output_item.added",
+                    "response.output_item.done" -> {
+                        root.get("item")?.asJsonObjectOrNull()?.let { item ->
+                            if (item.getString("type")?.contains("call") == true) {
+                                emitEvent(
+                                    AiStreamEvent.ToolCallDelta(
+                                        id = item.getString("call_id") ?: item.getString("id"),
+                                        index = root.getString("output_index")?.toIntOrNull(),
+                                        name = item.getString("name") ?: item.getString("server_label"),
+                                        argumentsDelta = item.getString("arguments"),
+                                        rawType = item.getString("type") ?: root.getString("type").orEmpty()
+                                    )
+                                )
+                            }
+                        }
+                    }
+                    "response.function_call_arguments.delta",
+                    "response.mcp_call_arguments.delta",
+                    "response.code_interpreter_call_code.delta",
+                    "response.custom_tool_call_input.delta" -> {
+                        emitEvent(
+                            AiStreamEvent.ToolCallDelta(
+                                id = root.getString("call_id"),
+                                index = root.getString("output_index")?.toIntOrNull(),
+                                name = null,
+                                argumentsDelta = root.getString("delta"),
+                                rawType = root.getString("type").orEmpty()
+                            )
+                        )
+                    }
+                    "response.function_call_arguments.done",
+                    "response.mcp_call_arguments.done",
+                    "response.code_interpreter_call_code.done",
+                    "response.custom_tool_call_input.done",
+                    "response.file_search_call.in_progress",
+                    "response.file_search_call.searching",
+                    "response.file_search_call.completed",
+                    "response.web_search_call.in_progress",
+                    "response.web_search_call.searching",
+                    "response.web_search_call.completed",
+                    "response.mcp_call.in_progress",
+                    "response.mcp_call.completed",
+                    "response.mcp_call.failed",
+                    "response.mcp_list_tools.in_progress",
+                    "response.mcp_list_tools.completed",
+                    "response.mcp_list_tools.failed",
+                    "response.code_interpreter_call.in_progress",
+                    "response.code_interpreter_call.interpreting",
+                    "response.code_interpreter_call.completed" -> {
+                        emitEvent(
+                            AiStreamEvent.ToolCallDelta(
+                                id = root.getString("call_id"),
+                                index = root.getString("output_index")?.toIntOrNull(),
+                                name = null,
+                                argumentsDelta = root.getString("arguments")
+                                    ?: root.getString("code")
+                                    ?: root.getString("input"),
+                                rawType = root.getString("type").orEmpty()
+                            )
+                        )
+                    }
+                    "response.failed" -> {
+                        throw Exception(root.extractResponseFailureMessage() ?: "OpenAI response failed")
+                    }
+                    "response.incomplete" -> {
+                        throw Exception(root.extractResponseIncompleteMessage() ?: "OpenAI response incomplete")
+                    }
+                }
+            }
+        } finally {
+            response.close()
+        }
+    }
+
+    private suspend fun fetchOpenAiCompatibleModels(provider: AiProviderConfig): List<AiAvailableModel> {
+        require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
+            "AI provider configuration incomplete: baseUrl and apiKey are required"
+        }
+        val keyRotator = KeyRotator(provider.apiKey)
+        val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
+            ?: provider.modelsUrl
+            ?: (provider.baseUrl + "/models")
+        return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
+            val response = okHttpClient.newCallStrResponse {
+                url(modelsUrl)
+                addHeaders(
+                    provider.headers + provider.customHeaders + mapOf(
+                        "Authorization" to "Bearer ${keyRotator.currentKey}",
+                        "Content-Type" to "application/json"
+                    )
+                )
+            }
+            if (!response.isSuccessful()) {
+                throw Exception("HTTP ${response.code()}: ${response.message()}")
+            }
+            val json = GSON.fromJson(response.body, OpenAiModelsResponse::class.java)
+            json?.data.toAvailableModels()
+        }
+    }
+}
+
+// ---- Message & tool format converters ----
+
+internal fun List<AiMessage>.toOpenAiResponsesInput(): List<Map<String, Any?>> {
+    return flatMap { message ->
+        when {
+            message.role == AiMessageRole.TOOL -> listOf(
+                mapOf(
+                    "type" to "function_call_output",
+                    "call_id" to message.toolCallId,
+                    "output" to message.content
+                )
+            )
+            message.toolCalls.isNotEmpty() -> {
+                val textMessage = message.content.takeIf { it.isNotBlank() }?.let {
+                    mapOf("role" to "assistant", "content" to it)
+                }
+                val toolCalls = message.toolCalls.map {
+                    mapOf(
+                        "type" to "function_call",
+                        "call_id" to it.id,
+                        "name" to it.name,
+                        "arguments" to it.arguments
+                    )
+                }
+                listOfNotNull(textMessage) + toolCalls
+            }
+            else -> listOf(mapOf("role" to message.role, "content" to message.content))
+        }
+    }
+}
+
+internal fun List<AiToolDefinition>.toOpenAiResponsesTools(): List<Map<String, Any?>> {
+    return map {
+        mapOf(
+            "type" to "function",
+            "name" to it.name,
+            "description" to it.description,
+            "parameters" to it.inputSchema
+        )
+    }
+}
+
+private fun JsonObject.extractResponseFailureMessage(): String? {
+    val response = get("response")?.asJsonObjectOrNull() ?: return null
+    val error = response.get("error")?.asJsonObjectOrNull()
+    return error?.getString("message") ?: response.getString("status")
+}
+
+private fun JsonObject.extractResponseIncompleteMessage(): String? {
+    val response = get("response")?.asJsonObjectOrNull() ?: return null
+    val reason = response.get("incomplete_details")
+        ?.asJsonObjectOrNull()
+        ?.getString("reason")
+    return reason?.let { "OpenAI response incomplete: $it" } ?: response.getString("status")
+}
+
+private fun JsonObject.extractResponsesOutputText(): String? {
+    return get("output")
+        ?.asJsonArrayOrNull()
+        ?.flatMap { output ->
+            output.asJsonObjectOrNull()
+                ?.get("content")
+                ?.asJsonArrayOrNull()
+                ?.mapNotNull { content ->
+                    content.asJsonObjectOrNull()
+                        ?.takeIf { it.getString("type") == "output_text" }
+                        ?.getString("text")
+                }
+                .orEmpty()
+        }
+        ?.joinToString("")
+        ?.takeIf { it.isNotBlank() }
+}
+
+private fun List<OpenAiModelItem>?.toAvailableModels(): List<AiAvailableModel> {
+    return orEmpty()
+        .mapNotNull { item ->
+            val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            AiAvailableModel(
+                id = id,
+                name = item.display_name?.takeIf { it.isNotBlank() }
+                    ?: item.displayName?.takeIf { it.isNotBlank() }
+                    ?: item.name?.takeIf { it.isNotBlank() }
+                    ?: id,
+                contextWindow = item.context_window ?: item.contextWindow ?: 0,
+                maxOutputTokens = item.max_tokens
+                    ?: item.maxTokens
+                    ?: item.max_output_tokens
+                    ?: item.maxOutputTokens
+                    ?: 0
+            )
+        }
+        .distinctBy { it.id }
+        .sortedBy { it.name.lowercase() }
+}

+ 5 - 1
app/src/main/java/io/legado/app/di/appDatabaseModule.kt

@@ -16,6 +16,10 @@ val appDatabaseModule = module {
 
     // 注册所有的 DAO 接口,通过 AppDatabase 实例获取
     factory<BookDao> { get<AppDatabase>().bookDao }
+    factory<AiProfileDao> { get<AppDatabase>().aiProfileDao }
+    factory<AiArtifactDao> { get<AppDatabase>().aiArtifactDao }
+    factory<AiChatDao> { get<AppDatabase>().aiChatDao }
+    factory<AiMemoryDao> { get<AppDatabase>().aiMemoryDao }
     factory<BookGroupDao> { get<AppDatabase>().bookGroupDao }
     factory<BookSourceDao> { get<AppDatabase>().bookSourceDao }
     factory<BookChapterDao> { get<AppDatabase>().bookChapterDao }
@@ -39,4 +43,4 @@ val appDatabaseModule = module {
     factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
     factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
     factory<HighlightRuleDao> { get<AppDatabase>().highlightRuleDao }
-}
+}

+ 43 - 3
app/src/main/java/io/legado/app/di/appModule.kt

@@ -8,6 +8,12 @@ import coil.decode.SvgDecoder
 import io.legado.app.data.AppDatabase
 import io.legado.app.data.local.preferences.LocalPreferencesRepository
 import io.legado.app.data.repository.AppStartupRepository
+import io.legado.app.data.repository.AiArtifactRepository
+import io.legado.app.data.repository.AiChatRepository
+import io.legado.app.data.repository.AiMemoryRepository
+import io.legado.app.data.repository.AiProfileRepository
+import io.legado.app.data.repository.AiTextRepositoryImpl
+import io.legado.app.data.repository.AiToolRepository
 import io.legado.app.data.repository.BookCacheCleanupRepository
 import io.legado.app.data.repository.BookDomainRepositoryImpl
 import io.legado.app.data.repository.BookGroupRepository
@@ -24,7 +30,6 @@ import io.legado.app.data.repository.ExploreRepository
 import io.legado.app.data.repository.ExploreRepositoryImpl
 import io.legado.app.data.repository.HighlightRuleRepository
 import io.legado.app.data.repository.HomepageModulesRepository
-import io.legado.app.data.repository.LlmTranslateRepositoryImpl
 import io.legado.app.data.repository.LocalBookRepository
 import io.legado.app.data.repository.ReadAloudSettingsRepository
 import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -42,6 +47,12 @@ import io.legado.app.data.repository.UploadRepository
 import io.legado.app.data.repository.WebDavBackupRepository
 import io.legado.app.data.repository.WebDavReadingProgressRepository
 import io.legado.app.domain.gateway.AppStartupGateway
+import io.legado.app.domain.gateway.AiArtifactGateway
+import io.legado.app.domain.gateway.AiChatGateway
+import io.legado.app.domain.gateway.AiMemoryGateway
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.AiToolGateway
 import io.legado.app.domain.gateway.BookCacheCleanupGateway
 import io.legado.app.domain.gateway.BookCacheDownloadGateway
 import io.legado.app.domain.gateway.BookSearchGateway
@@ -50,7 +61,6 @@ import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
 import io.legado.app.domain.gateway.DictionaryGateway
 import io.legado.app.domain.gateway.ExploreBooksGateway
 import io.legado.app.domain.gateway.HomepageModulesGateway
-import io.legado.app.domain.gateway.LlmGateway
 import io.legado.app.domain.gateway.LocalBookGateway
 import io.legado.app.domain.gateway.ReadingProgressGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
@@ -70,6 +80,8 @@ import io.legado.app.domain.usecase.ExploreKindUiUseCase
 import io.legado.app.domain.usecase.ExportBookshelfUseCase
 import io.legado.app.domain.usecase.GetChapterContentUseCase
 import io.legado.app.domain.usecase.GetReadingProgressUseCase
+import io.legado.app.domain.usecase.AiChatGenerationUseCase
+import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
 import io.legado.app.domain.usecase.ImportBookshelfUseCase
 import io.legado.app.domain.usecase.RefreshTocUseCase
 import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
@@ -110,6 +122,10 @@ import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.toc.TocViewModel
 import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
 import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
+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.ai.chat.AiChatViewModel
 import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
 import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
 import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
@@ -188,6 +204,12 @@ val appModule = module {
 
     single<UploadRepository> { DirectLinkUploadRepository() }
     single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
+    single<AiProfileGateway> { AiProfileRepository(get()) }
+    single<AiArtifactGateway> { AiArtifactRepository(get()) }
+    single<AiChatGateway> { AiChatRepository(get()) }
+    single<AiMemoryGateway> { AiMemoryRepository(get()) }
+    single<AiTextGateway> { AiTextRepositoryImpl() }
+    single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
     single<AppStartupGateway> { AppStartupRepository(get()) }
     single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
     single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
@@ -210,9 +232,10 @@ val appModule = module {
     singleOf(::SearchBooksUseCase)
     singleOf(::ChangeSourceSearchUseCase)
     singleOf(::GetChapterContentUseCase)
-    single<LlmGateway> { LlmTranslateRepositoryImpl() }
+    singleOf(::GenerateChapterSummaryUseCase)
     single<DictionaryGateway> { DictionaryRepositoryImpl() }
     singleOf(::TranslateChapterUseCase)
+    singleOf(::AiChatGenerationUseCase)
 
     single<ImageLoader> {
         ImageLoader.Builder(get())
@@ -256,6 +279,23 @@ val appModule = module {
     viewModelOf(::DownloadCacheConfigViewModel)
     viewModelOf(::ThemeConfigViewModel)
     viewModelOf(::BackupConfigViewModel)
+    viewModelOf(::AiConfigViewModel)
+    viewModelOf(::AiChatViewModel)
+    viewModel { (providerId: String?) ->
+        AiProviderEditViewModel(
+            initialProviderId = providerId,
+            aiProfileGateway = get(),
+            aiTextGateway = get()
+        )
+    }
+    viewModel { (providerId: String?, modelProfileId: String?) ->
+        AiModelEditViewModel(
+            initialProviderId = providerId,
+            initialModelProfileId = modelProfileId,
+            aiProfileGateway = get(),
+            aiTextGateway = get()
+        )
+    }
     viewModelOf(::TocViewModel)
     viewModelOf(::ImportBookViewModel)
     viewModelOf(::RemoteBookViewModel)

+ 17 - 0
app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt

@@ -0,0 +1,17 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.AiArtifact
+import kotlinx.coroutines.flow.Flow
+
+interface AiArtifactGateway {
+    fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>>
+    suspend fun getCachedArtifact(
+        bookUrl: String,
+        chapterIndex: Int?,
+        taskType: String,
+        contentHash: String,
+        promptHash: String,
+        modelProfileId: String
+    ): AiArtifact?
+    suspend fun upsertArtifact(artifact: AiArtifact)
+}

+ 33 - 0
app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt

@@ -0,0 +1,33 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.AiChatConversation
+import io.legado.app.data.entities.AiChatMessage
+import io.legado.app.domain.model.AiMessagePart
+import kotlinx.coroutines.flow.Flow
+
+interface AiChatGateway {
+    fun observeConversations(): Flow<List<AiChatConversation>>
+    fun observeMessages(conversationId: String): Flow<List<AiChatMessage>>
+    fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>>
+    suspend fun getConversation(id: String): AiChatConversation?
+    suspend fun createConversation(title: String = "New Chat"): AiChatConversation
+    suspend fun saveMessage(
+        conversationId: String,
+        role: String,
+        parts: List<AiMessagePart>,
+        parentMessageId: String? = null,
+        thinkingDuration: Int = 0
+    ): AiChatMessage
+    suspend fun saveRegeneratedMessage(
+        conversationId: String,
+        role: String,
+        parts: List<AiMessagePart>,
+        parentMessageId: String,
+        thinkingDuration: Int = 0
+    ): AiChatMessage
+    suspend fun selectBranch(messageId: String)
+    suspend fun getBranches(parentMessageId: String): List<AiChatMessage>
+    suspend fun getBranchCounts(conversationId: String): Map<String, Int>
+    suspend fun updateConversationTitle(conversationId: String, title: String)
+    suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String)
+}

+ 15 - 0
app/src/main/java/io/legado/app/domain/gateway/AiMemoryGateway.kt

@@ -0,0 +1,15 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.AiMemory
+import kotlinx.coroutines.flow.Flow
+
+interface AiMemoryGateway {
+    fun observeByConversation(conversationId: String): Flow<List<AiMemory>>
+    fun observeGlobal(): Flow<List<AiMemory>>
+    suspend fun getByConversation(conversationId: String): List<AiMemory>
+    suspend fun getGlobal(): List<AiMemory>
+    suspend fun getForPrompt(conversationId: String): List<AiMemory>
+    suspend fun upsert(memory: AiMemory)
+    suspend fun delete(conversationId: String, key: String)
+    suspend fun deleteAllForConversation(conversationId: String)
+}

+ 26 - 0
app/src/main/java/io/legado/app/domain/gateway/AiProfileGateway.kt

@@ -0,0 +1,26 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.data.entities.AiModelProfile
+import io.legado.app.data.entities.AiProviderProfile
+import io.legado.app.data.entities.AiTaskPreset
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiModelDraft
+import io.legado.app.domain.model.AiProfileDraft
+import io.legado.app.domain.model.AiProviderDraft
+import io.legado.app.domain.model.AiTaskPresetConfig
+import kotlinx.coroutines.flow.Flow
+
+interface AiProfileGateway {
+    fun observeProviders(): Flow<List<AiProviderProfile>>
+    fun observeModels(): Flow<List<AiModelProfile>>
+    fun observePresets(): Flow<List<AiTaskPreset>>
+    suspend fun getProvider(id: String): AiProviderProfile?
+    suspend fun getModel(id: String): AiModelProfile?
+    suspend fun getTaskPreset(taskType: String): AiTaskPresetConfig?
+    suspend fun getProviderApiKey(providerId: String): String
+    suspend fun saveProvider(draft: AiProviderDraft): AiProviderProfile
+    suspend fun saveModel(draft: AiModelDraft): AiModelProfile
+    suspend fun importProviderModels(providerId: String, models: List<AiAvailableModel>): List<AiModelProfile>
+    suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig
+    suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig
+}

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

@@ -0,0 +1,25 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerateResponse
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiProviderConfig
+import kotlinx.coroutines.flow.Flow
+
+sealed interface AiStreamEvent {
+    data class Content(val text: String) : AiStreamEvent
+    data class Reasoning(val text: String) : AiStreamEvent
+    data class ToolCallDelta(
+        val id: String?,
+        val index: Int?,
+        val name: String?,
+        val argumentsDelta: String?,
+        val rawType: String
+    ) : AiStreamEvent
+}
+
+interface AiTextGateway {
+    suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse>
+    fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent>
+    suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>>
+}

+ 11 - 0
app/src/main/java/io/legado/app/domain/gateway/AiToolGateway.kt

@@ -0,0 +1,11 @@
+package io.legado.app.domain.gateway
+
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.AiToolDefinition
+import io.legado.app.domain.model.AiToolResult
+
+interface AiToolGateway {
+    fun availableTools(): List<AiToolDefinition>
+    fun requiresConfirmation(toolName: String): Boolean
+    suspend fun execute(call: AiToolCall): AiToolResult
+}

+ 0 - 21
app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt

@@ -1,21 +0,0 @@
-package io.legado.app.domain.gateway
-
-import io.legado.app.domain.model.DictPair
-import io.legado.app.domain.model.RetryReason
-import io.legado.app.domain.model.TranslationConstants
-
-interface LlmGateway {
-    suspend fun translate(
-        text: String,
-        targetLanguage: String,
-        provider: String,
-        baseUrl: String,
-        apiKey: String,
-        model: String,
-        prompt: String,
-        temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
-        dictionaries: List<DictPair> = emptyList(),
-        onUpdate: ((List<DictPair>) -> Unit)? = null,
-        retryReason: RetryReason? = null
-    ): Result<String>
-}

+ 177 - 0
app/src/main/java/io/legado/app/domain/model/AiMessageParts.kt

@@ -0,0 +1,177 @@
+package io.legado.app.domain.model
+
+import androidx.annotation.Keep
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+@Keep
+@Serializable
+sealed interface AiMessagePart {
+
+    @Keep
+    @Serializable
+    @SerialName("text")
+    data class Text(val text: String) : AiMessagePart
+
+    @Keep
+    @Serializable
+    @SerialName("reasoning")
+    data class Reasoning(val text: String) : AiMessagePart
+
+    /**
+     * Unified tool part — represents the full lifecycle of a tool call.
+     * When the model requests a tool, [input] is filled and [output] is empty.
+     * After execution, [output] is filled and [approvalState] reflects the outcome.
+     */
+    @Keep
+    @Serializable
+    @SerialName("tool")
+    data class Tool(
+        val toolCallId: String,
+        val toolName: String,
+        val input: String,
+        val output: String = "",
+        val approvalState: AiToolApprovalState = AiToolApprovalState.AUTO,
+        val rawType: String = "tool_call",
+        val metadata: String? = null
+    ) : AiMessagePart
+
+    @Keep
+    @Serializable
+    @SerialName("book_result")
+    data class BookResult(
+        val bookUrl: String,
+        val name: String,
+        val author: String,
+        val origin: String? = null,
+        val coverPath: String? = null,
+        val latestChapterTitle: String? = null,
+        val currentChapterTitle: String? = null,
+        val intro: String? = null
+    ) : AiMessagePart
+
+    // ---- Legacy parts (deprecated, kept for backward-compatible deserialization) ----
+
+    @Deprecated("Use Tool instead — tool call and result are now a single part")
+    @Keep
+    @Serializable
+    @SerialName("tool_call")
+    data class ToolCall(
+        val id: String,
+        val name: String,
+        val arguments: String,
+        val rawType: String = "tool_call",
+        val approvalState: AiToolApprovalState = AiToolApprovalState.AUTO
+    ) : AiMessagePart
+
+    @Deprecated("Use Tool instead — tool call and result are now a single part")
+    @Keep
+    @Serializable
+    @SerialName("tool_result")
+    data class ToolResult(
+        val callId: String,
+        val name: String,
+        val content: String
+    ) : AiMessagePart
+}
+
+@Keep
+@Serializable
+enum class AiToolApprovalState {
+    @SerialName("auto")
+    AUTO,
+
+    @SerialName("pending")
+    PENDING,
+
+    @SerialName("approved")
+    APPROVED,
+
+    @SerialName("denied")
+    DENIED,
+
+    @SerialName("answered")
+    ANSWERED;
+
+    fun canResumeExecution(): Boolean =
+        this == APPROVED || this == DENIED || this == ANSWERED
+}
+
+object AiMessagePartJson {
+    private val json = Json {
+        ignoreUnknownKeys = true
+        explicitNulls = false
+        encodeDefaults = true
+        classDiscriminator = "type"
+    }
+
+    fun encode(parts: List<AiMessagePart>): String {
+        return json.encodeToString(parts)
+    }
+
+    fun decode(partsJson: String): List<AiMessagePart> {
+        if (partsJson.isBlank()) return emptyList()
+        return runCatching { json.decodeFromString<List<AiMessagePart>>(partsJson) }
+            .getOrElse { emptyList() }
+            .map { it.migrateLegacy() }
+    }
+
+    /**
+     * Migrate legacy ToolCall/ToolResult pairs into unified Tool parts.
+     * Handles old messages stored before the unified Tool part was introduced.
+     */
+    private fun AiMessagePart.migrateLegacy(): AiMessagePart {
+        return when (this) {
+            is AiMessagePart.ToolCall -> AiMessagePart.Tool(
+                toolCallId = id,
+                toolName = name,
+                input = arguments,
+                rawType = rawType,
+                approvalState = approvalState
+            )
+            is AiMessagePart.ToolResult -> AiMessagePart.Tool(
+                toolCallId = callId,
+                toolName = name,
+                input = "",
+                output = content,
+                approvalState = AiToolApprovalState.AUTO
+            )
+            else -> this
+        }
+    }
+}
+
+// ---- Extension functions ----
+
+fun List<AiMessagePart>.textContent(): String {
+    return filterIsInstance<AiMessagePart.Text>()
+        .joinToString("\n\n") { it.text }
+        .trim()
+}
+
+fun List<AiMessagePart>.reasoningContent(): String? {
+    return filterIsInstance<AiMessagePart.Reasoning>()
+        .joinToString("\n\n") { it.text }
+        .trim()
+        .takeIf { it.isNotBlank() }
+}
+
+/** Returns all unified Tool parts (includes migrated legacy parts). */
+fun List<AiMessagePart>.toolParts(): List<AiMessagePart.Tool> {
+    return filterIsInstance<AiMessagePart.Tool>()
+}
+
+/** Returns tool calls that are pending approval (output is empty, state is PENDING). */
+fun List<AiMessagePart>.pendingToolCalls(): List<AiMessagePart.Tool> {
+    return toolParts().filter {
+        it.output.isBlank() && it.approvalState == AiToolApprovalState.PENDING
+    }
+}
+
+/** Returns tool calls that need execution (output is empty, approval allows execution). */
+fun List<AiMessagePart>.executableToolCalls(): List<AiMessagePart.Tool> {
+    return toolParts().filter {
+        it.output.isBlank() && it.approvalState.canResumeExecution()
+    }
+}

+ 113 - 0
app/src/main/java/io/legado/app/domain/model/AiModelRegistry.kt

@@ -0,0 +1,113 @@
+package io.legado.app.domain.model
+
+/**
+ * Lightweight model capability registry inspired by RikkaHub's ModelRegistry.
+ *
+ * Infers model capabilities (tools, reasoning, vision, streaming) from the model ID
+ * using token-based pattern matching. Used during model import to auto-populate
+ * the capabilities field, and during request building to decide which parameters to send.
+ */
+object AiModelRegistry {
+
+    private data class ModelPattern(
+        val tokens: List<String>,
+        val notTokens: List<String> = emptyList(),
+        val exactIds: Set<String> = emptySet(),
+        val capabilities: Set<String>
+    )
+
+    private val patterns = listOf(
+        // OpenAI GPT-4o / GPT-4.1 / GPT-5
+        ModelPattern(
+            tokens = listOf("gpt"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.VISION, AiCapability.STREAMING)
+        ),
+        // OpenAI o-series (reasoning models)
+        ModelPattern(
+            tokens = listOf("o1"),
+            notTokens = listOf("mini"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
+        ),
+        ModelPattern(
+            tokens = listOf("o3"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
+        ),
+        ModelPattern(
+            tokens = listOf("o4"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
+        ),
+        // Claude
+        ModelPattern(
+            tokens = listOf("claude"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.VISION, AiCapability.STREAMING)
+        ),
+        // DeepSeek Reasoner
+        ModelPattern(
+            tokens = listOf("deepseek", "reasoner"),
+            capabilities = setOf(AiCapability.REASONING, AiCapability.STREAMING)
+        ),
+        // DeepSeek Chat
+        ModelPattern(
+            tokens = listOf("deepseek"),
+            notTokens = listOf("reasoner"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+        // Gemini
+        ModelPattern(
+            tokens = listOf("gemini"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.VISION, AiCapability.STREAMING)
+        ),
+        // Xiaomi MiMo
+        ModelPattern(
+            tokens = listOf("mimo"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
+        ),
+        // Qwen
+        ModelPattern(
+            tokens = listOf("qwen"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+        // Grok
+        ModelPattern(
+            tokens = listOf("grok"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.VISION, AiCapability.STREAMING)
+        ),
+        // Doubao / Kimi / Step / GLM / MiniMax — assume tools + streaming
+        ModelPattern(
+            tokens = listOf("doubao"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+        ModelPattern(
+            tokens = listOf("kimi"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+        ModelPattern(
+            tokens = listOf("step"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+        ModelPattern(
+            tokens = listOf("glm"),
+            capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
+        ),
+    )
+
+    /**
+     * Infer capabilities from a model ID using token-based matching.
+     * Returns the capabilities of the highest-scoring pattern match.
+     * Falls back to STREAMING only if nothing matches.
+     */
+    fun inferCapabilities(modelId: String): Set<String> {
+        val lower = modelId.lowercase()
+        val scored = patterns.mapNotNull { pattern ->
+            if (pattern.exactIds.isNotEmpty() && modelId in pattern.exactIds) {
+                return pattern.capabilities
+            }
+            if (pattern.notTokens.any { it in lower }) return@mapNotNull null
+            if (pattern.tokens.all { it in lower }) {
+                pattern to pattern.tokens.size
+            } else null
+        }
+        return scored.maxByOrNull { it.second }?.first?.capabilities
+            ?: setOf(AiCapability.STREAMING)
+    }
+}

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

@@ -0,0 +1,307 @@
+package io.legado.app.domain.model
+
+import androidx.annotation.Keep
+
+object AiCapability {
+    const val TOOLS = "tools"
+    const val REASONING = "reasoning"
+    const val VISION = "vision"
+    const val STREAMING = "streaming"
+}
+
+object AiProtocol {
+    const val OPENAI_CHAT_COMPLETIONS = "openai_chat_completions"
+    const val OPENAI_RESPONSES = "openai_responses"
+    const val ANTHROPIC_MESSAGES = "anthropic_messages"
+    const val GOOGLE_TRANSLATE = "google_translate"
+}
+
+object AiTaskType {
+    const val CHAT = "chat"
+    const val TRANSLATE_CHAPTER = "translate_chapter"
+    const val SUMMARIZE_CHAPTER = "summarize_chapter"
+    const val SUMMARIZE_BOOK = "summarize_book"
+    const val EXPLAIN_SELECTION = "explain_selection"
+}
+
+object AiPromptTemplate {
+    const val DEFAULT_CHAPTER_SUMMARY =
+        "Summarize the following fiction chapter in the reader's language. Keep it concise, cover key events, character changes, conflicts, and unresolved hooks. Do not invent facts."
+}
+
+object AiMessageRole {
+    const val SYSTEM = "system"
+    const val USER = "user"
+    const val ASSISTANT = "assistant"
+    const val TOOL = "tool"
+}
+
+object AiProviderPresets {
+    val items = listOf(
+        AiProviderPreset(
+            id = "openai_chat",
+            name = "OpenAI",
+            protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+            baseUrl = "https://api.openai.com/v1",
+            modelsUrl = "https://api.openai.com/v1/models",
+            modelName = "GPT-4.1 mini",
+            modelId = "gpt-4.1-mini"
+        ),
+        AiProviderPreset(
+            id = "openai_responses",
+            name = "OpenAI Responses",
+            protocol = AiProtocol.OPENAI_RESPONSES,
+            baseUrl = "https://api.openai.com/v1",
+            modelsUrl = "https://api.openai.com/v1/models",
+            modelName = "GPT-4.1 mini",
+            modelId = "gpt-4.1-mini"
+        ),
+        AiProviderPreset(
+            id = "deepseek",
+            name = "DeepSeek",
+            protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+            baseUrl = "https://api.deepseek.com",
+            modelsUrl = "https://api.deepseek.com/models",
+            modelName = "DeepSeek Chat",
+            modelId = "deepseek-chat"
+        ),
+        AiProviderPreset(
+            id = "deepseek_anthropic",
+            name = "DeepSeek",
+            protocol = AiProtocol.ANTHROPIC_MESSAGES,
+            baseUrl = "https://api.deepseek.com/anthropic",
+            modelsUrl = "https://api.deepseek.com/models",
+            modelName = "DeepSeek Chat",
+            modelId = "deepseek-chat"
+        ),
+        AiProviderPreset(
+            id = "xiaomi_mimo",
+            name = "Xiaomi MiMo",
+            protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+            baseUrl = "https://api.xiaomimimo.com/v1",
+            modelsUrl = "https://api.xiaomimimo.com/v1/models",
+            modelName = "MiMo V2.5 Pro",
+            modelId = "mimo-v2.5-pro"
+        ),
+        AiProviderPreset(
+            id = "xiaomi_mimo_anthropic",
+            name = "Xiaomi MiMo",
+            protocol = AiProtocol.ANTHROPIC_MESSAGES,
+            baseUrl = "https://api.xiaomimimo.com/anthropic",
+            modelsUrl = "https://api.xiaomimimo.com/v1/models",
+            modelName = "MiMo V2.5 Pro",
+            modelId = "mimo-v2.5-pro"
+        ),
+        AiProviderPreset(
+            id = "anthropic",
+            name = "Anthropic",
+            protocol = AiProtocol.ANTHROPIC_MESSAGES,
+            baseUrl = "https://api.anthropic.com",
+            modelsUrl = "https://api.anthropic.com/v1/models",
+            modelName = "Claude Sonnet",
+            modelId = "claude-sonnet-4-20250514"
+        )
+    )
+}
+
+@Keep
+data class AiProviderPreset(
+    val id: String,
+    val name: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelsUrl: String,
+    val modelName: String,
+    val modelId: String
+)
+
+@Keep
+data class AiProviderConfig(
+    val id: String,
+    val name: String,
+    val protocol: String,
+    val baseUrl: String,
+    val apiKey: String,
+    val modelsUrl: String? = null,
+    val headers: Map<String, String> = emptyMap(),
+    val chatPath: String = "/chat/completions",
+    val responsesPath: String = "/responses",
+    val messagesPath: String = "/v1/messages",
+    val modelsPath: String? = null,
+    val customHeaders: Map<String, String> = emptyMap()
+)
+
+@Keep
+data class AiModelConfig(
+    val id: String,
+    val provider: AiProviderConfig,
+    val displayName: String,
+    val modelId: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val capabilities: Set<String> = emptySet(),
+    val defaultParams: AiGenerationParams = AiGenerationParams()
+)
+
+@Keep
+data class AiTaskPresetConfig(
+    val id: String,
+    val taskType: String,
+    val name: String,
+    val model: AiModelConfig,
+    val promptTemplate: String,
+    val params: AiGenerationParams = AiGenerationParams(),
+    val runtimeOptions: AiTaskRuntimeOptions = AiTaskRuntimeOptions()
+)
+
+@Keep
+data class AiProfileDraft(
+    val providerId: String? = null,
+    val modelProfileId: String? = null,
+    val providerName: String,
+    val protocol: String,
+    val baseUrl: String,
+    val apiKey: String,
+    val modelName: String,
+    val modelId: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
+    val translationTargetLanguage: String = AiTaskRuntimeOptions.DEFAULT_TARGET_LANGUAGE,
+    val maxInputChars: Int = AiTaskRuntimeOptions.DEFAULT_MAX_INPUT_CHARS,
+    val concurrentRequests: Int = AiTaskRuntimeOptions.DEFAULT_CONCURRENT_REQUESTS,
+    val retryCount: Int = AiTaskRuntimeOptions.DEFAULT_RETRY_COUNT
+)
+
+@Keep
+data class AiProviderDraft(
+    val providerId: String? = null,
+    val providerName: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelsUrl: String? = null,
+    val apiKey: String
+)
+
+@Keep
+data class AiModelDraft(
+    val modelProfileId: String? = null,
+    val providerId: String,
+    val modelName: String,
+    val modelId: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE
+)
+
+/**
+ * Model reasoning/thinking depth level.
+ * Maps to provider-specific API parameters:
+ * - OpenAI: reasoning_effort
+ * - OpenAI Responses: reasoning.effort
+ * - Anthropic: thinking.type + output_config.effort
+ */
+@Keep
+enum class AiReasoningLevel(val effort: String, val budgetTokens: Int) {
+    OFF("none", 0),
+    AUTO("auto", -1),
+    LOW("low", 1_000),
+    MEDIUM("medium", 2_000),
+    HIGH("high", 8_000),
+    XHIGH("xhigh", 16_000);
+
+    val isEnabled: Boolean get() = this != OFF
+
+    companion object {
+        fun fromEffort(effort: String): AiReasoningLevel =
+            entries.firstOrNull { it.effort == effort } ?: AUTO
+
+        fun fromThinkingStrength(mode: String, strength: Int): AiReasoningLevel {
+            return when (mode) {
+                "off" -> OFF
+                "deep" -> when (strength.coerceIn(1, 3)) {
+                    1 -> MEDIUM
+                    2 -> HIGH
+                    3 -> XHIGH
+                    else -> HIGH
+                }
+                else -> AUTO
+            }
+        }
+    }
+}
+
+@Keep
+data class AiGenerationParams(
+    val temperature: Float? = null,
+    val maxOutputTokens: Int? = null,
+    val topP: Float? = null,
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO
+)
+
+@Keep
+data class AiTaskRuntimeOptions(
+    val targetLanguage: String = DEFAULT_TARGET_LANGUAGE,
+    val maxInputChars: Int = DEFAULT_MAX_INPUT_CHARS,
+    val concurrentRequests: Int = DEFAULT_CONCURRENT_REQUESTS,
+    val retryCount: Int = DEFAULT_RETRY_COUNT
+) {
+    companion object {
+        const val DEFAULT_TARGET_LANGUAGE = "zh"
+        const val DEFAULT_MAX_INPUT_CHARS = 10000
+        const val DEFAULT_CONCURRENT_REQUESTS = 1
+        const val DEFAULT_RETRY_COUNT = 2
+    }
+}
+
+@Keep
+data class AiMessage(
+    val role: String,
+    val content: String,
+    val toolCalls: List<AiToolCall> = emptyList(),
+    val toolCallId: String? = null,
+    val name: String? = null
+)
+
+@Keep
+data class AiGenerateRequest(
+    val model: AiModelConfig,
+    val messages: List<AiMessage>,
+    val params: AiGenerationParams = AiGenerationParams(),
+    val tools: List<AiToolDefinition> = emptyList()
+)
+
+@Keep
+data class AiGenerateResponse(
+    val text: String,
+    val rawBody: String? = null
+)
+
+@Keep
+data class AiAvailableModel(
+    val id: String,
+    val name: String = id,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0
+)
+
+@Keep
+data class AiToolDefinition(
+    val name: String,
+    val description: String,
+    val inputSchema: Map<String, Any?>
+)
+
+@Keep
+data class AiToolCall(
+    val id: String,
+    val name: String,
+    val arguments: String
+)
+
+@Keep
+data class AiToolResult(
+    val callId: String,
+    val name: String,
+    val content: String
+)

+ 3 - 2
app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt

@@ -3,13 +3,14 @@ package io.legado.app.domain.model
 object TranslationConstants {
 
     const val PROVIDER_OPENAI = "openai"
+    const val PROVIDER_APP_AI = "app_ai"
     const val PROVIDER_GOOGLE = "google"
     const val MIN_TEMPERATURE = 0f
     const val MAX_TEMPERATURE = 2f
     const val DEFAULT_TEMPERATURE = 1.3f
 
-    val providerDisplayNames = listOf("Google Translate", "OpenAI适配接口")
-    val providerValues = listOf(PROVIDER_GOOGLE, PROVIDER_OPENAI)
+    val providerDisplayNames = listOf("Google Translate", "应用 AI 接口")
+    val providerValues = listOf(PROVIDER_GOOGLE, PROVIDER_APP_AI)
 
     val targetLanguages = listOf(
         "zh" to "简体中文",

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

@@ -0,0 +1,369 @@
+package io.legado.app.domain.usecase
+
+import com.google.gson.JsonObject
+import io.legado.app.domain.gateway.AiChatGateway
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiStreamEvent
+import io.legado.app.domain.gateway.AiMemoryGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.gateway.AiToolGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.AiToolApprovalState
+import io.legado.app.domain.model.AiToolCall
+import io.legado.app.domain.model.toolParts
+import io.legado.app.ui.ai.chat.AiChatBookResultUi
+import io.legado.app.ui.ai.chat.AiChatMessageUi
+import io.legado.app.utils.GSON
+
+/**
+ * Encapsulates chat generation logic: request building, streaming, tool execution loop.
+ * ViewModel only handles UI state updates by collecting [GenerationEvent]s.
+ */
+class AiChatGenerationUseCase(
+    private val aiTextGateway: AiTextGateway,
+    private val aiToolGateway: AiToolGateway,
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiChatGateway: AiChatGateway,
+    private val aiMemoryGateway: AiMemoryGateway
+) {
+
+    suspend fun buildRequest(
+        userContent: String,
+        history: List<AiChatMessageUi>,
+        reasoningLevel: AiReasoningLevel,
+        conversationId: String? = null
+    ): AiGenerateRequest {
+        val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
+            ?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
+            ?: error("Please configure a default AI model first")
+        return AiGenerateRequest(
+            model = preset.model,
+            messages = buildRequestMessages(userContent, history, conversationId),
+            params = preset.params.copy(reasoningLevel = reasoningLevel),
+            tools = aiToolGateway.availableTools()
+        )
+    }
+
+    suspend fun collectStream(
+        request: AiGenerateRequest,
+        toolTrace: ToolTraceBuilder,
+        onContent: suspend (String) -> Unit,
+        onReasoning: suspend (String) -> Unit,
+        onToolTraceUpdate: suspend () -> Unit
+    ) {
+        toolTrace.beginResponse()
+        aiTextGateway.generateStream(request).collect { event ->
+            when (event) {
+                is AiStreamEvent.Content -> onContent(event.text)
+                is AiStreamEvent.Reasoning -> onReasoning(event.text)
+                is AiStreamEvent.ToolCallDelta -> {
+                    toolTrace.append(event)
+                    onToolTraceUpdate()
+                }
+            }
+        }
+    }
+
+    suspend fun executeToolCalls(
+        request: AiGenerateRequest,
+        assistantContent: String,
+        toolTrace: ToolTraceBuilder,
+        toolCalls: List<AiToolCall>,
+        onToolTraceUpdate: suspend () -> Unit
+    ): AiGenerateRequest {
+        val toolResultMessages = toolCalls.map { toolCall ->
+            val result = aiToolGateway.execute(toolCall)
+            val truncated = result.content.truncateToolOutput()
+            toolTrace.appendResult(result.callId, truncated)
+            onToolTraceUpdate()
+            AiMessage(
+                role = AiMessageRole.TOOL,
+                content = truncated,
+                toolCallId = result.callId,
+                name = result.name
+            )
+        }
+        return request.copy(
+            messages = request.messages +
+                AiMessage(
+                    role = AiMessageRole.ASSISTANT,
+                    content = assistantContent,
+                    toolCalls = toolCalls
+                ) +
+                toolResultMessages
+        )
+    }
+
+    fun requiresConfirmation(toolName: String): Boolean {
+        return aiToolGateway.requiresConfirmation(toolName)
+    }
+
+    fun buildAssistantParts(
+        text: String,
+        reasoning: String,
+        toolTrace: ToolTraceBuilder
+    ): List<AiMessagePart> {
+        return buildList {
+            reasoning.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Reasoning(it)) }
+            text.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Text(it)) }
+            addAll(toolTrace.toParts())
+            addAll(toolTrace.bookResults())
+        }
+    }
+
+    private suspend fun buildRequestMessages(
+        newContent: String,
+        history: List<AiChatMessageUi>,
+        conversationId: String? = null
+    ): List<AiMessage> {
+        val system = buildSystemPrompt(conversationId)
+        val trimmedHistory = history.trimForRequest(MAX_HISTORY_MESSAGES)
+        val messages = trimmedHistory.flatMap {
+            when (it.role) {
+                AiMessageRole.USER -> listOf(AiMessage(AiMessageRole.USER, it.content))
+                AiMessageRole.ASSISTANT -> it.toRequestMessages()
+                else -> null
+            }.orEmpty()
+        }
+        return listOf(AiMessage(AiMessageRole.SYSTEM, system)) +
+            messages +
+            AiMessage(AiMessageRole.USER, newContent)
+    }
+
+    /**
+     * Tool-aware history trimming. Ensures tool_call and tool_result pairs
+     * are never split — if a tool_result would be kept without its tool_call,
+     * the message is dropped.
+     */
+    private fun List<AiChatMessageUi>.trimForRequest(maxMessages: Int): List<AiChatMessageUi> {
+        if (size <= maxMessages) return this
+        val trimmed = takeLast(maxMessages)
+        // Find the first message where all Tool parts with output have their
+        // corresponding ToolCall present in the trimmed set
+        val firstSafe = trimmed.indexOfFirst { msg ->
+            msg.parts.toolParts().filter { it.output.isNotBlank() }.all { tool ->
+                trimmed.any { other ->
+                    other.parts.toolParts().any { it.toolCallId == tool.toolCallId && it.output.isBlank() }
+                }
+            }
+        }
+        return if (firstSafe > 0) trimmed.drop(firstSafe) else trimmed
+    }
+
+    private fun AiChatMessageUi.toRequestMessages(): List<AiMessage> {
+        val tools = parts.toolParts()
+        val toolCalls = tools.filter {
+            it.output.isNotBlank() || it.approvalState == AiToolApprovalState.AUTO
+        }.map {
+            AiToolCall(id = it.toolCallId, name = it.toolName, arguments = it.input)
+        }
+        val toolResults = tools.filter { it.output.isNotBlank() }.map {
+            AiMessage(
+                role = AiMessageRole.TOOL,
+                content = it.output,
+                toolCallId = it.toolCallId,
+                name = it.toolName
+            )
+        }
+        return listOf(
+            AiMessage(role = AiMessageRole.ASSISTANT, content = content, toolCalls = toolCalls)
+        ) + toolResults
+    }
+
+    private suspend fun buildSystemPrompt(conversationId: String? = null): String {
+        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.
+            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.
+            Do not reveal hidden chain-of-thought. If reasoning is useful, provide a brief reasoning summary.
+        """.trimIndent()
+
+        if (conversationId == null) return base
+
+        val memories = aiMemoryGateway.getForPrompt(conversationId)
+        if (memories.isEmpty()) return base
+
+        val memoryBlock = memories.joinToString("\n") { "- ${it.key}: ${it.value}" }
+        return "$base\n\n## User Memory\nThe following facts about the user have been remembered from prior conversations:\n$memoryBlock"
+    }
+
+    companion object {
+        const val MAX_HISTORY_MESSAGES = 12
+        const val MAX_TOOL_OUTPUT_CHARS = 8_000
+    }
+}
+
+/**
+ * Accumulates streaming tool call deltas into complete [AiMessagePart.Tool] parts.
+ * Thread-safe for use within a single coroutine context.
+ */
+class ToolTraceBuilder {
+    private val calls = linkedMapOf<String, ToolCallTrace>()
+    private val indexKeys = mutableMapOf<Int, String>()
+
+    fun beginResponse() {
+        indexKeys.clear()
+    }
+
+    fun append(event: AiStreamEvent.ToolCallDelta): String {
+        val eventId = event.id?.takeIf { it.isNotBlank() }
+        if (eventId != null && event.index != null) {
+            indexKeys[event.index] = eventId
+        }
+        val baseId = eventId
+            ?: event.index?.let { indexKeys[it] ?: "tool_index_$it" }
+            ?: "tool_${calls.size + 1}"
+        val id = if (eventId == null && calls[baseId]?.result != null) {
+            "${baseId}_${calls.size + 1}"
+        } else {
+            baseId
+        }
+        val call = calls.getOrPut(id) { ToolCallTrace(id = id, rawType = event.rawType) }
+        event.name?.takeIf { it.isNotBlank() }?.let { call.name = it }
+        event.argumentsDelta?.takeIf { it.isNotEmpty() }?.let { call.arguments.append(it) }
+        if (call.rawType.isBlank()) call.rawType = event.rawType
+        return toString()
+    }
+
+    fun appendResult(id: String, result: String): String {
+        calls[id]?.result = result
+        return toString()
+    }
+
+    fun pendingToolCalls(): List<AiToolCall> {
+        return calls.values.filter { it.result == null }.mapNotNull { call ->
+            val name = call.name.takeIf { it.isNotBlank() } ?: return@mapNotNull null
+            AiToolCall(
+                id = call.id,
+                name = name,
+                arguments = call.arguments.toString().ifBlank { "{}" }
+            )
+        }
+    }
+
+    fun toParts(): List<AiMessagePart> {
+        val parts = mutableListOf<AiMessagePart>()
+        calls.values.forEach { call ->
+            val name = call.name.takeIf { it.isNotBlank() } ?: return@forEach
+            parts += AiMessagePart.Tool(
+                toolCallId = call.id,
+                toolName = name,
+                input = call.arguments.toString().ifBlank { "{}" },
+                output = call.result ?: "",
+                rawType = call.rawType.ifBlank { "tool_call" },
+                approvalState = if (call.result == null) {
+                    AiToolApprovalState.PENDING
+                } else {
+                    AiToolApprovalState.AUTO
+                }
+            )
+        }
+        return parts
+    }
+
+    fun bookResults(): List<AiMessagePart.BookResult> {
+        val books = linkedMapOf<String, AiMessagePart.BookResult>()
+        calls.values.mapNotNull { it.result }.forEach { result ->
+            val root = runCatching {
+                GSON.fromJson(result, JsonObject::class.java)
+            }.getOrNull() ?: return@forEach
+            root.getAsJsonArrayOrNull("books")?.forEach { element ->
+                element.asJsonObjectOrNull()?.toBookResultPart()?.let {
+                    books.putIfAbsent(it.bookUrl, it)
+                }
+            }
+            root.getAsJsonObjectOrNull("book")?.toBookResultPart()?.let {
+                books.putIfAbsent(it.bookUrl, it)
+            }
+        }
+        return books.values.toList()
+    }
+
+    override fun toString(): String {
+        return calls.values.joinToString("\n\n") { call ->
+            buildString {
+                append("Tool: ")
+                append(call.name.ifBlank { call.rawType.ifBlank { call.id } })
+                append('\n')
+                append("ID: ")
+                append(call.id)
+                if (call.arguments.isNotBlank()) {
+                    append('\n')
+                    append(call.arguments)
+                }
+                call.result?.takeIf { it.isNotBlank() }?.let {
+                    append('\n')
+                    append("Result: ")
+                    append(it.take(2000))
+                }
+            }
+        }
+    }
+}
+
+internal data class ToolCallTrace(
+    val id: String,
+    var rawType: String,
+    var name: String = "",
+    val arguments: StringBuilder = StringBuilder(),
+    var result: String? = null
+)
+
+data class PendingToolRun(
+    val conversationId: String?,
+    val request: AiGenerateRequest,
+    val fullText: String,
+    val fullReasoning: String,
+    val toolTrace: ToolTraceBuilder,
+    val toolCalls: List<AiToolCall>,
+    val assistantTextStart: Int,
+    val round: Int,
+    val parentMessageId: String? = null
+)
+
+// ---- Book result extraction helpers ----
+
+internal fun JsonObject.toBookResultPart(): AiMessagePart.BookResult? {
+    val bookUrl = string("bookUrl")?.takeIf { it.isNotBlank() } ?: return null
+    return AiMessagePart.BookResult(
+        bookUrl = bookUrl,
+        name = string("name").orEmpty(),
+        author = string("author").orEmpty(),
+        origin = string("origin") ?: string("originName"),
+        coverPath = string("coverPath") ?: string("coverUrl"),
+        latestChapterTitle = string("latestChapterTitle"),
+        currentChapterTitle = string("currentChapterTitle"),
+        intro = string("intro")
+    )
+}
+
+internal fun JsonObject.string(name: String): String? {
+    return get(name)?.takeIf { !it.isJsonNull }?.asString
+}
+
+internal fun JsonObject.getAsJsonObjectOrNull(name: String): JsonObject? {
+    return get(name)?.let { if (it.isJsonObject) it.asJsonObject else null }
+}
+
+internal fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
+    get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
+}.getOrNull()
+
+internal fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? {
+    return takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
+}
+
+/** Truncate tool output to [AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS] to avoid overflowing context. */
+internal fun String.truncateToolOutput(): String {
+    if (length <= AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS) return this
+    return take(AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS) +
+        "\n\n[...truncated from ${length} chars]"
+}

+ 107 - 0
app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt

@@ -0,0 +1,107 @@
+package io.legado.app.domain.usecase
+
+import io.legado.app.data.entities.AiArtifact
+import io.legado.app.data.entities.Book
+import io.legado.app.data.entities.BookChapter
+import io.legado.app.domain.gateway.AiArtifactGateway
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiTaskPresetConfig
+import io.legado.app.domain.model.AiTaskType
+import io.legado.app.domain.model.ContentChunker
+import io.legado.app.help.book.BookHelp
+import io.legado.app.utils.MD5Utils
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+class GenerateChapterSummaryUseCase(
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiTextGateway: AiTextGateway,
+    private val aiArtifactGateway: AiArtifactGateway
+) {
+
+    suspend fun execute(
+        book: Book,
+        bookChapter: BookChapter,
+        maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK
+    ): Result<String> = withContext(Dispatchers.IO) {
+        runCatching {
+            val content = BookHelp.getContent(book, bookChapter)
+                ?: error("Failed to read chapter content")
+            val preset = resolvePreset() ?: error("No AI model configured for chapter summary")
+            val contentHash = MD5Utils.md5Encode(content)
+            val promptHash = MD5Utils.md5Encode(preset.promptTemplate)
+            aiArtifactGateway.getCachedArtifact(
+                bookUrl = book.bookUrl,
+                chapterIndex = bookChapter.index,
+                taskType = AiTaskType.SUMMARIZE_CHAPTER,
+                contentHash = contentHash,
+                promptHash = promptHash,
+                modelProfileId = preset.model.id
+            )?.output?.let { return@runCatching it }
+
+            val chunks = ContentChunker.chunk(content, maxCharsPerChunk)
+            if (chunks.isEmpty()) error("Failed to chunk chapter content")
+
+            val partialSummaries = chunks.map { chunk ->
+                generate(
+                    preset = preset,
+                    userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}"
+                )
+            }
+            val summary = if (partialSummaries.size == 1) {
+                partialSummaries.first()
+            } else {
+                generate(
+                    preset = preset,
+                    userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}"
+                )
+            }
+            val now = System.currentTimeMillis()
+            aiArtifactGateway.upsertArtifact(
+                AiArtifact(
+                    id = "${book.bookUrl}_${bookChapter.index}_${AiTaskType.SUMMARIZE_CHAPTER}_${contentHash}_${preset.model.id}",
+                    taskType = AiTaskType.SUMMARIZE_CHAPTER,
+                    bookUrl = book.bookUrl,
+                    chapterIndex = bookChapter.index,
+                    contentHash = contentHash,
+                    promptHash = promptHash,
+                    modelProfileId = preset.model.id,
+                    status = AiArtifact.STATUS_SUCCESS,
+                    output = summary,
+                    createdAt = now,
+                    updatedAt = now
+                )
+            )
+            summary
+        }
+    }
+
+    private suspend fun resolvePreset(): AiTaskPresetConfig? {
+        return aiProfileGateway.getTaskPreset(AiTaskType.SUMMARIZE_CHAPTER)
+    }
+
+    private suspend fun generate(
+        preset: AiTaskPresetConfig,
+        userContent: String
+    ): String {
+        val response = aiTextGateway.generate(
+            AiGenerateRequest(
+                model = preset.model,
+                messages = listOf(
+                    AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
+                    AiMessage(AiMessageRole.USER, userContent)
+                ),
+                params = preset.params
+            )
+        )
+        return response.getOrThrow().text.trim()
+    }
+
+    private companion object {
+        const val DEFAULT_MAX_CHARS_PER_CHUNK = 8000
+    }
+}

+ 296 - 25
app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt

@@ -1,29 +1,43 @@
 package io.legado.app.domain.usecase
 
+import androidx.annotation.Keep
 import io.legado.app.data.entities.Book
 import io.legado.app.data.entities.BookChapter
 import io.legado.app.data.entities.TranslationCache
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
 import io.legado.app.domain.gateway.DictionaryGateway
-import io.legado.app.domain.gateway.LlmGateway
 import io.legado.app.domain.gateway.TranslationCacheGateway
+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.ContentChunker
 import io.legado.app.domain.model.DictPair
 import io.legado.app.domain.model.PartialTranslationAssembler
 import io.legado.app.domain.model.RetryReason
 import io.legado.app.domain.model.TextChunk
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
 import io.legado.app.help.book.BookHelp
+import io.legado.app.help.http.newCallStrResponse
+import io.legado.app.help.http.okHttpClient
 import io.legado.app.ui.config.translation.TranslationConfig
+import io.legado.app.utils.GSON
 import kotlinx.coroutines.CancellationException
 import kotlinx.coroutines.Dispatchers
 import kotlinx.coroutines.async
 import kotlinx.coroutines.awaitAll
 import kotlinx.coroutines.coroutineScope
 import kotlinx.coroutines.withContext
+import java.net.URLEncoder
 
 class TranslateChapterUseCase(
-    private val llmGateway: LlmGateway,
+    private val aiTextGateway: AiTextGateway,
     private val translationCacheGateway: TranslationCacheGateway,
-    private val dictionaryGateway: DictionaryGateway
+    private val dictionaryGateway: DictionaryGateway,
+    private val aiProfileGateway: AiProfileGateway
 ) {
 
     data class TranslationProgress(
@@ -42,11 +56,19 @@ class TranslateChapterUseCase(
     suspend fun execute(
         book: Book,
         bookChapter: BookChapter,
-        targetLanguage: String,
         onProgress: (TranslationProgress) -> Unit,
         onTranslateStarted: () -> Unit
     ): Result<String> = withContext(Dispatchers.IO) {
         try {
+            val provider = TranslationConfig.llmProvider
+            val preset = if (provider == TranslationConstants.PROVIDER_APP_AI) {
+                resolveTranslationPreset()
+                    ?: return@withContext Result.failure(Exception("No AI translation preset configured"))
+            } else {
+                null
+            }
+            val targetLanguage = TranslationConfig.llmTargetLanguage
+
             val originalContent = BookHelp.getContent(book, bookChapter)
                 ?: return@withContext Result.failure(Exception("Failed to read original content"))
 
@@ -61,8 +83,7 @@ class TranslateChapterUseCase(
 
             // Load book dictionary for consistent terminology
             val bookDictionary = dictionaryGateway.getBookDictionaries(book)
-            @Suppress("SENSELESS_COMPARISON")
-            val dictionaries = (bookDictionary.pairs ?: emptyList()).toMutableList()
+            val dictionaries = bookDictionary.pairs.toMutableList()
 
             // Callback to update dictionary pairs immediately (persist as soon as discovered)
             val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
@@ -76,7 +97,10 @@ class TranslateChapterUseCase(
                 }
             }
 
-            val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk)
+            val chunks = ContentChunker.chunk(
+                originalContent,
+                TranslationConfig.llmMaxCharsPerChunk.coerceAtLeast(1000)
+            )
             if (chunks.isEmpty()) {
                 return@withContext Result.failure(Exception("Failed to chunk content"))
             }
@@ -131,7 +155,7 @@ class TranslateChapterUseCase(
                 for ((groupIndex, group) in chunkGroups.withIndex()) {
                     val results = group.map { chunk ->
                         async {
-                            translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate)
+                            translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, provider, preset, dictionaries, onDictionaryUpdate)
                         }
                     }.awaitAll()
 
@@ -216,6 +240,8 @@ class TranslateChapterUseCase(
         bookChapter: BookChapter,
         targetLanguage: String,
         contentHash: String,
+        provider: String,
+        preset: AiTaskPresetConfig?,
         dictionaries: MutableList<DictPair>,
         onDictionaryUpdate: (List<DictPair>) -> Unit
     ): Result<String> {
@@ -225,12 +251,12 @@ class TranslateChapterUseCase(
             return Result.success(existingCache.translatedChunkContent)
         }
 
-        val result = translateChunkWithRetry(chunk, targetLanguage, dictionaries, onDictionaryUpdate)
+        val result = translateChunkWithRetry(chunk, targetLanguage, provider, preset, dictionaries, onDictionaryUpdate)
         if (result.isSuccess) {
             translationCacheGateway.saveChunk(
                 book, bookChapter, targetLanguage,
                 chunk.index, chunk.content, contentHash,
-                TranslationConfig.llmProvider,
+                provider,
                 TranslationCache.STATUS_SUCCESS, result.getOrThrow(), null
             )
         } else {
@@ -238,7 +264,7 @@ class TranslateChapterUseCase(
             translationCacheGateway.saveChunk(
                 book, bookChapter, targetLanguage,
                 chunk.index, chunk.content, contentHash,
-                TranslationConfig.llmProvider,
+                provider,
                 TranslationCache.STATUS_FAILED, null, errorMessage
             )
         }
@@ -248,26 +274,27 @@ class TranslateChapterUseCase(
     private suspend fun translateChunkWithRetry(
         chunk: TextChunk,
         targetLanguage: String,
+        provider: String,
+        preset: AiTaskPresetConfig?,
         dictionaries: MutableList<DictPair>,
         onDictionaryUpdate: (List<DictPair>) -> Unit
     ): Result<String> {
         var lastError: Exception? = null
         var lastRetryReason: RetryReason? = null
-        for (attempt in 0..TranslationConfig.llmRetryCount) {
+        for (attempt in 0..TranslationConfig.llmRetryCount.coerceIn(0, 5)) {
             val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() }
-            val result = llmGateway.translate(
-                text = chunk.content,
-                targetLanguage = targetLanguage,
-                provider = TranslationConfig.llmProvider,
-                baseUrl = TranslationConfig.llmBaseUrl,
-                apiKey = TranslationConfig.llmApiKey,
-                model = TranslationConfig.llmModel,
-                prompt = TranslationConfig.llmPrompt,
-                temperature = TranslationConfig.llmTemperature,
-                dictionaries = dictSnapshot,
-                onUpdate = onDictionaryUpdate,
-                retryReason = lastRetryReason
-            )
+            val result = when (provider) {
+                TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(chunk.content, targetLanguage)
+                TranslationConstants.PROVIDER_APP_AI -> translateWithAiGateway(
+                    text = chunk.content,
+                    targetLanguage = targetLanguage,
+                    preset = preset ?: return Result.failure(Exception("No AI translation preset configured")),
+                    dictionaries = dictSnapshot,
+                    onUpdate = onDictionaryUpdate,
+                    retryReason = lastRetryReason
+                )
+                else -> Result.failure(IllegalArgumentException("Unknown translation provider: $provider"))
+            }
             if (result.isSuccess) {
                 return result
             }
@@ -277,6 +304,240 @@ class TranslateChapterUseCase(
         return Result.failure(lastError ?: Exception("Translation failed after retries"))
     }
 
+    private suspend fun resolveTranslationPreset(): AiTaskPresetConfig? {
+        return aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
+    }
+
+    private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result<String> {
+        val encodedText = URLEncoder.encode(text, Charsets.UTF_8.name())
+        val url =
+            "https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText"
+        val response = okHttpClient.newCallStrResponse {
+            url(url)
+        }
+        return if (response.isSuccessful()) {
+            runCatching {
+                val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
+                json?.sentences?.mapNotNull { it.trans }?.joinToString("").orEmpty()
+            }.fold(
+                onSuccess = { translatedText ->
+                    if (translatedText.isNotEmpty()) {
+                        Result.success(translatedText)
+                    } else {
+                        Result.failure(Exception("Empty translation result"))
+                    }
+                },
+                onFailure = { Result.failure(it) }
+            )
+        } else {
+            Result.failure(Exception("HTTP ${response.code()}: ${response.message()}"))
+        }
+    }
+
+    private suspend fun translateWithAiGateway(
+        text: String,
+        targetLanguage: String,
+        preset: AiTaskPresetConfig,
+        dictionaries: List<DictPair>,
+        onUpdate: ((List<DictPair>) -> Unit)?,
+        retryReason: RetryReason?
+    ): Result<String> {
+        if (targetLanguage == "en" && isMostlyEnglish(text)) {
+            return Result.success(text)
+        }
+        if (targetLanguage == "zh" && isMostlyChinese(text)) {
+            return Result.success(text)
+        }
+        if (preset.model.provider.baseUrl.isBlank() ||
+            preset.model.provider.apiKey.isBlank() ||
+            preset.model.modelId.isBlank()
+        ) {
+            return Result.failure(IllegalArgumentException("AI model configuration is incomplete"))
+        }
+
+        val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
+        val retryInstruction = buildRetryInstruction(retryReason)
+        val systemPrompt = buildSystemPrompt(
+            prompt = preset.promptTemplate,
+            targetLanguage = targetLanguage,
+            dictionaryInstruction = dictionaryInstruction,
+            retryInstruction = retryInstruction,
+            outputFormat = OUTPUT_FORMAT
+        )
+        val params = preset.params.copy(
+            temperature = preset.params.temperature
+                ?: preset.model.defaultParams.temperature
+                ?: TranslationConstants.DEFAULT_TEMPERATURE
+        )
+        val result = aiTextGateway.generate(
+            AiGenerateRequest(
+                model = preset.model,
+                messages = listOf(
+                    AiMessage(AiMessageRole.SYSTEM, systemPrompt),
+                    AiMessage(AiMessageRole.USER, "Translate the following text:\n\n$text")
+                ),
+                params = params
+            )
+        )
+        if (result.isFailure) {
+            return Result.failure(result.exceptionOrNull() ?: Exception("Translation failed"))
+        }
+        val rawContent = result.getOrThrow().text
+        if (rawContent.isBlank()) {
+            return Result.failure(Exception("Empty translation result"))
+        }
+        val parseResult = parseLlmOutput(rawContent, dictionaries)
+        if (parseResult.extractedPairs.isNotEmpty()) {
+            onUpdate?.invoke(parseResult.extractedPairs.take(10))
+        }
+        val finalText = if (targetLanguage == "zh") {
+            filterHighEnglishParagraphs(parseResult.translatedText)
+        } else {
+            parseResult.translatedText
+        }
+        return Result.success(finalText)
+    }
+
+    private data class ParseOutputResult(
+        val translatedText: String,
+        val extractedPairs: List<DictPair>
+    )
+
+    private fun parseLlmOutput(
+        rawOutput: String,
+        existingDictionaries: List<DictPair>
+    ): ParseOutputResult {
+        val existingOriginals = existingDictionaries.map { it.original }.toSet()
+        var dictionarySection: String? = null
+        var resultSection: String? = null
+        var currentSection: String? = null
+
+        for (line in rawOutput.split('\n')) {
+            val trimmedLine = line.trim()
+            when {
+                trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> currentSection = "dictionary"
+                trimmedLine.startsWith("[result]", ignoreCase = true) -> currentSection = "result"
+                currentSection == "dictionary" -> dictionarySection = (dictionarySection ?: "") + line + "\n"
+                currentSection == "result" -> resultSection = (resultSection ?: "") + line + "\n"
+            }
+        }
+
+        val extractedPairs = dictionarySection
+            ?.let { parseDictionarySection(it, existingOriginals) }
+            .orEmpty()
+        val translatedText = resultSection?.trim() ?: rawOutput.trim()
+        return ParseOutputResult(translatedText, extractedPairs)
+    }
+
+    private fun parseDictionarySection(
+        section: String,
+        existingOriginals: Set<String>
+    ): List<DictPair> {
+        val pairs = mutableListOf<DictPair>()
+        for (line in section.split('\n')) {
+            val trimmedLine = line.trim()
+            if (trimmedLine.isEmpty()) continue
+            if (trimmedLine.startsWith("[") || trimmedLine.startsWith("dictionary", ignoreCase = true)) {
+                continue
+            }
+
+            val separator = when {
+                trimmedLine.contains(" -> ") -> " -> "
+                trimmedLine.contains(" ->") -> " ->"
+                trimmedLine.contains("-> ") -> "-> "
+                trimmedLine.contains("->") -> "->"
+                trimmedLine.contains(" : ") -> " : "
+                trimmedLine.contains(": ") -> ": "
+                trimmedLine.contains(" :") -> " :"
+                trimmedLine.contains(":") -> ":"
+                else -> null
+            }
+            if (separator != null) {
+                val parts = trimmedLine.split(separator, limit = 2)
+                if (parts.size == 2) {
+                    val original = parts[0].trim()
+                    val translation = parts[1].trim()
+                    if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
+                        pairs.add(DictPair(original, translation))
+                        if (pairs.size >= 10) break
+                    }
+                }
+            }
+        }
+        return pairs
+    }
+
+    private fun buildDictionaryInstruction(dictionaries: List<DictPair>): String {
+        if (dictionaries.isEmpty()) return ""
+        val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" }
+        return """
+
+Terminology Dictionary (use these exact translations):
+$terms
+"""
+    }
+
+    private fun buildRetryInstruction(retryReason: RetryReason?): String {
+        return when (retryReason) {
+            RetryReason.EMPTY_RESPONSE -> "\nPrevious attempt returned empty content. Return only the required formatted translation."
+            RetryReason.PARSE_ERROR -> "\nPrevious attempt used an invalid format. Follow the [dictionary] and [result] format exactly."
+            RetryReason.RATE_LIMIT,
+            RetryReason.SERVER_ERROR,
+            RetryReason.AUTH_ERROR,
+            RetryReason.TIMEOUT,
+            RetryReason.NETWORK_ERROR,
+            RetryReason.UNKNOWN,
+            RetryReason.PERMANENT_FAILURE,
+            null -> ""
+        }
+    }
+
+    private fun buildSystemPrompt(
+        prompt: String,
+        targetLanguage: String,
+        dictionaryInstruction: String,
+        retryInstruction: String,
+        outputFormat: String
+    ): String {
+        return buildString {
+            append(prompt)
+            append("\nTarget language: ").append(getLanguageDisplayName(targetLanguage))
+            if (dictionaryInstruction.isNotEmpty()) {
+                append(dictionaryInstruction)
+            }
+            if (retryInstruction.isNotEmpty()) {
+                append(retryInstruction)
+            }
+            append("\n").append(outputFormat)
+        }
+    }
+
+    private fun getLanguageDisplayName(code: String): String {
+        return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code
+    }
+
+    private fun isMostlyEnglish(text: String): Boolean {
+        if (text.isEmpty()) return false
+        val englishChars =
+            text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}-" }
+        return englishChars.toDouble() / text.length > 0.8
+    }
+
+    private fun isMostlyChinese(text: String): Boolean {
+        if (text.isEmpty()) return false
+        val chinesePunctuation = "。,!?;:“”‘’()【】《》"
+        val chineseChars = text.count {
+            it in '一'..'鿿' || it in chinesePunctuation
+        }
+        return chineseChars.toDouble() / text.length > 0.8
+    }
+
+    private fun filterHighEnglishParagraphs(text: String): String {
+        return text.split("\n")
+            .filter { paragraph -> !isMostlyEnglish(paragraph) }
+            .joinToString("\n")
+    }
+
     private fun parseRetryReason(error: Exception?): RetryReason? {
         val message = error?.message ?: return null
         return when {
@@ -289,3 +550,13 @@ class TranslateChapterUseCase(
         }
     }
 }
+
+@Keep
+private data class GoogleTranslateResponse(
+    val sentences: List<GoogleSentence>?
+)
+
+@Keep
+private data class GoogleSentence(
+    val trans: String?
+)

+ 10 - 6
app/src/main/java/io/legado/app/model/translation/TranslationManager.kt

@@ -43,7 +43,7 @@ object TranslationManager : KoinComponent {
      */
     fun hasTranslatedCache(book: Book, chapter: BookChapter): Boolean {
         val cacheFile =
-            translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
+            translationCacheGateway.getCacheFile(book, chapter, currentTargetLanguage())
         return cacheFile.exists()
     }
 
@@ -52,7 +52,7 @@ object TranslationManager : KoinComponent {
      */
     fun getCachedTranslation(book: Book, chapter: BookChapter): String? {
         val cacheFile =
-            translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
+            translationCacheGateway.getCacheFile(book, chapter, currentTargetLanguage())
         return if (cacheFile.exists()) cacheFile.readText() else null
     }
 
@@ -113,7 +113,6 @@ object TranslationManager : KoinComponent {
         val result = translateChapterUseCase.execute(
             book = book,
             bookChapter = bookChapter,
-            targetLanguage = TranslationConfig.llmTargetLanguage,
             onProgress = { progress ->
                 taskFlow.update {
                     it.copy(
@@ -163,17 +162,22 @@ object TranslationManager : KoinComponent {
      * Delete translation cache and state for a chapter.
      */
     suspend fun deleteTranslationCache(book: Book, bookChapter: BookChapter) {
+        val targetLanguage = currentTargetLanguage()
         translationCacheGateway.deleteTranslation(
             book,
             bookChapter,
-            TranslationConfig.llmTargetLanguage
+            targetLanguage
         )
         translationCacheGateway.clearChunkCacheForChapter(
             book,
             bookChapter,
-            TranslationConfig.llmTargetLanguage
+            targetLanguage
         )
         clearChapterState(book.bookUrl, bookChapter.index)
     }
 
-}
+    private fun currentTargetLanguage(): String {
+        return TranslationConfig.llmTargetLanguage
+    }
+
+}

+ 3 - 21
app/src/main/java/io/legado/app/ui/about/AboutSheets.kt

@@ -19,9 +19,6 @@ import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
 import androidx.compose.ui.res.stringResource
 import androidx.compose.ui.unit.dp
-import com.mikepenz.markdown.m3.Markdown
-import com.mikepenz.markdown.m3.markdownColor
-import com.mikepenz.markdown.m3.markdownTypography
 import io.legado.app.BuildConfig
 import io.legado.app.R
 import io.legado.app.constant.AppConst.appInfo
@@ -31,6 +28,7 @@ import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.button.PrimaryButton
 import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
 import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.text.MarkdownBlock
 
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
@@ -51,16 +49,8 @@ fun MarkdownSheet(
                     .fillMaxWidth()
                     .verticalScroll(rememberScrollState())
             ) {
-                Markdown(
+                MarkdownBlock(
                     content = content,
-                    colors = markdownColor(
-                        text = LegadoTheme.colorScheme.onBackground,
-                        codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
-                        inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
-                        dividerColor = LegadoTheme.colorScheme.outlineVariant,
-                        tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
-                    ),
-                    typography = markdownTypography(),
                     modifier = Modifier.fillMaxWidth(),
                 )
                 Spacer(modifier = Modifier.heightIn(min = 16.dp))
@@ -164,16 +154,8 @@ fun UpdateSheet(
 
             val updateLog = updateInfo.updateLog
             if (updateLog.isNotBlank()) {
-                Markdown(
+                MarkdownBlock(
                     content = updateLog,
-                    colors = markdownColor(
-                        text = LegadoTheme.colorScheme.onBackground,
-                        codeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
-                        inlineCodeBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.1f),
-                        dividerColor = LegadoTheme.colorScheme.outlineVariant,
-                        tableBackground = LegadoTheme.colorScheme.onBackground.copy(alpha = 0.02f),
-                    ),
-                    typography = markdownTypography(),
                     modifier = Modifier.fillMaxWidth(),
                 )
             }

+ 78 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt

@@ -0,0 +1,78 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.domain.model.AiReasoningLevel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class AiChatUiState(
+    val conversations: ImmutableList<AiChatConversationUi> = persistentListOf(),
+    val messages: ImmutableList<AiChatMessageUi> = persistentListOf(),
+    val currentConversationId: String? = null,
+    val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
+    val isSending: Boolean = false,
+    val streamingMessage: AiChatMessageUi? = null,
+    val pendingToolConfirmation: AiToolConfirmationUi? = null
+)
+
+@Stable
+data class AiChatConversationUi(
+    val id: String,
+    val title: String,
+    val updatedAt: Long,
+    val isSelected: Boolean,
+    val providerName: String = "",
+    val modelName: String = ""
+)
+
+@Stable
+data class AiChatMessageUi(
+    val id: String,
+    val role: String,
+    val parts: ImmutableList<AiMessagePart> = persistentListOf(),
+    val content: String,
+    val reasoning: String?,
+    val toolTrace: String?,
+    val createdAt: Long,
+    val thinkingDuration: Int = 0,
+    val bookResults: ImmutableList<AiChatBookResultUi> = persistentListOf(),
+    val branchIndex: Int = 0,
+    val totalBranches: Int = 1,
+    val parentMessageId: String? = null
+)
+
+@Stable
+data class AiChatBookResultUi(
+    val bookUrl: String,
+    val name: String,
+    val author: String,
+    val origin: String?,
+    val coverPath: String?,
+    val latestChapterTitle: String?,
+    val currentChapterTitle: String?,
+    val intro: String?
+)
+
+@Stable
+data class AiToolConfirmationUi(
+    val title: String,
+    val description: String
+)
+
+sealed interface AiChatIntent {
+    data object NewConversation : AiChatIntent
+    data class SelectConversation(val id: String) : AiChatIntent
+    data class SendMessage(val content: String) : AiChatIntent
+    data object StopGenerating : AiChatIntent
+    data object ConfirmPendingTool : AiChatIntent
+    data object RejectPendingTool : AiChatIntent
+    data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
+    data class RegenerateMessage(val messageId: String) : AiChatIntent
+    data class SwitchBranch(val messageId: String) : AiChatIntent
+}
+
+sealed interface AiChatEffect {
+    data class ShowMessage(val message: String) : AiChatEffect
+}

+ 57 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiChatMessageParts.kt

@@ -0,0 +1,57 @@
+package io.legado.app.ui.ai.chat
+
+import io.legado.app.domain.model.AiMessagePart
+
+/**
+ * A single step inside a thinking block — either reasoning or a tool call.
+ */
+sealed interface AiThinkingStep {
+    data class ReasoningStep(val text: String) : AiThinkingStep
+    data class ToolStep(val tool: AiMessagePart.Tool) : AiThinkingStep
+}
+
+/**
+ * A renderable block of message content.
+ * ThinkingBlock groups consecutive reasoning + tool parts.
+ * ContentBlock wraps a single non-thinking part (Text, BookResult, etc.).
+ */
+sealed interface AiMessagePartBlock {
+    data class ThinkingBlock(
+        val steps: List<AiThinkingStep>,
+        val durationSeconds: Int = 0
+    ) : AiMessagePartBlock
+    data class ContentBlock(val part: AiMessagePart, val index: Int) : AiMessagePartBlock
+}
+
+/**
+ * Groups consecutive [AiMessagePart.Reasoning] and [AiMessagePart.Tool] into
+ * a single [AiMessagePartBlock.ThinkingBlock], preserving render order.
+ */
+fun List<AiMessagePart>.groupMessageParts(thinkingDurationSeconds: Int = 0): List<AiMessagePartBlock> {
+    val result = mutableListOf<AiMessagePartBlock>()
+    var currentThinkingSteps = mutableListOf<AiThinkingStep>()
+
+    fun flushThinkingSteps() {
+        if (currentThinkingSteps.isNotEmpty()) {
+            result.add(AiMessagePartBlock.ThinkingBlock(currentThinkingSteps.toList(), thinkingDurationSeconds))
+            currentThinkingSteps = mutableListOf()
+        }
+    }
+
+    forEachIndexed { index, part ->
+        when (part) {
+            is AiMessagePart.Reasoning -> {
+                currentThinkingSteps.add(AiThinkingStep.ReasoningStep(part.text))
+            }
+            is AiMessagePart.Tool -> {
+                currentThinkingSteps.add(AiThinkingStep.ToolStep(part))
+            }
+            else -> {
+                flushThinkingSteps()
+                result.add(AiMessagePartBlock.ContentBlock(part, index))
+            }
+        }
+    }
+    flushThinkingSteps()
+    return result
+}

+ 1102 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt

@@ -0,0 +1,1102 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.LinearEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.scrollBy
+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.Spacer
+import androidx.compose.foundation.layout.WindowInsets
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.layout.asPaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.ime
+import androidx.compose.foundation.layout.navigationBars
+import androidx.compose.foundation.layout.navigationBarsPadding
+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.LazyListState
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Send
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.ContentCopy
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowLeft
+import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
+import androidx.compose.material.icons.filled.Check
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.material.icons.filled.Menu
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material.icons.filled.Stop
+import androidx.compose.material3.DrawerValue
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.ModalDrawerSheet
+import androidx.compose.material3.ModalNavigationDrawer
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.material3.Surface
+import androidx.compose.material3.rememberDrawerState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalClipboardManager
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.button.series.MediumTonalButton
+import io.legado.app.ui.widget.components.button.series.SmallPlainButton
+import io.legado.app.ui.widget.components.card.GlassCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.image.cover.CoilBookCover
+import io.legado.app.ui.widget.components.text.AppText
+import io.legado.app.ui.widget.components.text.MarkdownBlock
+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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import kotlinx.coroutines.launch
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun AiChatRouteScreen(
+    onBackClick: () -> Unit,
+    onOpenBookInfo: (AiChatBookResultUi) -> Unit,
+    viewModel: AiChatViewModel = koinViewModel()
+) {
+    AiChatScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        effects = viewModel.effects,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        onOpenBookInfo = onOpenBookInfo
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiChatScreen(
+    state: AiChatUiState,
+    effects: Flow<AiChatEffect>,
+    onIntent: (AiChatIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onOpenBookInfo: (AiChatBookResultUi) -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val snackbarHostState = remember { SnackbarHostState() }
+    val drawerState = rememberDrawerState(DrawerValue.Closed)
+    val scope = rememberCoroutineScope()
+    val clipboardManager = LocalClipboardManager.current
+    var draft by rememberSaveable { mutableStateOf("") }
+    val listState = rememberLazyListState()
+
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is AiChatEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
+    ModalNavigationDrawer(
+        drawerState = drawerState,
+        drawerContent = {
+            RecentChatsDrawer(
+                conversations = state.conversations,
+                onNewChat = {
+                    onIntent(AiChatIntent.NewConversation)
+                    scope.launch { drawerState.close() }
+                },
+                onSelectConversation = {
+                    onIntent(AiChatIntent.SelectConversation(it))
+                    scope.launch { drawerState.close() }
+                }
+            )
+        }
+    ) {
+        AppScaffold(
+            snackbarHost = { SnackbarHost(snackbarHostState) },
+            topBar = {
+                GlassMediumFlexibleTopAppBar(
+                    title = stringResource(R.string.ai_chat),
+                    scrollBehavior = scrollBehavior,
+                    navigationIcon = {
+                        TopBarNavigationButton(onClick = onBackClick)
+                    },
+                    actions = {
+                        TopBarActionButton(
+                            onClick = { scope.launch { drawerState.open() } },
+                            imageVector = Icons.Default.Menu,
+                            contentDescription = stringResource(R.string.ai_recent_chats)
+                        )
+                        TopBarActionButton(
+                            onClick = { onIntent(AiChatIntent.NewConversation) },
+                            imageVector = Icons.Default.Add,
+                            contentDescription = stringResource(R.string.ai_new_chat)
+                        )
+                    }
+                )
+            }
+        ) { paddingValues ->
+            val inputBarReservedHeight = 96.dp + WindowInsets.navigationBars
+                .asPaddingValues()
+                .calculateBottomPadding()
+
+            // Keyboard-aware auto-scroll
+            ImeLazyListAutoScroller(lazyListState = listState)
+
+            // Track whether user is near the bottom
+            val isNearBottom by remember {
+                derivedStateOf {
+                    val info = listState.layoutInfo
+                    val lastVisible = info.visibleItemsInfo.lastOrNull()
+                    lastVisible == null || lastVisible.index >= info.totalItemsCount - 3
+                }
+            }
+            val isAtBottom by remember {
+                derivedStateOf {
+                    val info = listState.layoutInfo
+                    val lastVisible = info.visibleItemsInfo.lastOrNull()
+                    lastVisible == null || lastVisible.index >= info.totalItemsCount - 1
+                }
+            }
+
+            // Stick-to-bottom: tracks whether user has scrolled away
+            var shouldStickToBottom by remember { mutableStateOf(true) }
+
+            // Update stick-to-bottom when user scrolls
+            LaunchedEffect(listState) {
+                snapshotFlow { listState.isScrollInProgress to isNearBottom }
+                    .collectLatest { (isScrolling, nearBottom) ->
+                        if (isScrolling) {
+                            shouldStickToBottom = nearBottom
+                        }
+                    }
+            }
+
+            // When streaming message changes, re-evaluate stick
+            LaunchedEffect(state.streamingMessage?.id) {
+                if (shouldStickToBottom || isNearBottom) {
+                    shouldStickToBottom = true
+                }
+            }
+
+            // Auto-scroll: when messages or streaming content changes, scroll to bottom if sticking
+            LaunchedEffect(
+                shouldStickToBottom,
+                state.messages.size,
+                state.streamingMessage?.content?.length,
+                state.streamingMessage?.reasoning?.length,
+                state.streamingMessage?.toolTrace?.length
+            ) {
+                if (shouldStickToBottom && !listState.isScrollInProgress) {
+                    val lastIndex = listState.layoutInfo.totalItemsCount - 1
+                    if (lastIndex >= 0) {
+                        listState.animateScrollToItem(lastIndex)
+                    }
+                }
+            }
+
+            Box(
+                modifier = Modifier
+                    .fillMaxSize()
+                    .padding(paddingValues)
+            ) {
+                // Message list
+                LazyColumn(
+                    state = listState,
+                    modifier = Modifier
+                        .fillMaxSize()
+                        .fillMaxWidth()
+                        .padding(horizontal = 8.dp),
+                    contentPadding = PaddingValues(
+                        top = 8.dp,
+                        bottom = inputBarReservedHeight
+                    ),
+                    verticalArrangement = Arrangement.spacedBy(12.dp)
+                ) {
+                    if (state.messages.isEmpty() && state.streamingMessage == null) {
+                        item {
+                            EmptyChatHint()
+                        }
+                    }
+                    items(state.messages, key = { it.id }) { message ->
+                        val currentConversation = state.conversations.find { it.isSelected }
+                        val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
+                            "${currentConversation.providerName} ${currentConversation.modelName}"
+                        } else if (currentConversation?.modelName?.isNotBlank() == true) {
+                            currentConversation.modelName
+                        } else {
+                            ""
+                        }
+                        ChatMessageItem(
+                            message = message,
+                            isStreaming = false,
+                            assistantLabel = assistantLabel,
+                            onOpenBookInfo = onOpenBookInfo,
+                            onCopy = {
+                                clipboardManager.setText(AnnotatedString(message.content))
+                            },
+                            onRegenerate = if (message.role == AiMessageRole.ASSISTANT && message.parentMessageId != null) {
+                                { onIntent(AiChatIntent.RegenerateMessage(message.id)) }
+                            } else null,
+                            onSwitchBranch = if (message.totalBranches > 1) { direction ->
+                                val siblings = state.messages.filter {
+                                    it.parentMessageId == message.parentMessageId && it.role == message.role
+                                }
+                                val currentIdx = siblings.indexOfFirst { it.id == message.id }
+                                if (currentIdx >= 0) {
+                                    val targetIdx = when (direction) {
+                                        -1 -> (currentIdx - 1).coerceAtLeast(0)
+                                        1 -> (currentIdx + 1).coerceAtMost(siblings.size - 1)
+                                        else -> currentIdx
+                                    }
+                                    siblings.getOrNull(targetIdx)?.let {
+                                        onIntent(AiChatIntent.SwitchBranch(it.id))
+                                    }
+                                }
+                            } else null
+                        )
+                    }
+                    // Streaming message
+                    val streaming = state.streamingMessage
+                    if (streaming != null) {
+                        val currentConversation = state.conversations.find { it.isSelected }
+                        val assistantLabel = if (currentConversation != null && currentConversation.providerName.isNotBlank() && currentConversation.modelName.isNotBlank()) {
+                            "${currentConversation.providerName} ${currentConversation.modelName}"
+                        } else if (currentConversation?.modelName?.isNotBlank() == true) {
+                            currentConversation.modelName
+                        } else {
+                            ""
+                        }
+                        item(key = "streaming") {
+                            ChatMessageItem(
+                                message = streaming,
+                                isStreaming = true,
+                                assistantLabel = assistantLabel,
+                                onOpenBookInfo = onOpenBookInfo,
+                                onCopy = {}
+                            )
+                        }
+                    }
+                    // Bottom anchor for scroll target
+                    item(key = "bottom_anchor") {
+                        Spacer(modifier = Modifier.height(1.dp))
+                    }
+                }
+
+                // Bottom: tool confirmation + floating input
+                Column(
+                    modifier = Modifier
+                        .align(Alignment.BottomCenter)
+                        .fillMaxWidth()
+                        .navigationBarsPadding()
+                ) {
+                    // Scroll-to-bottom button
+                    if (!isAtBottom && (state.messages.isNotEmpty() || state.streamingMessage != null)) {
+                        Box(
+                            modifier = Modifier.fillMaxWidth(),
+                            contentAlignment = Alignment.Center
+                        ) {
+                            SmallPlainButton(
+                                onClick = {
+                                    scope.launch {
+                                        val lastIndex = listState.layoutInfo.totalItemsCount - 1
+                                        if (lastIndex >= 0) {
+                                            listState.animateScrollToItem(lastIndex)
+                                        }
+                                        shouldStickToBottom = true
+                                    }
+                                },
+                                icon = Icons.Default.KeyboardArrowDown,
+                                contentDescription = stringResource(R.string.ai_scroll_to_bottom),
+                                modifier = Modifier
+                                    .size(36.dp)
+                                    .clip(RoundedCornerShape(18.dp))
+                                    .background(LegadoTheme.colorScheme.surfaceVariant)
+                            )
+                        }
+                        Spacer(modifier = Modifier.height(8.dp))
+                    }
+
+                    state.pendingToolConfirmation?.let { confirmation ->
+                        PendingToolConfirmationCard(
+                            confirmation = confirmation,
+                            onConfirm = { onIntent(AiChatIntent.ConfirmPendingTool) },
+                            onReject = { onIntent(AiChatIntent.RejectPendingTool) }
+                        )
+                    }
+                    ChatInputBar(
+                        value = draft,
+                        isSending = state.isSending,
+                        reasoningLevel = state.reasoningLevel,
+                        onValueChange = { draft = it },
+                        onSend = {
+                            val text = draft
+                            draft = ""
+                            onIntent(AiChatIntent.SendMessage(text))
+                        },
+                        onStop = { onIntent(AiChatIntent.StopGenerating) },
+                        onUpdateReasoningLevel = { onIntent(AiChatIntent.UpdateReasoningLevel(it)) }
+                    )
+                }
+            }
+        }
+    }
+}
+
+/**
+ * Tracks keyboard (IME) height changes and scrolls the LazyList to compensate,
+ * so the list stays in place when the keyboard appears/disappears.
+ */
+@Composable
+private fun ImeLazyListAutoScroller(lazyListState: LazyListState) {
+    val ime = WindowInsets.ime
+    val density = LocalDensity.current
+    var imeHeight by remember { mutableIntStateOf(0) }
+    LaunchedEffect(Unit) {
+        snapshotFlow { ime.getBottom(density) }.collect { keyboardHeight ->
+            if (keyboardHeight > 0) {
+                lazyListState.scrollBy((keyboardHeight - imeHeight).toFloat())
+                imeHeight = keyboardHeight
+            }
+        }
+    }
+}
+
+@Composable
+private fun PendingToolConfirmationCard(
+    confirmation: AiToolConfirmationUi,
+    onConfirm: () -> Unit,
+    onReject: () -> Unit
+) {
+    GlassCard(
+        containerColor = LegadoTheme.colorScheme.tertiaryContainer,
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(top = 8.dp)
+    ) {
+        Column(modifier = Modifier.padding(12.dp)) {
+            AppText(
+                text = stringResource(R.string.ai_tool_confirmation),
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.onTertiaryContainer
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+            AppText(
+                text = confirmation.title,
+                style = LegadoTheme.typography.bodyMedium,
+                fontWeight = FontWeight.SemiBold,
+                color = LegadoTheme.colorScheme.onTertiaryContainer
+            )
+            Spacer(modifier = Modifier.height(4.dp))
+            AppText(
+                text = confirmation.description,
+                style = LegadoTheme.typography.bodySmall,
+                color = LegadoTheme.colorScheme.onTertiaryContainer,
+                maxLines = 6
+            )
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                horizontalArrangement = Arrangement.End
+            ) {
+                SmallPlainButton(
+                    onClick = onReject,
+                    text = stringResource(R.string.cancel)
+                )
+                SmallPlainButton(
+                    onClick = onConfirm,
+                    text = stringResource(R.string.confirm)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun RecentChatsDrawer(
+    conversations: List<AiChatConversationUi>,
+    onNewChat: () -> Unit,
+    onSelectConversation: (String) -> Unit
+) {
+    ModalDrawerSheet {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(16.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = stringResource(R.string.ai_recent_chats),
+                style = LegadoTheme.typography.titleMedium,
+                fontWeight = FontWeight.SemiBold,
+                modifier = Modifier.weight(1f)
+            )
+            SmallPlainButton(
+                onClick = onNewChat,
+                icon = Icons.Default.Add,
+                contentDescription = stringResource(R.string.ai_new_chat)
+            )
+        }
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            verticalArrangement = Arrangement.spacedBy(2.dp)
+        ) {
+            items(conversations, key = { it.id }) { conversation ->
+                val background = if (conversation.isSelected) {
+                    LegadoTheme.colorScheme.primaryContainer
+                } else {
+                    LegadoTheme.colorScheme.surface
+                }
+                Column(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .background(background)
+                        .clickable(onClick = { onSelectConversation(conversation.id) })
+                        .padding(horizontal = 16.dp, vertical = 12.dp)
+                ) {
+                    AppText(
+                        text = conversation.title,
+                        style = LegadoTheme.typography.bodyLarge,
+                        maxLines = 2
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ChatMessageItem(
+    message: AiChatMessageUi,
+    isStreaming: Boolean,
+    assistantLabel: String = "",
+    onOpenBookInfo: (AiChatBookResultUi) -> Unit,
+    onCopy: () -> Unit,
+    onRegenerate: (() -> Unit)? = null,
+    onSwitchBranch: ((direction: Int) -> Unit)? = null
+) {
+    val isUser = message.role == AiMessageRole.USER
+    val isAssistant = message.role == AiMessageRole.ASSISTANT
+
+    Row(
+        modifier = Modifier.fillMaxWidth(),
+        horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start
+    ) {
+        if (isUser) {
+            GlassCard(
+                containerColor = LegadoTheme.colorScheme.secondaryContainer,
+                modifier = Modifier.fillMaxWidth(0.86f)
+            ) {
+                ChatMessageContent(
+                    isUser = true,
+                    isAssistant = false,
+                    isStreaming = isStreaming,
+                    message = message,
+                    onOpenBookInfo = onOpenBookInfo,
+                    onCopy = onCopy,
+                    onRegenerate = onRegenerate,
+                    onSwitchBranch = onSwitchBranch,
+                    modifier = Modifier.padding(12.dp)
+                )
+            }
+        } else {
+            ChatMessageContent(
+                isUser = false,
+                isAssistant = isAssistant,
+                isStreaming = isStreaming,
+                message = message,
+                assistantLabel = assistantLabel,
+                onOpenBookInfo = onOpenBookInfo,
+                onCopy = onCopy,
+                onRegenerate = onRegenerate,
+                onSwitchBranch = onSwitchBranch,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 4.dp, vertical = 2.dp)
+            )
+        }
+    }
+}
+
+@Composable
+private fun ChatMessageContent(
+    isUser: Boolean,
+    isAssistant: Boolean,
+    isStreaming: Boolean,
+    message: AiChatMessageUi,
+    assistantLabel: String = "",
+    onOpenBookInfo: (AiChatBookResultUi) -> Unit,
+    onCopy: () -> Unit,
+    onRegenerate: (() -> Unit)?,
+    onSwitchBranch: ((direction: Int) -> Unit)?,
+    modifier: Modifier = Modifier
+) {
+    // Group parts into thinking blocks and content blocks
+    val groupedParts = remember(message.parts, message.thinkingDuration) {
+        message.parts.groupMessageParts(message.thinkingDuration)
+    }
+
+    Column(modifier = modifier) {
+        AppText(
+            text = if (isUser) {
+                stringResource(R.string.ai_you)
+            } else {
+                assistantLabel.ifBlank { stringResource(R.string.ai_assistant) }
+            },
+            style = LegadoTheme.typography.labelMedium,
+            color = LegadoTheme.colorScheme.outline
+        )
+        Spacer(modifier = Modifier.height(8.dp))
+
+        // Render parts in order: thinking blocks first, then content blocks
+        groupedParts.forEach { block ->
+            when (block) {
+                is AiMessagePartBlock.ThinkingBlock -> {
+                    if (block.steps.isNotEmpty()) {
+                        AiThinkingCard(
+                            steps = block.steps,
+                            isStreaming = isStreaming,
+                            durationSeconds = block.durationSeconds,
+                            modifier = Modifier.padding(bottom = 8.dp)
+                        )
+                    }
+                }
+                is AiMessagePartBlock.ContentBlock -> {
+                    when (val part = block.part) {
+                        is AiMessagePart.Text -> {
+                            MessageTextContent(
+                                text = part.text,
+                                isUser = isUser,
+                                isStreaming = isStreaming,
+                            )
+                        }
+                        is AiMessagePart.BookResult -> {
+                            BookResultsList(
+                                books = listOf(
+                                    AiChatBookResultUi(
+                                        bookUrl = part.bookUrl,
+                                        name = part.name,
+                                        author = part.author,
+                                        origin = part.origin,
+                                        coverPath = part.coverPath,
+                                        latestChapterTitle = part.latestChapterTitle,
+                                        currentChapterTitle = part.currentChapterTitle,
+                                        intro = part.intro
+                                    )
+                                ),
+                                onOpenBookInfo = onOpenBookInfo
+                            )
+                        }
+                        else -> { /* skip */ }
+                    }
+                }
+            }
+        }
+
+        // Legacy fallback: if no grouped parts but there's content, show it directly
+        if (groupedParts.isEmpty()) {
+            val displayContent = message.parts.filterIsInstance<AiMessagePart.Text>()
+                .joinToString("\n\n") { it.text }.trim()
+                .ifBlank { message.content }
+            if (displayContent.isNotBlank()) {
+                MessageTextContent(
+                    text = displayContent,
+                    isUser = isUser,
+                    isStreaming = isStreaming,
+                )
+            }
+            // Legacy reasoning fallback
+            val displayReasoning = message.parts.filterIsInstance<AiMessagePart.Reasoning>()
+                .joinToString("\n\n") { it.text }.trim()
+                .takeIf { it.isNotBlank() } ?: message.reasoning
+            if (!displayReasoning.isNullOrBlank()) {
+                AiThinkingCard(
+                    steps = listOf(AiThinkingStep.ReasoningStep(displayReasoning)),
+                    isStreaming = isStreaming,
+                    durationSeconds = message.thinkingDuration,
+                    modifier = Modifier.padding(bottom = 8.dp)
+                )
+            }
+            // Legacy tool trace fallback
+            val displayToolTrace = message.parts.filterIsInstance<AiMessagePart.Tool>()
+                .joinToString("\n\n") { tool ->
+                    buildString {
+                        append("Tool: "); append(tool.toolName); append('\n')
+                        append("ID: "); append(tool.toolCallId)
+                        tool.input.takeIf { it.isNotBlank() }?.let { append('\n'); append(it) }
+                        tool.output.takeIf { it.isNotBlank() }?.let { append('\n'); append("Result: "); append(it) }
+                    }
+                }.takeIf { it.isNotBlank() } ?: message.toolTrace
+            if (!displayToolTrace.isNullOrBlank()) {
+                TracePanel(
+                    title = stringResource(R.string.ai_tool_trace),
+                    content = displayToolTrace
+                )
+            }
+            // Legacy book results fallback
+            if (message.bookResults.isNotEmpty()) {
+                BookResultsList(books = message.bookResults, onOpenBookInfo = onOpenBookInfo)
+            }
+        }
+
+        // Action bar for assistant messages
+        if (isAssistant && !isStreaming) {
+            Spacer(modifier = Modifier.height(4.dp))
+            Row(
+                modifier = Modifier.fillMaxWidth(),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                if (onSwitchBranch != null && message.totalBranches > 1) {
+                    SmallPlainButton(
+                        onClick = { onSwitchBranch(-1) },
+                        icon = Icons.AutoMirrored.Filled.KeyboardArrowLeft,
+                        contentDescription = "Previous branch",
+                        modifier = Modifier.size(28.dp)
+                    )
+                    AppText(
+                        text = "${message.branchIndex + 1}/${message.totalBranches}",
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.outline
+                    )
+                    SmallPlainButton(
+                        onClick = { onSwitchBranch(1) },
+                        icon = Icons.AutoMirrored.Filled.KeyboardArrowRight,
+                        contentDescription = "Next branch",
+                        modifier = Modifier.size(28.dp)
+                    )
+                }
+                Spacer(modifier = Modifier.weight(1f))
+                if (onRegenerate != null) {
+                    SmallPlainButton(
+                        onClick = onRegenerate,
+                        icon = Icons.Default.Refresh,
+                        contentDescription = stringResource(R.string.ai_regenerate),
+                        modifier = Modifier.size(32.dp)
+                    )
+                }
+                SmallPlainButton(
+                    onClick = onCopy,
+                    icon = Icons.Default.ContentCopy,
+                    contentDescription = stringResource(R.string.copy_text),
+                    modifier = Modifier.size(32.dp)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun MessageTextContent(
+    text: String,
+    isUser: Boolean,
+    isStreaming: Boolean,
+) {
+    SelectionContainer {
+        if (isUser) {
+            AppText(
+                text = text,
+                style = LegadoTheme.typography.bodyLarge
+            )
+        } else if (text.isNotBlank()) {
+            MarkdownBlock(
+                content = text,
+                modifier = Modifier.fillMaxWidth()
+            )
+        } else if (isStreaming) {
+            StreamingDots()
+        }
+    }
+}
+
+@Composable
+private fun BookResultsList(
+    books: List<AiChatBookResultUi>,
+    onOpenBookInfo: (AiChatBookResultUi) -> Unit
+) {
+    if (books.isEmpty()) return
+    Spacer(modifier = Modifier.height(10.dp))
+    Column(
+        modifier = Modifier.fillMaxWidth(),
+        verticalArrangement = Arrangement.spacedBy(6.dp)
+    ) {
+        AppText(
+            text = stringResource(R.string.ai_book_results),
+            style = LegadoTheme.typography.labelMedium,
+            color = LegadoTheme.colorScheme.outline
+        )
+        books.take(8).forEach { book ->
+            ChatBookResultItem(
+                book = book,
+                onClick = { onOpenBookInfo(book) }
+            )
+        }
+    }
+}
+
+@Composable
+private fun ChatBookResultItem(
+    book: AiChatBookResultUi,
+    onClick: () -> Unit
+) {
+    Row(
+        modifier = Modifier
+            .fillMaxWidth()
+            .clip(RoundedCornerShape(6.dp))
+            .background(LegadoTheme.colorScheme.surfaceContainerLow)
+            .clickable(onClick = onClick)
+            .padding(8.dp),
+        verticalAlignment = Alignment.CenterVertically
+    ) {
+        CoilBookCover(
+            name = book.name,
+            author = book.author,
+            path = book.coverPath,
+            sourceOrigin = book.origin,
+            modifier = Modifier
+                .width(48.dp)
+                .height(68.dp)
+        )
+        Spacer(modifier = Modifier.width(10.dp))
+        Column(modifier = Modifier.weight(1f)) {
+            AppText(
+                text = book.name.ifBlank { book.bookUrl },
+                style = LegadoTheme.typography.titleSmall,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis
+            )
+            if (book.author.isNotBlank()) {
+                AppText(
+                    text = book.author,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis
+                )
+            }
+            val chapter = book.currentChapterTitle ?: book.latestChapterTitle
+            if (!chapter.isNullOrBlank()) {
+                AppText(
+                    text = chapter,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant,
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis
+                )
+            }
+            if (!book.intro.isNullOrBlank()) {
+                AppText(
+                    text = book.intro,
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.outline,
+                    maxLines = 2,
+                    overflow = TextOverflow.Ellipsis
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun StreamingDots() {
+    val infiniteTransition = androidx.compose.animation.core.rememberInfiniteTransition(label = "dots")
+    val dotCount by infiniteTransition.animateFloat(
+        initialValue = 0f,
+        targetValue = 4f,
+        animationSpec = infiniteRepeatable(
+            animation = tween(durationMillis = 1200, easing = LinearEasing),
+            repeatMode = RepeatMode.Restart
+        ),
+        label = "dotCount"
+    )
+    val dots = ".".repeat(dotCount.toInt().coerceIn(1, 3))
+    AppText(
+        text = dots,
+        color = LegadoTheme.colorScheme.outline,
+        style = LegadoTheme.typography.bodyLarge
+    )
+}
+
+/**
+ * Simple collapsible trace panel for legacy tool trace fallback.
+ */
+@Composable
+private fun TracePanel(
+    title: String,
+    content: String,
+) {
+    var expanded by rememberSaveable(title) { mutableStateOf(false) }
+
+    Column(modifier = Modifier.fillMaxWidth()) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clip(RoundedCornerShape(8.dp))
+                .clickable { expanded = !expanded }
+                .padding(horizontal = 8.dp, vertical = 6.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppText(
+                text = title,
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.outline
+            )
+            Spacer(modifier = Modifier.weight(1f))
+            Icon(
+                imageVector = Icons.Default.KeyboardArrowDown,
+                contentDescription = null,
+                modifier = Modifier.size(18.dp),
+                tint = LegadoTheme.colorScheme.outline
+            )
+        }
+        AnimatedVisibility(visible = expanded) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(start = 8.dp, end = 8.dp, bottom = 6.dp)
+            ) {
+                AppText(
+                    text = content,
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.onSurfaceVariant
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun ChatInputBar(
+    value: String,
+    isSending: Boolean,
+    reasoningLevel: AiReasoningLevel,
+    onValueChange: (String) -> Unit,
+    onSend: () -> Unit,
+    onStop: () -> Unit,
+    onUpdateReasoningLevel: (AiReasoningLevel) -> Unit
+) {
+    var showThinkingSheet by rememberSaveable { mutableStateOf(false) }
+    val thinkingLabel = when (reasoningLevel) {
+        AiReasoningLevel.OFF -> stringResource(R.string.ai_thinking_off)
+        AiReasoningLevel.AUTO -> "Auto"
+        AiReasoningLevel.LOW -> "Low"
+        AiReasoningLevel.MEDIUM -> "Med"
+        AiReasoningLevel.HIGH -> "High"
+        AiReasoningLevel.XHIGH -> "Max"
+    }
+    val isThinkingOn = reasoningLevel != AiReasoningLevel.OFF
+
+    // Thinking mode bottom sheet
+    AppModalBottomSheet(
+        show = showThinkingSheet,
+        onDismissRequest = { showThinkingSheet = false },
+        title = stringResource(R.string.ai_thinking_mode)
+    ) {
+        AiReasoningLevel.entries.forEach { level ->
+            val isSelected = level == reasoningLevel
+            val label = when (level) {
+                AiReasoningLevel.OFF -> stringResource(R.string.ai_thinking_off)
+                AiReasoningLevel.AUTO -> "Auto"
+                AiReasoningLevel.LOW -> "Low"
+                AiReasoningLevel.MEDIUM -> "Med"
+                AiReasoningLevel.HIGH -> "High"
+                AiReasoningLevel.XHIGH -> "Max"
+            }
+            val description = when (level) {
+                AiReasoningLevel.OFF -> "No thinking"
+                AiReasoningLevel.AUTO -> "Automatic"
+                AiReasoningLevel.LOW -> "1K tokens"
+                AiReasoningLevel.MEDIUM -> "2K tokens"
+                AiReasoningLevel.HIGH -> "8K tokens"
+                AiReasoningLevel.XHIGH -> "16K tokens"
+            }
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .clip(RoundedCornerShape(12.dp))
+                    .clickable {
+                        onUpdateReasoningLevel(level)
+                        showThinkingSheet = false
+                    }
+                    .background(
+                        if (isSelected) LegadoTheme.colorScheme.primaryContainer
+                        else LegadoTheme.colorScheme.surface
+                    )
+                    .padding(horizontal = 16.dp, vertical = 14.dp),
+                verticalAlignment = Alignment.CenterVertically
+            ) {
+                Column(modifier = Modifier.weight(1f)) {
+                    AppText(
+                        text = label,
+                        style = LegadoTheme.typography.bodyLarge,
+                        fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
+                        color = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer
+                        else LegadoTheme.colorScheme.onSurface
+                    )
+                    AppText(
+                        text = description,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = if (isSelected) LegadoTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
+                        else LegadoTheme.colorScheme.outline
+                    )
+                }
+                if (isSelected) {
+                    Icon(
+                        imageVector = Icons.Default.Check,
+                        contentDescription = null,
+                        modifier = Modifier.size(20.dp),
+                        tint = LegadoTheme.colorScheme.primary
+                    )
+                }
+            }
+            if (level != AiReasoningLevel.entries.last()) {
+                Spacer(modifier = Modifier.height(4.dp))
+            }
+        }
+        Spacer(modifier = Modifier.height(16.dp))
+    }
+
+    // Floating capsule input bar
+    Surface(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(horizontal = 12.dp, vertical = 6.dp),
+        shape = RoundedCornerShape(28.dp),
+        color = LegadoTheme.colorScheme.surfaceContainerHigh,
+        tonalElevation = 2.dp
+    ) {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 6.dp, vertical = 6.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(6.dp)
+        ) {
+            // Thinking mode button
+            MediumTonalButton(
+                onClick = { showThinkingSheet = true },
+                icon = Icons.Default.Lightbulb,
+                selected = isThinkingOn,
+                contentDescription = stringResource(R.string.ai_thinking_mode)
+            )
+
+            // Text field
+            BasicTextField(
+                value = value,
+                onValueChange = onValueChange,
+                enabled = !isSending,
+                modifier = Modifier
+                    .weight(1f)
+                    .heightIn(min = 36.dp, max = 160.dp),
+                textStyle = LegadoTheme.typography.bodyMedium.copy(
+                    color = LegadoTheme.colorScheme.onSurface
+                ),
+                decorationBox = { innerTextField ->
+                    Box(
+                        modifier = Modifier
+                            .fillMaxWidth()
+                            .padding(horizontal = 12.dp, vertical = 8.dp),
+                        contentAlignment = Alignment.CenterStart
+                    ) {
+                        if (value.isEmpty()) {
+                            AppText(
+                                text = stringResource(R.string.ai_chat_input_hint),
+                                style = LegadoTheme.typography.bodyMedium,
+                                color = LegadoTheme.colorScheme.onSurfaceVariant
+                            )
+                        }
+                        innerTextField()
+                    }
+                }
+            )
+
+            // Send/Stop button
+            if (isSending) {
+                MediumTonalButton(
+                    onClick = onStop,
+                    icon = Icons.Default.Stop,
+                    contentDescription = stringResource(R.string.stop)
+                )
+            } else {
+                MediumTonalButton(
+                    onClick = onSend,
+                    icon = Icons.AutoMirrored.Filled.Send,
+                    enabled = value.isNotBlank(),
+                    contentDescription = stringResource(R.string.ai_send)
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun EmptyChatHint() {
+    Box(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(top = 64.dp),
+        contentAlignment = Alignment.Center
+    ) {
+        AppText(
+            text = stringResource(R.string.ai_chat_empty),
+            color = LegadoTheme.colorScheme.outline,
+            style = LegadoTheme.typography.bodyLarge
+        )
+    }
+}
+
+@Composable
+private fun AiChatEffectHandler(
+    effects: Flow<AiChatEffect>,
+    snackbarHostState: SnackbarHostState
+) {
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is AiChatEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+}

+ 766 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt

@@ -0,0 +1,766 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import com.google.gson.JsonObject
+import io.legado.app.domain.gateway.AiChatGateway
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.domain.model.AiMessagePartJson
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiReasoningLevel
+import io.legado.app.domain.model.AiToolApprovalState
+import io.legado.app.domain.model.reasoningContent
+import io.legado.app.domain.model.textContent
+import io.legado.app.domain.model.toolParts
+import io.legado.app.domain.usecase.AiChatGenerationUseCase
+import io.legado.app.domain.usecase.PendingToolRun
+import io.legado.app.domain.usecase.ToolTraceBuilder
+import io.legado.app.utils.GSON
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.currentCoroutineContext
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.filterNotNull
+import kotlinx.coroutines.flow.flatMapLatest
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+@OptIn(ExperimentalCoroutinesApi::class)
+class AiChatViewModel(
+    private val aiChatGateway: AiChatGateway,
+    private val aiProfileGateway: AiProfileGateway,
+    private val generationUseCase: AiChatGenerationUseCase
+) : ViewModel() {
+
+    private val currentConversationId = MutableStateFlow<String?>(null)
+    private var lastMessages: List<AiChatMessageUi> = emptyList()
+    private var streamingJob: Job? = null
+    private var pendingToolRun: PendingToolRun? = null
+    private var thinkingStartTime: Long = 0L
+
+    private val _uiState = MutableStateFlow(AiChatUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<AiChatEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        observeConversations()
+        observeCurrentMessages()
+    }
+
+    fun onIntent(intent: AiChatIntent) {
+        when (intent) {
+            AiChatIntent.NewConversation -> createConversation()
+            is AiChatIntent.SelectConversation -> selectConversation(intent.id)
+            is AiChatIntent.SendMessage -> sendMessage(intent.content)
+            AiChatIntent.StopGenerating -> stopGenerating()
+            AiChatIntent.ConfirmPendingTool -> confirmPendingTool()
+            AiChatIntent.RejectPendingTool -> rejectPendingTool()
+            is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
+            is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
+            is AiChatIntent.SwitchBranch -> switchBranch(intent.messageId)
+        }
+    }
+
+    // ---- Conversation lifecycle ----
+
+    private fun observeConversations() {
+        viewModelScope.launch {
+            aiChatGateway.observeConversations().collect { conversations ->
+                val selectedId = currentConversationId.value
+                _uiState.update { current ->
+                    current.copy(
+                        conversations = conversations.map {
+                            AiChatConversationUi(
+                                id = it.id,
+                                title = it.title,
+                                updatedAt = it.updatedAt,
+                                isSelected = it.id == selectedId
+                            )
+                        }.toImmutableList()
+                    )
+                }
+                if (selectedId == null) {
+                    conversations.firstOrNull()?.let { selectConversation(it.id) }
+                        ?: createConversation()
+                }
+            }
+        }
+    }
+
+    private fun observeCurrentMessages() {
+        viewModelScope.launch {
+            currentConversationId
+                .filterNotNull()
+                .flatMapLatest { aiChatGateway.observeSelectedMessages(it) }
+                .collect { messages ->
+                    lastMessages = messages.map { msg ->
+                        val parts = AiMessagePartJson.decode(msg.partsJson)
+                        AiChatMessageUi(
+                            id = msg.id,
+                            role = msg.role,
+                            parts = parts.toImmutableList(),
+                            content = parts.textContent(),
+                            reasoning = parts.reasoningContent(),
+                            toolTrace = parts.toolTraceText(),
+                            createdAt = msg.createdAt,
+                            thinkingDuration = msg.thinkingDuration,
+                            bookResults = parts.bookResults().toImmutableList(),
+                            branchIndex = msg.branchIndex,
+                            totalBranches = 1, // will be updated below
+                            parentMessageId = msg.parentMessageId
+                        )
+                    }
+                    // Update totalBranches for assistant messages with parent (batch query)
+                    val conversationId = currentConversationId.value
+                    val branchCounts = if (conversationId != null) {
+                        runCatching { aiChatGateway.getBranchCounts(conversationId) }.getOrNull()
+                    } else null
+                    lastMessages = lastMessages.map { msg ->
+                        if (msg.role == AiMessageRole.ASSISTANT && msg.parentMessageId != null) {
+                            val count = branchCounts?.get(msg.parentMessageId) ?: 1
+                            msg.copy(totalBranches = count)
+                        } else msg
+                    }
+                    _uiState.update { it.copy(messages = lastMessages.toImmutableList()) }
+                }
+        }
+    }
+
+    private fun createConversation() {
+        viewModelScope.launch {
+            runCatching {
+                aiChatGateway.createConversation()
+            }.onSuccess {
+                selectConversation(it.id)
+            }.onFailure { error ->
+                _effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to create chat"))
+            }
+        }
+    }
+
+    private fun selectConversation(id: String) {
+        streamingJob?.cancel()
+        pendingToolRun = null
+        currentConversationId.value = id
+        viewModelScope.launch {
+            val conversation = aiChatGateway.getConversation(id)
+
+            // Load provider/model info for the conversation
+            var providerName = ""
+            var modelName = ""
+            conversation?.modelProfileId?.let { profileId ->
+                val modelProfile = aiProfileGateway.getModel(profileId)
+                if (modelProfile != null) {
+                    modelName = modelProfile.displayName
+                    val provider = aiProfileGateway.getProvider(modelProfile.providerId)
+                    providerName = provider?.name ?: ""
+                }
+            }
+
+            _uiState.update { current ->
+                current.copy(
+                    currentConversationId = id,
+                    reasoningLevel = conversation?.reasoningLevel?.let {
+                        runCatching { AiReasoningLevel.valueOf(it.uppercase()) }.getOrNull()
+                    } ?: current.reasoningLevel,
+                    streamingMessage = null,
+                    pendingToolConfirmation = null,
+                    isSending = false,
+                    conversations = current.conversations.map {
+                        if (it.id == id) {
+                            it.copy(
+                                isSelected = true,
+                                providerName = providerName,
+                                modelName = modelName
+                            )
+                        } else {
+                            it.copy(isSelected = false)
+                        }
+                    }.toImmutableList()
+                )
+            }
+        }
+    }
+
+    // ---- Generation ----
+
+    private fun stopGenerating() {
+        streamingJob?.cancel()
+    }
+
+    private fun confirmPendingTool() {
+        val pending = pendingToolRun ?: return
+        pendingToolRun = null
+        streamingJob = viewModelScope.launch {
+            _uiState.update {
+                it.copy(isSending = true, pendingToolConfirmation = null)
+            }
+            continueAfterToolApproval(pending)
+        }
+    }
+
+    private fun rejectPendingTool() {
+        val pending = pendingToolRun ?: return
+        pendingToolRun = null
+        val rejectionText = "工具调用已被你拒绝,我不会执行这些操作。还需要我继续帮你做什么?"
+        val assistantText = buildString {
+            pending.fullText.trim().takeIf { it.isNotBlank() }?.let {
+                append(it)
+                append("\n\n")
+            }
+            append(rejectionText)
+        }
+        val rejectedToolParts = pending.deniedToolParts()
+        val reasoning = pending.fullReasoning.takeIf { it.isNotBlank() }
+        streamingJob = viewModelScope.launch {
+            _uiState.update {
+                it.copy(
+                    isSending = false,
+                    pendingToolConfirmation = null,
+                    streamingMessage = AiChatMessageUi(
+                        id = "tool_rejected_temp",
+                        role = AiMessageRole.ASSISTANT,
+                        content = assistantText,
+                        reasoning = reasoning,
+                        toolTrace = null,
+                        createdAt = System.currentTimeMillis()
+                    ).withDisplayParts(rejectedToolParts)
+                )
+            }
+            val saveResult = runCatching {
+                pending.conversationId?.let { conversationId ->
+                    aiChatGateway.saveMessage(
+                        conversationId = conversationId,
+                        role = AiMessageRole.ASSISTANT,
+                        parts = buildList {
+                            reasoning?.let { add(AiMessagePart.Reasoning(it)) }
+                            add(AiMessagePart.Text(assistantText))
+                            addAll(rejectedToolParts)
+                        },
+                        parentMessageId = pending.parentMessageId
+                    )
+                }
+            }
+            saveResult.onSuccess {
+                _uiState.update { it.copy(streamingMessage = null) }
+            }.onFailure { error ->
+                _effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to save rejected tool response"))
+            }
+            if (streamingJob == currentCoroutineContext()[Job]) {
+                streamingJob = null
+            }
+        }
+    }
+
+    private fun updateReasoningLevel(level: AiReasoningLevel) {
+        _uiState.update { it.copy(reasoningLevel = level) }
+        val conversationId = currentConversationId.value ?: return
+        viewModelScope.launch {
+            aiChatGateway.updateReasoningLevel(conversationId, level.name.lowercase())
+        }
+    }
+
+    private fun regenerateMessage(messageId: String) {
+        val message = lastMessages.find { it.id == messageId }
+            ?: return
+        if (message.role != AiMessageRole.ASSISTANT || message.parentMessageId == null) return
+        val parentUserMsg = lastMessages.find { it.id == message.parentMessageId }
+            ?: return
+        val historyBeforeParent = lastMessages.filter { it.createdAt < parentUserMsg.createdAt }
+        streamingJob?.cancel()
+        streamingJob = viewModelScope.launch {
+            _uiState.update { it.copy(isSending = true) }
+            val conversationId = currentConversationId.value ?: return@launch
+            val fullText = StringBuilder()
+            val fullReasoning = StringBuilder()
+            val toolTrace = ToolTraceBuilder()
+            var failureMessage: String? = null
+            var wasCancelled = false
+            var waitingForToolConfirmation = false
+            try {
+                val state = _uiState.value
+                val request = generationUseCase.buildRequest(
+                    userContent = parentUserMsg.content,
+                    history = historyBeforeParent,
+                    reasoningLevel = state.reasoningLevel,
+                    conversationId = conversationId
+                )
+                setStreamingPlaceholder()
+                try {
+                    collectStream(request, fullText, fullReasoning, toolTrace)
+                    waitingForToolConfirmation = continueToolRounds(
+                        conversationId = conversationId,
+                        request = request,
+                        fullText = fullText,
+                        fullReasoning = fullReasoning,
+                        toolTrace = toolTrace,
+                        startRound = 0,
+                        assistantTextStart = 0,
+                        parentMessageId = message.parentMessageId
+                    )
+                } catch (e: CancellationException) {
+                    wasCancelled = true
+                    throw e
+                } catch (e: Exception) {
+                    failureMessage = "Stream interrupted: ${e.message ?: "unknown error"}"
+                    _effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
+                }
+            } catch (e: CancellationException) {
+                wasCancelled = true
+                throw e
+            } catch (e: Exception) {
+                failureMessage = e.message ?: "AI chat failed"
+                _effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
+            } finally {
+                val assistantContent = when {
+                    waitingForToolConfirmation -> null
+                    fullText.isNotEmpty() -> fullText.toString()
+                    !wasCancelled && !failureMessage.isNullOrBlank() -> "请求失败:$failureMessage"
+                    else -> null
+                }
+                if (assistantContent != null) {
+                    val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
+                    runCatching {
+                        aiChatGateway.saveRegeneratedMessage(
+                            conversationId = conversationId,
+                            role = AiMessageRole.ASSISTANT,
+                            parts = generationUseCase.buildAssistantParts(
+                                text = assistantContent,
+                                reasoning = fullReasoning.toString(),
+                                toolTrace = toolTrace
+                            ),
+                            parentMessageId = message.parentMessageId!!,
+                            thinkingDuration = duration
+                        )
+                    }
+                }
+                _uiState.update {
+                    if (waitingForToolConfirmation) it
+                    else it.copy(isSending = false, streamingMessage = null)
+                }
+                if (streamingJob == currentCoroutineContext()[Job]) {
+                    streamingJob = null
+                }
+            }
+        }
+    }
+
+    private fun switchBranch(messageId: String) {
+        viewModelScope.launch {
+            runCatching {
+                aiChatGateway.selectBranch(messageId)
+            }.onFailure { error ->
+                _effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to switch branch"))
+            }
+        }
+    }
+
+    private fun sendMessage(rawContent: String) {
+        val content = rawContent.trim()
+        if (content.isBlank() || _uiState.value.isSending) return
+        streamingJob?.cancel()
+        val historySnapshot = lastMessages
+        streamingJob = viewModelScope.launch {
+            _uiState.update { it.copy(isSending = true) }
+            var conversationIdForMsg: String? = null
+            var parentMessageId: String? = null
+            val fullText = StringBuilder()
+            val fullReasoning = StringBuilder()
+            val toolTrace = ToolTraceBuilder()
+            var failureMessage: String? = null
+            var wasCancelled = false
+            var waitingForToolConfirmation = false
+            try {
+                val conversationId = currentConversationId.value
+                    ?: aiChatGateway.createConversation().id.also {
+                        currentConversationId.value = it
+                    }
+                val userMessage = aiChatGateway.saveMessage(
+                    conversationId = conversationId,
+                    role = AiMessageRole.USER,
+                    parts = listOf(AiMessagePart.Text(content))
+                )
+                conversationIdForMsg = userMessage.conversationId
+                parentMessageId = userMessage.id
+                if (historySnapshot.none { it.role == AiMessageRole.USER }) {
+                    aiChatGateway.updateConversationTitle(conversationId, content.take(24))
+                }
+                val state = _uiState.value
+                val request = generationUseCase.buildRequest(
+                    userContent = content,
+                    history = historySnapshot,
+                    reasoningLevel = state.reasoningLevel,
+                    conversationId = conversationIdForMsg
+                )
+                setStreamingPlaceholder()
+                try {
+                    collectStream(request, fullText, fullReasoning, toolTrace)
+                    waitingForToolConfirmation = continueToolRounds(
+                        conversationId = conversationIdForMsg,
+                        request = request,
+                        fullText = fullText,
+                        fullReasoning = fullReasoning,
+                        toolTrace = toolTrace,
+                        startRound = 0,
+                        assistantTextStart = 0,
+                        parentMessageId = parentMessageId
+                    )
+                } catch (e: CancellationException) {
+                    wasCancelled = true
+                    throw e
+                } catch (e: Exception) {
+                    failureMessage = "Stream interrupted: ${e.message ?: "unknown error"}"
+                    _effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
+                }
+            } catch (e: CancellationException) {
+                wasCancelled = true
+                throw e
+            } catch (e: Exception) {
+                failureMessage = e.message ?: "AI chat failed"
+                _effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
+            } finally {
+                val assistantContent = when {
+                    waitingForToolConfirmation -> null
+                    fullText.isNotEmpty() -> fullText.toString()
+                    !wasCancelled && !failureMessage.isNullOrBlank() -> "请求失败:$failureMessage"
+                    else -> null
+                }
+                if (conversationIdForMsg != null && assistantContent != null) {
+                    val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
+                    runCatching {
+                        aiChatGateway.saveMessage(
+                            conversationId = conversationIdForMsg,
+                            role = AiMessageRole.ASSISTANT,
+                            parts = generationUseCase.buildAssistantParts(
+                                text = assistantContent,
+                                reasoning = fullReasoning.toString(),
+                                toolTrace = toolTrace
+                            ),
+                            parentMessageId = parentMessageId,
+                            thinkingDuration = duration
+                        )
+                    }
+                }
+                _uiState.update {
+                    if (waitingForToolConfirmation) it
+                    else it.copy(isSending = false, streamingMessage = null)
+                }
+                if (streamingJob == currentCoroutineContext()[Job]) {
+                    streamingJob = null
+                }
+            }
+        }
+    }
+
+    private suspend fun continueAfterToolApproval(pending: PendingToolRun) {
+        val fullText = StringBuilder(pending.fullText)
+        val fullReasoning = StringBuilder(pending.fullReasoning)
+        var waitingForToolConfirmation = false
+        try {
+            val assistantContent = fullText.substring(pending.assistantTextStart)
+            val followUpRequest = generationUseCase.executeToolCalls(
+                request = pending.request,
+                assistantContent = assistantContent,
+                toolTrace = pending.toolTrace,
+                toolCalls = pending.toolCalls,
+                onToolTraceUpdate = { updateStreamingToolTrace(pending.toolTrace) }
+            )
+            val nextAssistantTextStart = fullText.length
+            collectStream(followUpRequest, fullText, fullReasoning, pending.toolTrace)
+            waitingForToolConfirmation = continueToolRounds(
+                conversationId = pending.conversationId,
+                request = followUpRequest,
+                fullText = fullText,
+                fullReasoning = fullReasoning,
+                toolTrace = pending.toolTrace,
+                startRound = pending.round + 1,
+                assistantTextStart = nextAssistantTextStart,
+                parentMessageId = pending.parentMessageId
+            )
+        } catch (e: CancellationException) {
+            throw e
+        } catch (e: Exception) {
+            _effects.tryEmit(AiChatEffect.ShowMessage(e.message ?: "AI tool failed"))
+        } finally {
+            if (!waitingForToolConfirmation && pending.conversationId != null && fullText.isNotEmpty()) {
+                val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
+                runCatching {
+                    aiChatGateway.saveMessage(
+                        conversationId = pending.conversationId,
+                        role = AiMessageRole.ASSISTANT,
+                        parts = generationUseCase.buildAssistantParts(
+                            text = fullText.toString(),
+                            reasoning = fullReasoning.toString(),
+                            toolTrace = pending.toolTrace
+                        ),
+                        parentMessageId = pending.parentMessageId,
+                        thinkingDuration = duration
+                    )
+                }
+            }
+            _uiState.update {
+                if (waitingForToolConfirmation) it
+                else it.copy(isSending = false, streamingMessage = null)
+            }
+            if (streamingJob == currentCoroutineContext()[Job]) {
+                streamingJob = null
+            }
+        }
+    }
+
+    private suspend fun continueToolRounds(
+        conversationId: String?,
+        request: io.legado.app.domain.model.AiGenerateRequest,
+        fullText: StringBuilder,
+        fullReasoning: StringBuilder,
+        toolTrace: ToolTraceBuilder,
+        startRound: Int,
+        assistantTextStart: Int,
+        parentMessageId: String? = null
+    ): Boolean {
+        var currentRequest = request
+        var currentRound = startRound
+        var currentAssistantTextStart = assistantTextStart
+        while (true) {
+            val toolCalls = toolTrace.pendingToolCalls()
+            if (toolCalls.isEmpty()) return false
+            if (toolCalls.any { generationUseCase.requiresConfirmation(it.name) }) {
+                pendingToolRun = PendingToolRun(
+                    conversationId = conversationId,
+                    request = currentRequest,
+                    fullText = fullText.toString(),
+                    fullReasoning = fullReasoning.toString(),
+                    toolTrace = toolTrace,
+                    toolCalls = toolCalls,
+                    assistantTextStart = currentAssistantTextStart,
+                    round = currentRound,
+                    parentMessageId = parentMessageId
+                )
+                _uiState.update {
+                    it.copy(
+                        isSending = false,
+                        pendingToolConfirmation = AiToolConfirmationUi(
+                            title = toolCalls.joinToString { call -> call.name },
+                            description = toolTrace.toString().take(2000)
+                        )
+                    )
+                }
+                return true
+            }
+            currentRequest = generationUseCase.executeToolCalls(
+                request = currentRequest,
+                assistantContent = fullText.substring(currentAssistantTextStart),
+                toolTrace = toolTrace,
+                toolCalls = toolCalls,
+                onToolTraceUpdate = { updateStreamingToolTrace(toolTrace) }
+            )
+            currentRound += 1
+            currentAssistantTextStart = fullText.length
+            collectStream(currentRequest, fullText, fullReasoning, toolTrace)
+        }
+    }
+
+    // ---- Streaming helpers ----
+
+    private suspend fun collectStream(
+        request: io.legado.app.domain.model.AiGenerateRequest,
+        fullText: StringBuilder,
+        fullReasoning: StringBuilder,
+        toolTrace: ToolTraceBuilder
+    ) {
+        generationUseCase.collectStream(
+            request = request,
+            toolTrace = toolTrace,
+            onContent = { delta ->
+                // First content token: freeze thinking duration
+                if (fullText.isEmpty() && thinkingStartTime > 0) {
+                    val duration = ((System.currentTimeMillis() - thinkingStartTime) / 1000).toInt()
+                    _uiState.update { state ->
+                        state.streamingMessage?.let { msg ->
+                            state.copy(streamingMessage = msg.copy(thinkingDuration = duration))
+                        } ?: state
+                    }
+                }
+                fullText.append(delta)
+                _uiState.update { state ->
+                    state.streamingMessage?.let { msg ->
+                        state.copy(
+                            streamingMessage = msg.copy(content = fullText.toString())
+                                .withDisplayParts()
+                        )
+                    } ?: state
+                }
+            },
+            onReasoning = { delta ->
+                // First reasoning token: record start time
+                if (fullReasoning.isEmpty()) {
+                    thinkingStartTime = System.currentTimeMillis()
+                }
+                fullReasoning.append(delta)
+                _uiState.update { state ->
+                    state.streamingMessage?.let { msg ->
+                        state.copy(
+                            streamingMessage = msg.copy(reasoning = fullReasoning.toString())
+                                .withDisplayParts()
+                        )
+                    } ?: state
+                }
+            },
+            onToolTraceUpdate = { updateStreamingToolTrace(toolTrace) }
+        )
+    }
+
+    private fun setStreamingPlaceholder() {
+        thinkingStartTime = 0L
+        _uiState.update {
+            it.copy(
+                streamingMessage = AiChatMessageUi(
+                    id = "streaming_temp",
+                    role = AiMessageRole.ASSISTANT,
+                    content = "",
+                    reasoning = null,
+                    toolTrace = null,
+                    createdAt = System.currentTimeMillis()
+                )
+            )
+        }
+    }
+
+    private fun updateStreamingToolTrace(toolTrace: ToolTraceBuilder) {
+        _uiState.update { state ->
+            state.streamingMessage?.let { msg ->
+                state.copy(
+                    streamingMessage = msg.copy(
+                        toolTrace = toolTrace.toString()
+                    ).withDisplayParts(toolTrace.toParts())
+                )
+            } ?: state
+        }
+    }
+
+    private fun AiChatMessageUi.withDisplayParts(
+        toolParts: List<AiMessagePart> = parts.filter {
+            it is AiMessagePart.Tool || it is AiMessagePart.BookResult
+        }
+    ): AiChatMessageUi {
+        return copy(
+            parts = buildList {
+                reasoning?.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Reasoning(it)) }
+                content.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Text(it)) }
+                addAll(toolParts)
+            }.toImmutableList()
+        )
+    }
+
+    private fun PendingToolRun.deniedToolParts(): List<AiMessagePart> {
+        val deniedCallIds = toolCalls.mapTo(mutableSetOf()) { it.id }
+        return toolTrace.toParts().map { part ->
+            if (part is AiMessagePart.Tool &&
+                part.toolCallId in deniedCallIds &&
+                part.output.isBlank()
+            ) {
+                part.copy(
+                    output = "用户拒绝执行此工具调用。",
+                    approvalState = AiToolApprovalState.DENIED
+                )
+            } else {
+                part
+            }
+        }
+    }
+
+    // ---- Display helpers ----
+
+    private fun List<AiMessagePart>.bookResults(): List<AiChatBookResultUi> {
+        val explicit = filterIsInstance<AiMessagePart.BookResult>().map {
+            AiChatBookResultUi(
+                bookUrl = it.bookUrl,
+                name = it.name,
+                author = it.author,
+                origin = it.origin,
+                coverPath = it.coverPath,
+                latestChapterTitle = it.latestChapterTitle,
+                currentChapterTitle = it.currentChapterTitle,
+                intro = it.intro
+            )
+        }
+        if (explicit.isNotEmpty()) return explicit.distinctBy { it.bookUrl }
+        val books = linkedMapOf<String, AiChatBookResultUi>()
+        toolParts().forEach { tool ->
+            val root = runCatching {
+                GSON.fromJson(tool.output, JsonObject::class.java)
+            }.getOrNull() ?: return@forEach
+            root.getAsJsonArrayOrNull("books")?.forEach { element ->
+                element.asJsonObjectOrNull()?.toBookResultUi()?.let { books.putIfAbsent(it.bookUrl, it) }
+            }
+            root.getAsJsonObjectOrNull("book")?.toBookResultUi()?.let {
+                books.putIfAbsent(it.bookUrl, it)
+            }
+        }
+        return books.values.toList()
+    }
+
+    private fun List<AiMessagePart>.toolTraceText(): String? {
+        val tools = toolParts()
+        if (tools.isEmpty()) return null
+        return tools.joinToString("\n\n") { tool ->
+            buildString {
+                append("Tool: ")
+                append(tool.toolName.ifBlank { tool.rawType.ifBlank { tool.toolCallId } })
+                append('\n')
+                append("ID: ")
+                append(tool.toolCallId)
+                if (tool.input.isNotBlank()) {
+                    append('\n')
+                    append(tool.input)
+                }
+                if (tool.approvalState != AiToolApprovalState.AUTO) {
+                    append('\n')
+                    append("Approval: ")
+                    append(tool.approvalState.name.lowercase())
+                }
+                if (tool.output.isNotBlank()) {
+                    append('\n')
+                    append("Result: ")
+                    append(tool.output.take(2000))
+                }
+            }
+        }.takeIf { it.isNotBlank() }
+    }
+
+}
+
+private fun JsonObject.toBookResultUi(): AiChatBookResultUi? {
+    val bookUrl = string("bookUrl")?.takeIf { it.isNotBlank() } ?: return null
+    return AiChatBookResultUi(
+        bookUrl = bookUrl,
+        name = string("name").orEmpty(),
+        author = string("author").orEmpty(),
+        origin = string("origin") ?: string("originName"),
+        coverPath = string("coverPath") ?: string("coverUrl"),
+        latestChapterTitle = string("latestChapterTitle"),
+        currentChapterTitle = string("currentChapterTitle"),
+        intro = string("intro")
+    )
+}
+
+private fun JsonObject.string(name: String): String? =
+    get(name)?.takeIf { !it.isJsonNull }?.asString
+
+private fun JsonObject.getAsJsonObjectOrNull(name: String): JsonObject? =
+    get(name)?.let { if (it.isJsonObject) it.asJsonObject else null }
+
+private fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
+    get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
+}.getOrNull()
+
+private fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? =
+    takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject

+ 250 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiReasoningCard.kt

@@ -0,0 +1,250 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInVertically
+import androidx.compose.animation.slideOutVertically
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.ScrollState
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Lightbulb
+import androidx.compose.material3.Icon
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.Stable
+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.draw.drawWithCache
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.BlendMode
+import androidx.compose.ui.graphics.Brush
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.graphicsLayer
+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.text.AppText
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+
+/**
+ * Three states for the reasoning card display.
+ */
+enum class ReasoningCardState(val expanded: Boolean) {
+    /** Fully hidden — only the toggle header is visible. */
+    Collapsed(false),
+    /** Preview mode during streaming — limited height with fade gradient. */
+    Preview(true),
+    /** Fully expanded — all content visible. */
+    Expanded(true),
+}
+
+@Stable
+private class ReasoningState(
+    val scrollState: ScrollState,
+) {
+    var expandState by mutableStateOf(ReasoningCardState.Collapsed)
+    var elapsedSeconds by mutableStateOf(0f)
+}
+
+@Composable
+private fun rememberReasoningState(
+    text: String,
+    isStreaming: Boolean,
+    messageCreatedAt: Long,
+): Pair<ReasoningState, Boolean> {
+    val scrollState = rememberScrollState()
+    val state = remember(messageCreatedAt) {
+        ReasoningState(scrollState = scrollState)
+    }
+
+    // Auto-expand to Preview during streaming, auto-close after
+    LaunchedEffect(text, isStreaming) {
+        if (isStreaming) {
+            if (!state.expandState.expanded) {
+                state.expandState = ReasoningCardState.Preview
+            }
+            scrollState.animateScrollTo(scrollState.maxValue)
+        } else {
+            if (state.expandState.expanded) {
+                state.expandState = ReasoningCardState.Collapsed
+            }
+        }
+    }
+
+    // Duration timer during streaming - update once per second
+    LaunchedEffect(isStreaming) {
+        if (isStreaming) {
+            while (isActive) {
+                state.elapsedSeconds = (System.currentTimeMillis() - messageCreatedAt) / 1000f
+                delay(1000)
+            }
+        } else {
+            state.elapsedSeconds = (System.currentTimeMillis() - messageCreatedAt) / 1000f
+        }
+    }
+
+    return state to isStreaming
+}
+
+/**
+ * A reasoning card with three display states:
+ * - **Collapsed**: header row with "Thinking" label, click to expand
+ * - **Preview** (streaming): max 100dp height with vertical fade gradient, auto-scrolls
+ * - **Expanded**: full content with text selection
+ *
+ * Auto-enters Preview during streaming, auto-collapses when streaming ends.
+ */
+@Composable
+fun ReasoningCard(
+    text: String,
+    isStreaming: Boolean,
+    messageCreatedAt: Long,
+    modifier: Modifier = Modifier,
+) {
+    if (text.isBlank()) return
+
+    val (state, loading) = rememberReasoningState(text, isStreaming, messageCreatedAt)
+    val fadeHeight = 64f
+
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .animateContentSize()
+    ) {
+        // Header row
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable {
+                    state.expandState = when (state.expandState) {
+                        ReasoningCardState.Collapsed -> ReasoningCardState.Expanded
+                        ReasoningCardState.Preview -> ReasoningCardState.Expanded
+                        ReasoningCardState.Expanded -> if (loading)
+                            ReasoningCardState.Preview else ReasoningCardState.Collapsed
+                    }
+                }
+                .padding(vertical = 6.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            Icon(
+                imageVector = Icons.Default.Lightbulb,
+                contentDescription = null,
+                modifier = Modifier.size(16.dp),
+                tint = LegadoTheme.colorScheme.secondary
+            )
+            Spacer(modifier = Modifier.width(6.dp))
+            // Animated label: show duration during streaming, static text otherwise
+            AnimatedContent(
+                targetState = if (loading) {
+                    stringResource(R.string.ai_thinking_seconds, state.elapsedSeconds)
+                } else {
+                    stringResource(R.string.ai_reasoning_done)
+                },
+                transitionSpec = {
+                    (slideInVertically { it } + fadeIn()).togetherWith(
+                        slideOutVertically { -it } + fadeOut()
+                    )
+                },
+                label = "ReasoningLabel"
+            ) { label ->
+                AppText(
+                    text = label,
+                    style = LegadoTheme.typography.labelMedium,
+                    color = LegadoTheme.colorScheme.secondary
+                )
+            }
+            Spacer(modifier = Modifier.weight(1f))
+            Icon(
+                imageVector = if (state.expandState.expanded)
+                    Icons.Default.KeyboardArrowDown
+                else
+                    Icons.Default.KeyboardArrowDown,
+                contentDescription = null,
+                modifier = Modifier
+                    .size(18.dp)
+                    .then(
+                        if (state.expandState.expanded)
+                            Modifier  // pointing up when expanded
+                        else Modifier
+                    ),
+                tint = LegadoTheme.colorScheme.outline
+            )
+        }
+
+        // Content area
+        if (state.expandState != ReasoningCardState.Collapsed) {
+            val isPreview = state.expandState == ReasoningCardState.Preview
+            val contentModifier = Modifier
+                .fillMaxWidth()
+                .then(
+                    if (isPreview) {
+                        Modifier
+                            .graphicsLayer { alpha = 0.99f }
+                            .drawWithCache {
+                                val brush = Brush.verticalGradient(
+                                    startY = 0f,
+                                    endY = size.height,
+                                    colorStops = arrayOf(
+                                        0.0f to Color.Transparent,
+                                        (fadeHeight / size.height) to Color.Black,
+                                        (1 - fadeHeight / size.height) to Color.Black,
+                                        1.0f to Color.Transparent
+                                    )
+                                )
+                                onDrawWithContent {
+                                    drawContent()
+                                    drawRect(
+                                        brush = brush,
+                                        size = Size(size.width, size.height),
+                                        blendMode = BlendMode.DstIn,
+                                    )
+                                }
+                            }
+                            .heightIn(max = 100.dp)
+                            .verticalScroll(state.scrollState)
+                    } else Modifier
+                )
+
+            Column(modifier = contentModifier.padding(bottom = 6.dp)) {
+                if (loading) {
+                    AppText(
+                        text = text,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant
+                    )
+                } else {
+                    SelectionContainer {
+                        AppText(
+                            text = text,
+                            style = LegadoTheme.typography.bodySmall,
+                            color = LegadoTheme.colorScheme.onSurfaceVariant
+                        )
+                    }
+                }
+            }
+        }
+    }
+}

+ 205 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt

@@ -0,0 +1,205 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.AutoAwesome
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+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.unit.dp
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.NormalCard
+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.text.AppText
+import io.legado.app.ui.widget.components.text.MarkdownBlock
+import kotlinx.coroutines.delay
+
+/**
+ * Unified thinking/tool card.
+ *
+ * - Expands only while actively thinking or calling tools
+ * - Auto-collapses when streaming ends
+ * - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
+ */
+@Composable
+fun AiThinkingCard(
+    steps: List<AiThinkingStep>,
+    isStreaming: Boolean,
+    durationSeconds: Int = 0,
+    modifier: Modifier = Modifier,
+) {
+    val hasTools = steps.any { it is AiThinkingStep.ToolStep }
+    val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
+    val headerTitle = when {
+        hasTools && hasReasoning -> "思考与工具"
+        hasTools -> "工具调用"
+        isStreaming -> "思考中"
+        else -> "思考"
+    }
+    val headerIcon = if (hasTools) Icons.Default.Build else Icons.Default.AutoAwesome
+
+    // Live timer while streaming (fallback before first content token arrives)
+    var liveSeconds by remember { mutableStateOf(0) }
+    LaunchedEffect(isStreaming, durationSeconds) {
+        if (isStreaming && durationSeconds == 0) {
+            liveSeconds = 0
+            while (true) {
+                delay(1000)
+                liveSeconds++
+            }
+        }
+    }
+    val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
+
+    // Expand while streaming, collapse when done
+    var expanded by remember { mutableStateOf(isStreaming) }
+    LaunchedEffect(isStreaming) {
+        expanded = isStreaming
+    }
+
+    // Show time only for reasoning (not tool-only)
+    val showTime = hasReasoning && displaySeconds > 0
+
+    NormalCard(modifier = modifier.fillMaxWidth()) {
+        // Header
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .clickable { expanded = !expanded }
+                .padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
+            verticalAlignment = Alignment.CenterVertically
+        ) {
+            AppIcon(
+                imageVector = headerIcon,
+                contentDescription = null,
+                tint = LegadoTheme.colorScheme.onSurface,
+                modifier = Modifier.size(14.dp)
+            )
+            Spacer(modifier = Modifier.width(8.dp))
+            AppText(
+                text = headerTitle,
+                style = LegadoTheme.typography.labelSmallEmphasized,
+                color = LegadoTheme.colorScheme.onSurface,
+                maxLines = 1
+            )
+
+            Spacer(modifier = Modifier.weight(1f))
+
+            if (showTime) {
+                TextCard(text = "${displaySeconds}s")
+                Spacer(modifier = Modifier.width(8.dp))
+            }
+
+            AppIcon(
+                imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+                contentDescription = null,
+                modifier = Modifier.size(16.dp),
+                tint = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+        }
+
+        // Body
+        AnimatedVisibility(
+            visible = expanded,
+            enter = expandVertically(),
+            exit = shrinkVertically()
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .heightIn(max = 400.dp)
+                    .verticalScroll(rememberScrollState())
+            ) {
+                steps.forEach { step ->
+                    when (step) {
+                        is AiThinkingStep.ReasoningStep -> {
+                            MarkdownBlock(
+                                content = step.text,
+                                style = LegadoTheme.typography.bodySmall.copy(
+                                    color = LegadoTheme.colorScheme.onSurfaceVariant
+                                ),
+                                modifier = Modifier
+                                    .fillMaxWidth()
+                                    .padding(horizontal = 12.dp, vertical = 10.dp)
+                            )
+                        }
+                        is AiThinkingStep.ToolStep -> {
+                            ToolStepContent(tool = step.tool)
+                        }
+                    }
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ToolStepContent(tool: AiMessagePart.Tool) {
+    val hasContent = tool.output.isNotBlank() || tool.input.isNotBlank()
+
+    Column(
+        modifier = Modifier
+            .fillMaxWidth()
+            .padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)
+    ) {
+        Row(verticalAlignment = Alignment.CenterVertically) {
+            AppText(
+                text = tool.toolName,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant
+            )
+            if (!hasContent) {
+                Spacer(modifier = Modifier.width(6.dp))
+                AppText(
+                    text = "· 无输出",
+                    style = LegadoTheme.typography.labelSmall,
+                    color = LegadoTheme.colorScheme.outline
+                )
+            }
+        }
+        if (hasContent) {
+            MarkdownBlock(
+                content = buildToolContent(tool),
+                style = LegadoTheme.typography.bodySmall.copy(
+                    color = LegadoTheme.colorScheme.onSurfaceVariant
+                ),
+                modifier = Modifier.fillMaxWidth()
+            )
+        }
+    }
+}
+
+private fun buildToolContent(tool: AiMessagePart.Tool): String = buildString {
+    if (tool.input.isNotBlank()) {
+        append("**输入:**\n")
+        append(tool.input)
+        if (tool.output.isNotBlank()) append("\n\n")
+    }
+    if (tool.output.isNotBlank()) {
+        append("**结果:**\n")
+        append(tool.output)
+    }
+}

+ 267 - 0
app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingStepsCard.kt

@@ -0,0 +1,267 @@
+package io.legado.app.ui.ai.chat
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Build
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material3.Icon
+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.draw.clip
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.legado.app.R
+import io.legado.app.domain.model.AiMessagePart
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.widget.components.card.NormalCard
+import io.legado.app.ui.widget.components.text.AppText
+
+/**
+ * A card displaying a group of thinking steps (reasoning + tool calls).
+ *
+ * When there are more than [collapsedVisibleCount] steps, only the last
+ * few are shown with a "show more" toggle.
+ */
+@Composable
+fun AiThinkingStepsCard(
+    steps: List<AiThinkingStep>,
+    isStreaming: Boolean,
+    messageCreatedAt: Long,
+    modifier: Modifier = Modifier,
+    collapsedVisibleCount: Int = 2,
+) {
+    if (steps.isEmpty()) return
+
+    var expanded by remember { mutableStateOf(false) }
+    val canCollapse = steps.size > collapsedVisibleCount
+    val visibleSteps = if (expanded || !canCollapse) {
+        steps
+    } else {
+        steps.takeLast(collapsedVisibleCount)
+    }
+
+    NormalCard(
+        modifier = modifier.fillMaxWidth(),
+    ) {
+        Column(
+            modifier = Modifier
+                .animateContentSize()
+                .padding(horizontal = 12.dp, vertical = 8.dp)
+        ) {
+            // Collapse/expand toggle - no padding, ripple clipped by card
+            if (canCollapse) {
+                Row(
+                    modifier = Modifier
+                        .fillMaxWidth()
+                        .clickable { expanded = !expanded },
+                    verticalAlignment = Alignment.CenterVertically,
+                ) {
+                    Icon(
+                        imageVector = if (expanded)
+                            Icons.Default.KeyboardArrowDown
+                        else
+                            Icons.Default.KeyboardArrowDown,
+                        contentDescription = null,
+                        modifier = Modifier.size(16.dp),
+                        tint = LegadoTheme.colorScheme.primary,
+                    )
+                    AppText(
+                        modifier = Modifier.padding(start = 8.dp),
+                        text = if (expanded) {
+                            stringResource(R.string.ai_thinking_collapse)
+                        } else {
+                            stringResource(R.string.ai_thinking_show_more, steps.size - collapsedVisibleCount)
+                        },
+                        style = LegadoTheme.typography.labelMedium,
+                        color = LegadoTheme.colorScheme.primary,
+                    )
+                }
+            }
+
+            // Content
+            Column {
+                visibleSteps.forEachIndexed { index, step ->
+                    ThinkingStepRow(
+                        step = step,
+                        isStreaming = isStreaming && index == visibleSteps.lastIndex,
+                        messageCreatedAt = messageCreatedAt,
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun ThinkingStepRow(
+    step: AiThinkingStep,
+    isStreaming: Boolean,
+    messageCreatedAt: Long,
+) {
+    when (step) {
+        is AiThinkingStep.ReasoningStep -> {
+            ReasoningStepRow(
+                text = step.text,
+                isStreaming = isStreaming,
+                messageCreatedAt = messageCreatedAt,
+            )
+        }
+        is AiThinkingStep.ToolStep -> {
+            ToolStepRow(tool = step.tool)
+        }
+    }
+}
+
+@Composable
+private fun ReasoningStepRow(
+    text: String,
+    isStreaming: Boolean,
+    messageCreatedAt: Long,
+) {
+    ReasoningCard(
+        text = text,
+        isStreaming = isStreaming,
+        messageCreatedAt = messageCreatedAt,
+    )
+}
+
+@Composable
+private fun ToolStepRow(
+    tool: AiMessagePart.Tool,
+) {
+    var expanded by remember { mutableStateOf(false) }
+    val hasContent = tool.input.isNotBlank() || tool.output.isNotBlank()
+
+    Column {
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .then(
+                    if (hasContent) {
+                        Modifier
+                            .clickable { expanded = !expanded }
+                    } else Modifier
+                )
+                .padding(vertical = 6.dp),
+            verticalAlignment = Alignment.CenterVertically,
+            horizontalArrangement = Arrangement.spacedBy(8.dp)
+        ) {
+            // Icon
+            Box(
+                modifier = Modifier
+                    .size(20.dp)
+                    .clip(CircleShape)
+                    .background(LegadoTheme.colorScheme.surfaceContainerHigh),
+                contentAlignment = Alignment.Center,
+            ) {
+                Icon(
+                    imageVector = Icons.Default.Build,
+                    contentDescription = null,
+                    modifier = Modifier.size(14.dp),
+                    tint = LegadoTheme.colorScheme.primary,
+                )
+            }
+
+            // Label
+            AppText(
+                text = tool.toolName,
+                style = LegadoTheme.typography.labelMedium,
+                color = LegadoTheme.colorScheme.onSurface,
+                maxLines = 1,
+                overflow = TextOverflow.Ellipsis,
+                modifier = Modifier.weight(1f)
+            )
+
+            // Status
+            val statusText = when {
+                tool.output.isNotBlank() -> stringResource(R.string.ai_tool_done)
+                tool.approvalState == io.legado.app.domain.model.AiToolApprovalState.PENDING ->
+                    stringResource(R.string.ai_tool_pending)
+                else -> stringResource(R.string.ai_tool_running)
+            }
+            AppText(
+                text = statusText,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.outline
+            )
+
+            if (hasContent) {
+                Icon(
+                    imageVector = Icons.Default.KeyboardArrowDown,
+                    contentDescription = null,
+                    modifier = Modifier.size(16.dp),
+                    tint = LegadoTheme.colorScheme.outline
+                )
+            }
+        }
+
+        // Expandable tool details
+        AnimatedVisibility(
+            visible = expanded,
+            enter = fadeIn() + expandVertically(),
+            exit = shrinkVertically() + fadeOut()
+        ) {
+            Column(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(start = 28.dp, end = 4.dp, bottom = 6.dp),
+                verticalArrangement = Arrangement.spacedBy(4.dp)
+            ) {
+                if (tool.input.isNotBlank()) {
+                    AppText(
+                        text = stringResource(R.string.ai_tool_input),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.outline
+                    )
+                    AppText(
+                        text = tool.input,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 8,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+                if (tool.output.isNotBlank()) {
+                    Spacer(modifier = Modifier.height(2.dp))
+                    AppText(
+                        text = stringResource(R.string.ai_tool_output),
+                        style = LegadoTheme.typography.labelSmall,
+                        color = LegadoTheme.colorScheme.outline
+                    )
+                    AppText(
+                        text = tool.output,
+                        style = LegadoTheme.typography.bodySmall,
+                        color = LegadoTheme.colorScheme.onSurfaceVariant,
+                        maxLines = 12,
+                        overflow = TextOverflow.Ellipsis
+                    )
+                }
+            }
+        }
+    }
+}

+ 5 - 0
app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt

@@ -26,6 +26,7 @@ fun ConfigNavScreen(
     onNavigateToCover: () -> Unit,
     onNavigateToTheme: () -> Unit,
     onNavigateToBackup: () -> Unit,
+    onNavigateToAi: () -> Unit,
     onNavigateToDownloadCache: () -> Unit,
     onNavigateToTranslation: () -> Unit,
     onNavigateToLab: () -> Unit
@@ -77,6 +78,10 @@ fun ConfigNavScreen(
                         title = stringResource(R.string.backup_restore),
                         onClick = onNavigateToBackup
                     )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_config),
+                        onClick = onNavigateToAi
+                    )
                     ClickableSettingItem(
                         title = stringResource(R.string.translation_config),
                         onClick = onNavigateToTranslation

+ 51 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiConfigContract.kt

@@ -0,0 +1,51 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.runtime.Stable
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class AiConfigUiState(
+    val providers: ImmutableList<AiProviderListItemUi> = persistentListOf(),
+    val models: ImmutableList<AiModelListItemUi> = persistentListOf(),
+    val currentModelProfileId: String? = null,
+    val currentModelName: String = "",
+    val providerCount: Int = 0,
+    val modelCount: Int = 0,
+    val presetCount: Int = 0
+)
+
+@Stable
+data class AiProviderListItemUi(
+    val providerId: String,
+    val providerName: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelCount: Int,
+    val enabled: Boolean,
+    val models: ImmutableList<AiModelListItemUi> = persistentListOf()
+)
+
+@Stable
+data class AiModelListItemUi(
+    val providerId: String,
+    val modelProfileId: String,
+    val providerName: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelName: String,
+    val modelId: String,
+    val contextWindow: Int,
+    val maxOutputTokens: Int,
+    val enabled: Boolean,
+    val isCurrent: Boolean
+)
+
+sealed interface AiConfigIntent
+{
+    data class SetDefaultModel(val modelProfileId: String) : AiConfigIntent
+}
+
+sealed interface AiConfigEffect {
+    data class ShowMessage(val message: String) : AiConfigEffect
+}

+ 197 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt

@@ -0,0 +1,197 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.card.TextCard
+import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+
+@Composable
+fun AiConfigRouteScreen(
+    onBackClick: () -> Unit,
+    onNavigateToProviderEdit: (providerId: String?) -> Unit,
+    onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
+    onNavigateToChat: () -> Unit,
+    onNavigateToTranslation: () -> Unit,
+    viewModel: AiConfigViewModel = koinViewModel()
+) {
+    AiConfigScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        effects = viewModel.effects,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick,
+        onNavigateToProviderEdit = onNavigateToProviderEdit,
+        onNavigateToModelEdit = onNavigateToModelEdit,
+        onNavigateToChat = onNavigateToChat,
+        onNavigateToTranslation = onNavigateToTranslation
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiConfigScreen(
+    state: AiConfigUiState,
+    effects: Flow<AiConfigEffect>,
+    onIntent: (AiConfigIntent) -> Unit,
+    onBackClick: () -> Unit,
+    onNavigateToProviderEdit: (providerId: String?) -> Unit,
+    onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
+    onNavigateToChat: () -> Unit,
+    onNavigateToTranslation: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val snackbarHostState = remember { SnackbarHostState() }
+    var showModelSheet by remember { mutableStateOf(false) }
+
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is AiConfigEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.ai_config),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_provider_database)) {
+                    state.providers.forEach { provider ->
+                        ClickableSettingItem(
+                            title = provider.providerName,
+                            description = provider.baseUrl,
+                            option = "${provider.protocol} / ${provider.modelCount}",
+                            onClick = { onNavigateToProviderEdit(provider.providerId) }
+                        )
+                    }
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_new_provider),
+                        onClick = { onNavigateToProviderEdit(null) }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_model_database)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_current_model),
+                        description = state.currentModelName.ifBlank { stringResource(R.string.ai_model_not_configured) },
+                        onClick = {
+                            if (state.models.isEmpty()) {
+                                onNavigateToProviderEdit(null)
+                            } else {
+                                showModelSheet = true
+                            }
+                        }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_chat),
+                        onClick = onNavigateToChat
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.translation_config),
+                        onClick = onNavigateToTranslation
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_chapter_summary),
+                        onClick = {}
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_skills)) {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_new_skill),
+                        onClick = {}
+                    )
+                }
+            }
+        }
+    }
+
+    AppModalBottomSheet(
+        show = showModelSheet,
+        onDismissRequest = { showModelSheet = false },
+        title = stringResource(R.string.ai_select_model)
+    ) {
+        LazyColumn {
+            state.providers.forEach { provider ->
+                item {
+                    SplicedColumnGroup(title = provider.providerName) {
+                        if (provider.models.isEmpty()) {
+                            ClickableSettingItem(
+                                title = stringResource(R.string.ai_no_models_imported),
+                                description = stringResource(R.string.ai_fetch_and_save_models),
+                                onClick = {
+                                    showModelSheet = false
+                                    onNavigateToProviderEdit(provider.providerId)
+                                }
+                            )
+                        } else {
+                            provider.models.forEach { model ->
+                                ClickableSettingItem(
+                                    title = model.modelName,
+                                    description = model.modelId,
+                                    trailingContent = if (model.isCurrent) {
+                                        { TextCard(text = stringResource(R.string.ai_current)) }
+                                    } else {
+                                        null
+                                    },
+                                    onClick = {
+                                        onIntent(AiConfigIntent.SetDefaultModel(model.modelProfileId))
+                                        showModelSheet = false
+                                    }
+                                )
+                            }
+                        }
+                    }
+                }
+            }
+        }
+    }
+}

+ 107 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiConfigViewModel.kt

@@ -0,0 +1,107 @@
+package io.legado.app.ui.config.ai
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.model.AiTaskType
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class AiConfigViewModel(
+    private val aiProfileGateway: AiProfileGateway
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(AiConfigUiState())
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<AiConfigEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            combine(
+                aiProfileGateway.observeProviders(),
+                aiProfileGateway.observeModels(),
+                aiProfileGateway.observePresets()
+            ) { providers, models, presets ->
+                Triple(providers, models, presets)
+            }.collect { (providers, models, presets) ->
+                val providerMap = providers.associateBy { it.id }
+                val defaultTranslatePreset = presets.firstOrNull {
+                    it.taskType == AiTaskType.TRANSLATE_CHAPTER && it.isDefault
+                }
+                val currentModelProfileId = defaultTranslatePreset?.modelProfileId
+                    ?: models.firstOrNull()?.id
+                val modelItems = models.mapNotNull { model ->
+                    val provider = providerMap[model.providerId] ?: return@mapNotNull null
+                    AiModelListItemUi(
+                        providerId = provider.id,
+                        modelProfileId = model.id,
+                        providerName = provider.name,
+                        protocol = provider.protocol,
+                        baseUrl = provider.baseUrl,
+                        modelName = model.displayName,
+                        modelId = model.modelId,
+                        contextWindow = model.contextWindow,
+                        maxOutputTokens = model.maxOutputTokens,
+                        enabled = provider.enabled && model.enabled,
+                        isCurrent = model.id == currentModelProfileId
+                    )
+                }
+                val modelItemsByProvider = modelItems.groupBy { it.providerId }
+                val providerItems = providers.map { provider ->
+                    val providerModels = modelItemsByProvider[provider.id].orEmpty().toImmutableList()
+                    AiProviderListItemUi(
+                        providerId = provider.id,
+                        providerName = provider.name,
+                        protocol = provider.protocol,
+                        baseUrl = provider.baseUrl,
+                        modelCount = providerModels.size,
+                        enabled = provider.enabled,
+                        models = providerModels
+                    )
+                }.toImmutableList()
+                val modelNameById = models.associate { it.id to it.displayName }
+                val currentModelName = currentModelProfileId
+                    ?.let { modelNameById[it] }
+                    .orEmpty()
+
+                _uiState.update {
+                    it.copy(
+                        providers = providerItems,
+                        models = modelItems.toImmutableList(),
+                        currentModelProfileId = currentModelProfileId,
+                        currentModelName = currentModelName,
+                        providerCount = providers.size,
+                        modelCount = models.size,
+                        presetCount = presets.size
+                    )
+                }
+            }
+        }
+    }
+
+    fun onIntent(intent: AiConfigIntent) {
+        when (intent) {
+            is AiConfigIntent.SetDefaultModel -> setDefaultModel(intent.modelProfileId)
+        }
+    }
+
+    private fun setDefaultModel(modelProfileId: String) {
+        viewModelScope.launch {
+            runCatching {
+                aiProfileGateway.setDefaultModel(modelProfileId)
+            }.onSuccess {
+                _effects.tryEmit(AiConfigEffect.ShowMessage("Default AI model saved"))
+            }.onFailure { error ->
+                _effects.tryEmit(AiConfigEffect.ShowMessage(error.message ?: "Failed to save default AI model"))
+            }
+        }
+    }
+}

+ 45 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiModelEditContract.kt

@@ -0,0 +1,45 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.TranslationConstants
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class AiModelEditUiState(
+    val providers: ImmutableList<AiModelProviderOptionUi> = persistentListOf(),
+    val providerId: String? = null,
+    val modelProfileId: String? = null,
+    val modelName: String = "",
+    val modelId: String = "",
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
+    val isSaving: Boolean = false,
+    val isTesting: Boolean = false,
+    val initialized: Boolean = false
+)
+
+@Stable
+data class AiModelProviderOptionUi(
+    val id: String,
+    val name: String,
+    val protocol: String,
+    val baseUrl: String
+)
+
+sealed interface AiModelEditIntent {
+    data class SelectProvider(val providerId: String) : AiModelEditIntent
+    data class UpdateModelName(val value: String) : AiModelEditIntent
+    data class UpdateModelId(val value: String) : AiModelEditIntent
+    data class UpdateContextWindow(val value: Int) : AiModelEditIntent
+    data class UpdateMaxOutputTokens(val value: Int) : AiModelEditIntent
+    data class UpdateTemperature(val value: Float) : AiModelEditIntent
+    data object Save : AiModelEditIntent
+    data object TestConnection : AiModelEditIntent
+}
+
+sealed interface AiModelEditEffect {
+    data class ShowMessage(val message: String) : AiModelEditEffect
+    data object NavigateBack : AiModelEditEffect
+}

+ 220 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiModelEditScreen.kt

@@ -0,0 +1,220 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.model.TranslationConstants
+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.SplicedColumnGroup
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.InputSettingItem
+import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
+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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Composable
+fun AiModelEditRouteScreen(
+    providerId: String?,
+    modelProfileId: String?,
+    onBackClick: () -> Unit,
+    viewModel: AiModelEditViewModel = koinViewModel(
+        key = "${providerId.orEmpty()}_${modelProfileId.orEmpty()}",
+        parameters = { parametersOf(providerId, modelProfileId) }
+    )
+) {
+    AiModelEditScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        effects = viewModel.effects,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiModelEditScreen(
+    state: AiModelEditUiState,
+    effects: Flow<AiModelEditEffect>,
+    onIntent: (AiModelEditIntent) -> Unit,
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val snackbarHostState = remember { SnackbarHostState() }
+    val contextWindowOptions = buildLimitOptions(
+        baseOptions = listOf(
+            0 to stringResource(R.string.ai_not_set),
+            8_000 to "8K",
+            16_000 to "16K",
+            32_000 to "32K",
+            64_000 to "64K",
+            128_000 to "128K",
+            200_000 to "200K",
+            256_000 to "256K",
+            512_000 to "512K",
+            1_000_000 to "1M",
+            2_000_000 to "2M"
+        ),
+        currentValue = state.contextWindow,
+        currentLabel = stringResource(R.string.ai_current_value, formatTokenLimit(state.contextWindow))
+    )
+    val maxOutputTokenOptions = buildLimitOptions(
+        baseOptions = listOf(
+            0 to stringResource(R.string.ai_not_set),
+            1_000 to "1K",
+            2_000 to "2K",
+            4_000 to "4K",
+            8_000 to "8K",
+            16_000 to "16K",
+            32_000 to "32K",
+            64_000 to "64K",
+            128_000 to "128K"
+        ),
+        currentValue = state.maxOutputTokens,
+        currentLabel = stringResource(R.string.ai_current_value, formatTokenLimit(state.maxOutputTokens))
+    )
+
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is AiModelEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+                AiModelEditEffect.NavigateBack -> onBackClick()
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.ai_model_edit),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(AiModelEditIntent.Save) },
+                icon = Icons.Default.Save,
+                tooltipText = stringResource(R.string.ai_save_default_model)
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_model_config)) {
+                    if (state.providers.isEmpty()) {
+                        ClickableSettingItem(
+                            title = stringResource(R.string.ai_provider),
+                            description = stringResource(R.string.ai_no_provider_configured),
+                            onClick = {}
+                        )
+                    } else {
+                        DropdownListSettingItem(
+                            title = stringResource(R.string.ai_provider),
+                            selectedValue = state.providerId.orEmpty(),
+                            displayEntries = state.providers.map {
+                                "${it.name} / ${it.protocol}"
+                            }.toTypedArray(),
+                            entryValues = state.providers.map { it.id }.toTypedArray(),
+                            onValueChange = { onIntent(AiModelEditIntent.SelectProvider(it)) }
+                        )
+                    }
+                    InputSettingItem(
+                        title = stringResource(R.string.ai_model_name),
+                        value = state.modelName,
+                        onConfirm = { onIntent(AiModelEditIntent.UpdateModelName(it)) }
+                    )
+                    InputSettingItem(
+                        title = stringResource(R.string.ai_model_id),
+                        value = state.modelId,
+                        onConfirm = { onIntent(AiModelEditIntent.UpdateModelId(it)) }
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.ai_context_window),
+                        selectedValue = state.contextWindow.toString(),
+                        displayEntries = contextWindowOptions.displayEntries,
+                        entryValues = contextWindowOptions.entryValues,
+                        onValueChange = { onIntent(AiModelEditIntent.UpdateContextWindow(it.toIntOrNull() ?: 0)) }
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.ai_max_output_tokens),
+                        selectedValue = state.maxOutputTokens.toString(),
+                        displayEntries = maxOutputTokenOptions.displayEntries,
+                        entryValues = maxOutputTokenOptions.entryValues,
+                        onValueChange = { onIntent(AiModelEditIntent.UpdateMaxOutputTokens(it.toIntOrNull() ?: 0)) }
+                    )
+                    SliderSettingItem(
+                        title = stringResource(R.string.ai_temperature),
+                        value = state.temperature,
+                        defaultValue = TranslationConstants.DEFAULT_TEMPERATURE,
+                        valueRange = TranslationConstants.MIN_TEMPERATURE..TranslationConstants.MAX_TEMPERATURE,
+                        steps = 19,
+                        description = state.temperature.toString(),
+                        onValueChange = { onIntent(AiModelEditIntent.UpdateTemperature(it)) }
+                    )
+                }
+            }
+
+        }
+    }
+}
+
+private data class TokenLimitOptions(
+    val displayEntries: Array<String>,
+    val entryValues: Array<String>
+)
+
+private fun buildLimitOptions(
+    baseOptions: List<Pair<Int, String>>,
+    currentValue: Int,
+    currentLabel: String
+): TokenLimitOptions {
+    val options = if (currentValue > 0 && baseOptions.none { it.first == currentValue }) {
+        baseOptions + (currentValue to currentLabel)
+    } else {
+        baseOptions
+    }
+    return TokenLimitOptions(
+        displayEntries = options.map { it.second }.toTypedArray(),
+        entryValues = options.map { it.first.toString() }.toTypedArray()
+    )
+}
+
+internal fun formatTokenLimit(value: Int): String {
+    return when {
+        value <= 0 -> "0"
+        value >= 1_000_000 && value % 1_000_000 == 0 -> "${value / 1_000_000}M"
+        value >= 1_000 && value % 1_000 == 0 -> "${value / 1_000}K"
+        else -> value.toString()
+    }
+}

+ 159 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiModelEditViewModel.kt

@@ -0,0 +1,159 @@
+package io.legado.app.ui.config.ai
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.model.AiGenerateRequest
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiMessage
+import io.legado.app.domain.model.AiMessageRole
+import io.legado.app.domain.model.AiModelDraft
+import io.legado.app.utils.GSON
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class AiModelEditViewModel(
+    private val initialProviderId: String?,
+    private val initialModelProfileId: String?,
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiTextGateway: AiTextGateway
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        AiModelEditUiState(
+            providerId = initialProviderId,
+            modelProfileId = initialModelProfileId
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<AiModelEditEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            combine(
+                aiProfileGateway.observeProviders(),
+                aiProfileGateway.observeModels()
+            ) { providers, models ->
+                providers to models
+            }.collect { (providers, models) ->
+                val providerOptions = providers.map {
+                    AiModelProviderOptionUi(
+                        id = it.id,
+                        name = it.name,
+                        protocol = it.protocol,
+                        baseUrl = it.baseUrl
+                    )
+                }.toImmutableList()
+                _uiState.update { current ->
+                    val model = models.firstOrNull { it.id == initialModelProfileId }
+                    val params = parseParams(model?.defaultParamsJson)
+                    val selectedProviderId = current.providerId
+                        ?: model?.providerId
+                        ?: initialProviderId
+                        ?: providers.firstOrNull()?.id
+                    if (current.initialized) {
+                        current.copy(providers = providerOptions)
+                    } else {
+                        current.copy(
+                            providers = providerOptions,
+                            providerId = selectedProviderId,
+                            modelProfileId = model?.id ?: current.modelProfileId,
+                            modelName = model?.displayName.orEmpty(),
+                            modelId = model?.modelId.orEmpty(),
+                            contextWindow = model?.contextWindow ?: 0,
+                            maxOutputTokens = model?.maxOutputTokens ?: 0,
+                            temperature = params.temperature ?: current.temperature,
+                            initialized = true
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun onIntent(intent: AiModelEditIntent) {
+        when (intent) {
+            is AiModelEditIntent.SelectProvider -> _uiState.update { it.copy(providerId = intent.providerId) }
+            is AiModelEditIntent.UpdateModelName -> _uiState.update { it.copy(modelName = intent.value) }
+            is AiModelEditIntent.UpdateModelId -> _uiState.update { it.copy(modelId = intent.value) }
+            is AiModelEditIntent.UpdateContextWindow -> _uiState.update { it.copy(contextWindow = intent.value) }
+            is AiModelEditIntent.UpdateMaxOutputTokens -> _uiState.update { it.copy(maxOutputTokens = intent.value) }
+            is AiModelEditIntent.UpdateTemperature -> _uiState.update { it.copy(temperature = intent.value) }
+            AiModelEditIntent.Save -> save(navigateBack = true)
+            AiModelEditIntent.TestConnection -> testConnection()
+        }
+    }
+
+    private fun save(navigateBack: Boolean) {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            runCatching {
+                val model = aiProfileGateway.saveModel(_uiState.value.toDraft())
+                aiProfileGateway.setDefaultModel(model.id)
+                model
+            }.onSuccess { model ->
+                _uiState.update { it.copy(modelProfileId = model.id) }
+                _effects.tryEmit(AiModelEditEffect.ShowMessage("Default AI model saved"))
+                if (navigateBack) {
+                    _effects.tryEmit(AiModelEditEffect.NavigateBack)
+                }
+            }.onFailure { error ->
+                _effects.tryEmit(AiModelEditEffect.ShowMessage(error.message ?: "Failed to save AI model"))
+            }
+            _uiState.update { it.copy(isSaving = false) }
+        }
+    }
+
+    private fun testConnection() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isTesting = true) }
+            runCatching {
+                val model = aiProfileGateway.saveModel(_uiState.value.toDraft())
+                val preset = aiProfileGateway.setDefaultModel(model.id)
+                aiTextGateway.generate(
+                    AiGenerateRequest(
+                        model = preset.model,
+                        messages = listOf(
+                            AiMessage(AiMessageRole.SYSTEM, "Reply with OK only."),
+                            AiMessage(AiMessageRole.USER, "Connection test")
+                        ),
+                        params = preset.params.copy(maxOutputTokens = 16)
+                    )
+                ).getOrThrow()
+            }.onSuccess {
+                _effects.tryEmit(AiModelEditEffect.ShowMessage("AI connection test succeeded"))
+            }.onFailure { error ->
+                _effects.tryEmit(AiModelEditEffect.ShowMessage(error.message ?: "AI connection test failed"))
+            }
+            _uiState.update { it.copy(isTesting = false) }
+        }
+    }
+
+    private fun AiModelEditUiState.toDraft(): AiModelDraft {
+        return AiModelDraft(
+            modelProfileId = modelProfileId,
+            providerId = providerId.orEmpty(),
+            modelName = modelName.trim(),
+            modelId = modelId.trim(),
+            contextWindow = contextWindow,
+            maxOutputTokens = maxOutputTokens,
+            temperature = temperature
+        )
+    }
+
+    private fun parseParams(json: String?): AiGenerationParams {
+        if (json.isNullOrBlank()) return AiGenerationParams()
+        return runCatching {
+            GSON.fromJson(json, AiGenerationParams::class.java)
+        }.getOrDefault(AiGenerationParams())
+    }
+}

+ 89 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditContract.kt

@@ -0,0 +1,89 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.runtime.Stable
+import io.legado.app.domain.model.AiProtocol
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Stable
+data class AiProviderEditUiState(
+    val providerPresets: ImmutableList<AiProviderPresetUi> = persistentListOf(),
+    val fetchedModels: ImmutableList<AiFetchedModelUi> = persistentListOf(),
+    val selectedProviderPresetId: String = "",
+    val providerId: String? = null,
+    val providerName: String = "OpenAI Compatible",
+    val protocol: String = AiProtocol.OPENAI_CHAT_COMPLETIONS,
+    val baseUrl: String = "",
+    val modelsUrl: String = "",
+    val apiKey: String = "",
+    val providerModels: ImmutableList<AiProviderModelUi> = persistentListOf(),
+    val editingModel: AiProviderModelEditorUi? = null,
+    val isSaving: Boolean = false,
+    val isFetchingModels: Boolean = false,
+    val initialized: Boolean = false
+)
+
+@Stable
+data class AiProviderPresetUi(
+    val id: String,
+    val name: String,
+    val protocol: String,
+    val baseUrl: String,
+    val modelsUrl: String,
+    val modelName: String,
+    val modelId: String
+)
+
+@Stable
+data class AiFetchedModelUi(
+    val id: String,
+    val name: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0
+)
+
+@Stable
+data class AiProviderModelUi(
+    val modelProfileId: String,
+    val providerId: String,
+    val modelName: String,
+    val modelId: String,
+    val contextWindow: Int = 0,
+    val maxOutputTokens: Int = 0,
+    val temperature: Float = 0.3f
+)
+
+@Stable
+data class AiProviderModelEditorUi(
+    val modelProfileId: String? = null,
+    val modelName: String = "",
+    val modelId: String = "",
+    val contextWindow: String = "",
+    val maxOutputTokens: String = "",
+    val temperature: String = "0.3"
+)
+
+sealed interface AiProviderEditIntent {
+    data class ApplyProviderPreset(val id: String) : AiProviderEditIntent
+    data class UpdateProviderName(val value: String) : AiProviderEditIntent
+    data class UpdateProtocol(val value: String) : AiProviderEditIntent
+    data class UpdateBaseUrl(val value: String) : AiProviderEditIntent
+    data class UpdateModelsUrl(val value: String) : AiProviderEditIntent
+    data class UpdateApiKey(val value: String) : AiProviderEditIntent
+    data object AddModel : AiProviderEditIntent
+    data class EditModel(val modelProfileId: String) : AiProviderEditIntent
+    data object DismissModelEditor : AiProviderEditIntent
+    data class UpdateEditingModelName(val value: String) : AiProviderEditIntent
+    data class UpdateEditingModelId(val value: String) : AiProviderEditIntent
+    data class UpdateEditingContextWindow(val value: String) : AiProviderEditIntent
+    data class UpdateEditingMaxOutputTokens(val value: String) : AiProviderEditIntent
+    data class UpdateEditingTemperature(val value: String) : AiProviderEditIntent
+    data object SaveEditingModel : AiProviderEditIntent
+    data object SaveProvider : AiProviderEditIntent
+    data object SyncModels : AiProviderEditIntent
+}
+
+sealed interface AiProviderEditEffect {
+    data class ShowMessage(val message: String) : AiProviderEditEffect
+    data object NavigateBack : AiProviderEditEffect
+}

+ 303 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditScreen.kt

@@ -0,0 +1,303 @@
+package io.legado.app.ui.config.ai
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Save
+import androidx.compose.material.icons.filled.Visibility
+import androidx.compose.material.icons.filled.VisibilityOff
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.SnackbarHost
+import androidx.compose.material3.SnackbarHostState
+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.Modifier
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.text.input.PasswordVisualTransformation
+import androidx.compose.ui.text.input.VisualTransformation
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import io.legado.app.R
+import io.legado.app.domain.model.AiProtocol
+import io.legado.app.ui.theme.LegadoTheme
+import io.legado.app.ui.theme.adaptiveContentPadding
+import io.legado.app.ui.widget.components.AppFloatingActionButton
+import io.legado.app.ui.widget.components.AppScaffold
+import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
+import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
+import io.legado.app.ui.widget.components.settingItem.InputSettingItem
+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 kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.collectLatest
+import org.koin.androidx.compose.koinViewModel
+import org.koin.core.parameter.parametersOf
+
+@Composable
+fun AiProviderEditRouteScreen(
+    providerId: String?,
+    onBackClick: () -> Unit,
+    viewModel: AiProviderEditViewModel = koinViewModel(
+        key = providerId.orEmpty(),
+        parameters = { parametersOf(providerId) }
+    )
+) {
+    AiProviderEditScreen(
+        state = viewModel.uiState.collectAsStateWithLifecycle().value,
+        effects = viewModel.effects,
+        onIntent = viewModel::onIntent,
+        onBackClick = onBackClick
+    )
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AiProviderEditScreen(
+    state: AiProviderEditUiState,
+    effects: Flow<AiProviderEditEffect>,
+    onIntent: (AiProviderEditIntent) -> Unit,
+    onBackClick: () -> Unit
+) {
+    val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
+    val snackbarHostState = remember { SnackbarHostState() }
+    val providerPresetEntries = arrayOf(stringResource(R.string.ai_custom_provider)) +
+            state.providerPresets
+                .filter { it.protocol == state.protocol }
+                .map { it.name }
+                .toTypedArray()
+    val providerPresetValues = arrayOf("") +
+            state.providerPresets
+                .filter { it.protocol == state.protocol }
+                .map { it.id }
+                .toTypedArray()
+    var showApiKeyDialog by remember { mutableStateOf(false) }
+    var apiKeyDraft by remember { mutableStateOf("") }
+    var apiKeyVisible by remember { mutableStateOf(false) }
+
+    LaunchedEffect(Unit) {
+        effects.collectLatest { effect ->
+            when (effect) {
+                is AiProviderEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
+                AiProviderEditEffect.NavigateBack -> onBackClick()
+            }
+        }
+    }
+
+    AppScaffold(
+        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
+        snackbarHost = { SnackbarHost(snackbarHostState) },
+        topBar = {
+            GlassMediumFlexibleTopAppBar(
+                title = stringResource(R.string.ai_provider_edit),
+                scrollBehavior = scrollBehavior,
+                navigationIcon = {
+                    TopBarNavigationButton(onClick = onBackClick)
+                }
+            )
+        },
+        floatingActionButton = {
+            AppFloatingActionButton(
+                onClick = { onIntent(AiProviderEditIntent.SaveProvider) },
+                icon = Icons.Default.Save,
+                tooltipText = stringResource(R.string.ai_save_provider)
+            )
+        }
+    ) { paddingValues ->
+        LazyColumn(
+            modifier = Modifier.fillMaxSize(),
+            contentPadding = adaptiveContentPadding(
+                top = paddingValues.calculateTopPadding(),
+                bottom = 120.dp
+            )
+        ) {
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_provider)) {
+                    InputSettingItem(
+                        title = stringResource(R.string.ai_provider_name),
+                        value = state.providerName,
+                        onConfirm = { onIntent(AiProviderEditIntent.UpdateProviderName(it)) }
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.ai_protocol),
+                        selectedValue = state.protocol,
+                        displayEntries = arrayOf("OpenAI Chat Completions", "OpenAI Responses", "Anthropic Messages"),
+                        entryValues = arrayOf(
+                            AiProtocol.OPENAI_CHAT_COMPLETIONS,
+                            AiProtocol.OPENAI_RESPONSES,
+                            AiProtocol.ANTHROPIC_MESSAGES
+                        ),
+                        onValueChange = { onIntent(AiProviderEditIntent.UpdateProtocol(it)) }
+                    )
+                    DropdownListSettingItem(
+                        title = stringResource(R.string.ai_provider_preset),
+                        selectedValue = state.selectedProviderPresetId,
+                        displayEntries = providerPresetEntries,
+                        entryValues = providerPresetValues,
+                        onValueChange = { onIntent(AiProviderEditIntent.ApplyProviderPreset(it)) }
+                    )
+                    InputSettingItem(
+                        title = stringResource(R.string.ai_base_url),
+                        value = state.baseUrl,
+                        onConfirm = { onIntent(AiProviderEditIntent.UpdateBaseUrl(it)) }
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_api_key),
+                        description = stringResource(R.string.ai_api_key_summary),
+                        onClick = {
+                            apiKeyDraft = state.apiKey
+                            showApiKeyDialog = true
+                        }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_provider_models)) {
+                    state.providerModels.forEach { model ->
+                        ClickableSettingItem(
+                            title = model.modelName,
+                            description = model.modelId,
+                            option = formatFetchedLimit(model.contextWindow, model.maxOutputTokens),
+                            onClick = { onIntent(AiProviderEditIntent.EditModel(model.modelProfileId)) }
+                        )
+                    }
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_add_model_manually),
+                        onClick = { onIntent(AiProviderEditIntent.AddModel) }
+                    )
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_fetch_and_save_models),
+                        description = stringResource(R.string.ai_fetch_models),
+                        onClick = { onIntent(AiProviderEditIntent.SyncModels) }
+                    )
+                }
+            }
+
+            item {
+                SplicedColumnGroup(title = stringResource(R.string.ai_advanced)) {
+                    InputSettingItem(
+                        title = stringResource(R.string.ai_models_url),
+                        value = state.modelsUrl,
+                        description = stringResource(R.string.ai_models_url_summary),
+                        onConfirm = { onIntent(AiProviderEditIntent.UpdateModelsUrl(it)) }
+                    )
+                }
+            }
+        }
+    }
+
+    AppAlertDialog(
+        show = showApiKeyDialog,
+        onDismissRequest = { showApiKeyDialog = false },
+        title = stringResource(R.string.ai_api_key),
+        content = {
+            Column {
+                AppTextField(
+                    value = apiKeyDraft,
+                    onValueChange = { apiKeyDraft = it },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_api_key),
+                    visualTransformation = if (apiKeyVisible) VisualTransformation.None else PasswordVisualTransformation(),
+                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
+                    trailingIcon = {
+                        val image = if (apiKeyVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
+                        IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
+                            Icon(imageVector = image, contentDescription = null)
+                        }
+                    }
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+            }
+        },
+        confirmText = stringResource(R.string.ok),
+        onConfirm = {
+            onIntent(AiProviderEditIntent.UpdateApiKey(apiKeyDraft))
+            showApiKeyDialog = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showApiKeyDialog = false }
+    )
+
+    AppAlertDialog(
+        data = state.editingModel,
+        onDismissRequest = { onIntent(AiProviderEditIntent.DismissModelEditor) },
+        title = stringResource(R.string.ai_model_edit),
+        content = { model ->
+            Column {
+                AppTextField(
+                    value = model.modelName,
+                    onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingModelName(it)) },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_model_name)
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+                AppTextField(
+                    value = model.modelId,
+                    onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingModelId(it)) },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_model_id)
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+                AppTextField(
+                    value = model.contextWindow,
+                    onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingContextWindow(it)) },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_context_window),
+                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+                AppTextField(
+                    value = model.maxOutputTokens,
+                    onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingMaxOutputTokens(it)) },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_max_output_tokens),
+                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
+                )
+                Spacer(modifier = Modifier.height(8.dp))
+                AppTextField(
+                    value = model.temperature,
+                    onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingTemperature(it)) },
+                    modifier = Modifier.fillMaxWidth(),
+                    backgroundColor = LegadoTheme.colorScheme.surface,
+                    label = stringResource(R.string.ai_temperature),
+                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
+                )
+            }
+        },
+        confirmText = stringResource(R.string.ai_save_model),
+        onConfirm = { onIntent(AiProviderEditIntent.SaveEditingModel) },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { onIntent(AiProviderEditIntent.DismissModelEditor) }
+    )
+}
+
+private fun formatFetchedLimit(contextWindow: Int, maxOutputTokens: Int): String? {
+    return when {
+        contextWindow > 0 && maxOutputTokens > 0 -> "${formatTokenLimit(contextWindow)} / ${formatTokenLimit(maxOutputTokens)}"
+        contextWindow > 0 -> formatTokenLimit(contextWindow)
+        maxOutputTokens > 0 -> formatTokenLimit(maxOutputTokens)
+        else -> null
+    }
+}

+ 283 - 0
app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditViewModel.kt

@@ -0,0 +1,283 @@
+package io.legado.app.ui.config.ai
+
+import androidx.lifecycle.ViewModel
+import androidx.lifecycle.viewModelScope
+import io.legado.app.domain.gateway.AiProfileGateway
+import io.legado.app.domain.gateway.AiTextGateway
+import io.legado.app.domain.model.AiAvailableModel
+import io.legado.app.domain.model.AiGenerationParams
+import io.legado.app.domain.model.AiModelDraft
+import io.legado.app.domain.model.AiProviderConfig
+import io.legado.app.domain.model.AiProviderDraft
+import io.legado.app.domain.model.AiProviderPresets
+import io.legado.app.domain.model.TranslationConstants
+import io.legado.app.utils.GSON
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.flow.MutableSharedFlow
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.asSharedFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+
+class AiProviderEditViewModel(
+    private val initialProviderId: String?,
+    private val aiProfileGateway: AiProfileGateway,
+    private val aiTextGateway: AiTextGateway
+) : ViewModel() {
+
+    private val _uiState = MutableStateFlow(
+        AiProviderEditUiState(
+            providerPresets = AiProviderPresets.items.map {
+                AiProviderPresetUi(
+                    id = it.id,
+                    name = it.name,
+                    protocol = it.protocol,
+                    baseUrl = it.baseUrl,
+                    modelsUrl = it.modelsUrl,
+                    modelName = it.modelName,
+                    modelId = it.modelId
+                )
+            }.toImmutableList(),
+            providerId = initialProviderId
+        )
+    )
+    val uiState = _uiState.asStateFlow()
+
+    private val _effects = MutableSharedFlow<AiProviderEditEffect>(extraBufferCapacity = 16)
+    val effects = _effects.asSharedFlow()
+
+    init {
+        viewModelScope.launch {
+            combine(
+                aiProfileGateway.observeProviders(),
+                aiProfileGateway.observeModels()
+            ) { providers, models ->
+                providers to models
+            }.collect { (providers, models) ->
+                _uiState.update { current ->
+                    val providerId = current.providerId ?: initialProviderId
+                    val provider = providerId?.let { id -> providers.firstOrNull { it.id == id } }
+                    val providerModels = models
+                        .filter { it.providerId == providerId }
+                        .map { model ->
+                            val params = parseParams(model.defaultParamsJson)
+                            AiProviderModelUi(
+                                modelProfileId = model.id,
+                                providerId = model.providerId,
+                                modelName = model.displayName,
+                                modelId = model.modelId,
+                                contextWindow = model.contextWindow,
+                                maxOutputTokens = model.maxOutputTokens,
+                                temperature = params.temperature ?: TranslationConstants.DEFAULT_TEMPERATURE
+                            )
+                        }
+                        .toImmutableList()
+                    if (current.initialized) {
+                        current.copy(providerModels = providerModels)
+                    } else {
+                        current.copy(
+                            providerId = provider?.id ?: current.providerId,
+                            providerName = provider?.name ?: current.providerName,
+                            protocol = provider?.protocol ?: current.protocol,
+                            baseUrl = provider?.baseUrl.orEmpty(),
+                            modelsUrl = provider?.modelsUrl.orEmpty(),
+                            apiKey = provider?.apiKey.orEmpty(),
+                            providerModels = providerModels,
+                            initialized = true
+                        )
+                    }
+                }
+            }
+        }
+    }
+
+    fun onIntent(intent: AiProviderEditIntent) {
+        when (intent) {
+            is AiProviderEditIntent.ApplyProviderPreset -> applyProviderPreset(intent.id)
+            is AiProviderEditIntent.UpdateProviderName -> _uiState.update { it.copy(providerName = intent.value) }
+            is AiProviderEditIntent.UpdateProtocol -> _uiState.update {
+                it.copy(protocol = intent.value, selectedProviderPresetId = "", fetchedModels = emptyList<AiFetchedModelUi>().toImmutableList())
+            }
+            is AiProviderEditIntent.UpdateBaseUrl -> _uiState.update { it.copy(baseUrl = intent.value, selectedProviderPresetId = "") }
+            is AiProviderEditIntent.UpdateModelsUrl -> _uiState.update { it.copy(modelsUrl = intent.value, selectedProviderPresetId = "") }
+            is AiProviderEditIntent.UpdateApiKey -> _uiState.update { it.copy(apiKey = intent.value) }
+            AiProviderEditIntent.AddModel -> _uiState.update {
+                it.copy(editingModel = AiProviderModelEditorUi(temperature = TranslationConstants.DEFAULT_TEMPERATURE.toString()))
+            }
+            is AiProviderEditIntent.EditModel -> editModel(intent.modelProfileId)
+            AiProviderEditIntent.DismissModelEditor -> _uiState.update { it.copy(editingModel = null) }
+            is AiProviderEditIntent.UpdateEditingModelName -> updateEditingModel { copy(modelName = intent.value) }
+            is AiProviderEditIntent.UpdateEditingModelId -> updateEditingModel { copy(modelId = intent.value) }
+            is AiProviderEditIntent.UpdateEditingContextWindow -> updateEditingModel { copy(contextWindow = intent.value) }
+            is AiProviderEditIntent.UpdateEditingMaxOutputTokens -> updateEditingModel { copy(maxOutputTokens = intent.value) }
+            is AiProviderEditIntent.UpdateEditingTemperature -> updateEditingModel { copy(temperature = intent.value) }
+            AiProviderEditIntent.SaveEditingModel -> saveEditingModel()
+            AiProviderEditIntent.SaveProvider -> saveProvider()
+            AiProviderEditIntent.SyncModels -> syncModels()
+        }
+    }
+
+    private fun applyProviderPreset(id: String) {
+        if (id.isBlank()) {
+            _uiState.update { it.copy(selectedProviderPresetId = "") }
+            return
+        }
+        val preset = AiProviderPresets.items.firstOrNull { it.id == id } ?: return
+        _uiState.update {
+            it.copy(
+                selectedProviderPresetId = preset.id,
+                providerName = preset.name,
+                protocol = preset.protocol,
+                baseUrl = preset.baseUrl,
+                modelsUrl = preset.modelsUrl,
+                fetchedModels = emptyList<AiFetchedModelUi>().toImmutableList()
+            )
+        }
+    }
+
+    private fun editModel(modelProfileId: String) {
+        val model = _uiState.value.providerModels.firstOrNull { it.modelProfileId == modelProfileId } ?: return
+        _uiState.update {
+            it.copy(
+                editingModel = AiProviderModelEditorUi(
+                    modelProfileId = model.modelProfileId,
+                    modelName = model.modelName,
+                    modelId = model.modelId,
+                    contextWindow = model.contextWindow.takeIf { value -> value > 0 }?.toString().orEmpty(),
+                    maxOutputTokens = model.maxOutputTokens.takeIf { value -> value > 0 }?.toString().orEmpty(),
+                    temperature = model.temperature.toString()
+                )
+            )
+        }
+    }
+
+    private fun updateEditingModel(update: AiProviderModelEditorUi.() -> AiProviderModelEditorUi) {
+        _uiState.update {
+            it.copy(editingModel = it.editingModel?.update())
+        }
+    }
+
+    private fun saveEditingModel() {
+        viewModelScope.launch {
+            val editor = _uiState.value.editingModel ?: return@launch
+            _uiState.update { it.copy(isSaving = true) }
+            runCatching {
+                val providerId = _uiState.value.providerId
+                    ?: aiProfileGateway.saveProvider(_uiState.value.toDraft()).id
+                aiProfileGateway.saveModel(
+                    AiModelDraft(
+                        modelProfileId = editor.modelProfileId,
+                        providerId = providerId,
+                        modelName = editor.modelName.trim(),
+                        modelId = editor.modelId.trim(),
+                        contextWindow = editor.contextWindow.toIntOrNull() ?: 0,
+                        maxOutputTokens = editor.maxOutputTokens.toIntOrNull() ?: 0,
+                        temperature = editor.temperature.toFloatOrNull()
+                            ?: TranslationConstants.DEFAULT_TEMPERATURE
+                    )
+                )
+            }.onSuccess { model ->
+                _uiState.update { it.copy(editingModel = null, providerId = model.providerId) }
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage("AI model saved"))
+            }.onFailure { error ->
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI model"))
+            }
+            _uiState.update { it.copy(isSaving = false) }
+        }
+    }
+
+    private fun saveProvider() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true) }
+            runCatching {
+                aiProfileGateway.saveProvider(_uiState.value.toDraft())
+            }.onSuccess { provider ->
+                _uiState.update { it.copy(providerId = provider.id) }
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage("AI provider saved"))
+                _effects.tryEmit(AiProviderEditEffect.NavigateBack)
+            }.onFailure { error ->
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI provider"))
+            }
+            _uiState.update { it.copy(isSaving = false) }
+        }
+    }
+
+    private fun syncModels() {
+        viewModelScope.launch {
+            _uiState.update { it.copy(isSaving = true, isFetchingModels = true) }
+            runCatching {
+                aiProfileGateway.saveProvider(_uiState.value.toDraft())
+            }.onSuccess { provider ->
+                _uiState.update { it.copy(providerId = provider.id) }
+                val state = _uiState.value
+                runCatching {
+                    aiTextGateway.fetchModels(
+                        AiProviderConfig(
+                            id = provider.id,
+                            name = state.providerName,
+                            protocol = state.protocol,
+                            baseUrl = state.baseUrl,
+                            apiKey = state.apiKey,
+                            modelsUrl = state.modelsUrl.ifBlank { null }
+                        )
+                    ).getOrThrow()
+                }.onSuccess { models ->
+                    aiProfileGateway.importProviderModels(provider.id, models)
+                    applyFetchedModels(provider.id, models)
+                }.onFailure { error ->
+                    _effects.tryEmit(
+                        AiProviderEditEffect.ShowMessage(
+                            error.message ?: "AI provider saved, but failed to fetch models"
+                        )
+                    )
+                }
+            }.onFailure { error ->
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI provider"))
+            }
+            _uiState.update { it.copy(isSaving = false, isFetchingModels = false) }
+        }
+    }
+
+    private fun applyFetchedModels(providerId: String, models: List<AiAvailableModel>) {
+        val options = models.map {
+            AiFetchedModelUi(
+                id = it.id,
+                name = it.name,
+                contextWindow = it.contextWindow,
+                maxOutputTokens = it.maxOutputTokens
+            )
+        }.toImmutableList()
+        _uiState.update {
+            it.copy(
+                providerId = providerId,
+                fetchedModels = options
+            )
+        }
+        val message = if (options.isEmpty()) {
+            "No models found"
+        } else {
+            "Fetched and saved ${options.size} models"
+        }
+        _effects.tryEmit(AiProviderEditEffect.ShowMessage(message))
+    }
+
+    private fun AiProviderEditUiState.toDraft(): AiProviderDraft {
+        return AiProviderDraft(
+            providerId = providerId,
+            providerName = providerName,
+            protocol = protocol,
+            baseUrl = baseUrl,
+            modelsUrl = modelsUrl,
+            apiKey = apiKey
+        )
+    }
+
+    private fun parseParams(json: String?): AiGenerationParams {
+        if (json.isNullOrBlank()) return AiGenerationParams()
+        return runCatching {
+            GSON.fromJson(json, AiGenerationParams::class.java)
+        }.getOrDefault(AiGenerationParams())
+    }
+}

+ 11 - 1
app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt

@@ -11,11 +11,20 @@ object TranslationConfig {
         false
     )
 
-    var llmProvider by prefDelegate(
+    private var storedLlmProvider by prefDelegate(
         PreferKey.llmProvider,
         "google"
     )
 
+    var llmProvider: String
+        get() = when (storedLlmProvider) {
+            TranslationConstants.PROVIDER_OPENAI -> TranslationConstants.PROVIDER_APP_AI
+            else -> storedLlmProvider
+        }
+        set(value) {
+            storedLlmProvider = value
+        }
+
     var llmBaseUrl by prefDelegate(
         PreferKey.llmBaseUrl,
         ""
@@ -69,6 +78,7 @@ object TranslationConfig {
 
     // Delegate constants to domain layer
     const val PROVIDER_OPENAI = TranslationConstants.PROVIDER_OPENAI
+    const val PROVIDER_APP_AI = TranslationConstants.PROVIDER_APP_AI
     const val PROVIDER_GOOGLE = TranslationConstants.PROVIDER_GOOGLE
     val providerDisplayNames get() = TranslationConstants.providerDisplayNames
     val providerValues get() = TranslationConstants.providerValues

+ 9 - 45
app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt

@@ -5,10 +5,6 @@ import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.material3.ExperimentalMaterial3Api
 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.Modifier
 import androidx.compose.ui.input.nestedscroll.nestedScroll
 import androidx.compose.ui.res.stringResource
@@ -17,8 +13,8 @@ import io.legado.app.R
 import io.legado.app.ui.theme.adaptiveContentPadding
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.SplicedColumnGroup
+import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
 import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
-import io.legado.app.ui.widget.components.settingItem.InputSettingItem
 import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
 import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
 import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
@@ -28,12 +24,11 @@ import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
 @OptIn(ExperimentalMaterial3Api::class)
 @Composable
 fun TranslationConfigScreen(
-    onBackClick: () -> Unit
+    onBackClick: () -> Unit,
+    onNavigateToAi: () -> Unit
 ) {
     val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
 
-    var tempPrompt by remember { mutableStateOf(TranslationConfig.llmPrompt) }
-
     AppScaffold(
         modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
         topBar = {
@@ -88,44 +83,13 @@ fun TranslationConfigScreen(
                 }
             }
 
-            if (TranslationConfig.llmProvider == TranslationConfig.PROVIDER_OPENAI) {
+            if (TranslationConfig.llmProvider == TranslationConfig.PROVIDER_APP_AI) {
                 item {
-                    SplicedColumnGroup(title = stringResource(R.string.openai_config)) {
-                        InputSettingItem(
-                            title = stringResource(R.string.llm_base_url),
-                            value = TranslationConfig.llmBaseUrl,
-                            onConfirm = { TranslationConfig.llmBaseUrl = it }
-                        )
-
-                        InputSettingItem(
-                            title = stringResource(R.string.llm_api_key),
-                            value = TranslationConfig.llmApiKey,
-                            onConfirm = { TranslationConfig.llmApiKey = it }
-                        )
-
-                        InputSettingItem(
-                            title = stringResource(R.string.llm_model),
-                            value = TranslationConfig.llmModel,
-                            onConfirm = { TranslationConfig.llmModel = it }
-                        )
-
-                        SliderSettingItem(
-                            title = stringResource(R.string.llm_temperature),
-                            value = TranslationConfig.llmTemperature,
-                            defaultValue = TranslationConfig.DEFAULT_TEMPERATURE,
-                            valueRange = TranslationConfig.MIN_TEMPERATURE..TranslationConfig.MAX_TEMPERATURE,
-                            steps = 19,
-                            description = stringResource(R.string.llm_temperature_description),
-                            onValueChange = { TranslationConfig.llmTemperature = it }
-                        )
-
-                        InputSettingItem(
-                            title = stringResource(R.string.llm_prompt),
-                            value = tempPrompt,
-                            onConfirm = {
-                                tempPrompt = it
-                                TranslationConfig.llmPrompt = it
-                            }
+                    SplicedColumnGroup(title = stringResource(R.string.ai_config)) {
+                        ClickableSettingItem(
+                            title = stringResource(R.string.translation_app_ai_provider),
+                            description = stringResource(R.string.translation_app_ai_provider_summary),
+                            onClick = onNavigateToAi
                         )
                     }
                 }

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

@@ -24,6 +24,7 @@ import io.legado.app.model.Download
 import io.legado.app.ui.about.AboutEffect
 import io.legado.app.ui.about.AboutScreen
 import io.legado.app.ui.about.AboutViewModel
+import io.legado.app.ui.ai.chat.AiChatRouteScreen
 import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
 import io.legado.app.ui.book.explore.ExploreShowIntent
 import io.legado.app.ui.book.explore.ExploreShowScreen
@@ -46,6 +47,9 @@ import io.legado.app.ui.book.searchContent.SearchContentScreen
 import io.legado.app.ui.book.searchContent.SearchContentViewModel
 import io.legado.app.ui.book.source.manage.BookSourceActivity
 import io.legado.app.ui.config.ConfigNavScreen
+import io.legado.app.ui.config.ai.AiConfigRouteScreen
+import io.legado.app.ui.config.ai.AiModelEditRouteScreen
+import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
 import io.legado.app.ui.config.backupConfig.BackupConfigScreen
 import io.legado.app.ui.config.coverConfig.CoverConfigScreen
 import io.legado.app.ui.config.customTheme.CustomThemeScreen
@@ -174,6 +178,7 @@ fun MainActivity.mainEntryProvider(
             onNavigateToCover = { backStack.add(MainRouteSettingsCover) },
             onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
             onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) },
+            onNavigateToAi = { backStack.add(MainRouteSettingsAi) },
             onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) },
             onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) },
             onNavigateToLab = { backStack.add(MainRouteSettingsLabConfig) }
@@ -204,12 +209,66 @@ fun MainActivity.mainEntryProvider(
         BackupConfigScreen(onBackClick = { onNavigateBack() })
     }
 
+    entry<MainRouteSettingsAi> {
+        AiConfigRouteScreen(
+            onBackClick = { onNavigateBack() },
+            onNavigateToProviderEdit = { providerId ->
+                backStack.add(MainRouteSettingsAiProviderEdit(providerId = providerId))
+            },
+            onNavigateToModelEdit = { providerId, modelProfileId ->
+                backStack.add(
+                    MainRouteSettingsAiModelEdit(
+                        providerId = providerId,
+                        modelProfileId = modelProfileId
+                    )
+                )
+            },
+            onNavigateToChat = { backStack.add(MainRouteAiChat) },
+            onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
+        )
+    }
+
+    entry<MainRouteSettingsAiProviderEdit> { route ->
+        AiProviderEditRouteScreen(
+            providerId = route.providerId,
+            onBackClick = { onNavigateBack() }
+        )
+    }
+
+    entry<MainRouteAiChat> {
+        AiChatRouteScreen(
+            onBackClick = { onNavigateBack() },
+            onOpenBookInfo = { book ->
+                onNavigateToRoute(
+                    MainRouteBookInfo(
+                        name = book.name,
+                        author = book.author,
+                        bookUrl = book.bookUrl,
+                        origin = book.origin,
+                        coverPath = book.coverPath
+                    )
+                )
+            }
+        )
+    }
+
+    entry<MainRouteSettingsAiModelEdit> { route ->
+        AiModelEditRouteScreen(
+            providerId = route.providerId,
+            modelProfileId = route.modelProfileId,
+            onBackClick = { onNavigateBack() }
+        )
+    }
+
     entry<MainRouteSettingsDownloadCache> {
         DownloadCacheConfigScreen(onBackClick = { onNavigateBack() })
     }
 
     entry<MainRouteSettingsTranslation> {
-        TranslationConfigScreen(onBackClick = { onNavigateBack() })
+        TranslationConfigScreen(
+            onBackClick = { onNavigateBack() },
+            onNavigateToAi = { backStack.add(MainRouteSettingsAi) }
+        )
     }
 
     entry<MainRouteSettingsLabConfig> {

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

@@ -27,6 +27,23 @@ data object MainRouteSettingsTheme : MainRoute
 @Serializable
 data object MainRouteSettingsBackup : MainRoute
 
+@Serializable
+data object MainRouteSettingsAi : MainRoute
+
+@Serializable
+data object MainRouteAiChat : MainRoute
+
+@Serializable
+data class MainRouteSettingsAiProviderEdit(
+    val providerId: String? = null
+) : MainRoute
+
+@Serializable
+data class MainRouteSettingsAiModelEdit(
+    val providerId: String? = null,
+    val modelProfileId: String? = null
+) : MainRoute
+
 @Serializable
 data object MainRouteSettingsCustomTheme : MainRoute
 
@@ -115,6 +132,8 @@ object MainRouteConst {
     const val ROUTE_SETTINGS_COVER = "settings/cover"
     const val ROUTE_SETTINGS_THEME = "settings/theme"
     const val ROUTE_SETTINGS_BACKUP = "settings/backup"
+    const val ROUTE_SETTINGS_AI = "settings/ai"
+    const val ROUTE_AI_CHAT = "ai/chat"
     const val ROUTE_SETTINGS_CUSTOM_THEME = "settings/custom_theme"
     const val ROUTE_SETTINGS_LAB_CONFIG = "settings/lab_config"
     const val ROUTE_SETTINGS_DOWNLOAD_CACHE = "settings/download_cache"

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

@@ -38,11 +38,26 @@ object MainNavigator {
                 }
             }
 
+            MainRouteAiChat -> {
+                if (currentRoute == MainRouteSettingsAi || currentRoute == MainRouteHome) {
+                    backStack.add(route)
+                } else {
+                    backStack.clear()
+                    backStack.add(MainRouteHome)
+                    backStack.add(MainRouteSettings)
+                    backStack.add(MainRouteSettingsAi)
+                    backStack.add(route)
+                }
+            }
+
             MainRouteSettingsOther,
             MainRouteSettingsRead,
             MainRouteSettingsCover,
             MainRouteSettingsTheme,
             MainRouteSettingsBackup,
+            MainRouteSettingsAi,
+            is MainRouteSettingsAiProviderEdit,
+            is MainRouteSettingsAiModelEdit,
             MainRouteSettingsCustomTheme,
             MainRouteSettingsThemeManage,
             MainRouteSettingsDownloadCache,
@@ -262,6 +277,8 @@ object MainNavigator {
             MainRouteConst.ROUTE_SETTINGS_COVER -> MainRouteSettingsCover
             MainRouteConst.ROUTE_SETTINGS_THEME -> MainRouteSettingsTheme
             MainRouteConst.ROUTE_SETTINGS_BACKUP -> MainRouteSettingsBackup
+            MainRouteConst.ROUTE_SETTINGS_AI -> MainRouteSettingsAi
+            MainRouteConst.ROUTE_AI_CHAT -> MainRouteAiChat
             MainRouteConst.ROUTE_SETTINGS_CUSTOM_THEME -> MainRouteSettingsCustomTheme
             MainRouteConst.ROUTE_SETTINGS_DOWNLOAD_CACHE -> MainRouteSettingsDownloadCache
             MainRouteConst.ROUTE_SETTINGS_TRANSLATION -> MainRouteSettingsTranslation

+ 782 - 0
app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt

@@ -0,0 +1,782 @@
+package io.legado.app.ui.widget.components.text
+
+import android.content.Intent
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.LocalTextStyle
+import androidx.compose.material3.ProvideTextStyle
+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.rememberUpdatedState
+import androidx.compose.runtime.setValue
+import androidx.compose.runtime.snapshotFlow
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawWithContent
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.text.LinkAnnotation
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextDecoration
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.text.withLink
+import androidx.compose.ui.text.withStyle
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.em
+import androidx.compose.ui.unit.sp
+import androidx.core.net.toUri
+import io.legado.app.ui.theme.LegadoTheme
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.flow.catch
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.mapLatest
+import org.intellij.markdown.IElementType
+import org.intellij.markdown.MarkdownElementTypes
+import org.intellij.markdown.MarkdownTokenTypes
+import org.intellij.markdown.ast.ASTNode
+import org.intellij.markdown.ast.LeafASTNode
+import org.intellij.markdown.flavours.gfm.GFMElementTypes
+import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
+import org.intellij.markdown.flavours.gfm.GFMTokenTypes
+import org.intellij.markdown.parser.MarkdownParser
+
+// ---- Markdown parser (lazy singleton) ----
+
+private val flavour by lazy {
+    GFMFlavourDescriptor(makeHttpsAutoLinks = true, useSafeLinks = true)
+}
+
+private val parser by lazy {
+    MarkdownParser(flavour)
+}
+
+private data class MarkdownParseResult(
+    val content: String,
+    val astTree: ASTNode,
+)
+
+private fun parseMarkdown(content: String): MarkdownParseResult {
+    return MarkdownParseResult(content, parser.buildMarkdownTreeFromString(content))
+}
+
+// ---- Main composable ----
+
+/**
+ * Markdown renderer using JetBrains intellij-markdown AST parser.
+ * Parses on a background thread to avoid blocking the UI during streaming.
+ * Uses AppText for proper theme integration.
+ */
+@OptIn(ExperimentalCoroutinesApi::class)
+@Composable
+fun MarkdownBlock(
+    content: String,
+    modifier: Modifier = Modifier,
+    style: TextStyle = LegadoTheme.typography.bodyLarge,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    var data by remember { mutableStateOf(parseMarkdown(content)) }
+
+    val updatedContent by rememberUpdatedState(content)
+    LaunchedEffect(Unit) {
+        snapshotFlow { updatedContent }
+            .distinctUntilChanged()
+            .mapLatest { parseMarkdown(it) }
+            .catch { it.printStackTrace() }
+            .flowOn(Dispatchers.Default)
+            .collect { data = it }
+    }
+
+    ProvideTextStyle(style) {
+        Column(modifier = modifier.padding(horizontal = 4.dp)) {
+            data.astTree.children.fastForEach { child ->
+                MarkdownNode(
+                    node = child,
+                    content = data.content,
+                    onClickLink = onClickLink,
+                )
+            }
+        }
+    }
+}
+
+// ---- AST node dispatcher ----
+
+@Composable
+private fun MarkdownNode(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+    listLevel: Int = 0,
+) {
+    when (node.type) {
+        MarkdownElementTypes.MARKDOWN_FILE -> {
+            node.children.fastForEach { child ->
+                MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
+            }
+        }
+
+        MarkdownElementTypes.PARAGRAPH -> {
+            MarkdownParagraph(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
+        }
+
+        MarkdownElementTypes.ATX_1, MarkdownElementTypes.ATX_2, MarkdownElementTypes.ATX_3,
+        MarkdownElementTypes.ATX_4, MarkdownElementTypes.ATX_5, MarkdownElementTypes.ATX_6 -> {
+            MarkdownHeading(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
+        }
+
+        MarkdownElementTypes.UNORDERED_LIST -> {
+            MarkdownUnorderedList(node = node, content = content, modifier = modifier, onClickLink = onClickLink, level = listLevel)
+        }
+
+        MarkdownElementTypes.ORDERED_LIST -> {
+            MarkdownOrderedList(node = node, content = content, modifier = modifier, onClickLink = onClickLink, level = listLevel)
+        }
+
+        GFMTokenTypes.CHECK_BOX -> {
+            val isChecked = node.getTextInNode(content).trim() == "[x]"
+            AppText(
+                text = if (isChecked) "☑ " else "☐ ",
+                color = LegadoTheme.colorScheme.primary,
+            )
+        }
+
+        MarkdownElementTypes.BLOCK_QUOTE -> {
+            MarkdownBlockquote(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
+        }
+
+        MarkdownElementTypes.INLINE_LINK -> {
+            MarkdownInlineLink(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
+        }
+
+        MarkdownElementTypes.EMPH -> {
+            ProvideTextStyle(TextStyle(fontStyle = FontStyle.Italic)) {
+                node.children.fastForEach { child ->
+                    MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
+                }
+            }
+        }
+
+        MarkdownElementTypes.STRONG -> {
+            ProvideTextStyle(TextStyle(fontWeight = FontWeight.SemiBold)) {
+                node.children.fastForEach { child ->
+                    MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
+                }
+            }
+        }
+
+        GFMElementTypes.STRIKETHROUGH -> {
+            AppText(
+                text = node.getTextInNode(content),
+                style = LocalTextStyle.current.copy(textDecoration = TextDecoration.LineThrough),
+                modifier = modifier,
+            )
+        }
+
+        GFMElementTypes.TABLE -> {
+            MarkdownTable(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
+        }
+
+        MarkdownTokenTypes.HORIZONTAL_RULE -> {
+            HorizontalDivider(
+                modifier = Modifier.padding(vertical = 16.dp),
+                color = LegadoTheme.colorScheme.outlineVariant,
+                thickness = 0.5.dp,
+            )
+        }
+
+        MarkdownElementTypes.IMAGE -> {
+            val altText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content) ?: ""
+            val imageUrl = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
+            if (imageUrl.isNotBlank()) {
+                val context = LocalContext.current
+                AppText(
+                    text = "🖼 $altText",
+                    style = LegadoTheme.typography.bodySmall,
+                    color = LegadoTheme.colorScheme.primary,
+                    modifier = modifier.clickable {
+                        context.startActivity(Intent(Intent.ACTION_VIEW, imageUrl.toUri()))
+                    }
+                )
+            }
+        }
+
+        GFMElementTypes.INLINE_MATH -> {
+            val formula = node.getTextInNode(content)
+            AppText(
+                text = formula,
+                style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 0.9.em),
+                modifier = modifier.padding(horizontal = 1.dp),
+            )
+        }
+
+        GFMElementTypes.BLOCK_MATH -> {
+            val formula = node.getTextInNode(content)
+            AppText(
+                text = formula,
+                style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
+                modifier = modifier
+                    .fillMaxWidth()
+                    .padding(vertical = 8.dp)
+                    .clip(RoundedCornerShape(4.dp))
+                    .background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+                    .padding(8.dp),
+            )
+        }
+
+        MarkdownElementTypes.CODE_SPAN -> {
+            val code = node.getTextInNode(content).trim('`')
+            AppText(
+                text = code,
+                style = LocalTextStyle.current.copy(
+                    fontFamily = FontFamily.Monospace,
+                    fontSize = 0.9.em,
+                ),
+                color = LegadoTheme.colorScheme.primary,
+            )
+        }
+
+        MarkdownElementTypes.CODE_FENCE -> {
+            MarkdownCodeFence(node = node, content = content, modifier = modifier)
+        }
+
+        MarkdownElementTypes.CODE_BLOCK -> {
+            val code = node.getTextInNode(content)
+            MarkdownCodeBlock(code = code, language = null, modifier = modifier)
+        }
+
+        MarkdownTokenTypes.TEXT -> {
+            AppText(text = node.getTextInNode(content), modifier = modifier)
+        }
+
+        else -> {
+            node.children.fastForEach { child ->
+                MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
+            }
+        }
+    }
+}
+
+// ---- Block renderers ----
+
+@Composable
+private fun MarkdownParagraph(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    val colorScheme = LegadoTheme.colorScheme
+    val textStyle = LocalTextStyle.current
+
+    FlowRow(
+        modifier = modifier.then(
+            if (node.nextSibling() != null) Modifier.padding(bottom = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() })
+            else Modifier
+        )
+    ) {
+        val annotatedString = remember(content) {
+            buildAnnotatedString {
+                node.children.fastForEach { child ->
+                    appendMarkdownInline(
+                        node = child,
+                        content = content,
+                        colorScheme = colorScheme,
+                        onClickLink = onClickLink,
+                    )
+                }
+            }
+        }
+        AppText(
+            text = annotatedString,
+            style = textStyle,
+            overflow = TextOverflow.Visible,
+        )
+    }
+}
+
+@Composable
+private fun MarkdownHeading(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    val level = when (node.type) {
+        MarkdownElementTypes.ATX_1 -> 1
+        MarkdownElementTypes.ATX_2 -> 2
+        MarkdownElementTypes.ATX_3 -> 3
+        MarkdownElementTypes.ATX_4 -> 4
+        MarkdownElementTypes.ATX_5 -> 5
+        else -> 6
+    }
+    val fontSize = when (level) {
+        1 -> 24.sp; 2 -> 22.sp; 3 -> 20.sp; 4 -> 18.sp; 5 -> 16.sp; else -> 14.sp
+    }
+    val verticalPadding = when (level) {
+        1 -> 16.dp; 2 -> 14.dp; 3 -> 12.dp; 4 -> 10.dp; 5 -> 8.dp; else -> 6.dp
+    }
+    val headingStyle = TextStyle(
+        fontWeight = FontWeight.Bold,
+        fontSize = fontSize,
+        lineHeight = fontSize * 1.25f,
+    )
+
+    ProvideTextStyle(headingStyle) {
+        FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
+            node.children.fastForEach { child ->
+                if (child.type == MarkdownTokenTypes.ATX_CONTENT) {
+                    MarkdownParagraph(
+                        node = child,
+                        content = content,
+                        modifier = modifier.padding(vertical = verticalPadding),
+                        onClickLink = onClickLink,
+                    )
+                }
+            }
+        }
+    }
+}
+
+@Composable
+private fun MarkdownUnorderedList(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+    level: Int = 0,
+) {
+    val bullet = when (level % 3) {
+        0 -> "• "; 1 -> "◦ "; else -> "▪ "
+    }
+    Column(modifier = modifier.padding(start = (level * 8).dp)) {
+        node.children.fastForEach { child ->
+            if (child.type == MarkdownElementTypes.LIST_ITEM) {
+                MarkdownListItem(
+                    node = child,
+                    content = content,
+                    bulletText = bullet,
+                    onClickLink = onClickLink,
+                    level = level,
+                )
+            }
+        }
+    }
+}
+
+@Composable
+private fun MarkdownOrderedList(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+    level: Int = 0,
+) {
+    Column(modifier = modifier.padding(start = (level * 8).dp)) {
+        var index = 1
+        node.children.fastForEach { child ->
+            if (child.type == MarkdownElementTypes.LIST_ITEM) {
+                val numberText = child.findChildOfTypeRecursive(MarkdownTokenTypes.LIST_NUMBER)
+                    ?.getTextInNode(content) ?: "$index. "
+                MarkdownListItem(
+                    node = child,
+                    content = content,
+                    bulletText = numberText,
+                    onClickLink = onClickLink,
+                    level = level,
+                )
+                index++
+            }
+        }
+    }
+}
+
+@Composable
+private fun MarkdownListItem(
+    node: ASTNode,
+    content: String,
+    bulletText: String,
+    onClickLink: ((String) -> Unit)? = null,
+    level: Int,
+) {
+    Column {
+        val (directContent, nestedLists) = separateContentAndLists(node)
+        if (directContent.isNotEmpty()) {
+            Row {
+                AppText(
+                    text = bulletText,
+                    color = LegadoTheme.colorScheme.primary,
+                    modifier = Modifier.alignByBaseline(),
+                )
+                FlowRow(
+                    horizontalArrangement = Arrangement.spacedBy(8.dp),
+                    itemVerticalAlignment = Alignment.CenterVertically,
+                ) {
+                    directContent.fastForEach { contentChild ->
+                        MarkdownNode(
+                            node = contentChild,
+                            content = content,
+                            onClickLink = onClickLink,
+                            listLevel = level,
+                        )
+                    }
+                }
+            }
+        }
+        nestedLists.fastForEach { nestedList ->
+            MarkdownNode(
+                node = nestedList,
+                content = content,
+                onClickLink = onClickLink,
+                listLevel = level + 1,
+            )
+        }
+    }
+}
+
+private fun separateContentAndLists(listItemNode: ASTNode): Pair<List<ASTNode>, List<ASTNode>> {
+    val directContent = mutableListOf<ASTNode>()
+    val nestedLists = mutableListOf<ASTNode>()
+    listItemNode.children.fastForEach { child ->
+        when (child.type) {
+            MarkdownElementTypes.UNORDERED_LIST, MarkdownElementTypes.ORDERED_LIST -> nestedLists.add(child)
+            else -> directContent.add(child)
+        }
+    }
+    return directContent to nestedLists
+}
+
+@Composable
+private fun MarkdownBlockquote(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    ProvideTextStyle(LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)) {
+        val borderColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
+        val bgColor = LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f)
+        Column(
+            modifier = Modifier
+                .drawWithContent {
+                    drawRect(color = bgColor, size = size)
+                    drawContent()
+                    drawRect(color = borderColor, size = Size(10f, size.height))
+                }
+                .padding(8.dp)
+        ) {
+            node.children.fastForEach { child ->
+                MarkdownNode(node = child, content = content, onClickLink = onClickLink)
+            }
+        }
+    }
+}
+
+@Composable
+private fun MarkdownInlineLink(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    val linkDest = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
+    val linkText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content) ?: linkDest
+    val context = LocalContext.current
+    AppText(
+        text = linkText,
+        color = LegadoTheme.colorScheme.primary,
+        textDecoration = TextDecoration.Underline,
+        modifier = modifier.clickable {
+            if (onClickLink != null) {
+                onClickLink(linkDest)
+            } else {
+                context.startActivity(Intent(Intent.ACTION_VIEW, linkDest.toUri()))
+            }
+        }
+    )
+}
+
+@Composable
+private fun MarkdownCodeFence(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+) {
+    val contentStartIndex = node.children.indexOfFirst { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }
+    if (contentStartIndex == -1) return
+    val eolElement = node.children.subList(0, contentStartIndex).findLast { it.type == MarkdownTokenTypes.EOL } ?: return
+    val codeContentStartOffset = eolElement.endOffset
+    val codeContentEndOffset = node.children.findLast { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }?.endOffset ?: return
+    val code = content.substring(codeContentStartOffset, codeContentEndOffset).trimIndent()
+    val language = node.findChildOfTypeRecursive(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)
+
+    MarkdownCodeBlock(code = code, language = language, modifier = modifier)
+}
+
+@Composable
+private fun MarkdownCodeBlock(
+    code: String,
+    language: String?,
+    modifier: Modifier = Modifier,
+) {
+    Column(
+        modifier = modifier
+            .fillMaxWidth()
+            .padding(vertical = 4.dp)
+            .clip(RoundedCornerShape(8.dp))
+            .background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
+            .border(
+                BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant),
+                RoundedCornerShape(8.dp)
+            )
+    ) {
+        if (!language.isNullOrBlank()) {
+            AppText(
+                text = language,
+                style = LegadoTheme.typography.labelSmall,
+                color = LegadoTheme.colorScheme.onSurfaceVariant,
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+                    .padding(horizontal = 12.dp, vertical = 4.dp),
+            )
+        }
+        AppText(
+            text = code,
+            style = LegadoTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 0.85.em),
+            modifier = Modifier
+                .fillMaxWidth()
+                .padding(horizontal = 12.dp, vertical = 8.dp),
+        )
+    }
+}
+
+@Composable
+private fun MarkdownTable(
+    node: ASTNode,
+    content: String,
+    modifier: Modifier = Modifier,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    val headerNode = node.children.find { it.type == GFMElementTypes.HEADER }
+    val rowNodes = node.children.filter { it.type == GFMElementTypes.ROW }
+    val columnCount = headerNode?.children?.count { it.type == GFMTokenTypes.CELL } ?: 0
+    if (columnCount == 0) return
+
+    val headerCells = headerNode?.children
+        ?.filter { it.type == GFMTokenTypes.CELL }
+        ?.map { it.getTextInNode(content).trim() } ?: emptyList()
+
+    val rows = rowNodes.map { rowNode ->
+        rowNode.children.filter { it.type == GFMTokenTypes.CELL }.map { it.getTextInNode(content).trim() }
+    }
+
+    Column(
+        modifier = modifier
+            .padding(vertical = 8.dp)
+            .clip(RoundedCornerShape(8.dp))
+            .border(BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant), RoundedCornerShape(8.dp))
+    ) {
+        // Header
+        Row(
+            modifier = Modifier
+                .fillMaxWidth()
+                .background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
+                .padding(horizontal = 8.dp, vertical = 6.dp),
+        ) {
+            headerCells.forEach { cell ->
+                AppText(
+                    text = cell,
+                    style = LegadoTheme.typography.bodySmall,
+                    fontWeight = FontWeight.SemiBold,
+                    modifier = Modifier.weight(1f),
+                    maxLines = 1,
+                    overflow = TextOverflow.Ellipsis,
+                )
+            }
+        }
+        // Rows
+        rows.forEach { row ->
+            Row(
+                modifier = Modifier
+                    .fillMaxWidth()
+                    .padding(horizontal = 8.dp, vertical = 4.dp),
+            ) {
+                row.forEachIndexed { index, cell ->
+                    AppText(
+                        text = cell,
+                        style = LegadoTheme.typography.bodySmall,
+                        modifier = Modifier.weight(1f),
+                        maxLines = 2,
+                        overflow = TextOverflow.Ellipsis,
+                    )
+                }
+            }
+            HorizontalDivider(
+                thickness = 0.25.dp,
+                color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
+            )
+        }
+    }
+}
+
+// ---- Inline AnnotatedString builder ----
+
+private fun androidx.compose.ui.text.AnnotatedString.Builder.appendMarkdownInline(
+    node: ASTNode,
+    content: String,
+    colorScheme: io.legado.app.ui.theme.LegadoColorScheme,
+    onClickLink: ((String) -> Unit)? = null,
+) {
+    when {
+        node is LeafASTNode -> {
+            append(node.getTextInNode(content))
+        }
+
+        node.type == MarkdownElementTypes.EMPH -> {
+            withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+                node.children.trimSurrounding(MarkdownTokenTypes.EMPH, 1).fastForEach {
+                    appendMarkdownInline(it, content, colorScheme, onClickLink)
+                }
+            }
+        }
+
+        node.type == MarkdownElementTypes.STRONG -> {
+            withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
+                node.children.trimSurrounding(MarkdownTokenTypes.EMPH, 2).fastForEach {
+                    appendMarkdownInline(it, content, colorScheme, onClickLink)
+                }
+            }
+        }
+
+        node.type == GFMElementTypes.STRIKETHROUGH -> {
+            withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
+                node.children.trimSurrounding(GFMTokenTypes.TILDE, 2).fastForEach {
+                    appendMarkdownInline(it, content, colorScheme, onClickLink)
+                }
+            }
+        }
+
+        node.type == MarkdownElementTypes.INLINE_LINK -> {
+            val linkDest = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
+            val linkText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content)
+                ?.trim { it == '[' || it == ']' } ?: linkDest
+            withLink(LinkAnnotation.Url(linkDest)) {
+                withStyle(SpanStyle(color = colorScheme.primary, textDecoration = TextDecoration.Underline)) {
+                    append(linkText)
+                }
+            }
+        }
+
+        node.type == MarkdownElementTypes.AUTOLINK -> {
+            val link = node.children.trimSurrounding(MarkdownTokenTypes.LT, 1).trimSurrounding(MarkdownTokenTypes.GT, 1)
+            link.fastForEach { l ->
+                withLink(LinkAnnotation.Url(l.getTextInNode(content))) {
+                    withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+                        append(l.getTextInNode(content))
+                    }
+                }
+            }
+        }
+
+        node.type == MarkdownElementTypes.CODE_SPAN -> {
+            val code = node.getTextInNode(content).trim('`')
+            withStyle(
+                SpanStyle(
+                    fontFamily = FontFamily.Monospace,
+                    fontSize = 0.9.em,
+                    color = colorScheme.primary,
+                )
+            ) {
+                append(' ')
+                append(code)
+                append(' ')
+            }
+        }
+
+        node.type == GFMElementTypes.INLINE_MATH -> {
+            val formula = node.getTextInNode(content)
+            withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 0.95.em)) {
+                append(formula)
+            }
+        }
+
+        node.type == GFMTokenTypes.GFM_AUTOLINK -> {
+            val link = node.getTextInNode(content)
+            withLink(LinkAnnotation.Url(link)) {
+                withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
+                    append(link)
+                }
+            }
+        }
+
+        else -> {
+            node.children.fastForEach { child ->
+                appendMarkdownInline(child, content, colorScheme, onClickLink)
+            }
+        }
+    }
+}
+
+// ---- Utility extensions ----
+
+private fun ASTNode.getTextInNode(text: String): String {
+    return text.substring(startOffset, endOffset)
+}
+
+private fun ASTNode.nextSibling(): ASTNode? {
+    val siblings = this.parent?.children ?: return null
+    for (i in siblings.indices) {
+        if (siblings[i] == this && i + 1 < siblings.size) {
+            return siblings[i + 1]
+        }
+    }
+    return null
+}
+
+private fun ASTNode.findChildOfTypeRecursive(vararg types: IElementType): ASTNode? {
+    if (this.type in types) return this
+    for (child in children) {
+        val result = child.findChildOfTypeRecursive(*types)
+        if (result != null) return result
+    }
+    return null
+}
+
+private fun List<ASTNode>.trimSurrounding(type: IElementType, size: Int): List<ASTNode> {
+    if (isEmpty() || size <= 0) return this
+    var start = 0
+    var end = this.size
+    var trimmed = 0
+    while (start < end && trimmed < size && this[start].type == type) { start++; trimmed++ }
+    trimmed = 0
+    while (end > start && trimmed < size && this[end - 1].type == type) { end--; trimmed++ }
+    return this.subList(start, end)
+}
+
+/** Fast forEach without inline overhead for List<ASTNode>. */
+private inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
+    for (element in this) action(element)
+}

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

@@ -1877,6 +1877,96 @@
     <string name="return_to_original">返回原文</string>
     <string name="translation_progress">翻译中 %1$d%%</string>
     <string name="translation_started">开始进行翻译,请耐心等待……</string>
+    <string name="translation_app_ai_provider">使用应用 AI 接口</string>
+    <string name="translation_app_ai_provider_summary">模型、API Key 和协议在 AI 设置中配置。</string>
+
+    <string name="ai_config">AI 设置</string>
+    <string name="ai_profile_database">配置库</string>
+    <string name="ai_model_database">模型</string>
+    <string name="ai_provider_database">供应商</string>
+    <string name="ai_tasks">任务</string>
+    <string name="ai_chapter_summary">章节梗概</string>
+    <string name="ai_skills">Skills</string>
+    <string name="ai_new_skill">新建 Skill</string>
+    <string name="ai_chat">AI 对话</string>
+    <string name="ai_recent_chats">最近对话</string>
+    <string name="ai_new_chat">新建对话</string>
+    <string name="ai_replying">AI 正在回复…</string>
+    <string name="ai_send">发送</string>
+    <string name="ai_chat_input_hint">输入问题…</string>
+    <string name="ai_chat_empty">开始一段对话</string>
+    <string name="ai_regenerate">重新生成</string>
+    <string name="ai_thinking_mode">思考模式</string>
+    <string name="ai_thinking_strength">强度</string>
+    <string name="ai_thinking_off">关闭</string>
+    <string name="ai_thinking_auto">自动</string>
+    <string name="ai_thinking_deep">深度</string>
+    <string name="ai_you">你</string>
+    <string name="ai_assistant">助手</string>
+    <string name="ai_reasoning_trace">思考</string>
+    <string name="ai_reasoning_streaming">思考中</string>
+    <string name="ai_reasoning_done">已思考</string>
+    <string name="ai_tool_trace">工具</string>
+    <string name="ai_tool_confirmation">确认工具操作</string>
+    <string name="ai_book_results">相关书籍</string>
+    <string name="ai_scroll_to_bottom">回到底部</string>
+    <string name="ai_thinking_seconds">思考中 %.1f秒</string>
+    <string name="ai_thinking_show_more">展开剩余 %d 步</string>
+    <string name="ai_thinking_collapse">收起</string>
+    <string name="ai_tool_done">完成</string>
+    <string name="ai_tool_pending">等待确认</string>
+    <string name="ai_tool_running">执行中</string>
+    <string name="ai_tool_input">输入:</string>
+    <string name="ai_tool_output">输出:</string>
+    <string name="ai_current_provider">当前供应商</string>
+    <string name="ai_current_model">当前模型</string>
+    <string name="ai_current">当前</string>
+    <string name="ai_model_not_configured">未配置模型</string>
+    <string name="ai_new_provider">新建供应商</string>
+    <string name="ai_new_model">新建模型</string>
+    <string name="ai_new_profile">新建配置</string>
+    <string name="ai_provider">供应商</string>
+    <string name="ai_provider_edit">供应商配置</string>
+    <string name="ai_provider_name">供应商名称</string>
+    <string name="ai_protocol">协议</string>
+    <string name="ai_provider_preset">供应商预设</string>
+    <string name="ai_custom_provider">自定义供应商</string>
+    <string name="ai_base_url">基础 URL</string>
+    <string name="ai_models_url">模型列表 URL</string>
+    <string name="ai_api_key">API Key</string>
+    <string name="ai_api_key_summary">保存在本地。留空会保留已保存的密钥。</string>
+    <string name="ai_model_config">模型</string>
+    <string name="ai_model_edit">模型配置</string>
+    <string name="ai_provider_models">供应商模型</string>
+    <string name="ai_fetch_models">获取模型列表</string>
+    <string name="ai_fetch_and_save_models">获取并保存模型列表</string>
+    <string name="ai_no_provider_configured">请先新建供应商</string>
+    <string name="ai_no_models_imported">未导入模型</string>
+    <string name="ai_select_model">选择模型</string>
+    <string name="ai_add_model_manually">手动添加模型</string>
+    <string name="ai_not_set">未设置</string>
+    <string name="ai_current_value">当前:%1$s</string>
+    <string name="ai_model_name">模型名称</string>
+    <string name="ai_model_id">模型 ID</string>
+    <string name="ai_context_window">上下文长度</string>
+    <string name="ai_max_output_tokens">最大输出 Tokens</string>
+    <string name="ai_temperature">温度</string>
+    <string name="ai_model_actions">操作</string>
+    <string name="ai_save_default_model">保存默认模型</string>
+    <string name="ai_save_provider">保存供应商</string>
+    <string name="ai_save_and_sync_models">保存并同步模型</string>
+    <string name="ai_save_model">保存模型</string>
+    <string name="ai_advanced">高级</string>
+    <string name="ai_models_url_summary">可选。留空时使用该协议默认的模型列表端点。</string>
+    <string name="ai_translation_task">翻译任务</string>
+    <string name="ai_translation_target_language">目标语言</string>
+    <string name="ai_max_input_chars">最大输入字符数</string>
+    <string name="ai_concurrent_requests">并发请求数</string>
+    <string name="ai_retry_count">重试次数</string>
+    <string name="ai_profile_status">档案</string>
+    <string name="ai_save_profile">保存默认档案</string>
+    <string name="ai_test_connection">测试连接</string>
+    <string name="ai_profile_counts">供应商:%1$d,模型:%2$d,预设:%3$d</string>
     <string name="read_menu_bg_color_summary">阅读顶栏和底栏背景色</string>
     <string name="read_menu_color_source">色板来源</string>
     <string name="container_background_color">容器背景色</string>

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

@@ -1819,6 +1819,96 @@
     <string name="return_to_original">Return to Original</string>
     <string name="translation_progress">Translating %1$d%%</string>
     <string name="translation_started">Translation started, please wait…</string>
+    <string name="translation_app_ai_provider">Use App AI Interface</string>
+    <string name="translation_app_ai_provider_summary">Model, API key, and protocol are configured in AI Settings.</string>
+
+    <string name="ai_config">AI Settings</string>
+    <string name="ai_profile_database">Profile Database</string>
+    <string name="ai_model_database">Models</string>
+    <string name="ai_provider_database">Providers</string>
+    <string name="ai_tasks">Tasks</string>
+    <string name="ai_chapter_summary">Chapter Summary</string>
+    <string name="ai_skills">Skills</string>
+    <string name="ai_new_skill">New Skill</string>
+    <string name="ai_chat">AI Chat</string>
+    <string name="ai_recent_chats">Recent Chats</string>
+    <string name="ai_new_chat">New Chat</string>
+    <string name="ai_replying">AI is replying…</string>
+    <string name="ai_send">Send</string>
+    <string name="ai_chat_input_hint">Ask anything…</string>
+    <string name="ai_chat_empty">Start a conversation</string>
+    <string name="ai_regenerate">Regenerate</string>
+    <string name="ai_thinking_mode">Thinking</string>
+    <string name="ai_thinking_strength">Strength</string>
+    <string name="ai_thinking_off">Off</string>
+    <string name="ai_thinking_auto">Auto</string>
+    <string name="ai_thinking_deep">Deep</string>
+    <string name="ai_you">You</string>
+    <string name="ai_assistant">Assistant</string>
+    <string name="ai_reasoning_trace">Reasoning</string>
+    <string name="ai_reasoning_streaming">Thinking</string>
+    <string name="ai_reasoning_done">Thought</string>
+    <string name="ai_tool_trace">Tools</string>
+    <string name="ai_tool_confirmation">Confirm tool action</string>
+    <string name="ai_book_results">Related books</string>
+    <string name="ai_scroll_to_bottom">Scroll to bottom</string>
+    <string name="ai_thinking_seconds">Thinking %.1fs</string>
+    <string name="ai_thinking_show_more">Show %d more steps</string>
+    <string name="ai_thinking_collapse">Collapse</string>
+    <string name="ai_tool_done">Done</string>
+    <string name="ai_tool_pending">Pending</string>
+    <string name="ai_tool_running">Running</string>
+    <string name="ai_tool_input">Input:</string>
+    <string name="ai_tool_output">Output:</string>
+    <string name="ai_current_provider">Current Provider</string>
+    <string name="ai_current_model">Current Model</string>
+    <string name="ai_current">Current</string>
+    <string name="ai_model_not_configured">No model configured</string>
+    <string name="ai_new_provider">New Provider</string>
+    <string name="ai_new_model">New Model</string>
+    <string name="ai_new_profile">New Profile</string>
+    <string name="ai_provider">Provider</string>
+    <string name="ai_provider_edit">Provider Configuration</string>
+    <string name="ai_provider_name">Provider Name</string>
+    <string name="ai_protocol">Protocol</string>
+    <string name="ai_provider_preset">Provider Preset</string>
+    <string name="ai_custom_provider">Custom Provider</string>
+    <string name="ai_base_url">Base URL</string>
+    <string name="ai_models_url">Models URL</string>
+    <string name="ai_api_key">API Key</string>
+    <string name="ai_api_key_summary">Stored locally. Leave blank to keep the saved key.</string>
+    <string name="ai_model_config">Model</string>
+    <string name="ai_model_edit">Model Configuration</string>
+    <string name="ai_provider_models">Provider Models</string>
+    <string name="ai_fetch_models">Fetch Models</string>
+    <string name="ai_fetch_and_save_models">Fetch And Save Models</string>
+    <string name="ai_no_provider_configured">Create a provider first</string>
+    <string name="ai_no_models_imported">No models imported</string>
+    <string name="ai_select_model">Select Model</string>
+    <string name="ai_add_model_manually">Add Model Manually</string>
+    <string name="ai_not_set">Not Set</string>
+    <string name="ai_current_value">Current: %1$s</string>
+    <string name="ai_model_name">Model Name</string>
+    <string name="ai_model_id">Model ID</string>
+    <string name="ai_context_window">Context Window</string>
+    <string name="ai_max_output_tokens">Max Output Tokens</string>
+    <string name="ai_temperature">Temperature</string>
+    <string name="ai_model_actions">Actions</string>
+    <string name="ai_save_default_model">Save Default Model</string>
+    <string name="ai_save_provider">Save Provider</string>
+    <string name="ai_save_and_sync_models">Save And Sync Models</string>
+    <string name="ai_save_model">Save Model</string>
+    <string name="ai_advanced">Advanced</string>
+    <string name="ai_models_url_summary">Optional. Leave blank to use the protocol default models endpoint.</string>
+    <string name="ai_translation_task">Translation Task</string>
+    <string name="ai_translation_target_language">Target Language</string>
+    <string name="ai_max_input_chars">Max Input Characters</string>
+    <string name="ai_concurrent_requests">Concurrent Requests</string>
+    <string name="ai_retry_count">Retry Count</string>
+    <string name="ai_profile_status">Profile</string>
+    <string name="ai_save_profile">Save Default Profile</string>
+    <string name="ai_test_connection">Test Connection</string>
+    <string name="ai_profile_counts">Providers: %1$d, models: %2$d, presets: %3$d</string>
     <string name="homepage_load_more_failed">Failed to load more: %1$s</string>
     <string name="homepage_module_duplicate_infinite">This group already has an infinite loading module</string>
     <string name="homepage_quick_actions">Quick Actions</string>

+ 2 - 2
gradle/libs.versions.toml

@@ -40,13 +40,13 @@ jsonPath = "3.0.0"
 ktor = "3.5.0"
 # issue #3811,不要更新版本,新版引入了一个破坏性变更(详见https://github.com/jhy/jsoup/pull/2017)
 # 若要升级请确保相关代码不会受此变更影响(如AnalyzeByJSoup.kt、JsoupXpath库等)
+intellijMarkdown = "0.7.3"
 jsoup = "1.16.2"
 jsoupxpath = "2.5.3"
 coroutines = "1.10.2"
 lifecycleViewmodelCompose = "2.10.0"
 liveeventbus = "1.8.14"
 navigation3 = "1.1.2"
-markdownRenderer = "0.40.2"
 markwon = "4.6.2"
 material = "1.14.0"
 material3 = "1.5.0-alpha20"
@@ -159,7 +159,6 @@ libarchive = { module = "me.zhanghai.android.libarchive:library", version.ref =
 lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" }
 lifecycle-service = { module = "androidx.lifecycle:lifecycle-service", version.ref = "lifecycle" }
 
-markdown-renderer-m3 = { module = "com.mikepenz:multiplatform-markdown-renderer-m3", version.ref = "markdownRenderer" }
 material-kolor = { module = "com.materialkolor:material-kolor", version.ref = "materialKolor" }
 media-media = { module = "androidx.media:media", version.ref = "media" }
 media3-datasource-okhttp = { module = "androidx.media3:media3-datasource-okhttp", version.ref = "media3" }
@@ -215,6 +214,7 @@ gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
 json-path = { module = "com.jayway.jsonpath:json-path", version.ref = "jsonPath" }
 jsoup = { module = "org.jsoup:jsoup", version.ref = "jsoup" }
 jsoupxpath = { module = "cn.wanghaomiao:JsoupXpath", version.ref = "jsoupxpath" }
+intellij-markdown = { module = "org.jetbrains:markdown-jvm", version.ref = "intellijMarkdown" }
 junit = { module = "junit:junit", version = "4.13.2" }
 kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
 kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }