diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 905dd7d57..7a38b7119 100644 --- a/.claude/settings.local.json +++ b/.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)" ] } } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6306b2b8a..cd28bd5c5 100644 --- a/app/build.gradle.kts +++ b/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 diff --git a/app/schemas/io.legado.app.data.AppDatabase/92.json b/app/schemas/io.legado.app.data.AppDatabase/92.json new file mode 100644 index 000000000..18fa8c4b8 --- /dev/null +++ b/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')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/data/AppDatabase.kt b/app/src/main/java/io/legado/app/data/AppDatabase.kt index f271331dd..1b880460f 100644 --- a/app/src/main/java/io/legado/app/data/AppDatabase.kt +++ b/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 { diff --git a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt index d74bbdb09..6dcaa5559 100644 --- a/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt +++ b/app/src/main/java/io/legado/app/data/DatabaseMigrations.kt @@ -451,7 +451,6 @@ object DatabaseMigrations { } } - @Suppress("ClassName") class Migration_54_55 : AutoMigrationSpec { diff --git a/app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt b/app/src/main/java/io/legado/app/data/dao/AiArtifactDao.kt new file mode 100644 index 000000000..2432abd9b --- /dev/null +++ b/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> + + @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 + + @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) +} diff --git a/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt b/app/src/main/java/io/legado/app/data/dao/AiChatDao.kt new file mode 100644 index 000000000..9dad31478 --- /dev/null +++ b/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> + + @Query("SELECT * FROM ai_chat_messages WHERE conversationId = :conversationId ORDER BY createdAt ASC") + fun observeMessages(conversationId: String): Flow> + + /** 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> + + /** 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 + + /** 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 + + /** 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) +} diff --git a/app/src/main/java/io/legado/app/data/dao/AiMemoryDao.kt b/app/src/main/java/io/legado/app/data/dao/AiMemoryDao.kt new file mode 100644 index 000000000..a28ee1a67 --- /dev/null +++ b/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> + + @Query("SELECT * FROM ai_memory WHERE conversationId = '' ORDER BY updatedAt DESC") + fun observeGlobal(): Flow> + + @Query("SELECT * FROM ai_memory WHERE conversationId = :conversationId") + suspend fun getByConversation(conversationId: String): List + + @Query("SELECT * FROM ai_memory WHERE conversationId = ''") + suspend fun getGlobal(): List + + @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) +} diff --git a/app/src/main/java/io/legado/app/data/dao/AiProfileDao.kt b/app/src/main/java/io/legado/app/data/dao/AiProfileDao.kt new file mode 100644 index 000000000..4bf25dddb --- /dev/null +++ b/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> + + @Query("select * from ai_model_profiles order by sortNumber, createdAt") + fun observeModels(): Flow> + + @Query("select * from ai_task_presets order by taskType, sortNumber, createdAt") + fun observePresets(): Flow> + + @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 + + @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) + } +} diff --git a/app/src/main/java/io/legado/app/data/entities/AiArtifact.kt b/app/src/main/java/io/legado/app/data/entities/AiArtifact.kt new file mode 100644 index 000000000..398b612bc --- /dev/null +++ b/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 + } +} diff --git a/app/src/main/java/io/legado/app/data/entities/AiChatConversation.kt b/app/src/main/java/io/legado/app/data/entities/AiChatConversation.kt new file mode 100644 index 000000000..b21408fd7 --- /dev/null +++ b/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() +) diff --git a/app/src/main/java/io/legado/app/data/entities/AiChatMessage.kt b/app/src/main/java/io/legado/app/data/entities/AiChatMessage.kt new file mode 100644 index 000000000..00cde7cf0 --- /dev/null +++ b/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 +) diff --git a/app/src/main/java/io/legado/app/data/entities/AiMemory.kt b/app/src/main/java/io/legado/app/data/entities/AiMemory.kt new file mode 100644 index 000000000..386f6d176 --- /dev/null +++ b/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() +) diff --git a/app/src/main/java/io/legado/app/data/entities/AiModelProfile.kt b/app/src/main/java/io/legado/app/data/entities/AiModelProfile.kt new file mode 100644 index 000000000..71de02992 --- /dev/null +++ b/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() +) diff --git a/app/src/main/java/io/legado/app/data/entities/AiProviderProfile.kt b/app/src/main/java/io/legado/app/data/entities/AiProviderProfile.kt new file mode 100644 index 000000000..9eac64729 --- /dev/null +++ b/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" + } +} diff --git a/app/src/main/java/io/legado/app/data/entities/AiTaskPreset.kt b/app/src/main/java/io/legado/app/data/entities/AiTaskPreset.kt new file mode 100644 index 000000000..8edd2a1a1 --- /dev/null +++ b/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() +) diff --git a/app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiArtifactRepository.kt new file mode 100644 index 000000000..b4d91c707 --- /dev/null +++ b/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> { + 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) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiChatRepository.kt new file mode 100644 index 000000000..4fbfe0325 --- /dev/null +++ b/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> = aiChatDao.observeConversations() + + override fun observeMessages(conversationId: String): Flow> = + aiChatDao.observeMessages(conversationId) + + override fun observeSelectedMessages(conversationId: String): Flow> = + 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, + 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, + 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 = + withContext(Dispatchers.IO) { + aiChatDao.getBranches(parentMessageId) + } + + override suspend fun getBranchCounts(conversationId: String): Map = + 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("-", "")}" +} diff --git a/app/src/main/java/io/legado/app/data/repository/AiMemoryRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiMemoryRepository.kt new file mode 100644 index 000000000..cfd38ea70 --- /dev/null +++ b/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> = + aiMemoryDao.observeByConversation(conversationId) + + override fun observeGlobal(): Flow> = aiMemoryDao.observeGlobal() + + override suspend fun getByConversation(conversationId: String): List = + withContext(Dispatchers.IO) { aiMemoryDao.getByConversation(conversationId) } + + override suspend fun getGlobal(): List = + withContext(Dispatchers.IO) { aiMemoryDao.getGlobal() } + + override suspend fun getForPrompt(conversationId: String): List = + 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) + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiProfileRepository.kt new file mode 100644 index 000000000..a0c93cc64 --- /dev/null +++ b/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> = aiProfileDao.observeProviders() + + override fun observeModels(): Flow> = aiProfileDao.observeModels() + + override fun observePresets(): Flow> = 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 + ): List = 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 { + 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" + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/AiTextRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/AiTextRepositoryImpl.kt new file mode 100644 index 000000000..36a6d8df5 --- /dev/null +++ b/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 = + withContext(Dispatchers.IO) { + runCatching { + registry.handlerFor(request.model.provider.protocol).generate(request) + }.mapCatching { it.getOrThrow() } + } + + override fun generateStream(request: AiGenerateRequest): Flow = flow { + registry.handlerFor(request.model.provider.protocol).stream(request) { emit(it) } + }.flowOn(Dispatchers.IO) + + override suspend fun fetchModels(provider: AiProviderConfig): Result> = + withContext(Dispatchers.IO) { + runCatching { + registry.handlerFor(provider.protocol).fetchModels(provider).getOrThrow() + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt b/app/src/main/java/io/legado/app/data/repository/AiToolRepository.kt new file mode 100644 index 000000000..ae0cd2a65 --- /dev/null +++ b/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 = 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 { 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 { + return mapOf( + "bookUrl" to bookUrl, + "name" to name, + "author" to author + ) + } + + private fun Book.toSummaryMap(): Map { + 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>): Map { + return mapOf( + "type" to "object", + "properties" to properties.toMap(), + "additionalProperties" to false + ) + } + + private fun stringSchema(description: String): Map { + return mapOf("type" to "string", "description" to description) + } + + private fun intSchema(description: String): Map { + return mapOf("type" to "integer", "description" to description) + } + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt b/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt deleted file mode 100644 index 96da23e12..000000000 --- a/app/src/main/java/io/legado/app/data/repository/LlmTranslateRepositoryImpl.kt +++ /dev/null @@ -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, - onUpdate: ((List) -> Unit)?, - retryReason: RetryReason? - ): Result = 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 { - 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, - onUpdate: ((List) -> Unit)?, - retryReason: RetryReason? - ): Result { - 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 - ) - - /** - * 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 - ): 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() - 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 - ): List { - val pairs = mutableListOf() - 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): 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, - 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 -) - -@Keep -data class OpenAIChoice( - val message: OpenAIMessage -) - -@Keep -data class OpenAIMessage( - val content: String -) diff --git a/app/src/main/java/io/legado/app/data/repository/ai/AiProtocolHandler.kt b/app/src/main/java/io/legado/app/data/repository/ai/AiProtocolHandler.kt new file mode 100644 index 000000000..e405e5d2d --- /dev/null +++ b/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 + + /** Single-shot text generation. */ + suspend fun generate(request: AiGenerateRequest): Result + + /** 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> +} diff --git a/app/src/main/java/io/legado/app/data/repository/ai/AiProviderRegistry.kt b/app/src/main/java/io/legado/app/data/repository/ai/AiProviderRegistry.kt new file mode 100644 index 000000000..7b11c911c --- /dev/null +++ b/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) { + + private val handlerMap: Map = + handlers.flatMap { handler -> handler.protocols.map { it to handler } }.toMap() + + fun handlerFor(protocol: String): AiProtocolHandler { + return handlerMap[protocol] + ?: error("Unsupported AI protocol: $protocol") + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ai/AiRetryUtils.kt b/app/src/main/java/io/legado/app/data/repository/ai/AiRetryUtils.kt new file mode 100644 index 000000000..4ee65c144 --- /dev/null +++ b/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 = 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 retryWithBackoff( + maxAttempts: Int = 3, + baseDelayMs: Long = 1_000, + maxDelayMs: Long = 30_000, + retryableStatusCodes: Set = 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): Boolean { + val message = e.message ?: return false + return retryableStatusCodes.any { code -> + message.contains("HTTP $code") || message.contains("$code:") + } +} diff --git a/app/src/main/java/io/legado/app/data/repository/ai/AiSseUtils.kt b/app/src/main/java/io/legado/app/data/repository/ai/AiSseUtils.kt new file mode 100644 index 000000000..4d0d3dddc --- /dev/null +++ b/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() + 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): 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 +} diff --git a/app/src/main/java/io/legado/app/data/repository/ai/AnthropicHandler.kt b/app/src/main/java/io/legado/app/data/repository/ai/AnthropicHandler.kt new file mode 100644 index 000000000..591c309fc --- /dev/null +++ b/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 = + 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> = + 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( + "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 { + 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( + "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 { + 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() + 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 { + 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.toAnthropicMessages(): List> { + 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.toAnthropicTools(): List> { + return map { + mapOf( + "name" to it.name, + "description" to it.description, + "input_schema" to it.inputSchema + ) + } +} + +private fun List?.toAvailableModels(): List { + 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? +) + +@Keep +internal data class AnthropicMessageContent( + val type: String?, + val text: String? +) + +@Keep +internal data class AnthropicModelsResponse( + val data: List? +) + +@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 +) diff --git a/app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt b/app/src/main/java/io/legado/app/data/repository/ai/OpenAiChatHandler.kt new file mode 100644 index 000000000..2456cb4db --- /dev/null +++ b/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 = + 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> = + 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( + "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( + "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 { + 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.toOpenAiChatMessages(): List> { + 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.toOpenAiChatTools(): List> { + return map { + mapOf( + "type" to "function", + "function" to mapOf( + "name" to it.name, + "description" to it.description, + "parameters" to it.inputSchema + ) + ) + } +} + +private fun List?.toAvailableModels(): List { + 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? +) + +@Keep +internal data class OpenAiChatChoice( + val message: OpenAiChatMessage? +) + +@Keep +internal data class OpenAiChatMessage( + val content: String? +) + +@Keep +internal data class OpenAiModelsResponse( + val data: List? +) + +@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? +) + +@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? +) + +@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? +) diff --git a/app/src/main/java/io/legado/app/data/repository/ai/OpenAiResponsesHandler.kt b/app/src/main/java/io/legado/app/data/repository/ai/OpenAiResponsesHandler.kt new file mode 100644 index 000000000..7598867cb --- /dev/null +++ b/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 = + 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> = + 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( + "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 { + 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( + "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 { + 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 { + 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.toOpenAiResponsesInput(): List> { + 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.toOpenAiResponsesTools(): List> { + 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?.toAvailableModels(): List { + 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() } +} diff --git a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt index 09fe510bf..f426cb207 100644 --- a/app/src/main/java/io/legado/app/di/appDatabaseModule.kt +++ b/app/src/main/java/io/legado/app/di/appDatabaseModule.kt @@ -16,6 +16,10 @@ val appDatabaseModule = module { // 注册所有的 DAO 接口,通过 AppDatabase 实例获取 factory { get().bookDao } + factory { get().aiProfileDao } + factory { get().aiArtifactDao } + factory { get().aiChatDao } + factory { get().aiMemoryDao } factory { get().bookGroupDao } factory { get().bookSourceDao } factory { get().bookChapterDao } @@ -39,4 +43,4 @@ val appDatabaseModule = module { factory { get().homepageModuleDao } factory { get().homepageCustomSetDao } factory { get().highlightRuleDao } -} \ No newline at end of file +} diff --git a/app/src/main/java/io/legado/app/di/appModule.kt b/app/src/main/java/io/legado/app/di/appModule.kt index b9b2116de..017bd313d 100644 --- a/app/src/main/java/io/legado/app/di/appModule.kt +++ b/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 { DirectLinkUploadRepository() } single { TranslationCacheRepositoryImpl() } + single { AiProfileRepository(get()) } + single { AiArtifactRepository(get()) } + single { AiChatRepository(get()) } + single { AiMemoryRepository(get()) } + single { AiTextRepositoryImpl() } + single { AiToolRepository(get(), get(), get(), get(), get(), get()) } single { AppStartupRepository(get()) } single { CacheBookDownloadRepository(get()) } single { BookCacheCleanupRepository(get()) } @@ -210,9 +232,10 @@ val appModule = module { singleOf(::SearchBooksUseCase) singleOf(::ChangeSourceSearchUseCase) singleOf(::GetChapterContentUseCase) - single { LlmTranslateRepositoryImpl() } + singleOf(::GenerateChapterSummaryUseCase) single { DictionaryRepositoryImpl() } singleOf(::TranslateChapterUseCase) + singleOf(::AiChatGenerationUseCase) single { 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) diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiArtifactGateway.kt new file mode 100644 index 000000000..f88bc8cf0 --- /dev/null +++ b/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> + suspend fun getCachedArtifact( + bookUrl: String, + chapterIndex: Int?, + taskType: String, + contentHash: String, + promptHash: String, + modelProfileId: String + ): AiArtifact? + suspend fun upsertArtifact(artifact: AiArtifact) +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiChatGateway.kt new file mode 100644 index 000000000..d451e3fd3 --- /dev/null +++ b/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> + fun observeMessages(conversationId: String): Flow> + fun observeSelectedMessages(conversationId: String): Flow> + suspend fun getConversation(id: String): AiChatConversation? + suspend fun createConversation(title: String = "New Chat"): AiChatConversation + suspend fun saveMessage( + conversationId: String, + role: String, + parts: List, + parentMessageId: String? = null, + thinkingDuration: Int = 0 + ): AiChatMessage + suspend fun saveRegeneratedMessage( + conversationId: String, + role: String, + parts: List, + parentMessageId: String, + thinkingDuration: Int = 0 + ): AiChatMessage + suspend fun selectBranch(messageId: String) + suspend fun getBranches(parentMessageId: String): List + suspend fun getBranchCounts(conversationId: String): Map + suspend fun updateConversationTitle(conversationId: String, title: String) + suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String) +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiMemoryGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiMemoryGateway.kt new file mode 100644 index 000000000..e804ca98a --- /dev/null +++ b/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> + fun observeGlobal(): Flow> + suspend fun getByConversation(conversationId: String): List + suspend fun getGlobal(): List + suspend fun getForPrompt(conversationId: String): List + suspend fun upsert(memory: AiMemory) + suspend fun delete(conversationId: String, key: String) + suspend fun deleteAllForConversation(conversationId: String) +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiProfileGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiProfileGateway.kt new file mode 100644 index 000000000..f1acc74b5 --- /dev/null +++ b/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> + fun observeModels(): Flow> + fun observePresets(): Flow> + 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): List + suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig + suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiTextGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiTextGateway.kt new file mode 100644 index 000000000..3e9235f1b --- /dev/null +++ b/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 + fun generateStream(request: AiGenerateRequest): Flow + suspend fun fetchModels(provider: AiProviderConfig): Result> +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/AiToolGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/AiToolGateway.kt new file mode 100644 index 000000000..cb394f0af --- /dev/null +++ b/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 + fun requiresConfirmation(toolName: String): Boolean + suspend fun execute(call: AiToolCall): AiToolResult +} diff --git a/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt b/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt deleted file mode 100644 index 7c3867dc3..000000000 --- a/app/src/main/java/io/legado/app/domain/gateway/LlmGateway.kt +++ /dev/null @@ -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 = emptyList(), - onUpdate: ((List) -> Unit)? = null, - retryReason: RetryReason? = null - ): Result -} \ No newline at end of file diff --git a/app/src/main/java/io/legado/app/domain/model/AiMessageParts.kt b/app/src/main/java/io/legado/app/domain/model/AiMessageParts.kt new file mode 100644 index 000000000..097160936 --- /dev/null +++ b/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): String { + return json.encodeToString(parts) + } + + fun decode(partsJson: String): List { + if (partsJson.isBlank()) return emptyList() + return runCatching { json.decodeFromString>(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.textContent(): String { + return filterIsInstance() + .joinToString("\n\n") { it.text } + .trim() +} + +fun List.reasoningContent(): String? { + return filterIsInstance() + .joinToString("\n\n") { it.text } + .trim() + .takeIf { it.isNotBlank() } +} + +/** Returns all unified Tool parts (includes migrated legacy parts). */ +fun List.toolParts(): List { + return filterIsInstance() +} + +/** Returns tool calls that are pending approval (output is empty, state is PENDING). */ +fun List.pendingToolCalls(): List { + return toolParts().filter { + it.output.isBlank() && it.approvalState == AiToolApprovalState.PENDING + } +} + +/** Returns tool calls that need execution (output is empty, approval allows execution). */ +fun List.executableToolCalls(): List { + return toolParts().filter { + it.output.isBlank() && it.approvalState.canResumeExecution() + } +} diff --git a/app/src/main/java/io/legado/app/domain/model/AiModelRegistry.kt b/app/src/main/java/io/legado/app/domain/model/AiModelRegistry.kt new file mode 100644 index 000000000..2a4b1e4e4 --- /dev/null +++ b/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, + val notTokens: List = emptyList(), + val exactIds: Set = emptySet(), + val capabilities: Set + ) + + 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 { + 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) + } +} diff --git a/app/src/main/java/io/legado/app/domain/model/AiModels.kt b/app/src/main/java/io/legado/app/domain/model/AiModels.kt new file mode 100644 index 000000000..53a7779ca --- /dev/null +++ b/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 = emptyMap(), + val chatPath: String = "/chat/completions", + val responsesPath: String = "/responses", + val messagesPath: String = "/v1/messages", + val modelsPath: String? = null, + val customHeaders: Map = 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 = 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 = emptyList(), + val toolCallId: String? = null, + val name: String? = null +) + +@Keep +data class AiGenerateRequest( + val model: AiModelConfig, + val messages: List, + val params: AiGenerationParams = AiGenerationParams(), + val tools: List = 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 +) + +@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 +) diff --git a/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt b/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt index d962878d1..e221c5f93 100644 --- a/app/src/main/java/io/legado/app/domain/model/TranslationConstants.kt +++ b/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 "简体中文", diff --git a/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/AiChatGenerationUseCase.kt new file mode 100644 index 000000000..e85dff552 --- /dev/null +++ b/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, + 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, + 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 { + 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, + conversationId: String? = null + ): List { + 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.trimForRequest(maxMessages: Int): List { + 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 { + 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() + private val indexKeys = mutableMapOf() + + 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 { + 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 { + val parts = mutableListOf() + 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 { + val books = linkedMapOf() + 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, + 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]" +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/GenerateChapterSummaryUseCase.kt new file mode 100644 index 000000000..b4b5cbcc9 --- /dev/null +++ b/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 = 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 + } +} diff --git a/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt b/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt index 8459621e3..f780255d0 100644 --- a/app/src/main/java/io/legado/app/domain/usecase/TranslateChapterUseCase.kt +++ b/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 = 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) -> 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, onDictionaryUpdate: (List) -> Unit ): Result { @@ -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, onDictionaryUpdate: (List) -> Unit ): Result { 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 { + 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, + onUpdate: ((List) -> Unit)?, + retryReason: RetryReason? + ): Result { + 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 + ) + + private fun parseLlmOutput( + rawOutput: String, + existingDictionaries: List + ): 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 + ): List { + val pairs = mutableListOf() + 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): 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? +) + +@Keep +private data class GoogleSentence( + val trans: String? +) diff --git a/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt b/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt index f37c295c9..429c32ef6 100644 --- a/app/src/main/java/io/legado/app/model/translation/TranslationManager.kt +++ b/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) } -} \ No newline at end of file + private fun currentTargetLanguage(): String { + return TranslationConfig.llmTargetLanguage + } + +} diff --git a/app/src/main/java/io/legado/app/ui/about/AboutSheets.kt b/app/src/main/java/io/legado/app/ui/about/AboutSheets.kt index 0f500f5c5..07e47148c 100644 --- a/app/src/main/java/io/legado/app/ui/about/AboutSheets.kt +++ b/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(), ) } diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatContract.kt new file mode 100644 index 000000000..da90a6fc8 --- /dev/null +++ b/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 = persistentListOf(), + val messages: ImmutableList = 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 = persistentListOf(), + val content: String, + val reasoning: String?, + val toolTrace: String?, + val createdAt: Long, + val thinkingDuration: Int = 0, + val bookResults: ImmutableList = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatMessageParts.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatMessageParts.kt new file mode 100644 index 000000000..a7675223b --- /dev/null +++ b/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, + 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.groupMessageParts(thinkingDurationSeconds: Int = 0): List { + val result = mutableListOf() + var currentThinkingSteps = mutableListOf() + + 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 +} diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatScreen.kt new file mode 100644 index 000000000..ac2d9b39a --- /dev/null +++ b/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, + 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, + 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() + .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() + .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() + .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, + 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, + snackbarHostState: SnackbarHostState +) { + LaunchedEffect(Unit) { + effects.collectLatest { effect -> + when (effect) { + is AiChatEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiChatViewModel.kt new file mode 100644 index 000000000..05b645037 --- /dev/null +++ b/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(null) + private var lastMessages: List = 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(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 = 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 { + 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.bookResults(): List { + val explicit = filterIsInstance().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() + 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.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 diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiReasoningCard.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiReasoningCard.kt new file mode 100644 index 000000000..5db950db1 --- /dev/null +++ b/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 { + 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 + ) + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingCard.kt new file mode 100644 index 000000000..396e2ab11 --- /dev/null +++ b/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, + 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) + } +} diff --git a/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingStepsCard.kt b/app/src/main/java/io/legado/app/ui/ai/chat/AiThinkingStepsCard.kt new file mode 100644 index 000000000..bcea16352 --- /dev/null +++ b/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, + 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 + ) + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt b/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt index bada404f2..7c0842035 100644 --- a/app/src/main/java/io/legado/app/ui/config/ConfigNavScreen.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiConfigContract.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiConfigContract.kt new file mode 100644 index 000000000..a2fc4a44b --- /dev/null +++ b/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 = persistentListOf(), + val models: ImmutableList = 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 = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiConfigScreen.kt new file mode 100644 index 000000000..bc3f5a80a --- /dev/null +++ b/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, + 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 + } + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiConfigViewModel.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiConfigViewModel.kt new file mode 100644 index 000000000..2ec27e09b --- /dev/null +++ b/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(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")) + } + } + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditContract.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditContract.kt new file mode 100644 index 000000000..0bca73588 --- /dev/null +++ b/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 = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditScreen.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditScreen.kt new file mode 100644 index 000000000..932523980 --- /dev/null +++ b/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, + 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, + val entryValues: Array +) + +private fun buildLimitOptions( + baseOptions: List>, + 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() + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditViewModel.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiModelEditViewModel.kt new file mode 100644 index 000000000..3eca6d538 --- /dev/null +++ b/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(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()) + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditContract.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditContract.kt new file mode 100644 index 000000000..7ba95e697 --- /dev/null +++ b/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 = persistentListOf(), + val fetchedModels: ImmutableList = 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 = 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 +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditScreen.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditScreen.kt new file mode 100644 index 000000000..c91528429 --- /dev/null +++ b/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, + 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 + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditViewModel.kt b/app/src/main/java/io/legado/app/ui/config/ai/AiProviderEditViewModel.kt new file mode 100644 index 000000000..0be15f657 --- /dev/null +++ b/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(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().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().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) { + 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()) + } +} diff --git a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt index 494a3bae4..9c76053f1 100644 --- a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfig.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt b/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt index 945c0c07d..1d18c3c26 100644 --- a/app/src/main/java/io/legado/app/ui/config/translation/TranslationConfigScreen.kt +++ b/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 ) } } diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt b/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt index 072f56771..98d60b88d 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavGraph.kt +++ b/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 { + 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 { route -> + AiProviderEditRouteScreen( + providerId = route.providerId, + onBackClick = { onNavigateBack() } + ) + } + + entry { + AiChatRouteScreen( + onBackClick = { onNavigateBack() }, + onOpenBookInfo = { book -> + onNavigateToRoute( + MainRouteBookInfo( + name = book.name, + author = book.author, + bookUrl = book.bookUrl, + origin = book.origin, + coverPath = book.coverPath + ) + ) + } + ) + } + + entry { route -> + AiModelEditRouteScreen( + providerId = route.providerId, + modelProfileId = route.modelProfileId, + onBackClick = { onNavigateBack() } + ) + } + entry { DownloadCacheConfigScreen(onBackClick = { onNavigateBack() }) } entry { - TranslationConfigScreen(onBackClick = { onNavigateBack() }) + TranslationConfigScreen( + onBackClick = { onNavigateBack() }, + onNavigateToAi = { backStack.add(MainRouteSettingsAi) } + ) } entry { diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt b/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt index a35e80da3..4062407e8 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavKey.kt +++ b/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" diff --git a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt b/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt index 0b7d69b8a..b7c599bfc 100644 --- a/app/src/main/java/io/legado/app/ui/main/MainNavigator.kt +++ b/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 diff --git a/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt b/app/src/main/java/io/legado/app/ui/widget/components/text/MarkdownBlock.kt new file mode 100644 index 000000000..c897c41ae --- /dev/null +++ b/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> { + val directContent = mutableListOf() + val nestedLists = mutableListOf() + 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.trimSurrounding(type: IElementType, size: Int): List { + 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. */ +private inline fun List.fastForEach(action: (T) -> Unit) { + for (element in this) action(element) +} diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index f6607f776..2322fe156 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1877,6 +1877,96 @@ 返回原文 翻译中 %1$d%% 开始进行翻译,请耐心等待…… + 使用应用 AI 接口 + 模型、API Key 和协议在 AI 设置中配置。 + + AI 设置 + 配置库 + 模型 + 供应商 + 任务 + 章节梗概 + Skills + 新建 Skill + AI 对话 + 最近对话 + 新建对话 + AI 正在回复… + 发送 + 输入问题… + 开始一段对话 + 重新生成 + 思考模式 + 强度 + 关闭 + 自动 + 深度 + + 助手 + 思考 + 思考中 + 已思考 + 工具 + 确认工具操作 + 相关书籍 + 回到底部 + 思考中 %.1f秒 + 展开剩余 %d 步 + 收起 + 完成 + 等待确认 + 执行中 + 输入: + 输出: + 当前供应商 + 当前模型 + 当前 + 未配置模型 + 新建供应商 + 新建模型 + 新建配置 + 供应商 + 供应商配置 + 供应商名称 + 协议 + 供应商预设 + 自定义供应商 + 基础 URL + 模型列表 URL + API Key + 保存在本地。留空会保留已保存的密钥。 + 模型 + 模型配置 + 供应商模型 + 获取模型列表 + 获取并保存模型列表 + 请先新建供应商 + 未导入模型 + 选择模型 + 手动添加模型 + 未设置 + 当前:%1$s + 模型名称 + 模型 ID + 上下文长度 + 最大输出 Tokens + 温度 + 操作 + 保存默认模型 + 保存供应商 + 保存并同步模型 + 保存模型 + 高级 + 可选。留空时使用该协议默认的模型列表端点。 + 翻译任务 + 目标语言 + 最大输入字符数 + 并发请求数 + 重试次数 + 档案 + 保存默认档案 + 测试连接 + 供应商:%1$d,模型:%2$d,预设:%3$d 阅读顶栏和底栏背景色 色板来源 容器背景色 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 12cd6b6a1..ad729d9a5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1819,6 +1819,96 @@ Return to Original Translating %1$d%% Translation started, please wait… + Use App AI Interface + Model, API key, and protocol are configured in AI Settings. + + AI Settings + Profile Database + Models + Providers + Tasks + Chapter Summary + Skills + New Skill + AI Chat + Recent Chats + New Chat + AI is replying… + Send + Ask anything… + Start a conversation + Regenerate + Thinking + Strength + Off + Auto + Deep + You + Assistant + Reasoning + Thinking + Thought + Tools + Confirm tool action + Related books + Scroll to bottom + Thinking %.1fs + Show %d more steps + Collapse + Done + Pending + Running + Input: + Output: + Current Provider + Current Model + Current + No model configured + New Provider + New Model + New Profile + Provider + Provider Configuration + Provider Name + Protocol + Provider Preset + Custom Provider + Base URL + Models URL + API Key + Stored locally. Leave blank to keep the saved key. + Model + Model Configuration + Provider Models + Fetch Models + Fetch And Save Models + Create a provider first + No models imported + Select Model + Add Model Manually + Not Set + Current: %1$s + Model Name + Model ID + Context Window + Max Output Tokens + Temperature + Actions + Save Default Model + Save Provider + Save And Sync Models + Save Model + Advanced + Optional. Leave blank to use the protocol default models endpoint. + Translation Task + Target Language + Max Input Characters + Concurrent Requests + Retry Count + Profile + Save Default Profile + Test Connection + Providers: %1$d, models: %2$d, presets: %3$d Failed to load more: %1$s This group already has an infinite loading module Quick Actions diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a5934213e..ddbff021e 100644 --- a/gradle/libs.versions.toml +++ b/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" }