feat: ai底座

This commit is contained in:
HapeLee
2026-06-27 02:16:11 +08:00
parent bea1095b0a
commit 990fc78033
74 changed files with 12727 additions and 525 deletions
+4 -1
View File
@@ -114,7 +114,10 @@
"Bash(Select-String -Pattern \"\\(BUILD|error:|FAILED\\)\")",
"PowerShell(grep *)",
"Bash(powershell -Command \"./gradlew.bat :app:compileAppDebugKotlin 2>&1 | Select-Object -Last 30\")",
"Bash(git revert *)"
"Bash(git revert *)",
"Bash(xargs grep -l \"ImeLazyListAutoScroller\")",
"Bash(xargs grep -l \"MarkdownBlock\")",
"Bash(./gradlew.bat assembleAppDebug)"
]
}
}
+1 -1
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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 {
@@ -451,7 +451,6 @@ object DatabaseMigrations {
}
}
@Suppress("ClassName")
class Migration_54_55 : AutoMigrationSpec {
@@ -0,0 +1,61 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.AiArtifact
import kotlinx.coroutines.flow.Flow
@Dao
interface AiArtifactDao {
@Query("select * from ai_artifacts where bookUrl = :bookUrl and taskType = :taskType order by chapterIndex, updatedAt desc")
fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>>
@Query(
"""
select * from ai_artifacts
where bookUrl = :bookUrl
and (:chapterIndex is null or chapterIndex = :chapterIndex)
and taskType = :taskType
and contentHash = :contentHash
and promptHash = :promptHash
and modelProfileId = :modelProfileId
and status = ${AiArtifact.STATUS_SUCCESS}
order by updatedAt desc
limit 1
"""
)
suspend fun getCachedArtifact(
bookUrl: String,
chapterIndex: Int?,
taskType: String,
contentHash: String,
promptHash: String,
modelProfileId: String
): AiArtifact?
@Query(
"""
select * from ai_artifacts
where (:bookUrl is null or bookUrl = :bookUrl)
and (:taskType is null or taskType = :taskType)
and (:chapterIndex is null or chapterIndex = :chapterIndex)
order by updatedAt desc
limit :limit
"""
)
suspend fun queryArtifacts(
bookUrl: String?,
taskType: String?,
chapterIndex: Int?,
limit: Int
): List<AiArtifact>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(artifact: AiArtifact)
@Query("delete from ai_artifacts where bookUrl = :bookUrl and taskType = :taskType")
suspend fun deleteBookArtifacts(bookUrl: String, taskType: String)
}
@@ -0,0 +1,79 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.AiChatConversation
import io.legado.app.data.entities.AiChatMessage
import kotlinx.coroutines.flow.Flow
data class BranchCount(
val parentMessageId: String,
val cnt: Int
)
@Dao
interface AiChatDao {
@Query("SELECT * FROM ai_chat_conversations ORDER BY updatedAt DESC")
fun observeConversations(): Flow<List<AiChatConversation>>
@Query("SELECT * FROM ai_chat_messages WHERE conversationId = :conversationId ORDER BY createdAt ASC")
fun observeMessages(conversationId: String): Flow<List<AiChatMessage>>
/** Observe only the selected branch path for display */
@Query("SELECT * FROM ai_chat_messages WHERE conversationId = :conversationId AND isSelected = 1 ORDER BY createdAt ASC")
fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>>
/** Count branches for a given parent message (for regenerate UI) */
@Query("SELECT COUNT(*) FROM ai_chat_messages WHERE parentMessageId = :parentMessageId")
suspend fun countBranches(parentMessageId: String): Int
/** Get all branches for a given parent message */
@Query("SELECT * FROM ai_chat_messages WHERE parentMessageId = :parentMessageId ORDER BY branchIndex ASC")
suspend fun getBranches(parentMessageId: String): List<AiChatMessage>
/** Get branch counts grouped by parentMessageId for a conversation */
@Query("SELECT parentMessageId, COUNT(*) as cnt FROM ai_chat_messages WHERE conversationId = :conversationId AND parentMessageId IS NOT NULL GROUP BY parentMessageId")
suspend fun getBranchCounts(conversationId: String): List<BranchCount>
/** Mark all messages after a given point in the conversation as unselected */
@Query("UPDATE ai_chat_messages SET isSelected = 0 WHERE conversationId = :conversationId AND createdAt > :afterTimestamp AND role = 'assistant'")
suspend fun deselectAssistantAfter(conversationId: String, afterTimestamp: Long)
/** Select a specific branch by id */
@Query("UPDATE ai_chat_messages SET isSelected = 1 WHERE id = :messageId")
suspend fun selectBranch(messageId: String)
/** Deselect all messages for a conversation */
@Query("UPDATE ai_chat_messages SET isSelected = 0 WHERE conversationId = :conversationId")
suspend fun deselectAll(conversationId: String)
@Query("SELECT * FROM ai_chat_conversations WHERE id = :id")
suspend fun getConversation(id: String): AiChatConversation?
@Query("SELECT * FROM ai_chat_messages WHERE id = :id")
suspend fun getMessage(id: String): AiChatMessage?
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertConversation(conversation: AiChatConversation)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertMessage(message: AiChatMessage)
@Query("UPDATE ai_chat_conversations SET title = :title, updatedAt = :updatedAt WHERE id = :conversationId")
suspend fun updateConversationTitle(conversationId: String, title: String, updatedAt: Long)
@Query(
"""
UPDATE ai_chat_conversations
SET reasoningLevel = :reasoningLevel, updatedAt = :updatedAt
WHERE id = :conversationId
"""
)
suspend fun updateConversationReasoningLevel(conversationId: String, reasoningLevel: String, updatedAt: Long)
@Query("UPDATE ai_chat_conversations SET updatedAt = :updatedAt WHERE id = :conversationId")
suspend fun touchConversation(conversationId: String, updatedAt: Long)
}
@@ -0,0 +1,33 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import io.legado.app.data.entities.AiMemory
import kotlinx.coroutines.flow.Flow
@Dao
interface AiMemoryDao {
@Query("SELECT * FROM ai_memory WHERE conversationId = :conversationId ORDER BY updatedAt DESC")
fun observeByConversation(conversationId: String): Flow<List<AiMemory>>
@Query("SELECT * FROM ai_memory WHERE conversationId = '' ORDER BY updatedAt DESC")
fun observeGlobal(): Flow<List<AiMemory>>
@Query("SELECT * FROM ai_memory WHERE conversationId = :conversationId")
suspend fun getByConversation(conversationId: String): List<AiMemory>
@Query("SELECT * FROM ai_memory WHERE conversationId = ''")
suspend fun getGlobal(): List<AiMemory>
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(memory: AiMemory)
@Query("DELETE FROM ai_memory WHERE conversationId = :conversationId AND `key` = :key")
suspend fun delete(conversationId: String, key: String)
@Query("DELETE FROM ai_memory WHERE conversationId = :conversationId")
suspend fun deleteAllForConversation(conversationId: String)
}
@@ -0,0 +1,72 @@
package io.legado.app.data.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Transaction
import androidx.room.Update
import io.legado.app.data.entities.AiModelProfile
import io.legado.app.data.entities.AiProviderProfile
import io.legado.app.data.entities.AiTaskPreset
import kotlinx.coroutines.flow.Flow
@Dao
interface AiProfileDao {
@Query("select * from ai_provider_profiles order by createdAt")
fun observeProviders(): Flow<List<AiProviderProfile>>
@Query("select * from ai_model_profiles order by sortNumber, createdAt")
fun observeModels(): Flow<List<AiModelProfile>>
@Query("select * from ai_task_presets order by taskType, sortNumber, createdAt")
fun observePresets(): Flow<List<AiTaskPreset>>
@Query("select * from ai_provider_profiles where id = :id")
suspend fun getProvider(id: String): AiProviderProfile?
@Query("select * from ai_model_profiles where id = :id")
suspend fun getModel(id: String): AiModelProfile?
@Query("select * from ai_model_profiles where providerId = :providerId order by sortNumber, createdAt")
suspend fun getModelsByProvider(providerId: String): List<AiModelProfile>
@Query("select * from ai_task_presets where id = :id")
suspend fun getPreset(id: String): AiTaskPreset?
@Query("select * from ai_task_presets where taskType = :taskType and enabled = 1 order by isDefault desc, sortNumber, createdAt limit 1")
suspend fun getDefaultPreset(taskType: String): AiTaskPreset?
@Query("select count(*) from ai_provider_profiles")
suspend fun countProviders(): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertProvider(provider: AiProviderProfile)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertModel(model: AiModelProfile)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertPreset(preset: AiTaskPreset)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateProvider(provider: AiProviderProfile)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateModel(model: AiModelProfile)
@Update(onConflict = OnConflictStrategy.REPLACE)
suspend fun updatePreset(preset: AiTaskPreset)
@Transaction
suspend fun upsertProfile(
provider: AiProviderProfile,
model: AiModelProfile,
preset: AiTaskPreset
) {
insertProvider(provider)
insertModel(model)
insertPreset(preset)
}
}
@@ -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
}
}
@@ -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()
)
@@ -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
)
@@ -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()
)
@@ -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()
)
@@ -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"
}
}
@@ -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()
)
@@ -0,0 +1,37 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.AiArtifactDao
import io.legado.app.data.entities.AiArtifact
import io.legado.app.domain.gateway.AiArtifactGateway
import kotlinx.coroutines.flow.Flow
class AiArtifactRepository(
private val aiArtifactDao: AiArtifactDao
) : AiArtifactGateway {
override fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>> {
return aiArtifactDao.observeBookArtifacts(bookUrl, taskType)
}
override suspend fun getCachedArtifact(
bookUrl: String,
chapterIndex: Int?,
taskType: String,
contentHash: String,
promptHash: String,
modelProfileId: String
): AiArtifact? {
return aiArtifactDao.getCachedArtifact(
bookUrl = bookUrl,
chapterIndex = chapterIndex,
taskType = taskType,
contentHash = contentHash,
promptHash = promptHash,
modelProfileId = modelProfileId
)
}
override suspend fun upsertArtifact(artifact: AiArtifact) {
aiArtifactDao.upsert(artifact)
}
}
@@ -0,0 +1,130 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.AiChatDao
import io.legado.app.data.entities.AiChatConversation
import io.legado.app.data.entities.AiChatMessage
import io.legado.app.domain.gateway.AiChatGateway
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.domain.model.AiMessagePartJson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.util.UUID
class AiChatRepository(
private val aiChatDao: AiChatDao
) : AiChatGateway {
override fun observeConversations(): Flow<List<AiChatConversation>> = aiChatDao.observeConversations()
override fun observeMessages(conversationId: String): Flow<List<AiChatMessage>> =
aiChatDao.observeMessages(conversationId)
override fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>> =
aiChatDao.observeSelectedMessages(conversationId)
override suspend fun getConversation(id: String): AiChatConversation? = withContext(Dispatchers.IO) {
aiChatDao.getConversation(id)
}
override suspend fun createConversation(title: String): AiChatConversation = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
AiChatConversation(
id = newId("chat"),
title = title,
createdAt = now,
updatedAt = now
).also { aiChatDao.insertConversation(it) }
}
override suspend fun saveMessage(
conversationId: String,
role: String,
parts: List<AiMessagePart>,
parentMessageId: String?,
thinkingDuration: Int
): AiChatMessage = withContext(Dispatchers.IO) {
val now = System.currentTimeMillis()
AiChatMessage(
id = newId("message"),
conversationId = conversationId,
role = role,
partsJson = AiMessagePartJson.encode(parts),
createdAt = now,
branchIndex = 0,
isSelected = true,
parentMessageId = parentMessageId,
thinkingDuration = thinkingDuration
).also {
aiChatDao.insertMessage(it)
aiChatDao.touchConversation(conversationId, now)
}
}
override suspend fun saveRegeneratedMessage(
conversationId: String,
role: String,
parts: List<AiMessagePart>,
parentMessageId: String,
thinkingDuration: Int
): AiChatMessage = withContext(Dispatchers.IO) {
val branchCount = aiChatDao.countBranches(parentMessageId)
val now = System.currentTimeMillis()
// Deselect existing branches for this parent
val siblings = aiChatDao.getBranches(parentMessageId)
siblings.forEach { sibling ->
aiChatDao.insertMessage(sibling.copy(isSelected = false))
}
AiChatMessage(
id = newId("message"),
conversationId = conversationId,
role = role,
partsJson = AiMessagePartJson.encode(parts),
createdAt = now,
branchIndex = branchCount,
isSelected = true,
parentMessageId = parentMessageId,
thinkingDuration = thinkingDuration
).also {
aiChatDao.insertMessage(it)
aiChatDao.touchConversation(conversationId, now)
}
}
override suspend fun selectBranch(messageId: String) = withContext(Dispatchers.IO) {
val message = aiChatDao.getMessage(messageId) ?: return@withContext
val parentId = message.parentMessageId ?: return@withContext
// Deselect siblings
val siblings = aiChatDao.getBranches(parentId)
siblings.forEach { sibling ->
aiChatDao.insertMessage(sibling.copy(isSelected = false))
}
// Select this branch
aiChatDao.selectBranch(messageId)
}
override suspend fun getBranches(parentMessageId: String): List<AiChatMessage> =
withContext(Dispatchers.IO) {
aiChatDao.getBranches(parentMessageId)
}
override suspend fun getBranchCounts(conversationId: String): Map<String, Int> =
withContext(Dispatchers.IO) {
aiChatDao.getBranchCounts(conversationId).associate { it.parentMessageId to it.cnt }
}
override suspend fun updateConversationTitle(conversationId: String, title: String) = withContext(Dispatchers.IO) {
aiChatDao.updateConversationTitle(conversationId, title, System.currentTimeMillis())
}
override suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String) =
withContext(Dispatchers.IO) {
aiChatDao.updateConversationReasoningLevel(
conversationId = conversationId,
reasoningLevel = reasoningLevel,
updatedAt = System.currentTimeMillis()
)
}
private fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
}
@@ -0,0 +1,48 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.AiMemoryDao
import io.legado.app.data.entities.AiMemory
import io.legado.app.domain.gateway.AiMemoryGateway
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
class AiMemoryRepository(
private val aiMemoryDao: AiMemoryDao
) : AiMemoryGateway {
override fun observeByConversation(conversationId: String): Flow<List<AiMemory>> =
aiMemoryDao.observeByConversation(conversationId)
override fun observeGlobal(): Flow<List<AiMemory>> = aiMemoryDao.observeGlobal()
override suspend fun getByConversation(conversationId: String): List<AiMemory> =
withContext(Dispatchers.IO) { aiMemoryDao.getByConversation(conversationId) }
override suspend fun getGlobal(): List<AiMemory> =
withContext(Dispatchers.IO) { aiMemoryDao.getGlobal() }
override suspend fun getForPrompt(conversationId: String): List<AiMemory> =
withContext(Dispatchers.IO) {
val global = aiMemoryDao.getGlobal()
val scoped = if (conversationId.isNotBlank()) {
aiMemoryDao.getByConversation(conversationId)
} else {
emptyList()
}
global + scoped
}
override suspend fun upsert(memory: AiMemory) = withContext(Dispatchers.IO) {
aiMemoryDao.upsert(memory.copy(updatedAt = System.currentTimeMillis()))
}
override suspend fun delete(conversationId: String, key: String) = withContext(Dispatchers.IO) {
aiMemoryDao.delete(conversationId, key)
}
override suspend fun deleteAllForConversation(conversationId: String) =
withContext(Dispatchers.IO) {
aiMemoryDao.deleteAllForConversation(conversationId)
}
}
@@ -0,0 +1,351 @@
package io.legado.app.data.repository
import io.legado.app.data.dao.AiProfileDao
import io.legado.app.data.entities.AiModelProfile
import io.legado.app.data.entities.AiProviderProfile
import io.legado.app.data.entities.AiTaskPreset
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiGenerationParams
import io.legado.app.domain.model.AiModelDraft
import io.legado.app.domain.model.AiModelConfig
import io.legado.app.domain.model.AiModelRegistry
import io.legado.app.domain.model.AiProfileDraft
import io.legado.app.domain.model.AiPromptTemplate
import io.legado.app.domain.model.AiProtocol
import io.legado.app.domain.model.AiProviderConfig
import io.legado.app.domain.model.AiProviderDraft
import io.legado.app.domain.model.AiTaskRuntimeOptions
import io.legado.app.domain.model.AiTaskPresetConfig
import io.legado.app.domain.model.AiTaskType
import io.legado.app.domain.model.TranslationConstants
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import java.util.UUID
class AiProfileRepository(
private val aiProfileDao: AiProfileDao
) : AiProfileGateway {
override fun observeProviders(): Flow<List<AiProviderProfile>> = aiProfileDao.observeProviders()
override fun observeModels(): Flow<List<AiModelProfile>> = aiProfileDao.observeModels()
override fun observePresets(): Flow<List<AiTaskPreset>> = aiProfileDao.observePresets()
override suspend fun getProvider(id: String): AiProviderProfile? = withContext(Dispatchers.IO) {
aiProfileDao.getProvider(id)
}
override suspend fun getModel(id: String): AiModelProfile? = withContext(Dispatchers.IO) {
aiProfileDao.getModel(id)
}
override suspend fun getTaskPreset(taskType: String): AiTaskPresetConfig? = withContext(Dispatchers.IO) {
aiProfileDao.getDefaultPreset(taskType)?.toConfig()
}
override suspend fun getProviderApiKey(providerId: String): String = withContext(Dispatchers.IO) {
aiProfileDao.getProvider(providerId)
?.apiKey
.orEmpty()
}
override suspend fun saveProvider(draft: AiProviderDraft): AiProviderProfile = withContext(Dispatchers.IO) {
require(draft.providerName.isNotBlank()) { "Provider name is required" }
require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
val providerId = draft.providerId?.takeIf { it.isNotBlank() } ?: newId("provider")
val existingProvider = aiProfileDao.getProvider(providerId)
val apiKey = draft.apiKey.ifBlank { existingProvider?.apiKey.orEmpty() }
val now = System.currentTimeMillis()
val provider = AiProviderProfile(
id = providerId,
name = draft.providerName,
protocol = draft.protocol,
baseUrl = draft.baseUrl,
modelsUrl = draft.modelsUrl?.takeIf { it.isNotBlank() },
apiKey = apiKey,
authType = existingProvider?.authType ?: AiProviderProfile.AUTH_TYPE_BEARER,
secretRef = existingProvider?.secretRef,
headersJson = existingProvider?.headersJson,
chatPath = existingProvider?.chatPath,
responsesPath = existingProvider?.responsesPath,
messagesPath = existingProvider?.messagesPath,
modelsPath = existingProvider?.modelsPath,
customHeadersJson = existingProvider?.customHeadersJson,
enabled = existingProvider?.enabled ?: true,
createdAt = existingProvider?.createdAt ?: now,
updatedAt = now
)
aiProfileDao.insertProvider(provider)
provider
}
override suspend fun saveModel(draft: AiModelDraft): AiModelProfile = withContext(Dispatchers.IO) {
require(draft.providerId.isNotBlank()) { "Provider is required" }
require(draft.modelId.isNotBlank()) { "Model is required" }
val existingProvider = aiProfileDao.getProvider(draft.providerId)
require(existingProvider != null) { "Provider is required" }
val modelProfileId = draft.modelProfileId?.takeIf { it.isNotBlank() } ?: stableModelId(
providerId = draft.providerId,
modelId = draft.modelId
)
val existingModel = aiProfileDao.getModel(modelProfileId)
val now = System.currentTimeMillis()
val params = AiGenerationParams(temperature = draft.temperature)
val model = AiModelProfile(
id = modelProfileId,
providerId = draft.providerId,
displayName = draft.modelName.ifBlank { draft.modelId },
modelId = draft.modelId,
contextWindow = draft.contextWindow,
maxOutputTokens = draft.maxOutputTokens,
capabilities = existingModel?.capabilities.orEmpty(),
defaultParamsJson = GSON.toJson(params),
enabled = existingModel?.enabled ?: true,
sortNumber = existingModel?.sortNumber ?: 0,
createdAt = existingModel?.createdAt ?: now,
updatedAt = now
)
aiProfileDao.insertModel(model)
model
}
override suspend fun importProviderModels(
providerId: String,
models: List<AiAvailableModel>
): List<AiModelProfile> = withContext(Dispatchers.IO) {
require(aiProfileDao.getProvider(providerId) != null) { "Provider is required" }
val now = System.currentTimeMillis()
models.distinctBy { it.id }.map { availableModel ->
val modelProfileId = stableModelId(providerId, availableModel.id)
val existingModel = aiProfileDao.getModel(modelProfileId)
AiModelProfile(
id = modelProfileId,
providerId = providerId,
displayName = availableModel.name.ifBlank { availableModel.id },
modelId = availableModel.id,
contextWindow = availableModel.contextWindow.takeIf { it > 0 } ?: existingModel?.contextWindow ?: 0,
maxOutputTokens = availableModel.maxOutputTokens.takeIf { it > 0 } ?: existingModel?.maxOutputTokens ?: 0,
capabilities = existingModel?.capabilities?.takeIf { it.isNotBlank() }
?: AiModelRegistry.inferCapabilities(availableModel.id).joinToString(","),
defaultParamsJson = existingModel?.defaultParamsJson ?: GSON.toJson(AiGenerationParams()),
enabled = existingModel?.enabled ?: true,
sortNumber = existingModel?.sortNumber ?: 0,
createdAt = existingModel?.createdAt ?: now,
updatedAt = now
).also { aiProfileDao.insertModel(it) }
}
}
override suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig = withContext(Dispatchers.IO) {
val model = aiProfileDao.getModel(modelProfileId) ?: error("Model is required")
val params = parseParams(model.defaultParamsJson)
saveDefaultPresets(modelProfileId, params)
aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)?.toConfig()
?: error("Failed to save default model")
}
override suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig = withContext(Dispatchers.IO) {
require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
require(draft.modelId.isNotBlank()) { "Model is required" }
val providerId = draft.providerId?.takeIf { it.isNotBlank() } ?: newId("provider")
val existingProvider = aiProfileDao.getProvider(providerId)
val modelProfileId = draft.modelProfileId?.takeIf { it.isNotBlank() } ?: newId("model")
val existingModel = aiProfileDao.getModel(modelProfileId)
val apiKey = draft.apiKey.ifBlank { existingProvider?.apiKey.orEmpty() }
require(apiKey.isNotBlank()) { "API key is required" }
val now = System.currentTimeMillis()
val params = AiGenerationParams(temperature = draft.temperature)
val existingTranslatePreset = aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)
val translationRuntimeOptions = existingTranslatePreset
?.chunkPolicyJson
?.let { parseRuntimeOptions(it) }
?: AiTaskRuntimeOptions(
targetLanguage = draft.translationTargetLanguage,
maxInputChars = draft.maxInputChars,
concurrentRequests = draft.concurrentRequests,
retryCount = draft.retryCount
)
aiProfileDao.insertProvider(
AiProviderProfile(
id = providerId,
name = draft.providerName.ifBlank { "Default AI Provider" },
protocol = draft.protocol,
baseUrl = draft.baseUrl,
modelsUrl = existingProvider?.modelsUrl,
apiKey = apiKey,
secretRef = existingProvider?.secretRef,
headersJson = existingProvider?.headersJson,
chatPath = existingProvider?.chatPath,
responsesPath = existingProvider?.responsesPath,
messagesPath = existingProvider?.messagesPath,
modelsPath = existingProvider?.modelsPath,
customHeadersJson = existingProvider?.customHeadersJson,
createdAt = existingProvider?.createdAt ?: now,
updatedAt = now
)
)
aiProfileDao.insertModel(
AiModelProfile(
id = modelProfileId,
providerId = providerId,
displayName = draft.modelName.ifBlank { draft.modelId },
modelId = draft.modelId,
contextWindow = draft.contextWindow,
maxOutputTokens = draft.maxOutputTokens,
capabilities = existingModel?.capabilities.orEmpty(),
defaultParamsJson = GSON.toJson(params),
createdAt = existingModel?.createdAt ?: now,
updatedAt = now
)
)
saveDefaultPresets(modelProfileId, params, translationRuntimeOptions)
aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)?.toConfig()
?: error("Failed to save AI profile")
}
private suspend fun saveDefaultPresets(
modelProfileId: String,
params: AiGenerationParams,
translationRuntimeOptions: AiTaskRuntimeOptions? = null
) {
val now = System.currentTimeMillis()
val existingTranslatePreset = aiProfileDao.getPreset(DEFAULT_TRANSLATE_PRESET_ID)
val runtimeOptions = translationRuntimeOptions
?: existingTranslatePreset
?.chunkPolicyJson
?.let { parseRuntimeOptions(it) }
?: AiTaskRuntimeOptions()
aiProfileDao.insertPreset(
AiTaskPreset(
id = DEFAULT_TRANSLATE_PRESET_ID,
taskType = AiTaskType.TRANSLATE_CHAPTER,
name = "Default Translation",
modelProfileId = modelProfileId,
promptTemplate = TranslationConstants.DEFAULT_PROMPT,
paramsJson = GSON.toJson(params),
chunkPolicyJson = GSON.toJson(runtimeOptions),
isDefault = true,
createdAt = existingTranslatePreset?.createdAt ?: now,
updatedAt = now
)
)
val existingSummaryPreset = aiProfileDao.getPreset(DEFAULT_SUMMARY_PRESET_ID)
aiProfileDao.insertPreset(
AiTaskPreset(
id = DEFAULT_SUMMARY_PRESET_ID,
taskType = AiTaskType.SUMMARIZE_CHAPTER,
name = "Default Chapter Summary",
modelProfileId = modelProfileId,
promptTemplate = AiPromptTemplate.DEFAULT_CHAPTER_SUMMARY,
paramsJson = GSON.toJson(params.copy(maxOutputTokens = 1200)),
isDefault = true,
createdAt = existingSummaryPreset?.createdAt ?: now,
updatedAt = now
)
)
val existingChatPreset = aiProfileDao.getPreset(DEFAULT_CHAT_PRESET_ID)
aiProfileDao.insertPreset(
AiTaskPreset(
id = DEFAULT_CHAT_PRESET_ID,
taskType = AiTaskType.CHAT,
name = "Default Chat",
modelProfileId = modelProfileId,
promptTemplate = "You are a helpful AI assistant.",
paramsJson = GSON.toJson(params),
isDefault = true,
createdAt = existingChatPreset?.createdAt ?: now,
updatedAt = now
)
)
}
private suspend fun AiTaskPreset.toConfig(): AiTaskPresetConfig? {
val model = aiProfileDao.getModel(modelProfileId) ?: return null
val provider = aiProfileDao.getProvider(model.providerId) ?: return null
return AiTaskPresetConfig(
id = id,
taskType = taskType,
name = name,
model = model.toConfig(provider),
promptTemplate = promptTemplate,
params = parseParams(paramsJson),
runtimeOptions = parseRuntimeOptions(chunkPolicyJson)
)
}
private fun AiModelProfile.toConfig(provider: AiProviderProfile): AiModelConfig {
return AiModelConfig(
id = id,
provider = AiProviderConfig(
id = provider.id,
name = provider.name,
protocol = provider.protocol,
baseUrl = provider.baseUrl,
apiKey = provider.apiKey,
modelsUrl = provider.modelsUrl,
headers = parseHeaders(provider.headersJson),
chatPath = provider.chatPath ?: "/chat/completions",
responsesPath = provider.responsesPath ?: "/responses",
messagesPath = provider.messagesPath ?: "/v1/messages",
modelsPath = provider.modelsPath,
customHeaders = parseHeaders(provider.customHeadersJson)
),
displayName = displayName,
modelId = modelId,
contextWindow = contextWindow,
maxOutputTokens = maxOutputTokens,
capabilities = capabilities.split(',')
.map { it.trim() }
.filter { it.isNotEmpty() }
.toSet(),
defaultParams = parseParams(defaultParamsJson)
)
}
private fun parseParams(json: String?): AiGenerationParams {
if (json.isNullOrBlank()) return AiGenerationParams()
return runCatching {
GSON.fromJson(json, AiGenerationParams::class.java)
}.getOrDefault(AiGenerationParams())
}
private fun parseRuntimeOptions(json: String?): AiTaskRuntimeOptions {
if (json.isNullOrBlank()) return AiTaskRuntimeOptions()
return runCatching {
GSON.fromJson(json, AiTaskRuntimeOptions::class.java)
}.getOrDefault(AiTaskRuntimeOptions())
}
private fun parseHeaders(json: String?): Map<String, String> {
if (json.isNullOrBlank()) return emptyMap()
return runCatching {
@Suppress("UNCHECKED_CAST")
GSON.fromJson(json, Map::class.java)
.mapKeys { it.key.toString() }
.mapValues { it.value.toString() }
}.getOrDefault(emptyMap())
}
private companion object {
const val DEFAULT_TRANSLATE_PRESET_ID = "default_translate_chapter"
const val DEFAULT_SUMMARY_PRESET_ID = "default_summarize_chapter"
const val DEFAULT_CHAT_PRESET_ID = "default_chat"
fun newId(prefix: String): String = "${prefix}_${UUID.randomUUID().toString().replace("-", "")}"
fun stableModelId(providerId: String, modelId: String): String {
val uuid = UUID.nameUUIDFromBytes("$providerId:$modelId".toByteArray())
.toString()
.replace("-", "")
return "model_$uuid"
}
}
}
@@ -0,0 +1,46 @@
package io.legado.app.data.repository
import io.legado.app.data.repository.ai.AiProviderRegistry
import io.legado.app.data.repository.ai.AnthropicHandler
import io.legado.app.data.repository.ai.OpenAiChatHandler
import io.legado.app.data.repository.ai.OpenAiResponsesHandler
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiProviderConfig
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext
class AiTextRepositoryImpl : AiTextGateway {
private val registry = AiProviderRegistry(
handlers = listOf(
OpenAiChatHandler(),
OpenAiResponsesHandler(),
AnthropicHandler()
)
)
override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
withContext(Dispatchers.IO) {
runCatching {
registry.handlerFor(request.model.provider.protocol).generate(request)
}.mapCatching { it.getOrThrow() }
}
override fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent> = flow {
registry.handlerFor(request.model.provider.protocol).stream(request) { emit(it) }
}.flowOn(Dispatchers.IO)
override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
withContext(Dispatchers.IO) {
runCatching {
registry.handlerFor(provider.protocol).fetchModels(provider).getOrThrow()
}
}
}
@@ -0,0 +1,520 @@
package io.legado.app.data.repository
import com.google.gson.JsonObject
import io.legado.app.data.dao.AiArtifactDao
import io.legado.app.data.dao.BookChapterDao
import io.legado.app.data.dao.BookDao
import io.legado.app.data.dao.BookmarkDao
import io.legado.app.data.dao.ReadRecordDao
import io.legado.app.data.entities.AiArtifact
import io.legado.app.data.entities.AiMemory
import io.legado.app.data.entities.Book
import io.legado.app.domain.gateway.AiMemoryGateway
import io.legado.app.domain.gateway.AiToolGateway
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.AiToolDefinition
import io.legado.app.domain.model.AiToolResult
import io.legado.app.help.book.BookHelp
import io.legado.app.help.book.ContentProcessor
import io.legado.app.utils.GSON
import io.legado.app.utils.MD5Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AiToolRepository(
private val bookDao: BookDao,
private val bookChapterDao: BookChapterDao,
private val bookmarkDao: BookmarkDao,
private val readRecordDao: ReadRecordDao,
private val aiArtifactDao: AiArtifactDao,
private val aiMemoryGateway: AiMemoryGateway
) : AiToolGateway {
override fun availableTools(): List<AiToolDefinition> = tools
override fun requiresConfirmation(toolName: String): Boolean {
return toolName in confirmationRequiredTools
}
override suspend fun execute(call: AiToolCall): AiToolResult = withContext(Dispatchers.IO) {
val args = call.arguments.toJsonObject()
val content = when (call.name) {
TOOL_SEARCH_BOOKS -> searchBooks(args)
TOOL_GET_BOOK_DETAIL -> getBookDetail(args)
TOOL_LIST_BOOK_CHAPTERS -> listBookChapters(args)
TOOL_GET_CHAPTER_CONTENT -> getChapterContent(args)
TOOL_SEARCH_BOOKMARKS -> searchBookmarks(args)
TOOL_GET_READING_STATS -> getReadingStats(args)
TOOL_GET_AI_ARTIFACTS -> getAiArtifacts(args)
TOOL_SAVE_AI_ARTIFACT -> saveAiArtifact(args)
TOOL_SAVE_MEMORY -> saveMemory(args)
TOOL_RECALL_MEMORY -> recallMemory(args)
TOOL_DELETE_MEMORY -> deleteMemory(args)
else -> """{"error":"Unknown tool: ${call.name}"}"""
}
AiToolResult(
callId = call.id,
name = call.name,
content = content
)
}
private fun searchBooks(args: JsonObject): String {
val query = args.string("query").orEmpty().trim()
val limit = args.int("limit", 8).coerceIn(1, 20)
val books = bookDao.all
.asSequence()
.filter { book ->
query.isBlank() ||
book.name.contains(query, ignoreCase = true) ||
book.author.contains(query, ignoreCase = true) ||
book.originName.contains(query, ignoreCase = true)
}
.sortedByDescending { it.durChapterTime }
.take(limit)
.map { it.toSummaryMap() }
.toList()
return GSON.toJson(mapOf("books" to books))
}
private fun getBookDetail(args: JsonObject): String {
val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
return GSON.toJson(
book.toSummaryMap() + mapOf(
"bookUrl" to book.bookUrl,
"kind" to book.kind,
"intro" to book.getDisplayIntro(),
"remark" to book.remark,
"latestChapterTitle" to book.latestChapterTitle,
"lastCheckCount" to book.lastCheckCount,
"canUpdate" to book.canUpdate
)
)
}
private fun listBookChapters(args: JsonObject): String {
val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
val query = args.string("query").orEmpty().trim()
val start = args.int("start", 0).coerceAtLeast(0)
val limit = args.int("limit", 20).coerceIn(1, 80)
val chapters = if (query.isBlank()) {
bookChapterDao.getChapterList(book.bookUrl, start, start + limit - 1)
} else {
bookChapterDao.search(book.bookUrl, query).drop(start).take(limit)
}
return GSON.toJson(
mapOf(
"book" to book.toIdentityMap(),
"chapters" to chapters.map {
mapOf(
"index" to it.index,
"title" to it.title,
"isVolume" to it.isVolume,
"wordCount" to it.wordCount,
"tag" to it.tag
)
}
)
)
}
private fun getChapterContent(args: JsonObject): String {
val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
val chapterIndex = args.int("chapterIndex", book.durChapterIndex).coerceAtLeast(0)
val maxChars = args.int("maxChars", 6000).coerceIn(500, 12000)
val chapter = bookChapterDao.getChapter(book.bookUrl, chapterIndex)
?: return """{"error":"Chapter not found"}"""
val rawContent = BookHelp.getContent(book, chapter)
?: return """{"error":"Chapter content is not cached locally"}"""
val content = ContentProcessor.get(book.name, book.origin)
.getContent(book, chapter, rawContent, includeTitle = false)
.toString()
.take(maxChars)
return GSON.toJson(
mapOf(
"book" to book.toIdentityMap(),
"chapter" to mapOf("index" to chapter.index, "title" to chapter.title),
"truncated" to (content.length < rawContent.length),
"content" to content
)
)
}
private fun searchBookmarks(args: JsonObject): String {
val query = args.string("query").orEmpty().trim()
val limit = args.int("limit", 10).coerceIn(1, 30)
val bookName = args.string("bookName")?.trim().orEmpty()
val bookAuthor = args.string("bookAuthor")?.trim().orEmpty()
val bookmarks = bookmarkDao.all
.asSequence()
.filter { bookmark ->
(bookName.isBlank() || bookmark.bookName.equals(bookName, ignoreCase = true)) &&
(bookAuthor.isBlank() || bookmark.bookAuthor.equals(bookAuthor, ignoreCase = true)) &&
(query.isBlank() ||
bookmark.bookName.contains(query, ignoreCase = true) ||
bookmark.bookAuthor.contains(query, ignoreCase = true) ||
bookmark.chapterName.contains(query, ignoreCase = true) ||
bookmark.content.contains(query, ignoreCase = true) ||
bookmark.bookText.contains(query, ignoreCase = true))
}
.sortedWith(compareBy({ it.bookName }, { it.chapterIndex }, { it.chapterPos }))
.take(limit)
.map {
mapOf(
"bookName" to it.bookName,
"bookAuthor" to it.bookAuthor,
"chapterIndex" to it.chapterIndex,
"chapterName" to it.chapterName,
"chapterPos" to it.chapterPos,
"note" to it.content,
"text" to it.bookText.take(500),
"time" to it.time
)
}
.toList()
return GSON.toJson(mapOf("bookmarks" to bookmarks))
}
private fun getReadingStats(args: JsonObject): String {
val query = args.string("query").orEmpty().trim()
val date = args.string("date")?.trim().orEmpty()
val limit = args.int("limit", 10).coerceIn(1, 30)
val records = readRecordDao.all
.asSequence()
.filter {
query.isBlank() ||
it.bookName.contains(query, ignoreCase = true) ||
it.bookAuthor.contains(query, ignoreCase = true)
}
.sortedByDescending { it.lastRead }
.take(limit)
.map {
mapOf(
"bookName" to it.bookName,
"bookAuthor" to it.bookAuthor,
"readTimeMillis" to it.readTime,
"lastReadTime" to it.lastRead
)
}
.toList()
val dailyDetails = readRecordDao.allDetail
.asSequence()
.filter {
(date.isBlank() || it.date == date) &&
(query.isBlank() ||
it.bookName.contains(query, ignoreCase = true) ||
it.bookAuthor.contains(query, ignoreCase = true))
}
.sortedWith(compareByDescending<io.legado.app.data.entities.readRecord.ReadRecordDetail> { it.date }
.thenByDescending { it.lastReadTime })
.take(limit)
.map {
mapOf(
"date" to it.date,
"bookName" to it.bookName,
"bookAuthor" to it.bookAuthor,
"readTimeMillis" to it.readTime,
"readWords" to it.readWords,
"firstReadTime" to it.firstReadTime,
"lastReadTime" to it.lastReadTime
)
}
.toList()
return GSON.toJson(
mapOf(
"totalReadTimeMillis" to readRecordDao.all.sumOf { it.readTime },
"recentRecords" to records,
"dailyDetails" to dailyDetails
)
)
}
private suspend fun getAiArtifacts(args: JsonObject): String {
val book = resolveBook(args)
val taskType = args.string("taskType")?.trim()?.takeIf { it.isNotBlank() }
val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
val limit = args.int("limit", 8).coerceIn(1, 30)
val artifacts = aiArtifactDao.queryArtifacts(
bookUrl = book?.bookUrl,
taskType = taskType,
chapterIndex = chapterIndex,
limit = limit
).map {
mapOf(
"id" to it.id,
"bookUrl" to it.bookUrl,
"chapterIndex" to it.chapterIndex,
"taskType" to it.taskType,
"status" to it.status,
"modelProfileId" to it.modelProfileId,
"updatedAt" to it.updatedAt,
"output" to it.output.orEmpty().take(4000),
"errorMessage" to it.errorMessage,
"truncated" to (it.output.orEmpty().length > 4000)
)
}
return GSON.toJson(
mapOf(
"book" to book?.toIdentityMap(),
"artifacts" to artifacts
)
)
}
private suspend fun saveAiArtifact(args: JsonObject): String {
val book = resolveBook(args) ?: return """{"error":"Book not found"}"""
val output = args.string("output")?.trim().orEmpty()
if (output.isBlank()) return """{"error":"output is required"}"""
val taskType = args.string("taskType")?.trim()?.takeIf { it.isNotBlank() } ?: "ai_note"
val chapterIndex = args.string("chapterIndex")?.toIntOrNull()
val now = System.currentTimeMillis()
val contentHash = MD5Utils.md5Encode(output)
val promptHash = MD5Utils.md5Encode("tool:$TOOL_SAVE_AI_ARTIFACT:$taskType")
val artifact = AiArtifact(
id = "tool_${book.bookUrl}_${chapterIndex ?: "book"}_${taskType}_${contentHash}",
taskType = taskType,
bookUrl = book.bookUrl,
chapterIndex = chapterIndex,
contentHash = contentHash,
promptHash = promptHash,
modelProfileId = "tool",
status = AiArtifact.STATUS_SUCCESS,
output = output,
createdAt = now,
updatedAt = now
)
aiArtifactDao.upsert(artifact)
return GSON.toJson(
mapOf(
"saved" to true,
"artifactId" to artifact.id,
"book" to book.toIdentityMap(),
"taskType" to taskType,
"chapterIndex" to chapterIndex,
"updatedAt" to now
)
)
}
private suspend fun saveMemory(args: JsonObject): String {
val key = args.string("key")?.trim().orEmpty()
if (key.isBlank()) return """{"error":"key is required"}"""
val value = args.string("value")?.trim().orEmpty()
if (value.isBlank()) return """{"error":"value is required"}"""
val conversationId = args.string("conversationId")?.trim().orEmpty()
aiMemoryGateway.upsert(
AiMemory(
conversationId = conversationId,
key = key,
value = value
)
)
return GSON.toJson(mapOf("saved" to true, "key" to key, "scope" to if (conversationId.isBlank()) "global" else "conversation"))
}
private suspend fun recallMemory(args: JsonObject): String {
val conversationId = args.string("conversationId")?.trim().orEmpty()
val memories = aiMemoryGateway.getForPrompt(conversationId)
return GSON.toJson(
mapOf(
"memories" to memories.map { mapOf("key" to it.key, "value" to it.value, "scope" to if (it.conversationId.isBlank()) "global" else "conversation") }
)
)
}
private suspend fun deleteMemory(args: JsonObject): String {
val key = args.string("key")?.trim().orEmpty()
if (key.isBlank()) return """{"error":"key is required"}"""
val conversationId = args.string("conversationId")?.trim().orEmpty()
aiMemoryGateway.delete(conversationId, key)
return GSON.toJson(mapOf("deleted" to true, "key" to key))
}
private fun resolveBook(args: JsonObject): Book? {
args.string("bookUrl")?.takeIf { it.isNotBlank() }?.let { url ->
bookDao.getBook(url)?.let { return it }
}
val name = args.string("bookName")?.trim().orEmpty()
val author = args.string("bookAuthor")?.trim().orEmpty()
if (name.isNotBlank() && author.isNotBlank()) {
bookDao.getBook(name, author)?.let { return it }
}
if (name.isNotBlank()) {
return bookDao.findByName(name).firstOrNull()
}
return bookDao.lastReadBook
}
private fun Book.toIdentityMap(): Map<String, Any?> {
return mapOf(
"bookUrl" to bookUrl,
"name" to name,
"author" to author
)
}
private fun Book.toSummaryMap(): Map<String, Any?> {
return toIdentityMap() + mapOf(
"originName" to originName,
"currentChapterIndex" to durChapterIndex,
"currentChapterTitle" to durChapterTitle,
"totalChapterNum" to totalChapterNum,
"wordCount" to wordCount,
"lastReadTime" to durChapterTime
)
}
private fun JsonObject.string(name: String): String? {
return get(name)?.takeIf { !it.isJsonNull }?.asString
}
private fun JsonObject.int(name: String, defaultValue: Int): Int {
return runCatching { get(name)?.takeIf { !it.isJsonNull }?.asInt }.getOrNull() ?: defaultValue
}
private fun String.toJsonObject(): JsonObject {
return runCatching { GSON.fromJson(this, JsonObject::class.java) }.getOrNull() ?: JsonObject()
}
companion object {
const val TOOL_SEARCH_BOOKS = "search_books"
const val TOOL_GET_BOOK_DETAIL = "get_book_detail"
const val TOOL_LIST_BOOK_CHAPTERS = "list_book_chapters"
const val TOOL_GET_CHAPTER_CONTENT = "get_chapter_content"
const val TOOL_SEARCH_BOOKMARKS = "search_bookmarks"
const val TOOL_GET_READING_STATS = "get_reading_stats"
const val TOOL_GET_AI_ARTIFACTS = "get_ai_artifacts"
const val TOOL_SAVE_AI_ARTIFACT = "save_ai_artifact"
const val TOOL_SAVE_MEMORY = "save_memory"
const val TOOL_RECALL_MEMORY = "recall_memory"
const val TOOL_DELETE_MEMORY = "delete_memory"
private val confirmationRequiredTools = setOf(TOOL_SAVE_AI_ARTIFACT, TOOL_SAVE_MEMORY, TOOL_DELETE_MEMORY)
private val tools = listOf(
AiToolDefinition(
name = TOOL_SEARCH_BOOKS,
description = "Search the local bookshelf by book title, author, or source name.",
inputSchema = objectSchema(
"query" to stringSchema("Search keyword. Leave empty to list recently read books."),
"limit" to intSchema("Maximum number of books to return.")
)
),
AiToolDefinition(
name = TOOL_GET_BOOK_DETAIL,
description = "Get metadata and reading progress for one book. If no identifier is given, use the last read book.",
inputSchema = objectSchema(
"bookUrl" to stringSchema("Exact book URL/id from search_books."),
"bookName" to stringSchema("Book title."),
"bookAuthor" to stringSchema("Book author.")
)
),
AiToolDefinition(
name = TOOL_LIST_BOOK_CHAPTERS,
description = "List chapter metadata for a local bookshelf book.",
inputSchema = objectSchema(
"bookUrl" to stringSchema("Exact book URL/id from search_books."),
"bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
"bookAuthor" to stringSchema("Book author."),
"query" to stringSchema("Optional chapter title keyword."),
"start" to intSchema("Zero-based offset for returned chapters."),
"limit" to intSchema("Maximum number of chapters to return.")
)
),
AiToolDefinition(
name = TOOL_GET_CHAPTER_CONTENT,
description = "Read cached text content for a chapter. This never downloads network content.",
inputSchema = objectSchema(
"bookUrl" to stringSchema("Exact book URL/id from search_books."),
"bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
"bookAuthor" to stringSchema("Book author."),
"chapterIndex" to intSchema("Zero-based chapter index. Defaults to current reading chapter."),
"maxChars" to intSchema("Maximum characters to return, capped by the app.")
)
),
AiToolDefinition(
name = TOOL_SEARCH_BOOKMARKS,
description = "Search local bookmarks and notes across the bookshelf or within one book.",
inputSchema = objectSchema(
"query" to stringSchema("Keyword for bookmark text, note, book, or chapter."),
"bookName" to stringSchema("Optional book title filter."),
"bookAuthor" to stringSchema("Optional book author filter."),
"limit" to intSchema("Maximum number of bookmarks to return.")
)
),
AiToolDefinition(
name = TOOL_GET_READING_STATS,
description = "Get local reading statistics, recent read books, and daily read records.",
inputSchema = objectSchema(
"query" to stringSchema("Optional book title or author filter."),
"date" to stringSchema("Optional date filter for daily records, format YYYY-MM-DD."),
"limit" to intSchema("Maximum number of records to return.")
)
),
AiToolDefinition(
name = TOOL_GET_AI_ARTIFACTS,
description = "Read existing AI artifacts such as chapter summaries for a book or chapter.",
inputSchema = objectSchema(
"bookUrl" to stringSchema("Exact book URL/id from search_books."),
"bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
"bookAuthor" to stringSchema("Book author."),
"chapterIndex" to intSchema("Optional zero-based chapter index."),
"taskType" to stringSchema("Optional artifact task type, such as summarize_chapter."),
"limit" to intSchema("Maximum number of artifacts to return.")
)
),
AiToolDefinition(
name = TOOL_SAVE_AI_ARTIFACT,
description = "Save a user-approved AI note or summary into local AI artifacts. Use only when the user explicitly asks to save content.",
inputSchema = objectSchema(
"bookUrl" to stringSchema("Exact book URL/id from search_books."),
"bookName" to stringSchema("Book title, used when bookUrl is unavailable."),
"bookAuthor" to stringSchema("Book author."),
"chapterIndex" to intSchema("Optional zero-based chapter index."),
"taskType" to stringSchema("Artifact task type, such as ai_note or summarize_chapter."),
"output" to stringSchema("The note or summary content to save.")
)
),
AiToolDefinition(
name = TOOL_SAVE_MEMORY,
description = "Save a fact or preference about the user to long-term memory. Use when the user shares a preference, fact, or instruction you should remember for future conversations.",
inputSchema = objectSchema(
"key" to stringSchema("Short label for the memory, e.g. 'favorite_genre', 'reading_goal'."),
"value" to stringSchema("The fact or preference to remember."),
"conversationId" to stringSchema("Leave empty for global memory across all conversations, or pass current conversation id for scoped memory.")
)
),
AiToolDefinition(
name = TOOL_RECALL_MEMORY,
description = "Recall saved memories about the user.",
inputSchema = objectSchema(
"conversationId" to stringSchema("Leave empty to recall global memories, or pass current conversation id for scoped memories.")
)
),
AiToolDefinition(
name = TOOL_DELETE_MEMORY,
description = "Delete a saved memory entry.",
inputSchema = objectSchema(
"key" to stringSchema("The memory key to delete."),
"conversationId" to stringSchema("Leave empty for global scope, or pass conversation id for scoped memory.")
)
)
)
private fun objectSchema(vararg properties: Pair<String, Map<String, Any?>>): Map<String, Any?> {
return mapOf(
"type" to "object",
"properties" to properties.toMap(),
"additionalProperties" to false
)
}
private fun stringSchema(description: String): Map<String, Any?> {
return mapOf("type" to "string", "description" to description)
}
private fun intSchema(description: String): Map<String, Any?> {
return mapOf("type" to "integer", "description" to description)
}
}
}
@@ -1,391 +0,0 @@
package io.legado.app.data.repository
import androidx.annotation.Keep
import io.legado.app.domain.gateway.LlmGateway
import io.legado.app.domain.model.DictPair
import io.legado.app.domain.model.RetryReason
import io.legado.app.domain.model.TranslationConstants
import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.postJson
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class LlmTranslateRepositoryImpl : LlmGateway {
/**
* Get display name for a language code, e.g., "zh" -> "简体中文"
*/
private fun getLanguageDisplayName(code: String): String {
return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code
}
override suspend fun translate(
text: String,
targetLanguage: String,
provider: String,
baseUrl: String,
apiKey: String,
model: String,
prompt: String,
temperature: Float,
dictionaries: List<DictPair>,
onUpdate: ((List<DictPair>) -> Unit)?,
retryReason: RetryReason?
): Result<String> = withContext(Dispatchers.IO) {
// 如果目标语言是英语,且文本已经是英文(英文字符及标点占比超过80%),跳过翻译直接返回原文
if (targetLanguage == "en" && isMostlyEnglish(text)) {
return@withContext Result.success(text)
}
// 如果目标语言是其他语言(如中文),且文本已经是该语言,跳过翻译直接返回原文
if (targetLanguage == "zh" && isMostlyChinese(text)) {
return@withContext Result.success(text)
}
try {
when (provider) {
TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(text, targetLanguage)
TranslationConstants.PROVIDER_OPENAI -> translateWithOpenAI(
text,
targetLanguage,
baseUrl,
apiKey,
model,
prompt,
temperature,
dictionaries,
onUpdate,
retryReason
)
else -> Result.failure(IllegalArgumentException("Unknown provider: $provider"))
}
} catch (e: Exception) {
Result.failure(e)
}
}
/**
* 判断文本是否大部分为英文(英文字母及标点符号占比超过80%)
*/
private fun isMostlyEnglish(text: String): Boolean {
if (text.isEmpty()) return false
val englishChars =
text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}–—…" }
return englishChars.toDouble() / text.length > 0.8
}
/**
* 判断文本是否大部分为中文(中文及中文标点占比超过80%)
*/
private fun isMostlyChinese(text: String): Boolean {
if (text.isEmpty()) return false
val chinesePunctuation = "。,!?;:“”‘’()【】《》——…"
val chineseChars = text.count {
it in '一'..'鿿' || it in chinesePunctuation
}
return chineseChars.toDouble() / text.length > 0.8
}
/**
* 如果翻译结果是中文,删除其中英文含量超过80%的段落(LLM可能生成中英对照内容)
*/
private fun filterHighEnglishParagraphs(text: String): String {
val paragraphs = text.split("\n")
val filtered = paragraphs.filter { paragraph ->
!isMostlyEnglish(paragraph)
}
return filtered.joinToString("\n")
}
private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result<String> {
val encodedText = java.net.URLEncoder.encode(text, "UTF-8")
val url =
"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText"
val response = okHttpClient.newCallStrResponse {
url(url)
}
return if (response.isSuccessful()) {
try {
val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
val translatedText = json?.sentences?.mapNotNull { it.trans }?.joinToString("") ?: ""
if (translatedText.isNotEmpty()) {
Result.success(translatedText)
} else {
Result.failure(Exception("Empty translation result"))
}
} catch (e: Exception) {
Result.failure(e)
}
} else {
Result.failure(Exception("HTTP ${response.code()}: ${response.message()}"))
}
}
private suspend fun translateWithOpenAI(
text: String,
targetLanguage: String,
baseUrl: String,
apiKey: String,
model: String,
prompt: String,
temperature: Float,
dictionaries: List<DictPair>,
onUpdate: ((List<DictPair>) -> Unit)?,
retryReason: RetryReason?
): Result<String> {
if (baseUrl.isBlank() || apiKey.isBlank() || model.isBlank()) {
return Result.failure(IllegalArgumentException("OpenAI configuration incomplete: baseUrl, apiKey, and model are required"))
}
// Build dictionary instruction for consistent terminology
val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
val systemPrompt =
buildSystemPrompt(prompt, targetLanguage, dictionaryInstruction, OUTPUT_FORMAT)
val requestBody = mapOf(
"model" to model,
"messages" to listOf(
mapOf("role" to "system", "content" to systemPrompt),
mapOf("role" to "user", "content" to "Translate the following text:\n\n$text")
),
"temperature" to temperature.coerceIn(
TranslationConstants.MIN_TEMPERATURE,
TranslationConstants.MAX_TEMPERATURE
)
)
val jsonBody = GSON.toJson(requestBody)
val fullUrl = baseUrl.trimEnd('/') + "/v1/chat/completions"
val response = okHttpClient.newCallStrResponse {
url(fullUrl)
postJson(jsonBody)
addHeaders(
mapOf(
"Authorization" to "Bearer $apiKey",
"Content-Type" to "application/json"
)
)
}
return if (response.isSuccessful()) {
try {
val json = GSON.fromJson(response.body, OpenAIResponse::class.java)
val rawContent = json?.choices?.firstOrNull()?.message?.content
if (rawContent != null) {
// Parse the output with [dictionary] and [result] sections
val parseResult = parseLlmOutput(rawContent, dictionaries)
// Report extracted pairs for dictionary update (max 10)
if (parseResult.extractedPairs.isNotEmpty()) {
val limitedPairs = parseResult.extractedPairs.take(10)
onUpdate?.invoke(limitedPairs)
}
// Always return the result, even if dictionary parsing failed
val finalText = if (targetLanguage == "zh") {
filterHighEnglishParagraphs(parseResult.translatedText)
} else {
parseResult.translatedText
}
Result.success(finalText)
} else {
Result.failure(Exception("Empty translation result"))
}
} catch (e: Exception) {
Result.failure(e)
}
} else {
val errorMsg = "HTTP ${response.code()}: ${response.message()}"
Result.failure(Exception(errorMsg))
}
}
/**
* Result of parsing LLM output
*/
data class ParseOutputResult(
val translatedText: String,
val extractedPairs: List<DictPair>
)
/**
* Parse LLM output that should contain [dictionary] and [result] sections.
* Fallback: if parsing fails or format is wrong, still return the result part.
* Don't use regex for parsing.
*/
private fun parseLlmOutput(
rawOutput: String,
existingDictionaries: List<DictPair>
): ParseOutputResult {
val existingOriginals = existingDictionaries.map { it.original }.toSet()
var dictionarySection: String? = null
var resultSection: String? = null
val lines = rawOutput.split('\n')
var currentSection: String? = null
for (line in lines) {
val trimmedLine = line.trim()
// Detect section header
when {
trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> {
currentSection = "dictionary"
}
trimmedLine.startsWith("[result]", ignoreCase = true) -> {
currentSection = "result"
}
currentSection == "dictionary" -> {
dictionarySection = (dictionarySection ?: "") + line + "\n"
}
currentSection == "result" -> {
resultSection = (resultSection ?: "") + line + "\n"
}
}
}
// Extract pairs from dictionary section
val extractedPairs = mutableListOf<DictPair>()
if (dictionarySection != null) {
extractedPairs.addAll(parseDictionarySection(dictionarySection, existingOriginals))
}
// Determine translated text
val translatedText = when {
resultSection != null -> resultSection.trim()
else -> rawOutput.trim() // ultimate fallback: return raw output
}
return ParseOutputResult(translatedText, extractedPairs)
}
/**
* Parse dictionary section line by line without regex.
* Format: "Original -> Translation" or "Original: Translation"
*/
private fun parseDictionarySection(
section: String,
existingOriginals: Set<String>
): List<DictPair> {
val pairs = mutableListOf<DictPair>()
val lines = section.split('\n')
for (line in lines) {
val trimmedLine = line.trim()
if (trimmedLine.isEmpty()) continue
// Skip section headers
if (trimmedLine.startsWith("[") || trimmedLine.startsWith(
"dictionary",
ignoreCase = true
)
) {
continue
}
// Try to find separator: "->" or ":"
val separator = when {
trimmedLine.contains(" -> ") -> " -> "
trimmedLine.contains(" ->") -> " ->"
trimmedLine.contains("-> ") -> "-> "
trimmedLine.contains("->") -> "->"
trimmedLine.contains(" : ") -> " : "
trimmedLine.contains(": ") -> ": "
trimmedLine.contains(" :") -> " :"
trimmedLine.contains(":") -> ":"
else -> null
}
if (separator != null) {
val parts = trimmedLine.split(separator, limit = 2)
if (parts.size == 2) {
val original = parts[0].trim()
val translation = parts[1].trim()
// Skip if already in existing dictionary
if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
pairs.add(DictPair(original, translation))
// Max 10 pairs
if (pairs.size >= 10) break
}
}
}
}
return pairs
}
/**
* Build a dictionary instruction string for consistent terminology.
*/
private fun buildDictionaryInstruction(dictionaries: List<DictPair>): String {
if (dictionaries.isEmpty()) return ""
val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" }
return """
Terminology Dictionary (use these exact translations):
$terms
"""
}
/**
* Build the full system prompt with dictionary instructions.
*/
private fun buildSystemPrompt(
prompt: String,
targetLanguage: String,
dictionaryInstruction: String,
outputFormat: String,
): String {
return buildString {
append(prompt)
append("\n Target language: ").append(getLanguageDisplayName(targetLanguage))
if (dictionaryInstruction.isNotEmpty()) {
append(dictionaryInstruction)
}
append("\n ").append(outputFormat)
}
}
}
@Keep
data class GoogleTranslateResponse(
val sentences: List<GoogleSentence>,
val src: String?,
val spell: GoogleSpell?
)
@Keep
data class GoogleSentence(
val trans: String?,
val orig: String?,
val backend: Int?
)
@Keep
data class GoogleSpell(
val spell: String?
)
@Keep
data class OpenAIResponse(
val choices: List<OpenAIChoice>
)
@Keep
data class OpenAIChoice(
val message: OpenAIMessage
)
@Keep
data class OpenAIMessage(
val content: String
)
@@ -0,0 +1,29 @@
package io.legado.app.data.repository.ai
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiProviderConfig
/**
* Protocol-specific handler for AI text generation.
* Each implementation handles one or more protocol types (e.g. OpenAI Chat, Anthropic Messages).
*/
interface AiProtocolHandler {
/** Protocol identifiers this handler supports (e.g. "openai_chat_completions"). */
val protocols: Set<String>
/** Single-shot text generation. */
suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse>
/** Streaming text generation via SSE. */
suspend fun stream(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
)
/** Fetch available models from the provider. */
suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>>
}
@@ -0,0 +1,15 @@
package io.legado.app.data.repository.ai
/**
* Registry that maps protocol identifiers to their [AiProtocolHandler] implementations.
*/
class AiProviderRegistry(handlers: List<AiProtocolHandler>) {
private val handlerMap: Map<String, AiProtocolHandler> =
handlers.flatMap { handler -> handler.protocols.map { it to handler } }.toMap()
fun handlerFor(protocol: String): AiProtocolHandler {
return handlerMap[protocol]
?: error("Unsupported AI protocol: $protocol")
}
}
@@ -0,0 +1,86 @@
package io.legado.app.data.repository.ai
import kotlinx.coroutines.delay
import kotlin.math.min
import kotlin.random.Random
/**
* Round-robin key rotator for providers with multiple API keys.
* Keys are comma-separated in the provider's apiKey field.
*/
internal class KeyRotator(rawKey: String) {
private val keys: List<String> = rawKey
.split(",")
.map { it.trim() }
.filter { it.isNotBlank() }
private var index = 0
val currentKey: String
get() = keys[index % keys.size]
val hasMultipleKeys: Boolean
get() = keys.size > 1
/** Advance to the next key. Returns the new current key. */
fun rotate(): String {
if (keys.size > 1) {
index = (index + 1) % keys.size
}
return currentKey
}
}
/**
* Retry a block with exponential backoff + jitter.
* Retries on [retryableStatusCodes] (default: 429, 502, 503).
* If [keyRotator] is provided and has multiple keys, rotates key on each retry.
*
* @param maxAttempts Total attempts (1 = no retry, 2 = one retry, etc.)
* @param baseDelayMs Base delay in milliseconds
* @param maxDelayMs Maximum delay cap
* @param onRetry Called before each retry with (attempt, delayMs, exception)
*/
internal suspend fun <T> retryWithBackoff(
maxAttempts: Int = 3,
baseDelayMs: Long = 1_000,
maxDelayMs: Long = 30_000,
retryableStatusCodes: Set<Int> = setOf(429, 502, 503),
keyRotator: KeyRotator? = null,
onRetry: (suspend (attempt: Int, delayMs: Long, error: Exception) -> Unit)? = null,
block: suspend () -> T
): T {
var lastException: Exception? = null
for (attempt in 1..maxAttempts) {
try {
return block()
} catch (e: Exception) {
lastException = e
if (attempt >= maxAttempts) break
if (!isRetryable(e, retryableStatusCodes)) break
// Rotate key if available
if (keyRotator != null && keyRotator.hasMultipleKeys) {
keyRotator.rotate()
}
// Exponential backoff with jitter
val exponentialDelay = baseDelayMs * (1L shl (attempt - 1))
val cappedDelay = min(exponentialDelay, maxDelayMs)
val jitter = Random.nextLong(0, cappedDelay / 4 + 1)
val totalDelay = cappedDelay + jitter
onRetry?.invoke(attempt, totalDelay, e)
delay(totalDelay)
}
}
throw lastException ?: Exception("Retry failed after $maxAttempts attempts")
}
private fun isRetryable(e: Exception, retryableStatusCodes: Set<Int>): Boolean {
val message = e.message ?: return false
return retryableStatusCodes.any { code ->
message.contains("HTTP $code") || message.contains("$code:")
}
}
@@ -0,0 +1,76 @@
package io.legado.app.data.repository.ai
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import io.legado.app.domain.model.AiCapability
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.utils.GSON
import okhttp3.Response
/**
* Shared SSE parsing utilities used by all protocol handlers.
*/
internal suspend fun Response.readSseData(onData: suspend (String) -> Unit) {
val source = body.source()
val dataLines = mutableListOf<String>()
try {
while (!source.exhausted()) {
val line = source.readUtf8Line() ?: break
when {
line.isEmpty() -> {
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n").trim()
dataLines.clear()
if (data == "[DONE]") break
if (data.isNotEmpty()) onData(data)
}
}
line.startsWith("data:") -> {
dataLines += line.removePrefix("data:").trimStart()
}
}
}
if (dataLines.isNotEmpty()) {
val data = dataLines.joinToString("\n").trim()
if (data != "[DONE]" && data.isNotEmpty()) onData(data)
}
} finally {
source.close()
}
}
internal fun String.toJsonObject(): JsonObject? {
return runCatching {
GSON.fromJson(this, JsonObject::class.java)
}.getOrNull()
}
internal fun JsonObject.getString(name: String): String? {
return get(name)?.takeIf { !it.isJsonNull }?.asString
}
internal fun JsonObject.extractApiErrorMessage(): String? {
val error = get("error")?.asJsonObjectOrNull() ?: return null
return error.getString("message") ?: error.getString("code") ?: "AI provider returned an error"
}
internal fun JsonElement.asJsonObjectOrNull(): JsonObject? {
return if (isJsonObject) asJsonObject else null
}
internal fun JsonElement.asJsonArrayOrNull() = if (isJsonArray) asJsonArray else null
/**
* Check if the model supports reasoning capability.
*/
internal fun hasReasoningCapability(capabilities: Set<String>): Boolean {
return AiCapability.REASONING in capabilities
}
/**
* Map AiReasoningLevel to OpenAI-compatible reasoning_effort value.
* OpenAI doesn't accept "none" — remap to "low".
*/
internal fun AiReasoningLevel.toOpenAiEffort(): String {
return if (effort == "none") "low" else effort
}
@@ -0,0 +1,425 @@
package io.legado.app.data.repository.ai
import androidx.annotation.Keep
import com.google.gson.JsonObject
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiCapability
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiProtocol
import io.legado.app.domain.model.AiProviderConfig
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.AiToolDefinition
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.await
import io.legado.app.help.http.newCallResponse
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.postJson
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AnthropicHandler : AiProtocolHandler {
override val protocols = setOf(AiProtocol.ANTHROPIC_MESSAGES)
override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
withContext(Dispatchers.IO) {
runCatching { generateInternal(request) }
}
override suspend fun stream(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
streamInternal(request, emitEvent)
}
override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
withContext(Dispatchers.IO) {
runCatching { fetchModelsInternal(provider) }
}
private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"Anthropic configuration incomplete: baseUrl, apiKey, and model are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val systemPrompt = request.messages
.filter { it.role == AiMessageRole.SYSTEM }
.joinToString("\n\n") { it.content }
.takeIf { it.isNotBlank() }
val messages = request.messages
.filter { it.role != AiMessageRole.SYSTEM }
.toAnthropicMessages()
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"messages" to messages,
"max_tokens" to (request.params.maxOutputTokens ?: request.model.maxOutputTokens.takeIf { it > 0 } ?: 2048)
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toAnthropicTools() }
systemPrompt?.let { body["system"] = it }
// Reasoning config — suppress temperature when thinking is enabled
val reasoningLevel = request.params.reasoningLevel
if (hasReasoningCapability(request.model.capabilities)) {
val thinking = buildMap<String, Any> {
put("type", if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "adaptive")
if (reasoningLevel != AiReasoningLevel.OFF) {
put("display", "summarized")
}
}
body["thinking"] = thinking
if (reasoningLevel != AiReasoningLevel.OFF && reasoningLevel != AiReasoningLevel.AUTO) {
body["output_config"] = mapOf("effort" to reasoningLevel.effort)
}
} else {
request.params.temperature?.let { body["temperature"] = it }
}
request.params.topP?.let { body["top_p"] = it }
return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(provider.baseUrl + provider.messagesPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"x-api-key" to keyRotator.currentKey,
"anthropic-version" to "2023-06-01",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val json = GSON.fromJson(response.body, AnthropicMessageResponse::class.java)
val text = json?.content
?.mapNotNull { it.text?.takeIf { text -> text.isNotBlank() } }
?.joinToString("")
if (text.isNullOrBlank()) {
throw Exception("Empty AI response")
} else {
AiGenerateResponse(text = text, rawBody = response.body)
}
}
}
private suspend fun streamInternal(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"Anthropic configuration incomplete: baseUrl, apiKey, and model are required"
}
val systemPrompt = request.messages
.filter { it.role == AiMessageRole.SYSTEM }
.joinToString("\n\n") { it.content }
.takeIf { it.isNotBlank() }
val messages = request.messages
.filter { it.role != AiMessageRole.SYSTEM }
.toAnthropicMessages()
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"messages" to messages,
"max_tokens" to (request.params.maxOutputTokens ?: request.model.maxOutputTokens.takeIf { it > 0 } ?: 2048),
"stream" to true
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toAnthropicTools() }
systemPrompt?.let { body["system"] = it }
// Reasoning config — suppress temperature when thinking is enabled
val reasoningLevel = request.params.reasoningLevel
if (hasReasoningCapability(request.model.capabilities)) {
val thinking = buildMap<String, Any> {
put("type", if (reasoningLevel == AiReasoningLevel.OFF) "disabled" else "adaptive")
if (reasoningLevel != AiReasoningLevel.OFF) {
put("display", "summarized")
}
}
body["thinking"] = thinking
if (reasoningLevel != AiReasoningLevel.OFF && reasoningLevel != AiReasoningLevel.AUTO) {
body["output_config"] = mapOf("effort" to reasoningLevel.effort)
}
} else {
request.params.temperature?.let { body["temperature"] = it }
}
request.params.topP?.let { body["top_p"] = it }
val keyRotator = KeyRotator(provider.apiKey)
val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
okHttpClient.newCallResponse {
url(provider.baseUrl + provider.messagesPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"x-api-key" to keyRotator.currentKey,
"anthropic-version" to "2023-06-01",
"Content-Type" to "application/json"
)
)
}.also {
if (!it.isSuccessful) {
throw Exception("HTTP ${it.code}: ${it.message}")
}
}
}
val toolBlocks = mutableMapOf<Int, AnthropicToolBlock>()
try {
response.readSseData { data ->
val event = runCatching {
GSON.fromJson(data, AnthropicStreamEvent::class.java)
}.getOrElse {
throw Exception("Invalid Anthropic stream event", it)
} ?: return@readSseData
when (event.type) {
"error" -> {
throw Exception(event.error?.message ?: event.error?.type ?: "Anthropic stream error")
}
"content_block_start" -> {
val index = event.index ?: return@readSseData
val block = event.content_block ?: return@readSseData
if (block.type == "tool_use") {
toolBlocks[index] = AnthropicToolBlock(
id = block.id ?: "anthropic_tool_$index",
name = block.name.orEmpty()
)
emitEvent(
AiStreamEvent.ToolCallDelta(
id = block.id ?: "anthropic_tool_$index",
index = index,
name = block.name,
argumentsDelta = null,
rawType = "tool_use"
)
)
}
}
"content_block_delta" -> {
val delta = event.delta ?: return@readSseData
when (delta.type) {
"text_delta", null -> {
delta.text?.takeIf { it.isNotEmpty() }?.let {
emitEvent(AiStreamEvent.Content(it))
}
delta.thinking?.takeIf { it.isNotEmpty() }?.let {
emitEvent(AiStreamEvent.Reasoning(it))
}
delta.partial_json?.takeIf { it.isNotEmpty() }?.let {
val block = event.index?.let { index -> toolBlocks[index] }
emitEvent(
AiStreamEvent.ToolCallDelta(
id = block?.id ?: event.index?.let { index -> "anthropic_tool_$index" },
index = event.index,
name = block?.name,
argumentsDelta = it,
rawType = "input_json_delta"
)
)
}
}
"thinking_delta" -> {
delta.thinking?.takeIf { it.isNotEmpty() }?.let {
emitEvent(AiStreamEvent.Reasoning(it))
}
}
"input_json_delta" -> {
delta.partial_json?.takeIf { it.isNotEmpty() }?.let {
val block = event.index?.let { index -> toolBlocks[index] }
emitEvent(
AiStreamEvent.ToolCallDelta(
id = block?.id ?: event.index?.let { index -> "anthropic_tool_$index" },
index = event.index,
name = block?.name,
argumentsDelta = it,
rawType = "input_json_delta"
)
)
}
}
}
}
"content_block_stop" -> {
event.index?.let { toolBlocks.remove(it) }
}
}
}
} finally {
response.close()
}
}
private suspend fun fetchModelsInternal(provider: AiProviderConfig): List<AiAvailableModel> {
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
"Anthropic configuration incomplete: baseUrl and apiKey are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
?: provider.modelsUrl
?: (provider.baseUrl + "/v1/models")
return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(modelsUrl)
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"x-api-key" to keyRotator.currentKey,
"anthropic-version" to "2023-06-01",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val json = GSON.fromJson(response.body, AnthropicModelsResponse::class.java)
json?.data.toAvailableModels()
}
}
}
// ---- Message & tool format converters ----
internal fun List<AiMessage>.toAnthropicMessages(): List<Map<String, Any?>> {
return map { message ->
when {
message.role == AiMessageRole.TOOL -> mapOf(
"role" to "user",
"content" to listOf(
mapOf(
"type" to "tool_result",
"tool_use_id" to message.toolCallId,
"content" to message.content
)
)
)
message.toolCalls.isNotEmpty() -> {
val content = buildList {
if (message.content.isNotBlank()) {
add(mapOf("type" to "text", "text" to message.content))
}
message.toolCalls.forEach {
add(
mapOf(
"type" to "tool_use",
"id" to it.id,
"name" to it.name,
"input" to (it.arguments.toJsonObject() ?: JsonObject())
)
)
}
}
mapOf("role" to "assistant", "content" to content)
}
else -> mapOf(
"role" to if (message.role == AiMessageRole.ASSISTANT) "assistant" else "user",
"content" to message.content
)
}
}
}
internal fun List<AiToolDefinition>.toAnthropicTools(): List<Map<String, Any?>> {
return map {
mapOf(
"name" to it.name,
"description" to it.description,
"input_schema" to it.inputSchema
)
}
}
private fun List<AnthropicModelItem>?.toAvailableModels(): List<AiAvailableModel> {
return orEmpty()
.mapNotNull { item ->
val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
AiAvailableModel(
id = id,
name = item.display_name?.takeIf { it.isNotBlank() }
?: item.displayName?.takeIf { it.isNotBlank() }
?: item.name?.takeIf { it.isNotBlank() }
?: id,
contextWindow = item.context_window ?: item.contextWindow ?: 0,
maxOutputTokens = item.max_tokens
?: item.maxTokens
?: item.max_output_tokens
?: item.maxOutputTokens
?: 0
)
}
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
}
// ---- Data classes ----
@Keep
internal data class AnthropicMessageResponse(
val content: List<AnthropicMessageContent>?
)
@Keep
internal data class AnthropicMessageContent(
val type: String?,
val text: String?
)
@Keep
internal data class AnthropicModelsResponse(
val data: List<AnthropicModelItem>?
)
@Keep
internal data class AnthropicModelItem(
val id: String?,
val name: String?,
val display_name: String?,
val displayName: String?,
val context_window: Int?,
val contextWindow: Int?,
val max_tokens: Int?,
val maxTokens: Int?,
val max_output_tokens: Int?,
val maxOutputTokens: Int?
)
@Keep
internal data class AnthropicStreamEvent(
val type: String?,
val index: Int?,
val content_block: AnthropicContentBlock?,
val error: AnthropicStreamError?,
val delta: AnthropicStreamDelta?
)
@Keep
internal data class AnthropicStreamDelta(
val type: String?,
val text: String?,
val thinking: String?,
val partial_json: String?,
val signature: String?
)
@Keep
internal data class AnthropicContentBlock(
val type: String?,
val id: String?,
val name: String?,
val text: String?
)
@Keep
internal data class AnthropicStreamError(
val type: String?,
val message: String?
)
internal data class AnthropicToolBlock(
val id: String,
val name: String
)
@@ -0,0 +1,329 @@
package io.legado.app.data.repository.ai
import androidx.annotation.Keep
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiCapability
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiProtocol
import io.legado.app.domain.model.AiProviderConfig
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.AiToolDefinition
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.await
import io.legado.app.help.http.newCallResponse
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.postJson
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.Response
class OpenAiChatHandler : AiProtocolHandler {
override val protocols = setOf(AiProtocol.OPENAI_CHAT_COMPLETIONS)
override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
withContext(Dispatchers.IO) {
runCatching { generateInternal(request) }
}
override suspend fun stream(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
streamInternal(request, emitEvent)
}
override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
withContext(Dispatchers.IO) {
runCatching { fetchModelsInternal(provider) }
}
private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"OpenAI-compatible configuration incomplete: baseUrl, apiKey, and model are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val params = request.params
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"messages" to request.messages.toOpenAiChatMessages()
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiChatTools() }
params.temperature?.let { body["temperature"] = it }
params.maxOutputTokens?.let { body["max_tokens"] = it }
params.topP?.let { body["top_p"] = it }
if (hasReasoningCapability(request.model.capabilities) && params.reasoningLevel != AiReasoningLevel.AUTO) {
body["reasoning_effort"] = params.reasoningLevel.toOpenAiEffort()
}
return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(provider.baseUrl + provider.chatPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val json = GSON.fromJson(response.body, OpenAiChatResponse::class.java)
val text = json?.choices?.firstOrNull()?.message?.content
if (text.isNullOrBlank()) {
throw Exception("Empty AI response")
} else {
AiGenerateResponse(text = text, rawBody = response.body)
}
}
}
private suspend fun streamInternal(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"OpenAI-compatible configuration incomplete: baseUrl, apiKey, and model are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val params = request.params
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"messages" to request.messages.toOpenAiChatMessages(),
"stream" to true
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiChatTools() }
params.temperature?.let { body["temperature"] = it }
params.maxOutputTokens?.let { body["max_tokens"] = it }
params.topP?.let { body["top_p"] = it }
if (hasReasoningCapability(request.model.capabilities) && params.reasoningLevel != AiReasoningLevel.AUTO) {
body["reasoning_effort"] = params.reasoningLevel.toOpenAiEffort()
}
// For streaming, we retry before establishing the SSE connection.
// Once streaming starts, errors are not retried (partial output would be confusing).
val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
okHttpClient.newCallResponse {
url(provider.baseUrl + provider.chatPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}.also {
if (!it.isSuccessful) {
throw Exception("HTTP ${it.code}: ${it.message}")
}
}
}
try {
response.readSseData { data ->
val root = data.toJsonObject()
root?.extractApiErrorMessage()?.let { throw Exception(it) }
runCatching {
val chunk = GSON.fromJson(data, OpenAiChatStreamChunk::class.java)
val delta = chunk?.choices?.firstOrNull()?.delta
val reasoning = delta?.reasoning_content ?: delta?.reasoning
if (!reasoning.isNullOrEmpty()) {
emitEvent(AiStreamEvent.Reasoning(reasoning))
}
val content = delta?.content
if (!content.isNullOrEmpty()) {
emitEvent(AiStreamEvent.Content(content))
}
delta?.tool_calls?.forEach { toolCall ->
emitEvent(
AiStreamEvent.ToolCallDelta(
id = toolCall.id,
index = toolCall.index,
name = toolCall.function?.name,
argumentsDelta = toolCall.function?.arguments,
rawType = toolCall.type ?: "tool_call"
)
)
}
}.getOrElse {
throw Exception("Invalid OpenAI chat stream chunk", it)
}
}
} finally {
response.close()
}
}
private suspend fun fetchModelsInternal(provider: AiProviderConfig): List<AiAvailableModel> {
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
"AI provider configuration incomplete: baseUrl and apiKey are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
?: provider.modelsUrl
?: (provider.baseUrl + "/models")
return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(modelsUrl)
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val json = GSON.fromJson(response.body, OpenAiModelsResponse::class.java)
json?.data.toAvailableModels()
}
}
}
// ---- Message & tool format converters ----
internal fun List<AiMessage>.toOpenAiChatMessages(): List<Map<String, Any?>> {
return mapNotNull { message ->
when {
message.role == AiMessageRole.TOOL -> mapOf(
"role" to "tool",
"tool_call_id" to message.toolCallId,
"content" to message.content
)
message.toolCalls.isNotEmpty() -> {
buildMap {
put("role", "assistant")
put("content", message.content.takeIf { it.isNotBlank() })
put(
"tool_calls",
message.toolCalls.map {
mapOf(
"id" to it.id,
"type" to "function",
"function" to mapOf(
"name" to it.name,
"arguments" to it.arguments
)
)
}
)
}
}
else -> mapOf("role" to message.role, "content" to message.content)
}
}
}
internal fun List<AiToolDefinition>.toOpenAiChatTools(): List<Map<String, Any?>> {
return map {
mapOf(
"type" to "function",
"function" to mapOf(
"name" to it.name,
"description" to it.description,
"parameters" to it.inputSchema
)
)
}
}
private fun List<OpenAiModelItem>?.toAvailableModels(): List<AiAvailableModel> {
return orEmpty()
.mapNotNull { item ->
val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
AiAvailableModel(
id = id,
name = item.display_name?.takeIf { it.isNotBlank() }
?: item.displayName?.takeIf { it.isNotBlank() }
?: item.name?.takeIf { it.isNotBlank() }
?: id,
contextWindow = item.context_window ?: item.contextWindow ?: 0,
maxOutputTokens = item.max_tokens
?: item.maxTokens
?: item.max_output_tokens
?: item.maxOutputTokens
?: 0
)
}
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
}
// ---- Data classes ----
@Keep
internal data class OpenAiChatResponse(
val choices: List<OpenAiChatChoice>?
)
@Keep
internal data class OpenAiChatChoice(
val message: OpenAiChatMessage?
)
@Keep
internal data class OpenAiChatMessage(
val content: String?
)
@Keep
internal data class OpenAiModelsResponse(
val data: List<OpenAiModelItem>?
)
@Keep
internal data class OpenAiModelItem(
val id: String?,
val name: String?,
val display_name: String?,
val displayName: String?,
val context_window: Int?,
val contextWindow: Int?,
val max_tokens: Int?,
val maxTokens: Int?,
val max_output_tokens: Int?,
val maxOutputTokens: Int?
)
@Keep
internal data class OpenAiChatStreamChunk(
val choices: List<OpenAiChatStreamChoice>?
)
@Keep
internal data class OpenAiChatStreamChoice(
val delta: OpenAiChatStreamDelta?
)
@Keep
internal data class OpenAiChatStreamDelta(
val content: String?,
val reasoning_content: String?,
val reasoning: String?,
val tool_calls: List<OpenAiChatToolCall>?
)
@Keep
internal data class OpenAiChatToolCall(
val index: Int?,
val id: String?,
val type: String?,
val function: OpenAiChatToolCallFunction?
)
@Keep
internal data class OpenAiChatToolCallFunction(
val name: String?,
val arguments: String?
)
@@ -0,0 +1,351 @@
package io.legado.app.data.repository.ai
import com.google.gson.JsonObject
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiCapability
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiProtocol
import io.legado.app.domain.model.AiProviderConfig
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.AiToolDefinition
import io.legado.app.help.http.addHeaders
import io.legado.app.help.http.await
import io.legado.app.help.http.newCallResponse
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.help.http.postJson
import io.legado.app.utils.GSON
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class OpenAiResponsesHandler : AiProtocolHandler {
override val protocols = setOf(AiProtocol.OPENAI_RESPONSES)
override suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse> =
withContext(Dispatchers.IO) {
runCatching { generateInternal(request) }
}
override suspend fun stream(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
streamInternal(request, emitEvent)
}
override suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>> =
withContext(Dispatchers.IO) {
runCatching { fetchOpenAiCompatibleModels(provider) }
}
private suspend fun generateInternal(request: AiGenerateRequest): AiGenerateResponse {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"OpenAI Responses configuration incomplete: baseUrl, apiKey, and model are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val params = request.params
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"input" to request.messages.toOpenAiResponsesInput()
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiResponsesTools() }
params.temperature?.let { body["temperature"] = it }
params.maxOutputTokens?.let { body["max_output_tokens"] = it }
params.topP?.let { body["top_p"] = it }
if (hasReasoningCapability(request.model.capabilities)) {
body["reasoning"] = buildMap<String, Any> {
put("summary", "auto")
if (params.reasoningLevel != AiReasoningLevel.AUTO) {
put("effort", params.reasoningLevel.effort)
}
}
}
return retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(provider.baseUrl + provider.responsesPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val root = GSON.fromJson(response.body, JsonObject::class.java)
val text = root?.getString("output_text") ?: root.extractResponsesOutputText()
if (text.isNullOrBlank()) {
throw Exception("Empty AI response")
} else {
AiGenerateResponse(text = text, rawBody = response.body)
}
}
}
private suspend fun streamInternal(
request: AiGenerateRequest,
emitEvent: suspend (AiStreamEvent) -> Unit
) {
val provider = request.model.provider
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank() && request.model.modelId.isNotBlank()) {
"OpenAI Responses configuration incomplete: baseUrl, apiKey, and model are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val params = request.params
val body = mutableMapOf<String, Any?>(
"model" to request.model.modelId,
"input" to request.messages.toOpenAiResponsesInput(),
"stream" to true
)
request.tools.takeIf { it.isNotEmpty() }?.let { body["tools"] = it.toOpenAiResponsesTools() }
params.temperature?.let { body["temperature"] = it }
params.maxOutputTokens?.let { body["max_output_tokens"] = it }
params.topP?.let { body["top_p"] = it }
if (hasReasoningCapability(request.model.capabilities)) {
body["reasoning"] = buildMap<String, Any> {
put("summary", "auto")
if (params.reasoningLevel != AiReasoningLevel.AUTO) {
put("effort", params.reasoningLevel.effort)
}
}
}
val response = retryWithBackoff(maxAttempts = 3, keyRotator = keyRotator) {
okHttpClient.newCallResponse {
url(provider.baseUrl + provider.responsesPath)
postJson(GSON.toJson(body))
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}.also {
if (!it.isSuccessful) {
throw Exception("HTTP ${it.code}: ${it.message}")
}
}
}
try {
response.readSseData { data ->
val root = data.toJsonObject() ?: throw Exception("Invalid OpenAI Responses stream chunk")
root.extractApiErrorMessage()?.let { throw Exception(it) }
when (root.getString("type")) {
"response.output_text.delta",
"response.refusal.delta" -> {
root.getString("delta")?.takeIf { it.isNotEmpty() }?.let {
emitEvent(AiStreamEvent.Content(it))
}
}
"response.reasoning_summary_text.delta",
"response.reasoning_text.delta" -> {
root.getString("delta")?.takeIf { it.isNotEmpty() }?.let {
emitEvent(AiStreamEvent.Reasoning(it))
}
}
"response.output_item.added",
"response.output_item.done" -> {
root.get("item")?.asJsonObjectOrNull()?.let { item ->
if (item.getString("type")?.contains("call") == true) {
emitEvent(
AiStreamEvent.ToolCallDelta(
id = item.getString("call_id") ?: item.getString("id"),
index = root.getString("output_index")?.toIntOrNull(),
name = item.getString("name") ?: item.getString("server_label"),
argumentsDelta = item.getString("arguments"),
rawType = item.getString("type") ?: root.getString("type").orEmpty()
)
)
}
}
}
"response.function_call_arguments.delta",
"response.mcp_call_arguments.delta",
"response.code_interpreter_call_code.delta",
"response.custom_tool_call_input.delta" -> {
emitEvent(
AiStreamEvent.ToolCallDelta(
id = root.getString("call_id"),
index = root.getString("output_index")?.toIntOrNull(),
name = null,
argumentsDelta = root.getString("delta"),
rawType = root.getString("type").orEmpty()
)
)
}
"response.function_call_arguments.done",
"response.mcp_call_arguments.done",
"response.code_interpreter_call_code.done",
"response.custom_tool_call_input.done",
"response.file_search_call.in_progress",
"response.file_search_call.searching",
"response.file_search_call.completed",
"response.web_search_call.in_progress",
"response.web_search_call.searching",
"response.web_search_call.completed",
"response.mcp_call.in_progress",
"response.mcp_call.completed",
"response.mcp_call.failed",
"response.mcp_list_tools.in_progress",
"response.mcp_list_tools.completed",
"response.mcp_list_tools.failed",
"response.code_interpreter_call.in_progress",
"response.code_interpreter_call.interpreting",
"response.code_interpreter_call.completed" -> {
emitEvent(
AiStreamEvent.ToolCallDelta(
id = root.getString("call_id"),
index = root.getString("output_index")?.toIntOrNull(),
name = null,
argumentsDelta = root.getString("arguments")
?: root.getString("code")
?: root.getString("input"),
rawType = root.getString("type").orEmpty()
)
)
}
"response.failed" -> {
throw Exception(root.extractResponseFailureMessage() ?: "OpenAI response failed")
}
"response.incomplete" -> {
throw Exception(root.extractResponseIncompleteMessage() ?: "OpenAI response incomplete")
}
}
}
} finally {
response.close()
}
}
private suspend fun fetchOpenAiCompatibleModels(provider: AiProviderConfig): List<AiAvailableModel> {
require(provider.baseUrl.isNotBlank() && provider.apiKey.isNotBlank()) {
"AI provider configuration incomplete: baseUrl and apiKey are required"
}
val keyRotator = KeyRotator(provider.apiKey)
val modelsUrl = provider.modelsPath?.let { provider.baseUrl + it }
?: provider.modelsUrl
?: (provider.baseUrl + "/models")
return retryWithBackoff(maxAttempts = 2, keyRotator = keyRotator) {
val response = okHttpClient.newCallStrResponse {
url(modelsUrl)
addHeaders(
provider.headers + provider.customHeaders + mapOf(
"Authorization" to "Bearer ${keyRotator.currentKey}",
"Content-Type" to "application/json"
)
)
}
if (!response.isSuccessful()) {
throw Exception("HTTP ${response.code()}: ${response.message()}")
}
val json = GSON.fromJson(response.body, OpenAiModelsResponse::class.java)
json?.data.toAvailableModels()
}
}
}
// ---- Message & tool format converters ----
internal fun List<AiMessage>.toOpenAiResponsesInput(): List<Map<String, Any?>> {
return flatMap { message ->
when {
message.role == AiMessageRole.TOOL -> listOf(
mapOf(
"type" to "function_call_output",
"call_id" to message.toolCallId,
"output" to message.content
)
)
message.toolCalls.isNotEmpty() -> {
val textMessage = message.content.takeIf { it.isNotBlank() }?.let {
mapOf("role" to "assistant", "content" to it)
}
val toolCalls = message.toolCalls.map {
mapOf(
"type" to "function_call",
"call_id" to it.id,
"name" to it.name,
"arguments" to it.arguments
)
}
listOfNotNull(textMessage) + toolCalls
}
else -> listOf(mapOf("role" to message.role, "content" to message.content))
}
}
}
internal fun List<AiToolDefinition>.toOpenAiResponsesTools(): List<Map<String, Any?>> {
return map {
mapOf(
"type" to "function",
"name" to it.name,
"description" to it.description,
"parameters" to it.inputSchema
)
}
}
private fun JsonObject.extractResponseFailureMessage(): String? {
val response = get("response")?.asJsonObjectOrNull() ?: return null
val error = response.get("error")?.asJsonObjectOrNull()
return error?.getString("message") ?: response.getString("status")
}
private fun JsonObject.extractResponseIncompleteMessage(): String? {
val response = get("response")?.asJsonObjectOrNull() ?: return null
val reason = response.get("incomplete_details")
?.asJsonObjectOrNull()
?.getString("reason")
return reason?.let { "OpenAI response incomplete: $it" } ?: response.getString("status")
}
private fun JsonObject.extractResponsesOutputText(): String? {
return get("output")
?.asJsonArrayOrNull()
?.flatMap { output ->
output.asJsonObjectOrNull()
?.get("content")
?.asJsonArrayOrNull()
?.mapNotNull { content ->
content.asJsonObjectOrNull()
?.takeIf { it.getString("type") == "output_text" }
?.getString("text")
}
.orEmpty()
}
?.joinToString("")
?.takeIf { it.isNotBlank() }
}
private fun List<OpenAiModelItem>?.toAvailableModels(): List<AiAvailableModel> {
return orEmpty()
.mapNotNull { item ->
val id = item.id?.takeIf { it.isNotBlank() } ?: return@mapNotNull null
AiAvailableModel(
id = id,
name = item.display_name?.takeIf { it.isNotBlank() }
?: item.displayName?.takeIf { it.isNotBlank() }
?: item.name?.takeIf { it.isNotBlank() }
?: id,
contextWindow = item.context_window ?: item.contextWindow ?: 0,
maxOutputTokens = item.max_tokens
?: item.maxTokens
?: item.max_output_tokens
?: item.maxOutputTokens
?: 0
)
}
.distinctBy { it.id }
.sortedBy { it.name.lowercase() }
}
@@ -16,6 +16,10 @@ val appDatabaseModule = module {
// 注册所有的 DAO 接口,通过 AppDatabase 实例获取
factory<BookDao> { get<AppDatabase>().bookDao }
factory<AiProfileDao> { get<AppDatabase>().aiProfileDao }
factory<AiArtifactDao> { get<AppDatabase>().aiArtifactDao }
factory<AiChatDao> { get<AppDatabase>().aiChatDao }
factory<AiMemoryDao> { get<AppDatabase>().aiMemoryDao }
factory<BookGroupDao> { get<AppDatabase>().bookGroupDao }
factory<BookSourceDao> { get<AppDatabase>().bookSourceDao }
factory<BookChapterDao> { get<AppDatabase>().bookChapterDao }
@@ -39,4 +43,4 @@ val appDatabaseModule = module {
factory<HomepageModuleDao> { get<AppDatabase>().homepageModuleDao }
factory<HomepageCustomSetDao> { get<AppDatabase>().homepageCustomSetDao }
factory<HighlightRuleDao> { get<AppDatabase>().highlightRuleDao }
}
}
@@ -8,6 +8,12 @@ import coil.decode.SvgDecoder
import io.legado.app.data.AppDatabase
import io.legado.app.data.local.preferences.LocalPreferencesRepository
import io.legado.app.data.repository.AppStartupRepository
import io.legado.app.data.repository.AiArtifactRepository
import io.legado.app.data.repository.AiChatRepository
import io.legado.app.data.repository.AiMemoryRepository
import io.legado.app.data.repository.AiProfileRepository
import io.legado.app.data.repository.AiTextRepositoryImpl
import io.legado.app.data.repository.AiToolRepository
import io.legado.app.data.repository.BookCacheCleanupRepository
import io.legado.app.data.repository.BookDomainRepositoryImpl
import io.legado.app.data.repository.BookGroupRepository
@@ -24,7 +30,6 @@ import io.legado.app.data.repository.ExploreRepository
import io.legado.app.data.repository.ExploreRepositoryImpl
import io.legado.app.data.repository.HighlightRuleRepository
import io.legado.app.data.repository.HomepageModulesRepository
import io.legado.app.data.repository.LlmTranslateRepositoryImpl
import io.legado.app.data.repository.LocalBookRepository
import io.legado.app.data.repository.ReadAloudSettingsRepository
import io.legado.app.data.repository.ReadBookStyleConfigRepository
@@ -42,6 +47,12 @@ import io.legado.app.data.repository.UploadRepository
import io.legado.app.data.repository.WebDavBackupRepository
import io.legado.app.data.repository.WebDavReadingProgressRepository
import io.legado.app.domain.gateway.AppStartupGateway
import io.legado.app.domain.gateway.AiArtifactGateway
import io.legado.app.domain.gateway.AiChatGateway
import io.legado.app.domain.gateway.AiMemoryGateway
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.AiToolGateway
import io.legado.app.domain.gateway.BookCacheCleanupGateway
import io.legado.app.domain.gateway.BookCacheDownloadGateway
import io.legado.app.domain.gateway.BookSearchGateway
@@ -50,7 +61,6 @@ import io.legado.app.domain.gateway.DatabaseMaintenanceGateway
import io.legado.app.domain.gateway.DictionaryGateway
import io.legado.app.domain.gateway.ExploreBooksGateway
import io.legado.app.domain.gateway.HomepageModulesGateway
import io.legado.app.domain.gateway.LlmGateway
import io.legado.app.domain.gateway.LocalBookGateway
import io.legado.app.domain.gateway.ReadingProgressGateway
import io.legado.app.domain.gateway.TranslationCacheGateway
@@ -70,6 +80,8 @@ import io.legado.app.domain.usecase.ExploreKindUiUseCase
import io.legado.app.domain.usecase.ExportBookshelfUseCase
import io.legado.app.domain.usecase.GetChapterContentUseCase
import io.legado.app.domain.usecase.GetReadingProgressUseCase
import io.legado.app.domain.usecase.AiChatGenerationUseCase
import io.legado.app.domain.usecase.GenerateChapterSummaryUseCase
import io.legado.app.domain.usecase.ImportBookshelfUseCase
import io.legado.app.domain.usecase.RefreshTocUseCase
import io.legado.app.domain.usecase.RemoveBookGroupAssignmentUseCase
@@ -110,6 +122,10 @@ import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.toc.TocViewModel
import io.legado.app.ui.book.toc.rule.TxtTocRuleViewModel
import io.legado.app.ui.config.backupConfig.BackupConfigViewModel
import io.legado.app.ui.config.ai.AiConfigViewModel
import io.legado.app.ui.config.ai.AiModelEditViewModel
import io.legado.app.ui.config.ai.AiProviderEditViewModel
import io.legado.app.ui.ai.chat.AiChatViewModel
import io.legado.app.ui.config.bookshelfConfig.BookshelfManageScreenConfig
import io.legado.app.ui.config.coverConfig.CoverConfigViewModel
import io.legado.app.ui.config.downloadCacheConfig.DownloadCacheConfigViewModel
@@ -188,6 +204,12 @@ val appModule = module {
single<UploadRepository> { DirectLinkUploadRepository() }
single<TranslationCacheGateway> { TranslationCacheRepositoryImpl() }
single<AiProfileGateway> { AiProfileRepository(get()) }
single<AiArtifactGateway> { AiArtifactRepository(get()) }
single<AiChatGateway> { AiChatRepository(get()) }
single<AiMemoryGateway> { AiMemoryRepository(get()) }
single<AiTextGateway> { AiTextRepositoryImpl() }
single<AiToolGateway> { AiToolRepository(get(), get(), get(), get(), get(), get()) }
single<AppStartupGateway> { AppStartupRepository(get()) }
single<BookCacheDownloadGateway> { CacheBookDownloadRepository(get()) }
single<BookCacheCleanupGateway> { BookCacheCleanupRepository(get()) }
@@ -210,9 +232,10 @@ val appModule = module {
singleOf(::SearchBooksUseCase)
singleOf(::ChangeSourceSearchUseCase)
singleOf(::GetChapterContentUseCase)
single<LlmGateway> { LlmTranslateRepositoryImpl() }
singleOf(::GenerateChapterSummaryUseCase)
single<DictionaryGateway> { DictionaryRepositoryImpl() }
singleOf(::TranslateChapterUseCase)
singleOf(::AiChatGenerationUseCase)
single<ImageLoader> {
ImageLoader.Builder(get())
@@ -256,6 +279,23 @@ val appModule = module {
viewModelOf(::DownloadCacheConfigViewModel)
viewModelOf(::ThemeConfigViewModel)
viewModelOf(::BackupConfigViewModel)
viewModelOf(::AiConfigViewModel)
viewModelOf(::AiChatViewModel)
viewModel { (providerId: String?) ->
AiProviderEditViewModel(
initialProviderId = providerId,
aiProfileGateway = get(),
aiTextGateway = get()
)
}
viewModel { (providerId: String?, modelProfileId: String?) ->
AiModelEditViewModel(
initialProviderId = providerId,
initialModelProfileId = modelProfileId,
aiProfileGateway = get(),
aiTextGateway = get()
)
}
viewModelOf(::TocViewModel)
viewModelOf(::ImportBookViewModel)
viewModelOf(::RemoteBookViewModel)
@@ -0,0 +1,17 @@
package io.legado.app.domain.gateway
import io.legado.app.data.entities.AiArtifact
import kotlinx.coroutines.flow.Flow
interface AiArtifactGateway {
fun observeBookArtifacts(bookUrl: String, taskType: String): Flow<List<AiArtifact>>
suspend fun getCachedArtifact(
bookUrl: String,
chapterIndex: Int?,
taskType: String,
contentHash: String,
promptHash: String,
modelProfileId: String
): AiArtifact?
suspend fun upsertArtifact(artifact: AiArtifact)
}
@@ -0,0 +1,33 @@
package io.legado.app.domain.gateway
import io.legado.app.data.entities.AiChatConversation
import io.legado.app.data.entities.AiChatMessage
import io.legado.app.domain.model.AiMessagePart
import kotlinx.coroutines.flow.Flow
interface AiChatGateway {
fun observeConversations(): Flow<List<AiChatConversation>>
fun observeMessages(conversationId: String): Flow<List<AiChatMessage>>
fun observeSelectedMessages(conversationId: String): Flow<List<AiChatMessage>>
suspend fun getConversation(id: String): AiChatConversation?
suspend fun createConversation(title: String = "New Chat"): AiChatConversation
suspend fun saveMessage(
conversationId: String,
role: String,
parts: List<AiMessagePart>,
parentMessageId: String? = null,
thinkingDuration: Int = 0
): AiChatMessage
suspend fun saveRegeneratedMessage(
conversationId: String,
role: String,
parts: List<AiMessagePart>,
parentMessageId: String,
thinkingDuration: Int = 0
): AiChatMessage
suspend fun selectBranch(messageId: String)
suspend fun getBranches(parentMessageId: String): List<AiChatMessage>
suspend fun getBranchCounts(conversationId: String): Map<String, Int>
suspend fun updateConversationTitle(conversationId: String, title: String)
suspend fun updateReasoningLevel(conversationId: String, reasoningLevel: String)
}
@@ -0,0 +1,15 @@
package io.legado.app.domain.gateway
import io.legado.app.data.entities.AiMemory
import kotlinx.coroutines.flow.Flow
interface AiMemoryGateway {
fun observeByConversation(conversationId: String): Flow<List<AiMemory>>
fun observeGlobal(): Flow<List<AiMemory>>
suspend fun getByConversation(conversationId: String): List<AiMemory>
suspend fun getGlobal(): List<AiMemory>
suspend fun getForPrompt(conversationId: String): List<AiMemory>
suspend fun upsert(memory: AiMemory)
suspend fun delete(conversationId: String, key: String)
suspend fun deleteAllForConversation(conversationId: String)
}
@@ -0,0 +1,26 @@
package io.legado.app.domain.gateway
import io.legado.app.data.entities.AiModelProfile
import io.legado.app.data.entities.AiProviderProfile
import io.legado.app.data.entities.AiTaskPreset
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiModelDraft
import io.legado.app.domain.model.AiProfileDraft
import io.legado.app.domain.model.AiProviderDraft
import io.legado.app.domain.model.AiTaskPresetConfig
import kotlinx.coroutines.flow.Flow
interface AiProfileGateway {
fun observeProviders(): Flow<List<AiProviderProfile>>
fun observeModels(): Flow<List<AiModelProfile>>
fun observePresets(): Flow<List<AiTaskPreset>>
suspend fun getProvider(id: String): AiProviderProfile?
suspend fun getModel(id: String): AiModelProfile?
suspend fun getTaskPreset(taskType: String): AiTaskPresetConfig?
suspend fun getProviderApiKey(providerId: String): String
suspend fun saveProvider(draft: AiProviderDraft): AiProviderProfile
suspend fun saveModel(draft: AiModelDraft): AiModelProfile
suspend fun importProviderModels(providerId: String, models: List<AiAvailableModel>): List<AiModelProfile>
suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig
suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig
}
@@ -0,0 +1,25 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerateResponse
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiProviderConfig
import kotlinx.coroutines.flow.Flow
sealed interface AiStreamEvent {
data class Content(val text: String) : AiStreamEvent
data class Reasoning(val text: String) : AiStreamEvent
data class ToolCallDelta(
val id: String?,
val index: Int?,
val name: String?,
val argumentsDelta: String?,
val rawType: String
) : AiStreamEvent
}
interface AiTextGateway {
suspend fun generate(request: AiGenerateRequest): Result<AiGenerateResponse>
fun generateStream(request: AiGenerateRequest): Flow<AiStreamEvent>
suspend fun fetchModels(provider: AiProviderConfig): Result<List<AiAvailableModel>>
}
@@ -0,0 +1,11 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.AiToolDefinition
import io.legado.app.domain.model.AiToolResult
interface AiToolGateway {
fun availableTools(): List<AiToolDefinition>
fun requiresConfirmation(toolName: String): Boolean
suspend fun execute(call: AiToolCall): AiToolResult
}
@@ -1,21 +0,0 @@
package io.legado.app.domain.gateway
import io.legado.app.domain.model.DictPair
import io.legado.app.domain.model.RetryReason
import io.legado.app.domain.model.TranslationConstants
interface LlmGateway {
suspend fun translate(
text: String,
targetLanguage: String,
provider: String,
baseUrl: String,
apiKey: String,
model: String,
prompt: String,
temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
dictionaries: List<DictPair> = emptyList(),
onUpdate: ((List<DictPair>) -> Unit)? = null,
retryReason: RetryReason? = null
): Result<String>
}
@@ -0,0 +1,177 @@
package io.legado.app.domain.model
import androidx.annotation.Keep
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Keep
@Serializable
sealed interface AiMessagePart {
@Keep
@Serializable
@SerialName("text")
data class Text(val text: String) : AiMessagePart
@Keep
@Serializable
@SerialName("reasoning")
data class Reasoning(val text: String) : AiMessagePart
/**
* Unified tool part — represents the full lifecycle of a tool call.
* When the model requests a tool, [input] is filled and [output] is empty.
* After execution, [output] is filled and [approvalState] reflects the outcome.
*/
@Keep
@Serializable
@SerialName("tool")
data class Tool(
val toolCallId: String,
val toolName: String,
val input: String,
val output: String = "",
val approvalState: AiToolApprovalState = AiToolApprovalState.AUTO,
val rawType: String = "tool_call",
val metadata: String? = null
) : AiMessagePart
@Keep
@Serializable
@SerialName("book_result")
data class BookResult(
val bookUrl: String,
val name: String,
val author: String,
val origin: String? = null,
val coverPath: String? = null,
val latestChapterTitle: String? = null,
val currentChapterTitle: String? = null,
val intro: String? = null
) : AiMessagePart
// ---- Legacy parts (deprecated, kept for backward-compatible deserialization) ----
@Deprecated("Use Tool instead — tool call and result are now a single part")
@Keep
@Serializable
@SerialName("tool_call")
data class ToolCall(
val id: String,
val name: String,
val arguments: String,
val rawType: String = "tool_call",
val approvalState: AiToolApprovalState = AiToolApprovalState.AUTO
) : AiMessagePart
@Deprecated("Use Tool instead — tool call and result are now a single part")
@Keep
@Serializable
@SerialName("tool_result")
data class ToolResult(
val callId: String,
val name: String,
val content: String
) : AiMessagePart
}
@Keep
@Serializable
enum class AiToolApprovalState {
@SerialName("auto")
AUTO,
@SerialName("pending")
PENDING,
@SerialName("approved")
APPROVED,
@SerialName("denied")
DENIED,
@SerialName("answered")
ANSWERED;
fun canResumeExecution(): Boolean =
this == APPROVED || this == DENIED || this == ANSWERED
}
object AiMessagePartJson {
private val json = Json {
ignoreUnknownKeys = true
explicitNulls = false
encodeDefaults = true
classDiscriminator = "type"
}
fun encode(parts: List<AiMessagePart>): String {
return json.encodeToString(parts)
}
fun decode(partsJson: String): List<AiMessagePart> {
if (partsJson.isBlank()) return emptyList()
return runCatching { json.decodeFromString<List<AiMessagePart>>(partsJson) }
.getOrElse { emptyList() }
.map { it.migrateLegacy() }
}
/**
* Migrate legacy ToolCall/ToolResult pairs into unified Tool parts.
* Handles old messages stored before the unified Tool part was introduced.
*/
private fun AiMessagePart.migrateLegacy(): AiMessagePart {
return when (this) {
is AiMessagePart.ToolCall -> AiMessagePart.Tool(
toolCallId = id,
toolName = name,
input = arguments,
rawType = rawType,
approvalState = approvalState
)
is AiMessagePart.ToolResult -> AiMessagePart.Tool(
toolCallId = callId,
toolName = name,
input = "",
output = content,
approvalState = AiToolApprovalState.AUTO
)
else -> this
}
}
}
// ---- Extension functions ----
fun List<AiMessagePart>.textContent(): String {
return filterIsInstance<AiMessagePart.Text>()
.joinToString("\n\n") { it.text }
.trim()
}
fun List<AiMessagePart>.reasoningContent(): String? {
return filterIsInstance<AiMessagePart.Reasoning>()
.joinToString("\n\n") { it.text }
.trim()
.takeIf { it.isNotBlank() }
}
/** Returns all unified Tool parts (includes migrated legacy parts). */
fun List<AiMessagePart>.toolParts(): List<AiMessagePart.Tool> {
return filterIsInstance<AiMessagePart.Tool>()
}
/** Returns tool calls that are pending approval (output is empty, state is PENDING). */
fun List<AiMessagePart>.pendingToolCalls(): List<AiMessagePart.Tool> {
return toolParts().filter {
it.output.isBlank() && it.approvalState == AiToolApprovalState.PENDING
}
}
/** Returns tool calls that need execution (output is empty, approval allows execution). */
fun List<AiMessagePart>.executableToolCalls(): List<AiMessagePart.Tool> {
return toolParts().filter {
it.output.isBlank() && it.approvalState.canResumeExecution()
}
}
@@ -0,0 +1,113 @@
package io.legado.app.domain.model
/**
* Lightweight model capability registry inspired by RikkaHub's ModelRegistry.
*
* Infers model capabilities (tools, reasoning, vision, streaming) from the model ID
* using token-based pattern matching. Used during model import to auto-populate
* the capabilities field, and during request building to decide which parameters to send.
*/
object AiModelRegistry {
private data class ModelPattern(
val tokens: List<String>,
val notTokens: List<String> = emptyList(),
val exactIds: Set<String> = emptySet(),
val capabilities: Set<String>
)
private val patterns = listOf(
// OpenAI GPT-4o / GPT-4.1 / GPT-5
ModelPattern(
tokens = listOf("gpt"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.VISION, AiCapability.STREAMING)
),
// OpenAI o-series (reasoning models)
ModelPattern(
tokens = listOf("o1"),
notTokens = listOf("mini"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
),
ModelPattern(
tokens = listOf("o3"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
),
ModelPattern(
tokens = listOf("o4"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
),
// Claude
ModelPattern(
tokens = listOf("claude"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.VISION, AiCapability.STREAMING)
),
// DeepSeek Reasoner
ModelPattern(
tokens = listOf("deepseek", "reasoner"),
capabilities = setOf(AiCapability.REASONING, AiCapability.STREAMING)
),
// DeepSeek Chat
ModelPattern(
tokens = listOf("deepseek"),
notTokens = listOf("reasoner"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
// Gemini
ModelPattern(
tokens = listOf("gemini"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.VISION, AiCapability.STREAMING)
),
// Xiaomi MiMo
ModelPattern(
tokens = listOf("mimo"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.REASONING, AiCapability.STREAMING)
),
// Qwen
ModelPattern(
tokens = listOf("qwen"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
// Grok
ModelPattern(
tokens = listOf("grok"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.VISION, AiCapability.STREAMING)
),
// Doubao / Kimi / Step / GLM / MiniMax — assume tools + streaming
ModelPattern(
tokens = listOf("doubao"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
ModelPattern(
tokens = listOf("kimi"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
ModelPattern(
tokens = listOf("step"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
ModelPattern(
tokens = listOf("glm"),
capabilities = setOf(AiCapability.TOOLS, AiCapability.STREAMING)
),
)
/**
* Infer capabilities from a model ID using token-based matching.
* Returns the capabilities of the highest-scoring pattern match.
* Falls back to STREAMING only if nothing matches.
*/
fun inferCapabilities(modelId: String): Set<String> {
val lower = modelId.lowercase()
val scored = patterns.mapNotNull { pattern ->
if (pattern.exactIds.isNotEmpty() && modelId in pattern.exactIds) {
return pattern.capabilities
}
if (pattern.notTokens.any { it in lower }) return@mapNotNull null
if (pattern.tokens.all { it in lower }) {
pattern to pattern.tokens.size
} else null
}
return scored.maxByOrNull { it.second }?.first?.capabilities
?: setOf(AiCapability.STREAMING)
}
}
@@ -0,0 +1,307 @@
package io.legado.app.domain.model
import androidx.annotation.Keep
object AiCapability {
const val TOOLS = "tools"
const val REASONING = "reasoning"
const val VISION = "vision"
const val STREAMING = "streaming"
}
object AiProtocol {
const val OPENAI_CHAT_COMPLETIONS = "openai_chat_completions"
const val OPENAI_RESPONSES = "openai_responses"
const val ANTHROPIC_MESSAGES = "anthropic_messages"
const val GOOGLE_TRANSLATE = "google_translate"
}
object AiTaskType {
const val CHAT = "chat"
const val TRANSLATE_CHAPTER = "translate_chapter"
const val SUMMARIZE_CHAPTER = "summarize_chapter"
const val SUMMARIZE_BOOK = "summarize_book"
const val EXPLAIN_SELECTION = "explain_selection"
}
object AiPromptTemplate {
const val DEFAULT_CHAPTER_SUMMARY =
"Summarize the following fiction chapter in the reader's language. Keep it concise, cover key events, character changes, conflicts, and unresolved hooks. Do not invent facts."
}
object AiMessageRole {
const val SYSTEM = "system"
const val USER = "user"
const val ASSISTANT = "assistant"
const val TOOL = "tool"
}
object AiProviderPresets {
val items = listOf(
AiProviderPreset(
id = "openai_chat",
name = "OpenAI",
protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
baseUrl = "https://api.openai.com/v1",
modelsUrl = "https://api.openai.com/v1/models",
modelName = "GPT-4.1 mini",
modelId = "gpt-4.1-mini"
),
AiProviderPreset(
id = "openai_responses",
name = "OpenAI Responses",
protocol = AiProtocol.OPENAI_RESPONSES,
baseUrl = "https://api.openai.com/v1",
modelsUrl = "https://api.openai.com/v1/models",
modelName = "GPT-4.1 mini",
modelId = "gpt-4.1-mini"
),
AiProviderPreset(
id = "deepseek",
name = "DeepSeek",
protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
baseUrl = "https://api.deepseek.com",
modelsUrl = "https://api.deepseek.com/models",
modelName = "DeepSeek Chat",
modelId = "deepseek-chat"
),
AiProviderPreset(
id = "deepseek_anthropic",
name = "DeepSeek",
protocol = AiProtocol.ANTHROPIC_MESSAGES,
baseUrl = "https://api.deepseek.com/anthropic",
modelsUrl = "https://api.deepseek.com/models",
modelName = "DeepSeek Chat",
modelId = "deepseek-chat"
),
AiProviderPreset(
id = "xiaomi_mimo",
name = "Xiaomi MiMo",
protocol = AiProtocol.OPENAI_CHAT_COMPLETIONS,
baseUrl = "https://api.xiaomimimo.com/v1",
modelsUrl = "https://api.xiaomimimo.com/v1/models",
modelName = "MiMo V2.5 Pro",
modelId = "mimo-v2.5-pro"
),
AiProviderPreset(
id = "xiaomi_mimo_anthropic",
name = "Xiaomi MiMo",
protocol = AiProtocol.ANTHROPIC_MESSAGES,
baseUrl = "https://api.xiaomimimo.com/anthropic",
modelsUrl = "https://api.xiaomimimo.com/v1/models",
modelName = "MiMo V2.5 Pro",
modelId = "mimo-v2.5-pro"
),
AiProviderPreset(
id = "anthropic",
name = "Anthropic",
protocol = AiProtocol.ANTHROPIC_MESSAGES,
baseUrl = "https://api.anthropic.com",
modelsUrl = "https://api.anthropic.com/v1/models",
modelName = "Claude Sonnet",
modelId = "claude-sonnet-4-20250514"
)
)
}
@Keep
data class AiProviderPreset(
val id: String,
val name: String,
val protocol: String,
val baseUrl: String,
val modelsUrl: String,
val modelName: String,
val modelId: String
)
@Keep
data class AiProviderConfig(
val id: String,
val name: String,
val protocol: String,
val baseUrl: String,
val apiKey: String,
val modelsUrl: String? = null,
val headers: Map<String, String> = emptyMap(),
val chatPath: String = "/chat/completions",
val responsesPath: String = "/responses",
val messagesPath: String = "/v1/messages",
val modelsPath: String? = null,
val customHeaders: Map<String, String> = emptyMap()
)
@Keep
data class AiModelConfig(
val id: String,
val provider: AiProviderConfig,
val displayName: String,
val modelId: String,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0,
val capabilities: Set<String> = emptySet(),
val defaultParams: AiGenerationParams = AiGenerationParams()
)
@Keep
data class AiTaskPresetConfig(
val id: String,
val taskType: String,
val name: String,
val model: AiModelConfig,
val promptTemplate: String,
val params: AiGenerationParams = AiGenerationParams(),
val runtimeOptions: AiTaskRuntimeOptions = AiTaskRuntimeOptions()
)
@Keep
data class AiProfileDraft(
val providerId: String? = null,
val modelProfileId: String? = null,
val providerName: String,
val protocol: String,
val baseUrl: String,
val apiKey: String,
val modelName: String,
val modelId: String,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0,
val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
val translationTargetLanguage: String = AiTaskRuntimeOptions.DEFAULT_TARGET_LANGUAGE,
val maxInputChars: Int = AiTaskRuntimeOptions.DEFAULT_MAX_INPUT_CHARS,
val concurrentRequests: Int = AiTaskRuntimeOptions.DEFAULT_CONCURRENT_REQUESTS,
val retryCount: Int = AiTaskRuntimeOptions.DEFAULT_RETRY_COUNT
)
@Keep
data class AiProviderDraft(
val providerId: String? = null,
val providerName: String,
val protocol: String,
val baseUrl: String,
val modelsUrl: String? = null,
val apiKey: String
)
@Keep
data class AiModelDraft(
val modelProfileId: String? = null,
val providerId: String,
val modelName: String,
val modelId: String,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0,
val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE
)
/**
* Model reasoning/thinking depth level.
* Maps to provider-specific API parameters:
* - OpenAI: reasoning_effort
* - OpenAI Responses: reasoning.effort
* - Anthropic: thinking.type + output_config.effort
*/
@Keep
enum class AiReasoningLevel(val effort: String, val budgetTokens: Int) {
OFF("none", 0),
AUTO("auto", -1),
LOW("low", 1_000),
MEDIUM("medium", 2_000),
HIGH("high", 8_000),
XHIGH("xhigh", 16_000);
val isEnabled: Boolean get() = this != OFF
companion object {
fun fromEffort(effort: String): AiReasoningLevel =
entries.firstOrNull { it.effort == effort } ?: AUTO
fun fromThinkingStrength(mode: String, strength: Int): AiReasoningLevel {
return when (mode) {
"off" -> OFF
"deep" -> when (strength.coerceIn(1, 3)) {
1 -> MEDIUM
2 -> HIGH
3 -> XHIGH
else -> HIGH
}
else -> AUTO
}
}
}
}
@Keep
data class AiGenerationParams(
val temperature: Float? = null,
val maxOutputTokens: Int? = null,
val topP: Float? = null,
val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO
)
@Keep
data class AiTaskRuntimeOptions(
val targetLanguage: String = DEFAULT_TARGET_LANGUAGE,
val maxInputChars: Int = DEFAULT_MAX_INPUT_CHARS,
val concurrentRequests: Int = DEFAULT_CONCURRENT_REQUESTS,
val retryCount: Int = DEFAULT_RETRY_COUNT
) {
companion object {
const val DEFAULT_TARGET_LANGUAGE = "zh"
const val DEFAULT_MAX_INPUT_CHARS = 10000
const val DEFAULT_CONCURRENT_REQUESTS = 1
const val DEFAULT_RETRY_COUNT = 2
}
}
@Keep
data class AiMessage(
val role: String,
val content: String,
val toolCalls: List<AiToolCall> = emptyList(),
val toolCallId: String? = null,
val name: String? = null
)
@Keep
data class AiGenerateRequest(
val model: AiModelConfig,
val messages: List<AiMessage>,
val params: AiGenerationParams = AiGenerationParams(),
val tools: List<AiToolDefinition> = emptyList()
)
@Keep
data class AiGenerateResponse(
val text: String,
val rawBody: String? = null
)
@Keep
data class AiAvailableModel(
val id: String,
val name: String = id,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0
)
@Keep
data class AiToolDefinition(
val name: String,
val description: String,
val inputSchema: Map<String, Any?>
)
@Keep
data class AiToolCall(
val id: String,
val name: String,
val arguments: String
)
@Keep
data class AiToolResult(
val callId: String,
val name: String,
val content: String
)
@@ -3,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 "简体中文",
@@ -0,0 +1,369 @@
package io.legado.app.domain.usecase
import com.google.gson.JsonObject
import io.legado.app.domain.gateway.AiChatGateway
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiStreamEvent
import io.legado.app.domain.gateway.AiMemoryGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.AiToolGateway
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiTaskType
import io.legado.app.domain.model.AiToolApprovalState
import io.legado.app.domain.model.AiToolCall
import io.legado.app.domain.model.toolParts
import io.legado.app.ui.ai.chat.AiChatBookResultUi
import io.legado.app.ui.ai.chat.AiChatMessageUi
import io.legado.app.utils.GSON
/**
* Encapsulates chat generation logic: request building, streaming, tool execution loop.
* ViewModel only handles UI state updates by collecting [GenerationEvent]s.
*/
class AiChatGenerationUseCase(
private val aiTextGateway: AiTextGateway,
private val aiToolGateway: AiToolGateway,
private val aiProfileGateway: AiProfileGateway,
private val aiChatGateway: AiChatGateway,
private val aiMemoryGateway: AiMemoryGateway
) {
suspend fun buildRequest(
userContent: String,
history: List<AiChatMessageUi>,
reasoningLevel: AiReasoningLevel,
conversationId: String? = null
): AiGenerateRequest {
val preset = aiProfileGateway.getTaskPreset(AiTaskType.CHAT)
?: aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
?: error("Please configure a default AI model first")
return AiGenerateRequest(
model = preset.model,
messages = buildRequestMessages(userContent, history, conversationId),
params = preset.params.copy(reasoningLevel = reasoningLevel),
tools = aiToolGateway.availableTools()
)
}
suspend fun collectStream(
request: AiGenerateRequest,
toolTrace: ToolTraceBuilder,
onContent: suspend (String) -> Unit,
onReasoning: suspend (String) -> Unit,
onToolTraceUpdate: suspend () -> Unit
) {
toolTrace.beginResponse()
aiTextGateway.generateStream(request).collect { event ->
when (event) {
is AiStreamEvent.Content -> onContent(event.text)
is AiStreamEvent.Reasoning -> onReasoning(event.text)
is AiStreamEvent.ToolCallDelta -> {
toolTrace.append(event)
onToolTraceUpdate()
}
}
}
}
suspend fun executeToolCalls(
request: AiGenerateRequest,
assistantContent: String,
toolTrace: ToolTraceBuilder,
toolCalls: List<AiToolCall>,
onToolTraceUpdate: suspend () -> Unit
): AiGenerateRequest {
val toolResultMessages = toolCalls.map { toolCall ->
val result = aiToolGateway.execute(toolCall)
val truncated = result.content.truncateToolOutput()
toolTrace.appendResult(result.callId, truncated)
onToolTraceUpdate()
AiMessage(
role = AiMessageRole.TOOL,
content = truncated,
toolCallId = result.callId,
name = result.name
)
}
return request.copy(
messages = request.messages +
AiMessage(
role = AiMessageRole.ASSISTANT,
content = assistantContent,
toolCalls = toolCalls
) +
toolResultMessages
)
}
fun requiresConfirmation(toolName: String): Boolean {
return aiToolGateway.requiresConfirmation(toolName)
}
fun buildAssistantParts(
text: String,
reasoning: String,
toolTrace: ToolTraceBuilder
): List<AiMessagePart> {
return buildList {
reasoning.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Reasoning(it)) }
text.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Text(it)) }
addAll(toolTrace.toParts())
addAll(toolTrace.bookResults())
}
}
private suspend fun buildRequestMessages(
newContent: String,
history: List<AiChatMessageUi>,
conversationId: String? = null
): List<AiMessage> {
val system = buildSystemPrompt(conversationId)
val trimmedHistory = history.trimForRequest(MAX_HISTORY_MESSAGES)
val messages = trimmedHistory.flatMap {
when (it.role) {
AiMessageRole.USER -> listOf(AiMessage(AiMessageRole.USER, it.content))
AiMessageRole.ASSISTANT -> it.toRequestMessages()
else -> null
}.orEmpty()
}
return listOf(AiMessage(AiMessageRole.SYSTEM, system)) +
messages +
AiMessage(AiMessageRole.USER, newContent)
}
/**
* Tool-aware history trimming. Ensures tool_call and tool_result pairs
* are never split — if a tool_result would be kept without its tool_call,
* the message is dropped.
*/
private fun List<AiChatMessageUi>.trimForRequest(maxMessages: Int): List<AiChatMessageUi> {
if (size <= maxMessages) return this
val trimmed = takeLast(maxMessages)
// Find the first message where all Tool parts with output have their
// corresponding ToolCall present in the trimmed set
val firstSafe = trimmed.indexOfFirst { msg ->
msg.parts.toolParts().filter { it.output.isNotBlank() }.all { tool ->
trimmed.any { other ->
other.parts.toolParts().any { it.toolCallId == tool.toolCallId && it.output.isBlank() }
}
}
}
return if (firstSafe > 0) trimmed.drop(firstSafe) else trimmed
}
private fun AiChatMessageUi.toRequestMessages(): List<AiMessage> {
val tools = parts.toolParts()
val toolCalls = tools.filter {
it.output.isNotBlank() || it.approvalState == AiToolApprovalState.AUTO
}.map {
AiToolCall(id = it.toolCallId, name = it.toolName, arguments = it.input)
}
val toolResults = tools.filter { it.output.isNotBlank() }.map {
AiMessage(
role = AiMessageRole.TOOL,
content = it.output,
toolCallId = it.toolCallId,
name = it.toolName
)
}
return listOf(
AiMessage(role = AiMessageRole.ASSISTANT, content = content, toolCalls = toolCalls)
) + toolResults
}
private suspend fun buildSystemPrompt(conversationId: String? = null): String {
val base = """
You are a helpful AI assistant inside a reading app.
Render answers in complete Markdown when structure helps.
Use local reading tools when the user asks about bookshelf books, current reading progress, chapters, bookmarks, reading statistics, or existing AI notes.
For requests like summarizing, explaining, or continuing from the current chapter, use the local book and chapter tools before answering.
If a tool says content is missing or unavailable, state that limitation clearly and do not invent book content.
Save notes or summaries only when the user explicitly asks to save them.
Do not reveal hidden chain-of-thought. If reasoning is useful, provide a brief reasoning summary.
""".trimIndent()
if (conversationId == null) return base
val memories = aiMemoryGateway.getForPrompt(conversationId)
if (memories.isEmpty()) return base
val memoryBlock = memories.joinToString("\n") { "- ${it.key}: ${it.value}" }
return "$base\n\n## User Memory\nThe following facts about the user have been remembered from prior conversations:\n$memoryBlock"
}
companion object {
const val MAX_HISTORY_MESSAGES = 12
const val MAX_TOOL_OUTPUT_CHARS = 8_000
}
}
/**
* Accumulates streaming tool call deltas into complete [AiMessagePart.Tool] parts.
* Thread-safe for use within a single coroutine context.
*/
class ToolTraceBuilder {
private val calls = linkedMapOf<String, ToolCallTrace>()
private val indexKeys = mutableMapOf<Int, String>()
fun beginResponse() {
indexKeys.clear()
}
fun append(event: AiStreamEvent.ToolCallDelta): String {
val eventId = event.id?.takeIf { it.isNotBlank() }
if (eventId != null && event.index != null) {
indexKeys[event.index] = eventId
}
val baseId = eventId
?: event.index?.let { indexKeys[it] ?: "tool_index_$it" }
?: "tool_${calls.size + 1}"
val id = if (eventId == null && calls[baseId]?.result != null) {
"${baseId}_${calls.size + 1}"
} else {
baseId
}
val call = calls.getOrPut(id) { ToolCallTrace(id = id, rawType = event.rawType) }
event.name?.takeIf { it.isNotBlank() }?.let { call.name = it }
event.argumentsDelta?.takeIf { it.isNotEmpty() }?.let { call.arguments.append(it) }
if (call.rawType.isBlank()) call.rawType = event.rawType
return toString()
}
fun appendResult(id: String, result: String): String {
calls[id]?.result = result
return toString()
}
fun pendingToolCalls(): List<AiToolCall> {
return calls.values.filter { it.result == null }.mapNotNull { call ->
val name = call.name.takeIf { it.isNotBlank() } ?: return@mapNotNull null
AiToolCall(
id = call.id,
name = name,
arguments = call.arguments.toString().ifBlank { "{}" }
)
}
}
fun toParts(): List<AiMessagePart> {
val parts = mutableListOf<AiMessagePart>()
calls.values.forEach { call ->
val name = call.name.takeIf { it.isNotBlank() } ?: return@forEach
parts += AiMessagePart.Tool(
toolCallId = call.id,
toolName = name,
input = call.arguments.toString().ifBlank { "{}" },
output = call.result ?: "",
rawType = call.rawType.ifBlank { "tool_call" },
approvalState = if (call.result == null) {
AiToolApprovalState.PENDING
} else {
AiToolApprovalState.AUTO
}
)
}
return parts
}
fun bookResults(): List<AiMessagePart.BookResult> {
val books = linkedMapOf<String, AiMessagePart.BookResult>()
calls.values.mapNotNull { it.result }.forEach { result ->
val root = runCatching {
GSON.fromJson(result, JsonObject::class.java)
}.getOrNull() ?: return@forEach
root.getAsJsonArrayOrNull("books")?.forEach { element ->
element.asJsonObjectOrNull()?.toBookResultPart()?.let {
books.putIfAbsent(it.bookUrl, it)
}
}
root.getAsJsonObjectOrNull("book")?.toBookResultPart()?.let {
books.putIfAbsent(it.bookUrl, it)
}
}
return books.values.toList()
}
override fun toString(): String {
return calls.values.joinToString("\n\n") { call ->
buildString {
append("Tool: ")
append(call.name.ifBlank { call.rawType.ifBlank { call.id } })
append('\n')
append("ID: ")
append(call.id)
if (call.arguments.isNotBlank()) {
append('\n')
append(call.arguments)
}
call.result?.takeIf { it.isNotBlank() }?.let {
append('\n')
append("Result: ")
append(it.take(2000))
}
}
}
}
}
internal data class ToolCallTrace(
val id: String,
var rawType: String,
var name: String = "",
val arguments: StringBuilder = StringBuilder(),
var result: String? = null
)
data class PendingToolRun(
val conversationId: String?,
val request: AiGenerateRequest,
val fullText: String,
val fullReasoning: String,
val toolTrace: ToolTraceBuilder,
val toolCalls: List<AiToolCall>,
val assistantTextStart: Int,
val round: Int,
val parentMessageId: String? = null
)
// ---- Book result extraction helpers ----
internal fun JsonObject.toBookResultPart(): AiMessagePart.BookResult? {
val bookUrl = string("bookUrl")?.takeIf { it.isNotBlank() } ?: return null
return AiMessagePart.BookResult(
bookUrl = bookUrl,
name = string("name").orEmpty(),
author = string("author").orEmpty(),
origin = string("origin") ?: string("originName"),
coverPath = string("coverPath") ?: string("coverUrl"),
latestChapterTitle = string("latestChapterTitle"),
currentChapterTitle = string("currentChapterTitle"),
intro = string("intro")
)
}
internal fun JsonObject.string(name: String): String? {
return get(name)?.takeIf { !it.isJsonNull }?.asString
}
internal fun JsonObject.getAsJsonObjectOrNull(name: String): JsonObject? {
return get(name)?.let { if (it.isJsonObject) it.asJsonObject else null }
}
internal fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
}.getOrNull()
internal fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? {
return takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
}
/** Truncate tool output to [AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS] to avoid overflowing context. */
internal fun String.truncateToolOutput(): String {
if (length <= AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS) return this
return take(AiChatGenerationUseCase.MAX_TOOL_OUTPUT_CHARS) +
"\n\n[...truncated from ${length} chars]"
}
@@ -0,0 +1,107 @@
package io.legado.app.domain.usecase
import io.legado.app.data.entities.AiArtifact
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.domain.gateway.AiArtifactGateway
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiTaskPresetConfig
import io.legado.app.domain.model.AiTaskType
import io.legado.app.domain.model.ContentChunker
import io.legado.app.help.book.BookHelp
import io.legado.app.utils.MD5Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class GenerateChapterSummaryUseCase(
private val aiProfileGateway: AiProfileGateway,
private val aiTextGateway: AiTextGateway,
private val aiArtifactGateway: AiArtifactGateway
) {
suspend fun execute(
book: Book,
bookChapter: BookChapter,
maxCharsPerChunk: Int = DEFAULT_MAX_CHARS_PER_CHUNK
): Result<String> = withContext(Dispatchers.IO) {
runCatching {
val content = BookHelp.getContent(book, bookChapter)
?: error("Failed to read chapter content")
val preset = resolvePreset() ?: error("No AI model configured for chapter summary")
val contentHash = MD5Utils.md5Encode(content)
val promptHash = MD5Utils.md5Encode(preset.promptTemplate)
aiArtifactGateway.getCachedArtifact(
bookUrl = book.bookUrl,
chapterIndex = bookChapter.index,
taskType = AiTaskType.SUMMARIZE_CHAPTER,
contentHash = contentHash,
promptHash = promptHash,
modelProfileId = preset.model.id
)?.output?.let { return@runCatching it }
val chunks = ContentChunker.chunk(content, maxCharsPerChunk)
if (chunks.isEmpty()) error("Failed to chunk chapter content")
val partialSummaries = chunks.map { chunk ->
generate(
preset = preset,
userContent = "Chapter title: ${bookChapter.title}\n\nText:\n${chunk.content}"
)
}
val summary = if (partialSummaries.size == 1) {
partialSummaries.first()
} else {
generate(
preset = preset,
userContent = "Merge these partial summaries into one chapter summary:\n\n${partialSummaries.joinToString("\n\n")}"
)
}
val now = System.currentTimeMillis()
aiArtifactGateway.upsertArtifact(
AiArtifact(
id = "${book.bookUrl}_${bookChapter.index}_${AiTaskType.SUMMARIZE_CHAPTER}_${contentHash}_${preset.model.id}",
taskType = AiTaskType.SUMMARIZE_CHAPTER,
bookUrl = book.bookUrl,
chapterIndex = bookChapter.index,
contentHash = contentHash,
promptHash = promptHash,
modelProfileId = preset.model.id,
status = AiArtifact.STATUS_SUCCESS,
output = summary,
createdAt = now,
updatedAt = now
)
)
summary
}
}
private suspend fun resolvePreset(): AiTaskPresetConfig? {
return aiProfileGateway.getTaskPreset(AiTaskType.SUMMARIZE_CHAPTER)
}
private suspend fun generate(
preset: AiTaskPresetConfig,
userContent: String
): String {
val response = aiTextGateway.generate(
AiGenerateRequest(
model = preset.model,
messages = listOf(
AiMessage(AiMessageRole.SYSTEM, preset.promptTemplate),
AiMessage(AiMessageRole.USER, userContent)
),
params = preset.params
)
)
return response.getOrThrow().text.trim()
}
private companion object {
const val DEFAULT_MAX_CHARS_PER_CHUNK = 8000
}
}
@@ -1,29 +1,43 @@
package io.legado.app.domain.usecase
import androidx.annotation.Keep
import io.legado.app.data.entities.Book
import io.legado.app.data.entities.BookChapter
import io.legado.app.data.entities.TranslationCache
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.gateway.DictionaryGateway
import io.legado.app.domain.gateway.LlmGateway
import io.legado.app.domain.gateway.TranslationCacheGateway
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiTaskPresetConfig
import io.legado.app.domain.model.AiTaskType
import io.legado.app.domain.model.ContentChunker
import io.legado.app.domain.model.DictPair
import io.legado.app.domain.model.PartialTranslationAssembler
import io.legado.app.domain.model.RetryReason
import io.legado.app.domain.model.TextChunk
import io.legado.app.domain.model.TranslationConstants
import io.legado.app.domain.model.TranslationConstants.OUTPUT_FORMAT
import io.legado.app.help.book.BookHelp
import io.legado.app.help.http.newCallStrResponse
import io.legado.app.help.http.okHttpClient
import io.legado.app.ui.config.translation.TranslationConfig
import io.legado.app.utils.GSON
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.withContext
import java.net.URLEncoder
class TranslateChapterUseCase(
private val llmGateway: LlmGateway,
private val aiTextGateway: AiTextGateway,
private val translationCacheGateway: TranslationCacheGateway,
private val dictionaryGateway: DictionaryGateway
private val dictionaryGateway: DictionaryGateway,
private val aiProfileGateway: AiProfileGateway
) {
data class TranslationProgress(
@@ -42,11 +56,19 @@ class TranslateChapterUseCase(
suspend fun execute(
book: Book,
bookChapter: BookChapter,
targetLanguage: String,
onProgress: (TranslationProgress) -> Unit,
onTranslateStarted: () -> Unit
): Result<String> = withContext(Dispatchers.IO) {
try {
val provider = TranslationConfig.llmProvider
val preset = if (provider == TranslationConstants.PROVIDER_APP_AI) {
resolveTranslationPreset()
?: return@withContext Result.failure(Exception("No AI translation preset configured"))
} else {
null
}
val targetLanguage = TranslationConfig.llmTargetLanguage
val originalContent = BookHelp.getContent(book, bookChapter)
?: return@withContext Result.failure(Exception("Failed to read original content"))
@@ -61,8 +83,7 @@ class TranslateChapterUseCase(
// Load book dictionary for consistent terminology
val bookDictionary = dictionaryGateway.getBookDictionaries(book)
@Suppress("SENSELESS_COMPARISON")
val dictionaries = (bookDictionary.pairs ?: emptyList()).toMutableList()
val dictionaries = bookDictionary.pairs.toMutableList()
// Callback to update dictionary pairs immediately (persist as soon as discovered)
val onDictionaryUpdate: (List<DictPair>) -> Unit = { newPairs ->
@@ -76,7 +97,10 @@ class TranslateChapterUseCase(
}
}
val chunks = ContentChunker.chunk(originalContent, TranslationConfig.llmMaxCharsPerChunk)
val chunks = ContentChunker.chunk(
originalContent,
TranslationConfig.llmMaxCharsPerChunk.coerceAtLeast(1000)
)
if (chunks.isEmpty()) {
return@withContext Result.failure(Exception("Failed to chunk content"))
}
@@ -131,7 +155,7 @@ class TranslateChapterUseCase(
for ((groupIndex, group) in chunkGroups.withIndex()) {
val results = group.map { chunk ->
async {
translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, dictionaries, onDictionaryUpdate)
translateAndCacheChunk(chunk, book, bookChapter, targetLanguage, contentHash, provider, preset, dictionaries, onDictionaryUpdate)
}
}.awaitAll()
@@ -216,6 +240,8 @@ class TranslateChapterUseCase(
bookChapter: BookChapter,
targetLanguage: String,
contentHash: String,
provider: String,
preset: AiTaskPresetConfig?,
dictionaries: MutableList<DictPair>,
onDictionaryUpdate: (List<DictPair>) -> Unit
): Result<String> {
@@ -225,12 +251,12 @@ class TranslateChapterUseCase(
return Result.success(existingCache.translatedChunkContent)
}
val result = translateChunkWithRetry(chunk, targetLanguage, dictionaries, onDictionaryUpdate)
val result = translateChunkWithRetry(chunk, targetLanguage, provider, preset, dictionaries, onDictionaryUpdate)
if (result.isSuccess) {
translationCacheGateway.saveChunk(
book, bookChapter, targetLanguage,
chunk.index, chunk.content, contentHash,
TranslationConfig.llmProvider,
provider,
TranslationCache.STATUS_SUCCESS, result.getOrThrow(), null
)
} else {
@@ -238,7 +264,7 @@ class TranslateChapterUseCase(
translationCacheGateway.saveChunk(
book, bookChapter, targetLanguage,
chunk.index, chunk.content, contentHash,
TranslationConfig.llmProvider,
provider,
TranslationCache.STATUS_FAILED, null, errorMessage
)
}
@@ -248,26 +274,27 @@ class TranslateChapterUseCase(
private suspend fun translateChunkWithRetry(
chunk: TextChunk,
targetLanguage: String,
provider: String,
preset: AiTaskPresetConfig?,
dictionaries: MutableList<DictPair>,
onDictionaryUpdate: (List<DictPair>) -> Unit
): Result<String> {
var lastError: Exception? = null
var lastRetryReason: RetryReason? = null
for (attempt in 0..TranslationConfig.llmRetryCount) {
for (attempt in 0..TranslationConfig.llmRetryCount.coerceIn(0, 5)) {
val dictSnapshot = synchronized(dictionaryLock) { dictionaries.toList() }
val result = llmGateway.translate(
text = chunk.content,
targetLanguage = targetLanguage,
provider = TranslationConfig.llmProvider,
baseUrl = TranslationConfig.llmBaseUrl,
apiKey = TranslationConfig.llmApiKey,
model = TranslationConfig.llmModel,
prompt = TranslationConfig.llmPrompt,
temperature = TranslationConfig.llmTemperature,
dictionaries = dictSnapshot,
onUpdate = onDictionaryUpdate,
retryReason = lastRetryReason
)
val result = when (provider) {
TranslationConstants.PROVIDER_GOOGLE -> translateWithGoogle(chunk.content, targetLanguage)
TranslationConstants.PROVIDER_APP_AI -> translateWithAiGateway(
text = chunk.content,
targetLanguage = targetLanguage,
preset = preset ?: return Result.failure(Exception("No AI translation preset configured")),
dictionaries = dictSnapshot,
onUpdate = onDictionaryUpdate,
retryReason = lastRetryReason
)
else -> Result.failure(IllegalArgumentException("Unknown translation provider: $provider"))
}
if (result.isSuccess) {
return result
}
@@ -277,6 +304,240 @@ class TranslateChapterUseCase(
return Result.failure(lastError ?: Exception("Translation failed after retries"))
}
private suspend fun resolveTranslationPreset(): AiTaskPresetConfig? {
return aiProfileGateway.getTaskPreset(AiTaskType.TRANSLATE_CHAPTER)
}
private suspend fun translateWithGoogle(text: String, targetLanguage: String): Result<String> {
val encodedText = URLEncoder.encode(text, Charsets.UTF_8.name())
val url =
"https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=$targetLanguage&dj=1&dt=t&ie=UTF-8&q=$encodedText"
val response = okHttpClient.newCallStrResponse {
url(url)
}
return if (response.isSuccessful()) {
runCatching {
val json = GSON.fromJson(response.body, GoogleTranslateResponse::class.java)
json?.sentences?.mapNotNull { it.trans }?.joinToString("").orEmpty()
}.fold(
onSuccess = { translatedText ->
if (translatedText.isNotEmpty()) {
Result.success(translatedText)
} else {
Result.failure(Exception("Empty translation result"))
}
},
onFailure = { Result.failure(it) }
)
} else {
Result.failure(Exception("HTTP ${response.code()}: ${response.message()}"))
}
}
private suspend fun translateWithAiGateway(
text: String,
targetLanguage: String,
preset: AiTaskPresetConfig,
dictionaries: List<DictPair>,
onUpdate: ((List<DictPair>) -> Unit)?,
retryReason: RetryReason?
): Result<String> {
if (targetLanguage == "en" && isMostlyEnglish(text)) {
return Result.success(text)
}
if (targetLanguage == "zh" && isMostlyChinese(text)) {
return Result.success(text)
}
if (preset.model.provider.baseUrl.isBlank() ||
preset.model.provider.apiKey.isBlank() ||
preset.model.modelId.isBlank()
) {
return Result.failure(IllegalArgumentException("AI model configuration is incomplete"))
}
val dictionaryInstruction = buildDictionaryInstruction(dictionaries)
val retryInstruction = buildRetryInstruction(retryReason)
val systemPrompt = buildSystemPrompt(
prompt = preset.promptTemplate,
targetLanguage = targetLanguage,
dictionaryInstruction = dictionaryInstruction,
retryInstruction = retryInstruction,
outputFormat = OUTPUT_FORMAT
)
val params = preset.params.copy(
temperature = preset.params.temperature
?: preset.model.defaultParams.temperature
?: TranslationConstants.DEFAULT_TEMPERATURE
)
val result = aiTextGateway.generate(
AiGenerateRequest(
model = preset.model,
messages = listOf(
AiMessage(AiMessageRole.SYSTEM, systemPrompt),
AiMessage(AiMessageRole.USER, "Translate the following text:\n\n$text")
),
params = params
)
)
if (result.isFailure) {
return Result.failure(result.exceptionOrNull() ?: Exception("Translation failed"))
}
val rawContent = result.getOrThrow().text
if (rawContent.isBlank()) {
return Result.failure(Exception("Empty translation result"))
}
val parseResult = parseLlmOutput(rawContent, dictionaries)
if (parseResult.extractedPairs.isNotEmpty()) {
onUpdate?.invoke(parseResult.extractedPairs.take(10))
}
val finalText = if (targetLanguage == "zh") {
filterHighEnglishParagraphs(parseResult.translatedText)
} else {
parseResult.translatedText
}
return Result.success(finalText)
}
private data class ParseOutputResult(
val translatedText: String,
val extractedPairs: List<DictPair>
)
private fun parseLlmOutput(
rawOutput: String,
existingDictionaries: List<DictPair>
): ParseOutputResult {
val existingOriginals = existingDictionaries.map { it.original }.toSet()
var dictionarySection: String? = null
var resultSection: String? = null
var currentSection: String? = null
for (line in rawOutput.split('\n')) {
val trimmedLine = line.trim()
when {
trimmedLine.startsWith("[dictionary]", ignoreCase = true) -> currentSection = "dictionary"
trimmedLine.startsWith("[result]", ignoreCase = true) -> currentSection = "result"
currentSection == "dictionary" -> dictionarySection = (dictionarySection ?: "") + line + "\n"
currentSection == "result" -> resultSection = (resultSection ?: "") + line + "\n"
}
}
val extractedPairs = dictionarySection
?.let { parseDictionarySection(it, existingOriginals) }
.orEmpty()
val translatedText = resultSection?.trim() ?: rawOutput.trim()
return ParseOutputResult(translatedText, extractedPairs)
}
private fun parseDictionarySection(
section: String,
existingOriginals: Set<String>
): List<DictPair> {
val pairs = mutableListOf<DictPair>()
for (line in section.split('\n')) {
val trimmedLine = line.trim()
if (trimmedLine.isEmpty()) continue
if (trimmedLine.startsWith("[") || trimmedLine.startsWith("dictionary", ignoreCase = true)) {
continue
}
val separator = when {
trimmedLine.contains(" -> ") -> " -> "
trimmedLine.contains(" ->") -> " ->"
trimmedLine.contains("-> ") -> "-> "
trimmedLine.contains("->") -> "->"
trimmedLine.contains(" : ") -> " : "
trimmedLine.contains(": ") -> ": "
trimmedLine.contains(" :") -> " :"
trimmedLine.contains(":") -> ":"
else -> null
}
if (separator != null) {
val parts = trimmedLine.split(separator, limit = 2)
if (parts.size == 2) {
val original = parts[0].trim()
val translation = parts[1].trim()
if (original !in existingOriginals && original.isNotEmpty() && translation.isNotEmpty()) {
pairs.add(DictPair(original, translation))
if (pairs.size >= 10) break
}
}
}
}
return pairs
}
private fun buildDictionaryInstruction(dictionaries: List<DictPair>): String {
if (dictionaries.isEmpty()) return ""
val terms = dictionaries.joinToString("\n") { "${it.original} -> ${it.translation}" }
return """
Terminology Dictionary (use these exact translations):
$terms
"""
}
private fun buildRetryInstruction(retryReason: RetryReason?): String {
return when (retryReason) {
RetryReason.EMPTY_RESPONSE -> "\nPrevious attempt returned empty content. Return only the required formatted translation."
RetryReason.PARSE_ERROR -> "\nPrevious attempt used an invalid format. Follow the [dictionary] and [result] format exactly."
RetryReason.RATE_LIMIT,
RetryReason.SERVER_ERROR,
RetryReason.AUTH_ERROR,
RetryReason.TIMEOUT,
RetryReason.NETWORK_ERROR,
RetryReason.UNKNOWN,
RetryReason.PERMANENT_FAILURE,
null -> ""
}
}
private fun buildSystemPrompt(
prompt: String,
targetLanguage: String,
dictionaryInstruction: String,
retryInstruction: String,
outputFormat: String
): String {
return buildString {
append(prompt)
append("\nTarget language: ").append(getLanguageDisplayName(targetLanguage))
if (dictionaryInstruction.isNotEmpty()) {
append(dictionaryInstruction)
}
if (retryInstruction.isNotEmpty()) {
append(retryInstruction)
}
append("\n").append(outputFormat)
}
}
private fun getLanguageDisplayName(code: String): String {
return TranslationConstants.targetLanguages.find { it.first == code }?.second ?: code
}
private fun isMostlyEnglish(text: String): Boolean {
if (text.isEmpty()) return false
val englishChars =
text.count { it in 'A'..'Z' || it in 'a'..'z' || it in ".,!?;:'\"-()[]{}-" }
return englishChars.toDouble() / text.length > 0.8
}
private fun isMostlyChinese(text: String): Boolean {
if (text.isEmpty()) return false
val chinesePunctuation = "。,!?;:“”‘’()【】《》"
val chineseChars = text.count {
it in '一'..'鿿' || it in chinesePunctuation
}
return chineseChars.toDouble() / text.length > 0.8
}
private fun filterHighEnglishParagraphs(text: String): String {
return text.split("\n")
.filter { paragraph -> !isMostlyEnglish(paragraph) }
.joinToString("\n")
}
private fun parseRetryReason(error: Exception?): RetryReason? {
val message = error?.message ?: return null
return when {
@@ -289,3 +550,13 @@ class TranslateChapterUseCase(
}
}
}
@Keep
private data class GoogleTranslateResponse(
val sentences: List<GoogleSentence>?
)
@Keep
private data class GoogleSentence(
val trans: String?
)
@@ -43,7 +43,7 @@ object TranslationManager : KoinComponent {
*/
fun hasTranslatedCache(book: Book, chapter: BookChapter): Boolean {
val cacheFile =
translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
translationCacheGateway.getCacheFile(book, chapter, currentTargetLanguage())
return cacheFile.exists()
}
@@ -52,7 +52,7 @@ object TranslationManager : KoinComponent {
*/
fun getCachedTranslation(book: Book, chapter: BookChapter): String? {
val cacheFile =
translationCacheGateway.getCacheFile(book, chapter, TranslationConfig.llmTargetLanguage)
translationCacheGateway.getCacheFile(book, chapter, currentTargetLanguage())
return if (cacheFile.exists()) cacheFile.readText() else null
}
@@ -113,7 +113,6 @@ object TranslationManager : KoinComponent {
val result = translateChapterUseCase.execute(
book = book,
bookChapter = bookChapter,
targetLanguage = TranslationConfig.llmTargetLanguage,
onProgress = { progress ->
taskFlow.update {
it.copy(
@@ -163,17 +162,22 @@ object TranslationManager : KoinComponent {
* Delete translation cache and state for a chapter.
*/
suspend fun deleteTranslationCache(book: Book, bookChapter: BookChapter) {
val targetLanguage = currentTargetLanguage()
translationCacheGateway.deleteTranslation(
book,
bookChapter,
TranslationConfig.llmTargetLanguage
targetLanguage
)
translationCacheGateway.clearChunkCacheForChapter(
book,
bookChapter,
TranslationConfig.llmTargetLanguage
targetLanguage
)
clearChapterState(book.bookUrl, bookChapter.index)
}
}
private fun currentTargetLanguage(): String {
return TranslationConfig.llmTargetLanguage
}
}
@@ -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(),
)
}
@@ -0,0 +1,78 @@
package io.legado.app.ui.ai.chat
import androidx.compose.runtime.Stable
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.domain.model.AiReasoningLevel
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class AiChatUiState(
val conversations: ImmutableList<AiChatConversationUi> = persistentListOf(),
val messages: ImmutableList<AiChatMessageUi> = persistentListOf(),
val currentConversationId: String? = null,
val reasoningLevel: AiReasoningLevel = AiReasoningLevel.AUTO,
val isSending: Boolean = false,
val streamingMessage: AiChatMessageUi? = null,
val pendingToolConfirmation: AiToolConfirmationUi? = null
)
@Stable
data class AiChatConversationUi(
val id: String,
val title: String,
val updatedAt: Long,
val isSelected: Boolean,
val providerName: String = "",
val modelName: String = ""
)
@Stable
data class AiChatMessageUi(
val id: String,
val role: String,
val parts: ImmutableList<AiMessagePart> = persistentListOf(),
val content: String,
val reasoning: String?,
val toolTrace: String?,
val createdAt: Long,
val thinkingDuration: Int = 0,
val bookResults: ImmutableList<AiChatBookResultUi> = persistentListOf(),
val branchIndex: Int = 0,
val totalBranches: Int = 1,
val parentMessageId: String? = null
)
@Stable
data class AiChatBookResultUi(
val bookUrl: String,
val name: String,
val author: String,
val origin: String?,
val coverPath: String?,
val latestChapterTitle: String?,
val currentChapterTitle: String?,
val intro: String?
)
@Stable
data class AiToolConfirmationUi(
val title: String,
val description: String
)
sealed interface AiChatIntent {
data object NewConversation : AiChatIntent
data class SelectConversation(val id: String) : AiChatIntent
data class SendMessage(val content: String) : AiChatIntent
data object StopGenerating : AiChatIntent
data object ConfirmPendingTool : AiChatIntent
data object RejectPendingTool : AiChatIntent
data class UpdateReasoningLevel(val level: AiReasoningLevel) : AiChatIntent
data class RegenerateMessage(val messageId: String) : AiChatIntent
data class SwitchBranch(val messageId: String) : AiChatIntent
}
sealed interface AiChatEffect {
data class ShowMessage(val message: String) : AiChatEffect
}
@@ -0,0 +1,57 @@
package io.legado.app.ui.ai.chat
import io.legado.app.domain.model.AiMessagePart
/**
* A single step inside a thinking block — either reasoning or a tool call.
*/
sealed interface AiThinkingStep {
data class ReasoningStep(val text: String) : AiThinkingStep
data class ToolStep(val tool: AiMessagePart.Tool) : AiThinkingStep
}
/**
* A renderable block of message content.
* ThinkingBlock groups consecutive reasoning + tool parts.
* ContentBlock wraps a single non-thinking part (Text, BookResult, etc.).
*/
sealed interface AiMessagePartBlock {
data class ThinkingBlock(
val steps: List<AiThinkingStep>,
val durationSeconds: Int = 0
) : AiMessagePartBlock
data class ContentBlock(val part: AiMessagePart, val index: Int) : AiMessagePartBlock
}
/**
* Groups consecutive [AiMessagePart.Reasoning] and [AiMessagePart.Tool] into
* a single [AiMessagePartBlock.ThinkingBlock], preserving render order.
*/
fun List<AiMessagePart>.groupMessageParts(thinkingDurationSeconds: Int = 0): List<AiMessagePartBlock> {
val result = mutableListOf<AiMessagePartBlock>()
var currentThinkingSteps = mutableListOf<AiThinkingStep>()
fun flushThinkingSteps() {
if (currentThinkingSteps.isNotEmpty()) {
result.add(AiMessagePartBlock.ThinkingBlock(currentThinkingSteps.toList(), thinkingDurationSeconds))
currentThinkingSteps = mutableListOf()
}
}
forEachIndexed { index, part ->
when (part) {
is AiMessagePart.Reasoning -> {
currentThinkingSteps.add(AiThinkingStep.ReasoningStep(part.text))
}
is AiMessagePart.Tool -> {
currentThinkingSteps.add(AiThinkingStep.ToolStep(part))
}
else -> {
flushThinkingSteps()
result.add(AiMessagePartBlock.ContentBlock(part, index))
}
}
}
flushThinkingSteps()
return result
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,766 @@
package io.legado.app.ui.ai.chat
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.google.gson.JsonObject
import io.legado.app.domain.gateway.AiChatGateway
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.domain.model.AiMessagePartJson
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiReasoningLevel
import io.legado.app.domain.model.AiToolApprovalState
import io.legado.app.domain.model.reasoningContent
import io.legado.app.domain.model.textContent
import io.legado.app.domain.model.toolParts
import io.legado.app.domain.usecase.AiChatGenerationUseCase
import io.legado.app.domain.usecase.PendingToolRun
import io.legado.app.domain.usecase.ToolTraceBuilder
import io.legado.app.utils.GSON
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
class AiChatViewModel(
private val aiChatGateway: AiChatGateway,
private val aiProfileGateway: AiProfileGateway,
private val generationUseCase: AiChatGenerationUseCase
) : ViewModel() {
private val currentConversationId = MutableStateFlow<String?>(null)
private var lastMessages: List<AiChatMessageUi> = emptyList()
private var streamingJob: Job? = null
private var pendingToolRun: PendingToolRun? = null
private var thinkingStartTime: Long = 0L
private val _uiState = MutableStateFlow(AiChatUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AiChatEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
observeConversations()
observeCurrentMessages()
}
fun onIntent(intent: AiChatIntent) {
when (intent) {
AiChatIntent.NewConversation -> createConversation()
is AiChatIntent.SelectConversation -> selectConversation(intent.id)
is AiChatIntent.SendMessage -> sendMessage(intent.content)
AiChatIntent.StopGenerating -> stopGenerating()
AiChatIntent.ConfirmPendingTool -> confirmPendingTool()
AiChatIntent.RejectPendingTool -> rejectPendingTool()
is AiChatIntent.UpdateReasoningLevel -> updateReasoningLevel(intent.level)
is AiChatIntent.RegenerateMessage -> regenerateMessage(intent.messageId)
is AiChatIntent.SwitchBranch -> switchBranch(intent.messageId)
}
}
// ---- Conversation lifecycle ----
private fun observeConversations() {
viewModelScope.launch {
aiChatGateway.observeConversations().collect { conversations ->
val selectedId = currentConversationId.value
_uiState.update { current ->
current.copy(
conversations = conversations.map {
AiChatConversationUi(
id = it.id,
title = it.title,
updatedAt = it.updatedAt,
isSelected = it.id == selectedId
)
}.toImmutableList()
)
}
if (selectedId == null) {
conversations.firstOrNull()?.let { selectConversation(it.id) }
?: createConversation()
}
}
}
}
private fun observeCurrentMessages() {
viewModelScope.launch {
currentConversationId
.filterNotNull()
.flatMapLatest { aiChatGateway.observeSelectedMessages(it) }
.collect { messages ->
lastMessages = messages.map { msg ->
val parts = AiMessagePartJson.decode(msg.partsJson)
AiChatMessageUi(
id = msg.id,
role = msg.role,
parts = parts.toImmutableList(),
content = parts.textContent(),
reasoning = parts.reasoningContent(),
toolTrace = parts.toolTraceText(),
createdAt = msg.createdAt,
thinkingDuration = msg.thinkingDuration,
bookResults = parts.bookResults().toImmutableList(),
branchIndex = msg.branchIndex,
totalBranches = 1, // will be updated below
parentMessageId = msg.parentMessageId
)
}
// Update totalBranches for assistant messages with parent (batch query)
val conversationId = currentConversationId.value
val branchCounts = if (conversationId != null) {
runCatching { aiChatGateway.getBranchCounts(conversationId) }.getOrNull()
} else null
lastMessages = lastMessages.map { msg ->
if (msg.role == AiMessageRole.ASSISTANT && msg.parentMessageId != null) {
val count = branchCounts?.get(msg.parentMessageId) ?: 1
msg.copy(totalBranches = count)
} else msg
}
_uiState.update { it.copy(messages = lastMessages.toImmutableList()) }
}
}
}
private fun createConversation() {
viewModelScope.launch {
runCatching {
aiChatGateway.createConversation()
}.onSuccess {
selectConversation(it.id)
}.onFailure { error ->
_effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to create chat"))
}
}
}
private fun selectConversation(id: String) {
streamingJob?.cancel()
pendingToolRun = null
currentConversationId.value = id
viewModelScope.launch {
val conversation = aiChatGateway.getConversation(id)
// Load provider/model info for the conversation
var providerName = ""
var modelName = ""
conversation?.modelProfileId?.let { profileId ->
val modelProfile = aiProfileGateway.getModel(profileId)
if (modelProfile != null) {
modelName = modelProfile.displayName
val provider = aiProfileGateway.getProvider(modelProfile.providerId)
providerName = provider?.name ?: ""
}
}
_uiState.update { current ->
current.copy(
currentConversationId = id,
reasoningLevel = conversation?.reasoningLevel?.let {
runCatching { AiReasoningLevel.valueOf(it.uppercase()) }.getOrNull()
} ?: current.reasoningLevel,
streamingMessage = null,
pendingToolConfirmation = null,
isSending = false,
conversations = current.conversations.map {
if (it.id == id) {
it.copy(
isSelected = true,
providerName = providerName,
modelName = modelName
)
} else {
it.copy(isSelected = false)
}
}.toImmutableList()
)
}
}
}
// ---- Generation ----
private fun stopGenerating() {
streamingJob?.cancel()
}
private fun confirmPendingTool() {
val pending = pendingToolRun ?: return
pendingToolRun = null
streamingJob = viewModelScope.launch {
_uiState.update {
it.copy(isSending = true, pendingToolConfirmation = null)
}
continueAfterToolApproval(pending)
}
}
private fun rejectPendingTool() {
val pending = pendingToolRun ?: return
pendingToolRun = null
val rejectionText = "工具调用已被你拒绝,我不会执行这些操作。还需要我继续帮你做什么?"
val assistantText = buildString {
pending.fullText.trim().takeIf { it.isNotBlank() }?.let {
append(it)
append("\n\n")
}
append(rejectionText)
}
val rejectedToolParts = pending.deniedToolParts()
val reasoning = pending.fullReasoning.takeIf { it.isNotBlank() }
streamingJob = viewModelScope.launch {
_uiState.update {
it.copy(
isSending = false,
pendingToolConfirmation = null,
streamingMessage = AiChatMessageUi(
id = "tool_rejected_temp",
role = AiMessageRole.ASSISTANT,
content = assistantText,
reasoning = reasoning,
toolTrace = null,
createdAt = System.currentTimeMillis()
).withDisplayParts(rejectedToolParts)
)
}
val saveResult = runCatching {
pending.conversationId?.let { conversationId ->
aiChatGateway.saveMessage(
conversationId = conversationId,
role = AiMessageRole.ASSISTANT,
parts = buildList {
reasoning?.let { add(AiMessagePart.Reasoning(it)) }
add(AiMessagePart.Text(assistantText))
addAll(rejectedToolParts)
},
parentMessageId = pending.parentMessageId
)
}
}
saveResult.onSuccess {
_uiState.update { it.copy(streamingMessage = null) }
}.onFailure { error ->
_effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to save rejected tool response"))
}
if (streamingJob == currentCoroutineContext()[Job]) {
streamingJob = null
}
}
}
private fun updateReasoningLevel(level: AiReasoningLevel) {
_uiState.update { it.copy(reasoningLevel = level) }
val conversationId = currentConversationId.value ?: return
viewModelScope.launch {
aiChatGateway.updateReasoningLevel(conversationId, level.name.lowercase())
}
}
private fun regenerateMessage(messageId: String) {
val message = lastMessages.find { it.id == messageId }
?: return
if (message.role != AiMessageRole.ASSISTANT || message.parentMessageId == null) return
val parentUserMsg = lastMessages.find { it.id == message.parentMessageId }
?: return
val historyBeforeParent = lastMessages.filter { it.createdAt < parentUserMsg.createdAt }
streamingJob?.cancel()
streamingJob = viewModelScope.launch {
_uiState.update { it.copy(isSending = true) }
val conversationId = currentConversationId.value ?: return@launch
val fullText = StringBuilder()
val fullReasoning = StringBuilder()
val toolTrace = ToolTraceBuilder()
var failureMessage: String? = null
var wasCancelled = false
var waitingForToolConfirmation = false
try {
val state = _uiState.value
val request = generationUseCase.buildRequest(
userContent = parentUserMsg.content,
history = historyBeforeParent,
reasoningLevel = state.reasoningLevel,
conversationId = conversationId
)
setStreamingPlaceholder()
try {
collectStream(request, fullText, fullReasoning, toolTrace)
waitingForToolConfirmation = continueToolRounds(
conversationId = conversationId,
request = request,
fullText = fullText,
fullReasoning = fullReasoning,
toolTrace = toolTrace,
startRound = 0,
assistantTextStart = 0,
parentMessageId = message.parentMessageId
)
} catch (e: CancellationException) {
wasCancelled = true
throw e
} catch (e: Exception) {
failureMessage = "Stream interrupted: ${e.message ?: "unknown error"}"
_effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
}
} catch (e: CancellationException) {
wasCancelled = true
throw e
} catch (e: Exception) {
failureMessage = e.message ?: "AI chat failed"
_effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
} finally {
val assistantContent = when {
waitingForToolConfirmation -> null
fullText.isNotEmpty() -> fullText.toString()
!wasCancelled && !failureMessage.isNullOrBlank() -> "请求失败:$failureMessage"
else -> null
}
if (assistantContent != null) {
val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
runCatching {
aiChatGateway.saveRegeneratedMessage(
conversationId = conversationId,
role = AiMessageRole.ASSISTANT,
parts = generationUseCase.buildAssistantParts(
text = assistantContent,
reasoning = fullReasoning.toString(),
toolTrace = toolTrace
),
parentMessageId = message.parentMessageId!!,
thinkingDuration = duration
)
}
}
_uiState.update {
if (waitingForToolConfirmation) it
else it.copy(isSending = false, streamingMessage = null)
}
if (streamingJob == currentCoroutineContext()[Job]) {
streamingJob = null
}
}
}
}
private fun switchBranch(messageId: String) {
viewModelScope.launch {
runCatching {
aiChatGateway.selectBranch(messageId)
}.onFailure { error ->
_effects.tryEmit(AiChatEffect.ShowMessage(error.message ?: "Failed to switch branch"))
}
}
}
private fun sendMessage(rawContent: String) {
val content = rawContent.trim()
if (content.isBlank() || _uiState.value.isSending) return
streamingJob?.cancel()
val historySnapshot = lastMessages
streamingJob = viewModelScope.launch {
_uiState.update { it.copy(isSending = true) }
var conversationIdForMsg: String? = null
var parentMessageId: String? = null
val fullText = StringBuilder()
val fullReasoning = StringBuilder()
val toolTrace = ToolTraceBuilder()
var failureMessage: String? = null
var wasCancelled = false
var waitingForToolConfirmation = false
try {
val conversationId = currentConversationId.value
?: aiChatGateway.createConversation().id.also {
currentConversationId.value = it
}
val userMessage = aiChatGateway.saveMessage(
conversationId = conversationId,
role = AiMessageRole.USER,
parts = listOf(AiMessagePart.Text(content))
)
conversationIdForMsg = userMessage.conversationId
parentMessageId = userMessage.id
if (historySnapshot.none { it.role == AiMessageRole.USER }) {
aiChatGateway.updateConversationTitle(conversationId, content.take(24))
}
val state = _uiState.value
val request = generationUseCase.buildRequest(
userContent = content,
history = historySnapshot,
reasoningLevel = state.reasoningLevel,
conversationId = conversationIdForMsg
)
setStreamingPlaceholder()
try {
collectStream(request, fullText, fullReasoning, toolTrace)
waitingForToolConfirmation = continueToolRounds(
conversationId = conversationIdForMsg,
request = request,
fullText = fullText,
fullReasoning = fullReasoning,
toolTrace = toolTrace,
startRound = 0,
assistantTextStart = 0,
parentMessageId = parentMessageId
)
} catch (e: CancellationException) {
wasCancelled = true
throw e
} catch (e: Exception) {
failureMessage = "Stream interrupted: ${e.message ?: "unknown error"}"
_effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
}
} catch (e: CancellationException) {
wasCancelled = true
throw e
} catch (e: Exception) {
failureMessage = e.message ?: "AI chat failed"
_effects.tryEmit(AiChatEffect.ShowMessage(failureMessage))
} finally {
val assistantContent = when {
waitingForToolConfirmation -> null
fullText.isNotEmpty() -> fullText.toString()
!wasCancelled && !failureMessage.isNullOrBlank() -> "请求失败:$failureMessage"
else -> null
}
if (conversationIdForMsg != null && assistantContent != null) {
val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
runCatching {
aiChatGateway.saveMessage(
conversationId = conversationIdForMsg,
role = AiMessageRole.ASSISTANT,
parts = generationUseCase.buildAssistantParts(
text = assistantContent,
reasoning = fullReasoning.toString(),
toolTrace = toolTrace
),
parentMessageId = parentMessageId,
thinkingDuration = duration
)
}
}
_uiState.update {
if (waitingForToolConfirmation) it
else it.copy(isSending = false, streamingMessage = null)
}
if (streamingJob == currentCoroutineContext()[Job]) {
streamingJob = null
}
}
}
}
private suspend fun continueAfterToolApproval(pending: PendingToolRun) {
val fullText = StringBuilder(pending.fullText)
val fullReasoning = StringBuilder(pending.fullReasoning)
var waitingForToolConfirmation = false
try {
val assistantContent = fullText.substring(pending.assistantTextStart)
val followUpRequest = generationUseCase.executeToolCalls(
request = pending.request,
assistantContent = assistantContent,
toolTrace = pending.toolTrace,
toolCalls = pending.toolCalls,
onToolTraceUpdate = { updateStreamingToolTrace(pending.toolTrace) }
)
val nextAssistantTextStart = fullText.length
collectStream(followUpRequest, fullText, fullReasoning, pending.toolTrace)
waitingForToolConfirmation = continueToolRounds(
conversationId = pending.conversationId,
request = followUpRequest,
fullText = fullText,
fullReasoning = fullReasoning,
toolTrace = pending.toolTrace,
startRound = pending.round + 1,
assistantTextStart = nextAssistantTextStart,
parentMessageId = pending.parentMessageId
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
_effects.tryEmit(AiChatEffect.ShowMessage(e.message ?: "AI tool failed"))
} finally {
if (!waitingForToolConfirmation && pending.conversationId != null && fullText.isNotEmpty()) {
val duration = _uiState.value.streamingMessage?.thinkingDuration ?: 0
runCatching {
aiChatGateway.saveMessage(
conversationId = pending.conversationId,
role = AiMessageRole.ASSISTANT,
parts = generationUseCase.buildAssistantParts(
text = fullText.toString(),
reasoning = fullReasoning.toString(),
toolTrace = pending.toolTrace
),
parentMessageId = pending.parentMessageId,
thinkingDuration = duration
)
}
}
_uiState.update {
if (waitingForToolConfirmation) it
else it.copy(isSending = false, streamingMessage = null)
}
if (streamingJob == currentCoroutineContext()[Job]) {
streamingJob = null
}
}
}
private suspend fun continueToolRounds(
conversationId: String?,
request: io.legado.app.domain.model.AiGenerateRequest,
fullText: StringBuilder,
fullReasoning: StringBuilder,
toolTrace: ToolTraceBuilder,
startRound: Int,
assistantTextStart: Int,
parentMessageId: String? = null
): Boolean {
var currentRequest = request
var currentRound = startRound
var currentAssistantTextStart = assistantTextStart
while (true) {
val toolCalls = toolTrace.pendingToolCalls()
if (toolCalls.isEmpty()) return false
if (toolCalls.any { generationUseCase.requiresConfirmation(it.name) }) {
pendingToolRun = PendingToolRun(
conversationId = conversationId,
request = currentRequest,
fullText = fullText.toString(),
fullReasoning = fullReasoning.toString(),
toolTrace = toolTrace,
toolCalls = toolCalls,
assistantTextStart = currentAssistantTextStart,
round = currentRound,
parentMessageId = parentMessageId
)
_uiState.update {
it.copy(
isSending = false,
pendingToolConfirmation = AiToolConfirmationUi(
title = toolCalls.joinToString { call -> call.name },
description = toolTrace.toString().take(2000)
)
)
}
return true
}
currentRequest = generationUseCase.executeToolCalls(
request = currentRequest,
assistantContent = fullText.substring(currentAssistantTextStart),
toolTrace = toolTrace,
toolCalls = toolCalls,
onToolTraceUpdate = { updateStreamingToolTrace(toolTrace) }
)
currentRound += 1
currentAssistantTextStart = fullText.length
collectStream(currentRequest, fullText, fullReasoning, toolTrace)
}
}
// ---- Streaming helpers ----
private suspend fun collectStream(
request: io.legado.app.domain.model.AiGenerateRequest,
fullText: StringBuilder,
fullReasoning: StringBuilder,
toolTrace: ToolTraceBuilder
) {
generationUseCase.collectStream(
request = request,
toolTrace = toolTrace,
onContent = { delta ->
// First content token: freeze thinking duration
if (fullText.isEmpty() && thinkingStartTime > 0) {
val duration = ((System.currentTimeMillis() - thinkingStartTime) / 1000).toInt()
_uiState.update { state ->
state.streamingMessage?.let { msg ->
state.copy(streamingMessage = msg.copy(thinkingDuration = duration))
} ?: state
}
}
fullText.append(delta)
_uiState.update { state ->
state.streamingMessage?.let { msg ->
state.copy(
streamingMessage = msg.copy(content = fullText.toString())
.withDisplayParts()
)
} ?: state
}
},
onReasoning = { delta ->
// First reasoning token: record start time
if (fullReasoning.isEmpty()) {
thinkingStartTime = System.currentTimeMillis()
}
fullReasoning.append(delta)
_uiState.update { state ->
state.streamingMessage?.let { msg ->
state.copy(
streamingMessage = msg.copy(reasoning = fullReasoning.toString())
.withDisplayParts()
)
} ?: state
}
},
onToolTraceUpdate = { updateStreamingToolTrace(toolTrace) }
)
}
private fun setStreamingPlaceholder() {
thinkingStartTime = 0L
_uiState.update {
it.copy(
streamingMessage = AiChatMessageUi(
id = "streaming_temp",
role = AiMessageRole.ASSISTANT,
content = "",
reasoning = null,
toolTrace = null,
createdAt = System.currentTimeMillis()
)
)
}
}
private fun updateStreamingToolTrace(toolTrace: ToolTraceBuilder) {
_uiState.update { state ->
state.streamingMessage?.let { msg ->
state.copy(
streamingMessage = msg.copy(
toolTrace = toolTrace.toString()
).withDisplayParts(toolTrace.toParts())
)
} ?: state
}
}
private fun AiChatMessageUi.withDisplayParts(
toolParts: List<AiMessagePart> = parts.filter {
it is AiMessagePart.Tool || it is AiMessagePart.BookResult
}
): AiChatMessageUi {
return copy(
parts = buildList {
reasoning?.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Reasoning(it)) }
content.takeIf { it.isNotBlank() }?.let { add(AiMessagePart.Text(it)) }
addAll(toolParts)
}.toImmutableList()
)
}
private fun PendingToolRun.deniedToolParts(): List<AiMessagePart> {
val deniedCallIds = toolCalls.mapTo(mutableSetOf()) { it.id }
return toolTrace.toParts().map { part ->
if (part is AiMessagePart.Tool &&
part.toolCallId in deniedCallIds &&
part.output.isBlank()
) {
part.copy(
output = "用户拒绝执行此工具调用。",
approvalState = AiToolApprovalState.DENIED
)
} else {
part
}
}
}
// ---- Display helpers ----
private fun List<AiMessagePart>.bookResults(): List<AiChatBookResultUi> {
val explicit = filterIsInstance<AiMessagePart.BookResult>().map {
AiChatBookResultUi(
bookUrl = it.bookUrl,
name = it.name,
author = it.author,
origin = it.origin,
coverPath = it.coverPath,
latestChapterTitle = it.latestChapterTitle,
currentChapterTitle = it.currentChapterTitle,
intro = it.intro
)
}
if (explicit.isNotEmpty()) return explicit.distinctBy { it.bookUrl }
val books = linkedMapOf<String, AiChatBookResultUi>()
toolParts().forEach { tool ->
val root = runCatching {
GSON.fromJson(tool.output, JsonObject::class.java)
}.getOrNull() ?: return@forEach
root.getAsJsonArrayOrNull("books")?.forEach { element ->
element.asJsonObjectOrNull()?.toBookResultUi()?.let { books.putIfAbsent(it.bookUrl, it) }
}
root.getAsJsonObjectOrNull("book")?.toBookResultUi()?.let {
books.putIfAbsent(it.bookUrl, it)
}
}
return books.values.toList()
}
private fun List<AiMessagePart>.toolTraceText(): String? {
val tools = toolParts()
if (tools.isEmpty()) return null
return tools.joinToString("\n\n") { tool ->
buildString {
append("Tool: ")
append(tool.toolName.ifBlank { tool.rawType.ifBlank { tool.toolCallId } })
append('\n')
append("ID: ")
append(tool.toolCallId)
if (tool.input.isNotBlank()) {
append('\n')
append(tool.input)
}
if (tool.approvalState != AiToolApprovalState.AUTO) {
append('\n')
append("Approval: ")
append(tool.approvalState.name.lowercase())
}
if (tool.output.isNotBlank()) {
append('\n')
append("Result: ")
append(tool.output.take(2000))
}
}
}.takeIf { it.isNotBlank() }
}
}
private fun JsonObject.toBookResultUi(): AiChatBookResultUi? {
val bookUrl = string("bookUrl")?.takeIf { it.isNotBlank() } ?: return null
return AiChatBookResultUi(
bookUrl = bookUrl,
name = string("name").orEmpty(),
author = string("author").orEmpty(),
origin = string("origin") ?: string("originName"),
coverPath = string("coverPath") ?: string("coverUrl"),
latestChapterTitle = string("latestChapterTitle"),
currentChapterTitle = string("currentChapterTitle"),
intro = string("intro")
)
}
private fun JsonObject.string(name: String): String? =
get(name)?.takeIf { !it.isJsonNull }?.asString
private fun JsonObject.getAsJsonObjectOrNull(name: String): JsonObject? =
get(name)?.let { if (it.isJsonObject) it.asJsonObject else null }
private fun JsonObject.getAsJsonArrayOrNull(name: String) = runCatching {
get(name)?.takeIf { !it.isJsonNull && it.isJsonArray }?.asJsonArray
}.getOrNull()
private fun com.google.gson.JsonElement.asJsonObjectOrNull(): JsonObject? =
takeIf { !it.isJsonNull && it.isJsonObject }?.asJsonObject
@@ -0,0 +1,250 @@
package io.legado.app.ui.ai.chat
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Lightbulb
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.text.AppText
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
/**
* Three states for the reasoning card display.
*/
enum class ReasoningCardState(val expanded: Boolean) {
/** Fully hidden — only the toggle header is visible. */
Collapsed(false),
/** Preview mode during streaming — limited height with fade gradient. */
Preview(true),
/** Fully expanded — all content visible. */
Expanded(true),
}
@Stable
private class ReasoningState(
val scrollState: ScrollState,
) {
var expandState by mutableStateOf(ReasoningCardState.Collapsed)
var elapsedSeconds by mutableStateOf(0f)
}
@Composable
private fun rememberReasoningState(
text: String,
isStreaming: Boolean,
messageCreatedAt: Long,
): Pair<ReasoningState, Boolean> {
val scrollState = rememberScrollState()
val state = remember(messageCreatedAt) {
ReasoningState(scrollState = scrollState)
}
// Auto-expand to Preview during streaming, auto-close after
LaunchedEffect(text, isStreaming) {
if (isStreaming) {
if (!state.expandState.expanded) {
state.expandState = ReasoningCardState.Preview
}
scrollState.animateScrollTo(scrollState.maxValue)
} else {
if (state.expandState.expanded) {
state.expandState = ReasoningCardState.Collapsed
}
}
}
// Duration timer during streaming - update once per second
LaunchedEffect(isStreaming) {
if (isStreaming) {
while (isActive) {
state.elapsedSeconds = (System.currentTimeMillis() - messageCreatedAt) / 1000f
delay(1000)
}
} else {
state.elapsedSeconds = (System.currentTimeMillis() - messageCreatedAt) / 1000f
}
}
return state to isStreaming
}
/**
* A reasoning card with three display states:
* - **Collapsed**: header row with "Thinking" label, click to expand
* - **Preview** (streaming): max 100dp height with vertical fade gradient, auto-scrolls
* - **Expanded**: full content with text selection
*
* Auto-enters Preview during streaming, auto-collapses when streaming ends.
*/
@Composable
fun ReasoningCard(
text: String,
isStreaming: Boolean,
messageCreatedAt: Long,
modifier: Modifier = Modifier,
) {
if (text.isBlank()) return
val (state, loading) = rememberReasoningState(text, isStreaming, messageCreatedAt)
val fadeHeight = 64f
Column(
modifier = modifier
.fillMaxWidth()
.animateContentSize()
) {
// Header row
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {
state.expandState = when (state.expandState) {
ReasoningCardState.Collapsed -> ReasoningCardState.Expanded
ReasoningCardState.Preview -> ReasoningCardState.Expanded
ReasoningCardState.Expanded -> if (loading)
ReasoningCardState.Preview else ReasoningCardState.Collapsed
}
}
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Lightbulb,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = LegadoTheme.colorScheme.secondary
)
Spacer(modifier = Modifier.width(6.dp))
// Animated label: show duration during streaming, static text otherwise
AnimatedContent(
targetState = if (loading) {
stringResource(R.string.ai_thinking_seconds, state.elapsedSeconds)
} else {
stringResource(R.string.ai_reasoning_done)
},
transitionSpec = {
(slideInVertically { it } + fadeIn()).togetherWith(
slideOutVertically { -it } + fadeOut()
)
},
label = "ReasoningLabel"
) { label ->
AppText(
text = label,
style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.secondary
)
}
Spacer(modifier = Modifier.weight(1f))
Icon(
imageVector = if (state.expandState.expanded)
Icons.Default.KeyboardArrowDown
else
Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier
.size(18.dp)
.then(
if (state.expandState.expanded)
Modifier // pointing up when expanded
else Modifier
),
tint = LegadoTheme.colorScheme.outline
)
}
// Content area
if (state.expandState != ReasoningCardState.Collapsed) {
val isPreview = state.expandState == ReasoningCardState.Preview
val contentModifier = Modifier
.fillMaxWidth()
.then(
if (isPreview) {
Modifier
.graphicsLayer { alpha = 0.99f }
.drawWithCache {
val brush = Brush.verticalGradient(
startY = 0f,
endY = size.height,
colorStops = arrayOf(
0.0f to Color.Transparent,
(fadeHeight / size.height) to Color.Black,
(1 - fadeHeight / size.height) to Color.Black,
1.0f to Color.Transparent
)
)
onDrawWithContent {
drawContent()
drawRect(
brush = brush,
size = Size(size.width, size.height),
blendMode = BlendMode.DstIn,
)
}
}
.heightIn(max = 100.dp)
.verticalScroll(state.scrollState)
} else Modifier
)
Column(modifier = contentModifier.padding(bottom = 6.dp)) {
if (loading) {
AppText(
text = text,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant
)
} else {
SelectionContainer {
AppText(
text = text,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@@ -0,0 +1,205 @@
package io.legado.app.ui.ai.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AutoAwesome
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.icon.AppIcon
import io.legado.app.ui.widget.components.text.AppText
import io.legado.app.ui.widget.components.text.MarkdownBlock
import kotlinx.coroutines.delay
/**
* Unified thinking/tool card.
*
* - Expands only while actively thinking or calling tools
* - Auto-collapses when streaming ends
* - Header: left icon + title | right TextCard (time, reasoning only) + expand arrow
*/
@Composable
fun AiThinkingCard(
steps: List<AiThinkingStep>,
isStreaming: Boolean,
durationSeconds: Int = 0,
modifier: Modifier = Modifier,
) {
val hasTools = steps.any { it is AiThinkingStep.ToolStep }
val hasReasoning = steps.any { it is AiThinkingStep.ReasoningStep }
val headerTitle = when {
hasTools && hasReasoning -> "思考与工具"
hasTools -> "工具调用"
isStreaming -> "思考中"
else -> "思考"
}
val headerIcon = if (hasTools) Icons.Default.Build else Icons.Default.AutoAwesome
// Live timer while streaming (fallback before first content token arrives)
var liveSeconds by remember { mutableStateOf(0) }
LaunchedEffect(isStreaming, durationSeconds) {
if (isStreaming && durationSeconds == 0) {
liveSeconds = 0
while (true) {
delay(1000)
liveSeconds++
}
}
}
val displaySeconds = if (durationSeconds > 0) durationSeconds else liveSeconds
// Expand while streaming, collapse when done
var expanded by remember { mutableStateOf(isStreaming) }
LaunchedEffect(isStreaming) {
expanded = isStreaming
}
// Show time only for reasoning (not tool-only)
val showTime = hasReasoning && displaySeconds > 0
NormalCard(modifier = modifier.fillMaxWidth()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded }
.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp),
verticalAlignment = Alignment.CenterVertically
) {
AppIcon(
imageVector = headerIcon,
contentDescription = null,
tint = LegadoTheme.colorScheme.onSurface,
modifier = Modifier.size(14.dp)
)
Spacer(modifier = Modifier.width(8.dp))
AppText(
text = headerTitle,
style = LegadoTheme.typography.labelSmallEmphasized,
color = LegadoTheme.colorScheme.onSurface,
maxLines = 1
)
Spacer(modifier = Modifier.weight(1f))
if (showTime) {
TextCard(text = "${displaySeconds}s")
Spacer(modifier = Modifier.width(8.dp))
}
AppIcon(
imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = LegadoTheme.colorScheme.onSurfaceVariant
)
}
// Body
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 400.dp)
.verticalScroll(rememberScrollState())
) {
steps.forEach { step ->
when (step) {
is AiThinkingStep.ReasoningStep -> {
MarkdownBlock(
content = step.text,
style = LegadoTheme.typography.bodySmall.copy(
color = LegadoTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 10.dp)
)
}
is AiThinkingStep.ToolStep -> {
ToolStepContent(tool = step.tool)
}
}
}
}
}
}
}
@Composable
private fun ToolStepContent(tool: AiMessagePart.Tool) {
val hasContent = tool.output.isNotBlank() || tool.input.isNotBlank()
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 12.dp, end = 12.dp, top = 10.dp, bottom = 10.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
AppText(
text = tool.toolName,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant
)
if (!hasContent) {
Spacer(modifier = Modifier.width(6.dp))
AppText(
text = "· 无输出",
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.outline
)
}
}
if (hasContent) {
MarkdownBlock(
content = buildToolContent(tool),
style = LegadoTheme.typography.bodySmall.copy(
color = LegadoTheme.colorScheme.onSurfaceVariant
),
modifier = Modifier.fillMaxWidth()
)
}
}
}
private fun buildToolContent(tool: AiMessagePart.Tool): String = buildString {
if (tool.input.isNotBlank()) {
append("**输入:**\n")
append(tool.input)
if (tool.output.isNotBlank()) append("\n\n")
}
if (tool.output.isNotBlank()) {
append("**结果:**\n")
append(tool.output)
}
}
@@ -0,0 +1,267 @@
package io.legado.app.ui.ai.chat
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.animateContentSize
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material3.Icon
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import io.legado.app.R
import io.legado.app.domain.model.AiMessagePart
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.widget.components.card.NormalCard
import io.legado.app.ui.widget.components.text.AppText
/**
* A card displaying a group of thinking steps (reasoning + tool calls).
*
* When there are more than [collapsedVisibleCount] steps, only the last
* few are shown with a "show more" toggle.
*/
@Composable
fun AiThinkingStepsCard(
steps: List<AiThinkingStep>,
isStreaming: Boolean,
messageCreatedAt: Long,
modifier: Modifier = Modifier,
collapsedVisibleCount: Int = 2,
) {
if (steps.isEmpty()) return
var expanded by remember { mutableStateOf(false) }
val canCollapse = steps.size > collapsedVisibleCount
val visibleSteps = if (expanded || !canCollapse) {
steps
} else {
steps.takeLast(collapsedVisibleCount)
}
NormalCard(
modifier = modifier.fillMaxWidth(),
) {
Column(
modifier = Modifier
.animateContentSize()
.padding(horizontal = 12.dp, vertical = 8.dp)
) {
// Collapse/expand toggle - no padding, ripple clipped by card
if (canCollapse) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { expanded = !expanded },
verticalAlignment = Alignment.CenterVertically,
) {
Icon(
imageVector = if (expanded)
Icons.Default.KeyboardArrowDown
else
Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = LegadoTheme.colorScheme.primary,
)
AppText(
modifier = Modifier.padding(start = 8.dp),
text = if (expanded) {
stringResource(R.string.ai_thinking_collapse)
} else {
stringResource(R.string.ai_thinking_show_more, steps.size - collapsedVisibleCount)
},
style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.primary,
)
}
}
// Content
Column {
visibleSteps.forEachIndexed { index, step ->
ThinkingStepRow(
step = step,
isStreaming = isStreaming && index == visibleSteps.lastIndex,
messageCreatedAt = messageCreatedAt,
)
}
}
}
}
}
@Composable
private fun ThinkingStepRow(
step: AiThinkingStep,
isStreaming: Boolean,
messageCreatedAt: Long,
) {
when (step) {
is AiThinkingStep.ReasoningStep -> {
ReasoningStepRow(
text = step.text,
isStreaming = isStreaming,
messageCreatedAt = messageCreatedAt,
)
}
is AiThinkingStep.ToolStep -> {
ToolStepRow(tool = step.tool)
}
}
}
@Composable
private fun ReasoningStepRow(
text: String,
isStreaming: Boolean,
messageCreatedAt: Long,
) {
ReasoningCard(
text = text,
isStreaming = isStreaming,
messageCreatedAt = messageCreatedAt,
)
}
@Composable
private fun ToolStepRow(
tool: AiMessagePart.Tool,
) {
var expanded by remember { mutableStateOf(false) }
val hasContent = tool.input.isNotBlank() || tool.output.isNotBlank()
Column {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (hasContent) {
Modifier
.clickable { expanded = !expanded }
} else Modifier
)
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Icon
Box(
modifier = Modifier
.size(20.dp)
.clip(CircleShape)
.background(LegadoTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Default.Build,
contentDescription = null,
modifier = Modifier.size(14.dp),
tint = LegadoTheme.colorScheme.primary,
)
}
// Label
AppText(
text = tool.toolName,
style = LegadoTheme.typography.labelMedium,
color = LegadoTheme.colorScheme.onSurface,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f)
)
// Status
val statusText = when {
tool.output.isNotBlank() -> stringResource(R.string.ai_tool_done)
tool.approvalState == io.legado.app.domain.model.AiToolApprovalState.PENDING ->
stringResource(R.string.ai_tool_pending)
else -> stringResource(R.string.ai_tool_running)
}
AppText(
text = statusText,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.outline
)
if (hasContent) {
Icon(
imageVector = Icons.Default.KeyboardArrowDown,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = LegadoTheme.colorScheme.outline
)
}
}
// Expandable tool details
AnimatedVisibility(
visible = expanded,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(start = 28.dp, end = 4.dp, bottom = 6.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
if (tool.input.isNotBlank()) {
AppText(
text = stringResource(R.string.ai_tool_input),
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.outline
)
AppText(
text = tool.input,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 8,
overflow = TextOverflow.Ellipsis
)
}
if (tool.output.isNotBlank()) {
Spacer(modifier = Modifier.height(2.dp))
AppText(
text = stringResource(R.string.ai_tool_output),
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.outline
)
AppText(
text = tool.output,
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
maxLines = 12,
overflow = TextOverflow.Ellipsis
)
}
}
}
}
}
@@ -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
@@ -0,0 +1,51 @@
package io.legado.app.ui.config.ai
import androidx.compose.runtime.Stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class AiConfigUiState(
val providers: ImmutableList<AiProviderListItemUi> = persistentListOf(),
val models: ImmutableList<AiModelListItemUi> = persistentListOf(),
val currentModelProfileId: String? = null,
val currentModelName: String = "",
val providerCount: Int = 0,
val modelCount: Int = 0,
val presetCount: Int = 0
)
@Stable
data class AiProviderListItemUi(
val providerId: String,
val providerName: String,
val protocol: String,
val baseUrl: String,
val modelCount: Int,
val enabled: Boolean,
val models: ImmutableList<AiModelListItemUi> = persistentListOf()
)
@Stable
data class AiModelListItemUi(
val providerId: String,
val modelProfileId: String,
val providerName: String,
val protocol: String,
val baseUrl: String,
val modelName: String,
val modelId: String,
val contextWindow: Int,
val maxOutputTokens: Int,
val enabled: Boolean,
val isCurrent: Boolean
)
sealed interface AiConfigIntent
{
data class SetDefaultModel(val modelProfileId: String) : AiConfigIntent
}
sealed interface AiConfigEffect {
data class ShowMessage(val message: String) : AiConfigEffect
}
@@ -0,0 +1,197 @@
package io.legado.app.ui.config.ai
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.card.TextCard
import io.legado.app.ui.widget.components.modalBottomSheet.AppModalBottomSheet
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
@Composable
fun AiConfigRouteScreen(
onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit,
viewModel: AiConfigViewModel = koinViewModel()
) {
AiConfigScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
effects = viewModel.effects,
onIntent = viewModel::onIntent,
onBackClick = onBackClick,
onNavigateToProviderEdit = onNavigateToProviderEdit,
onNavigateToModelEdit = onNavigateToModelEdit,
onNavigateToChat = onNavigateToChat,
onNavigateToTranslation = onNavigateToTranslation
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiConfigScreen(
state: AiConfigUiState,
effects: Flow<AiConfigEffect>,
onIntent: (AiConfigIntent) -> Unit,
onBackClick: () -> Unit,
onNavigateToProviderEdit: (providerId: String?) -> Unit,
onNavigateToModelEdit: (providerId: String?, modelProfileId: String?) -> Unit,
onNavigateToChat: () -> Unit,
onNavigateToTranslation: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
var showModelSheet by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is AiConfigEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.ai_config),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
}
)
}
) { paddingValues ->
LazyColumn(
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp
)
) {
item {
SplicedColumnGroup(title = stringResource(R.string.ai_provider_database)) {
state.providers.forEach { provider ->
ClickableSettingItem(
title = provider.providerName,
description = provider.baseUrl,
option = "${provider.protocol} / ${provider.modelCount}",
onClick = { onNavigateToProviderEdit(provider.providerId) }
)
}
ClickableSettingItem(
title = stringResource(R.string.ai_new_provider),
onClick = { onNavigateToProviderEdit(null) }
)
}
}
item {
SplicedColumnGroup(title = stringResource(R.string.ai_model_database)) {
ClickableSettingItem(
title = stringResource(R.string.ai_current_model),
description = state.currentModelName.ifBlank { stringResource(R.string.ai_model_not_configured) },
onClick = {
if (state.models.isEmpty()) {
onNavigateToProviderEdit(null)
} else {
showModelSheet = true
}
}
)
}
}
item {
SplicedColumnGroup(title = stringResource(R.string.ai_tasks)) {
ClickableSettingItem(
title = stringResource(R.string.ai_chat),
onClick = onNavigateToChat
)
ClickableSettingItem(
title = stringResource(R.string.translation_config),
onClick = onNavigateToTranslation
)
ClickableSettingItem(
title = stringResource(R.string.ai_chapter_summary),
onClick = {}
)
}
}
item {
SplicedColumnGroup(title = stringResource(R.string.ai_skills)) {
ClickableSettingItem(
title = stringResource(R.string.ai_new_skill),
onClick = {}
)
}
}
}
}
AppModalBottomSheet(
show = showModelSheet,
onDismissRequest = { showModelSheet = false },
title = stringResource(R.string.ai_select_model)
) {
LazyColumn {
state.providers.forEach { provider ->
item {
SplicedColumnGroup(title = provider.providerName) {
if (provider.models.isEmpty()) {
ClickableSettingItem(
title = stringResource(R.string.ai_no_models_imported),
description = stringResource(R.string.ai_fetch_and_save_models),
onClick = {
showModelSheet = false
onNavigateToProviderEdit(provider.providerId)
}
)
} else {
provider.models.forEach { model ->
ClickableSettingItem(
title = model.modelName,
description = model.modelId,
trailingContent = if (model.isCurrent) {
{ TextCard(text = stringResource(R.string.ai_current)) }
} else {
null
},
onClick = {
onIntent(AiConfigIntent.SetDefaultModel(model.modelProfileId))
showModelSheet = false
}
)
}
}
}
}
}
}
}
}
@@ -0,0 +1,107 @@
package io.legado.app.ui.config.ai
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.model.AiTaskType
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AiConfigViewModel(
private val aiProfileGateway: AiProfileGateway
) : ViewModel() {
private val _uiState = MutableStateFlow(AiConfigUiState())
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AiConfigEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
viewModelScope.launch {
combine(
aiProfileGateway.observeProviders(),
aiProfileGateway.observeModels(),
aiProfileGateway.observePresets()
) { providers, models, presets ->
Triple(providers, models, presets)
}.collect { (providers, models, presets) ->
val providerMap = providers.associateBy { it.id }
val defaultTranslatePreset = presets.firstOrNull {
it.taskType == AiTaskType.TRANSLATE_CHAPTER && it.isDefault
}
val currentModelProfileId = defaultTranslatePreset?.modelProfileId
?: models.firstOrNull()?.id
val modelItems = models.mapNotNull { model ->
val provider = providerMap[model.providerId] ?: return@mapNotNull null
AiModelListItemUi(
providerId = provider.id,
modelProfileId = model.id,
providerName = provider.name,
protocol = provider.protocol,
baseUrl = provider.baseUrl,
modelName = model.displayName,
modelId = model.modelId,
contextWindow = model.contextWindow,
maxOutputTokens = model.maxOutputTokens,
enabled = provider.enabled && model.enabled,
isCurrent = model.id == currentModelProfileId
)
}
val modelItemsByProvider = modelItems.groupBy { it.providerId }
val providerItems = providers.map { provider ->
val providerModels = modelItemsByProvider[provider.id].orEmpty().toImmutableList()
AiProviderListItemUi(
providerId = provider.id,
providerName = provider.name,
protocol = provider.protocol,
baseUrl = provider.baseUrl,
modelCount = providerModels.size,
enabled = provider.enabled,
models = providerModels
)
}.toImmutableList()
val modelNameById = models.associate { it.id to it.displayName }
val currentModelName = currentModelProfileId
?.let { modelNameById[it] }
.orEmpty()
_uiState.update {
it.copy(
providers = providerItems,
models = modelItems.toImmutableList(),
currentModelProfileId = currentModelProfileId,
currentModelName = currentModelName,
providerCount = providers.size,
modelCount = models.size,
presetCount = presets.size
)
}
}
}
}
fun onIntent(intent: AiConfigIntent) {
when (intent) {
is AiConfigIntent.SetDefaultModel -> setDefaultModel(intent.modelProfileId)
}
}
private fun setDefaultModel(modelProfileId: String) {
viewModelScope.launch {
runCatching {
aiProfileGateway.setDefaultModel(modelProfileId)
}.onSuccess {
_effects.tryEmit(AiConfigEffect.ShowMessage("Default AI model saved"))
}.onFailure { error ->
_effects.tryEmit(AiConfigEffect.ShowMessage(error.message ?: "Failed to save default AI model"))
}
}
}
}
@@ -0,0 +1,45 @@
package io.legado.app.ui.config.ai
import androidx.compose.runtime.Stable
import io.legado.app.domain.model.TranslationConstants
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class AiModelEditUiState(
val providers: ImmutableList<AiModelProviderOptionUi> = persistentListOf(),
val providerId: String? = null,
val modelProfileId: String? = null,
val modelName: String = "",
val modelId: String = "",
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0,
val temperature: Float = TranslationConstants.DEFAULT_TEMPERATURE,
val isSaving: Boolean = false,
val isTesting: Boolean = false,
val initialized: Boolean = false
)
@Stable
data class AiModelProviderOptionUi(
val id: String,
val name: String,
val protocol: String,
val baseUrl: String
)
sealed interface AiModelEditIntent {
data class SelectProvider(val providerId: String) : AiModelEditIntent
data class UpdateModelName(val value: String) : AiModelEditIntent
data class UpdateModelId(val value: String) : AiModelEditIntent
data class UpdateContextWindow(val value: Int) : AiModelEditIntent
data class UpdateMaxOutputTokens(val value: Int) : AiModelEditIntent
data class UpdateTemperature(val value: Float) : AiModelEditIntent
data object Save : AiModelEditIntent
data object TestConnection : AiModelEditIntent
}
sealed interface AiModelEditEffect {
data class ShowMessage(val message: String) : AiModelEditEffect
data object NavigateBack : AiModelEditEffect
}
@@ -0,0 +1,220 @@
package io.legado.app.ui.config.ai
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.domain.model.TranslationConstants
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
import io.legado.app.ui.widget.components.settingItem.SliderSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun AiModelEditRouteScreen(
providerId: String?,
modelProfileId: String?,
onBackClick: () -> Unit,
viewModel: AiModelEditViewModel = koinViewModel(
key = "${providerId.orEmpty()}_${modelProfileId.orEmpty()}",
parameters = { parametersOf(providerId, modelProfileId) }
)
) {
AiModelEditScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
effects = viewModel.effects,
onIntent = viewModel::onIntent,
onBackClick = onBackClick
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiModelEditScreen(
state: AiModelEditUiState,
effects: Flow<AiModelEditEffect>,
onIntent: (AiModelEditIntent) -> Unit,
onBackClick: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
val contextWindowOptions = buildLimitOptions(
baseOptions = listOf(
0 to stringResource(R.string.ai_not_set),
8_000 to "8K",
16_000 to "16K",
32_000 to "32K",
64_000 to "64K",
128_000 to "128K",
200_000 to "200K",
256_000 to "256K",
512_000 to "512K",
1_000_000 to "1M",
2_000_000 to "2M"
),
currentValue = state.contextWindow,
currentLabel = stringResource(R.string.ai_current_value, formatTokenLimit(state.contextWindow))
)
val maxOutputTokenOptions = buildLimitOptions(
baseOptions = listOf(
0 to stringResource(R.string.ai_not_set),
1_000 to "1K",
2_000 to "2K",
4_000 to "4K",
8_000 to "8K",
16_000 to "16K",
32_000 to "32K",
64_000 to "64K",
128_000 to "128K"
),
currentValue = state.maxOutputTokens,
currentLabel = stringResource(R.string.ai_current_value, formatTokenLimit(state.maxOutputTokens))
)
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is AiModelEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
AiModelEditEffect.NavigateBack -> onBackClick()
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.ai_model_edit),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
}
)
},
floatingActionButton = {
AppFloatingActionButton(
onClick = { onIntent(AiModelEditIntent.Save) },
icon = Icons.Default.Save,
tooltipText = stringResource(R.string.ai_save_default_model)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp
)
) {
item {
SplicedColumnGroup(title = stringResource(R.string.ai_model_config)) {
if (state.providers.isEmpty()) {
ClickableSettingItem(
title = stringResource(R.string.ai_provider),
description = stringResource(R.string.ai_no_provider_configured),
onClick = {}
)
} else {
DropdownListSettingItem(
title = stringResource(R.string.ai_provider),
selectedValue = state.providerId.orEmpty(),
displayEntries = state.providers.map {
"${it.name} / ${it.protocol}"
}.toTypedArray(),
entryValues = state.providers.map { it.id }.toTypedArray(),
onValueChange = { onIntent(AiModelEditIntent.SelectProvider(it)) }
)
}
InputSettingItem(
title = stringResource(R.string.ai_model_name),
value = state.modelName,
onConfirm = { onIntent(AiModelEditIntent.UpdateModelName(it)) }
)
InputSettingItem(
title = stringResource(R.string.ai_model_id),
value = state.modelId,
onConfirm = { onIntent(AiModelEditIntent.UpdateModelId(it)) }
)
DropdownListSettingItem(
title = stringResource(R.string.ai_context_window),
selectedValue = state.contextWindow.toString(),
displayEntries = contextWindowOptions.displayEntries,
entryValues = contextWindowOptions.entryValues,
onValueChange = { onIntent(AiModelEditIntent.UpdateContextWindow(it.toIntOrNull() ?: 0)) }
)
DropdownListSettingItem(
title = stringResource(R.string.ai_max_output_tokens),
selectedValue = state.maxOutputTokens.toString(),
displayEntries = maxOutputTokenOptions.displayEntries,
entryValues = maxOutputTokenOptions.entryValues,
onValueChange = { onIntent(AiModelEditIntent.UpdateMaxOutputTokens(it.toIntOrNull() ?: 0)) }
)
SliderSettingItem(
title = stringResource(R.string.ai_temperature),
value = state.temperature,
defaultValue = TranslationConstants.DEFAULT_TEMPERATURE,
valueRange = TranslationConstants.MIN_TEMPERATURE..TranslationConstants.MAX_TEMPERATURE,
steps = 19,
description = state.temperature.toString(),
onValueChange = { onIntent(AiModelEditIntent.UpdateTemperature(it)) }
)
}
}
}
}
}
private data class TokenLimitOptions(
val displayEntries: Array<String>,
val entryValues: Array<String>
)
private fun buildLimitOptions(
baseOptions: List<Pair<Int, String>>,
currentValue: Int,
currentLabel: String
): TokenLimitOptions {
val options = if (currentValue > 0 && baseOptions.none { it.first == currentValue }) {
baseOptions + (currentValue to currentLabel)
} else {
baseOptions
}
return TokenLimitOptions(
displayEntries = options.map { it.second }.toTypedArray(),
entryValues = options.map { it.first.toString() }.toTypedArray()
)
}
internal fun formatTokenLimit(value: Int): String {
return when {
value <= 0 -> "0"
value >= 1_000_000 && value % 1_000_000 == 0 -> "${value / 1_000_000}M"
value >= 1_000 && value % 1_000 == 0 -> "${value / 1_000}K"
else -> value.toString()
}
}
@@ -0,0 +1,159 @@
package io.legado.app.ui.config.ai
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.model.AiGenerateRequest
import io.legado.app.domain.model.AiGenerationParams
import io.legado.app.domain.model.AiMessage
import io.legado.app.domain.model.AiMessageRole
import io.legado.app.domain.model.AiModelDraft
import io.legado.app.utils.GSON
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AiModelEditViewModel(
private val initialProviderId: String?,
private val initialModelProfileId: String?,
private val aiProfileGateway: AiProfileGateway,
private val aiTextGateway: AiTextGateway
) : ViewModel() {
private val _uiState = MutableStateFlow(
AiModelEditUiState(
providerId = initialProviderId,
modelProfileId = initialModelProfileId
)
)
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AiModelEditEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
viewModelScope.launch {
combine(
aiProfileGateway.observeProviders(),
aiProfileGateway.observeModels()
) { providers, models ->
providers to models
}.collect { (providers, models) ->
val providerOptions = providers.map {
AiModelProviderOptionUi(
id = it.id,
name = it.name,
protocol = it.protocol,
baseUrl = it.baseUrl
)
}.toImmutableList()
_uiState.update { current ->
val model = models.firstOrNull { it.id == initialModelProfileId }
val params = parseParams(model?.defaultParamsJson)
val selectedProviderId = current.providerId
?: model?.providerId
?: initialProviderId
?: providers.firstOrNull()?.id
if (current.initialized) {
current.copy(providers = providerOptions)
} else {
current.copy(
providers = providerOptions,
providerId = selectedProviderId,
modelProfileId = model?.id ?: current.modelProfileId,
modelName = model?.displayName.orEmpty(),
modelId = model?.modelId.orEmpty(),
contextWindow = model?.contextWindow ?: 0,
maxOutputTokens = model?.maxOutputTokens ?: 0,
temperature = params.temperature ?: current.temperature,
initialized = true
)
}
}
}
}
}
fun onIntent(intent: AiModelEditIntent) {
when (intent) {
is AiModelEditIntent.SelectProvider -> _uiState.update { it.copy(providerId = intent.providerId) }
is AiModelEditIntent.UpdateModelName -> _uiState.update { it.copy(modelName = intent.value) }
is AiModelEditIntent.UpdateModelId -> _uiState.update { it.copy(modelId = intent.value) }
is AiModelEditIntent.UpdateContextWindow -> _uiState.update { it.copy(contextWindow = intent.value) }
is AiModelEditIntent.UpdateMaxOutputTokens -> _uiState.update { it.copy(maxOutputTokens = intent.value) }
is AiModelEditIntent.UpdateTemperature -> _uiState.update { it.copy(temperature = intent.value) }
AiModelEditIntent.Save -> save(navigateBack = true)
AiModelEditIntent.TestConnection -> testConnection()
}
}
private fun save(navigateBack: Boolean) {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
runCatching {
val model = aiProfileGateway.saveModel(_uiState.value.toDraft())
aiProfileGateway.setDefaultModel(model.id)
model
}.onSuccess { model ->
_uiState.update { it.copy(modelProfileId = model.id) }
_effects.tryEmit(AiModelEditEffect.ShowMessage("Default AI model saved"))
if (navigateBack) {
_effects.tryEmit(AiModelEditEffect.NavigateBack)
}
}.onFailure { error ->
_effects.tryEmit(AiModelEditEffect.ShowMessage(error.message ?: "Failed to save AI model"))
}
_uiState.update { it.copy(isSaving = false) }
}
}
private fun testConnection() {
viewModelScope.launch {
_uiState.update { it.copy(isTesting = true) }
runCatching {
val model = aiProfileGateway.saveModel(_uiState.value.toDraft())
val preset = aiProfileGateway.setDefaultModel(model.id)
aiTextGateway.generate(
AiGenerateRequest(
model = preset.model,
messages = listOf(
AiMessage(AiMessageRole.SYSTEM, "Reply with OK only."),
AiMessage(AiMessageRole.USER, "Connection test")
),
params = preset.params.copy(maxOutputTokens = 16)
)
).getOrThrow()
}.onSuccess {
_effects.tryEmit(AiModelEditEffect.ShowMessage("AI connection test succeeded"))
}.onFailure { error ->
_effects.tryEmit(AiModelEditEffect.ShowMessage(error.message ?: "AI connection test failed"))
}
_uiState.update { it.copy(isTesting = false) }
}
}
private fun AiModelEditUiState.toDraft(): AiModelDraft {
return AiModelDraft(
modelProfileId = modelProfileId,
providerId = providerId.orEmpty(),
modelName = modelName.trim(),
modelId = modelId.trim(),
contextWindow = contextWindow,
maxOutputTokens = maxOutputTokens,
temperature = temperature
)
}
private fun parseParams(json: String?): AiGenerationParams {
if (json.isNullOrBlank()) return AiGenerationParams()
return runCatching {
GSON.fromJson(json, AiGenerationParams::class.java)
}.getOrDefault(AiGenerationParams())
}
}
@@ -0,0 +1,89 @@
package io.legado.app.ui.config.ai
import androidx.compose.runtime.Stable
import io.legado.app.domain.model.AiProtocol
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Stable
data class AiProviderEditUiState(
val providerPresets: ImmutableList<AiProviderPresetUi> = persistentListOf(),
val fetchedModels: ImmutableList<AiFetchedModelUi> = persistentListOf(),
val selectedProviderPresetId: String = "",
val providerId: String? = null,
val providerName: String = "OpenAI Compatible",
val protocol: String = AiProtocol.OPENAI_CHAT_COMPLETIONS,
val baseUrl: String = "",
val modelsUrl: String = "",
val apiKey: String = "",
val providerModels: ImmutableList<AiProviderModelUi> = persistentListOf(),
val editingModel: AiProviderModelEditorUi? = null,
val isSaving: Boolean = false,
val isFetchingModels: Boolean = false,
val initialized: Boolean = false
)
@Stable
data class AiProviderPresetUi(
val id: String,
val name: String,
val protocol: String,
val baseUrl: String,
val modelsUrl: String,
val modelName: String,
val modelId: String
)
@Stable
data class AiFetchedModelUi(
val id: String,
val name: String,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0
)
@Stable
data class AiProviderModelUi(
val modelProfileId: String,
val providerId: String,
val modelName: String,
val modelId: String,
val contextWindow: Int = 0,
val maxOutputTokens: Int = 0,
val temperature: Float = 0.3f
)
@Stable
data class AiProviderModelEditorUi(
val modelProfileId: String? = null,
val modelName: String = "",
val modelId: String = "",
val contextWindow: String = "",
val maxOutputTokens: String = "",
val temperature: String = "0.3"
)
sealed interface AiProviderEditIntent {
data class ApplyProviderPreset(val id: String) : AiProviderEditIntent
data class UpdateProviderName(val value: String) : AiProviderEditIntent
data class UpdateProtocol(val value: String) : AiProviderEditIntent
data class UpdateBaseUrl(val value: String) : AiProviderEditIntent
data class UpdateModelsUrl(val value: String) : AiProviderEditIntent
data class UpdateApiKey(val value: String) : AiProviderEditIntent
data object AddModel : AiProviderEditIntent
data class EditModel(val modelProfileId: String) : AiProviderEditIntent
data object DismissModelEditor : AiProviderEditIntent
data class UpdateEditingModelName(val value: String) : AiProviderEditIntent
data class UpdateEditingModelId(val value: String) : AiProviderEditIntent
data class UpdateEditingContextWindow(val value: String) : AiProviderEditIntent
data class UpdateEditingMaxOutputTokens(val value: String) : AiProviderEditIntent
data class UpdateEditingTemperature(val value: String) : AiProviderEditIntent
data object SaveEditingModel : AiProviderEditIntent
data object SaveProvider : AiProviderEditIntent
data object SyncModels : AiProviderEditIntent
}
sealed interface AiProviderEditEffect {
data class ShowMessage(val message: String) : AiProviderEditEffect
data object NavigateBack : AiProviderEditEffect
}
@@ -0,0 +1,303 @@
package io.legado.app.ui.config.ai
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import io.legado.app.R
import io.legado.app.domain.model.AiProtocol
import io.legado.app.ui.theme.LegadoTheme
import io.legado.app.ui.theme.adaptiveContentPadding
import io.legado.app.ui.widget.components.AppFloatingActionButton
import io.legado.app.ui.widget.components.AppScaffold
import io.legado.app.ui.widget.components.AppTextField
import io.legado.app.ui.widget.components.SplicedColumnGroup
import io.legado.app.ui.widget.components.alert.AppAlertDialog
import io.legado.app.ui.widget.components.settingItem.ClickableSettingItem
import io.legado.app.ui.widget.components.settingItem.DropdownListSettingItem
import io.legado.app.ui.widget.components.settingItem.InputSettingItem
import io.legado.app.ui.widget.components.topbar.GlassMediumFlexibleTopAppBar
import io.legado.app.ui.widget.components.topbar.GlassTopAppBarDefaults
import io.legado.app.ui.widget.components.topbar.TopBarNavigationButton
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.collectLatest
import org.koin.androidx.compose.koinViewModel
import org.koin.core.parameter.parametersOf
@Composable
fun AiProviderEditRouteScreen(
providerId: String?,
onBackClick: () -> Unit,
viewModel: AiProviderEditViewModel = koinViewModel(
key = providerId.orEmpty(),
parameters = { parametersOf(providerId) }
)
) {
AiProviderEditScreen(
state = viewModel.uiState.collectAsStateWithLifecycle().value,
effects = viewModel.effects,
onIntent = viewModel::onIntent,
onBackClick = onBackClick
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AiProviderEditScreen(
state: AiProviderEditUiState,
effects: Flow<AiProviderEditEffect>,
onIntent: (AiProviderEditIntent) -> Unit,
onBackClick: () -> Unit
) {
val scrollBehavior = GlassTopAppBarDefaults.defaultScrollBehavior()
val snackbarHostState = remember { SnackbarHostState() }
val providerPresetEntries = arrayOf(stringResource(R.string.ai_custom_provider)) +
state.providerPresets
.filter { it.protocol == state.protocol }
.map { it.name }
.toTypedArray()
val providerPresetValues = arrayOf("") +
state.providerPresets
.filter { it.protocol == state.protocol }
.map { it.id }
.toTypedArray()
var showApiKeyDialog by remember { mutableStateOf(false) }
var apiKeyDraft by remember { mutableStateOf("") }
var apiKeyVisible by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
effects.collectLatest { effect ->
when (effect) {
is AiProviderEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
AiProviderEditEffect.NavigateBack -> onBackClick()
}
}
}
AppScaffold(
modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
snackbarHost = { SnackbarHost(snackbarHostState) },
topBar = {
GlassMediumFlexibleTopAppBar(
title = stringResource(R.string.ai_provider_edit),
scrollBehavior = scrollBehavior,
navigationIcon = {
TopBarNavigationButton(onClick = onBackClick)
}
)
},
floatingActionButton = {
AppFloatingActionButton(
onClick = { onIntent(AiProviderEditIntent.SaveProvider) },
icon = Icons.Default.Save,
tooltipText = stringResource(R.string.ai_save_provider)
)
}
) { paddingValues ->
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = adaptiveContentPadding(
top = paddingValues.calculateTopPadding(),
bottom = 120.dp
)
) {
item {
SplicedColumnGroup(title = stringResource(R.string.ai_provider)) {
InputSettingItem(
title = stringResource(R.string.ai_provider_name),
value = state.providerName,
onConfirm = { onIntent(AiProviderEditIntent.UpdateProviderName(it)) }
)
DropdownListSettingItem(
title = stringResource(R.string.ai_protocol),
selectedValue = state.protocol,
displayEntries = arrayOf("OpenAI Chat Completions", "OpenAI Responses", "Anthropic Messages"),
entryValues = arrayOf(
AiProtocol.OPENAI_CHAT_COMPLETIONS,
AiProtocol.OPENAI_RESPONSES,
AiProtocol.ANTHROPIC_MESSAGES
),
onValueChange = { onIntent(AiProviderEditIntent.UpdateProtocol(it)) }
)
DropdownListSettingItem(
title = stringResource(R.string.ai_provider_preset),
selectedValue = state.selectedProviderPresetId,
displayEntries = providerPresetEntries,
entryValues = providerPresetValues,
onValueChange = { onIntent(AiProviderEditIntent.ApplyProviderPreset(it)) }
)
InputSettingItem(
title = stringResource(R.string.ai_base_url),
value = state.baseUrl,
onConfirm = { onIntent(AiProviderEditIntent.UpdateBaseUrl(it)) }
)
ClickableSettingItem(
title = stringResource(R.string.ai_api_key),
description = stringResource(R.string.ai_api_key_summary),
onClick = {
apiKeyDraft = state.apiKey
showApiKeyDialog = true
}
)
}
}
item {
SplicedColumnGroup(title = stringResource(R.string.ai_provider_models)) {
state.providerModels.forEach { model ->
ClickableSettingItem(
title = model.modelName,
description = model.modelId,
option = formatFetchedLimit(model.contextWindow, model.maxOutputTokens),
onClick = { onIntent(AiProviderEditIntent.EditModel(model.modelProfileId)) }
)
}
ClickableSettingItem(
title = stringResource(R.string.ai_add_model_manually),
onClick = { onIntent(AiProviderEditIntent.AddModel) }
)
ClickableSettingItem(
title = stringResource(R.string.ai_fetch_and_save_models),
description = stringResource(R.string.ai_fetch_models),
onClick = { onIntent(AiProviderEditIntent.SyncModels) }
)
}
}
item {
SplicedColumnGroup(title = stringResource(R.string.ai_advanced)) {
InputSettingItem(
title = stringResource(R.string.ai_models_url),
value = state.modelsUrl,
description = stringResource(R.string.ai_models_url_summary),
onConfirm = { onIntent(AiProviderEditIntent.UpdateModelsUrl(it)) }
)
}
}
}
}
AppAlertDialog(
show = showApiKeyDialog,
onDismissRequest = { showApiKeyDialog = false },
title = stringResource(R.string.ai_api_key),
content = {
Column {
AppTextField(
value = apiKeyDraft,
onValueChange = { apiKeyDraft = it },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_api_key),
visualTransformation = if (apiKeyVisible) VisualTransformation.None else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
trailingIcon = {
val image = if (apiKeyVisible) Icons.Filled.Visibility else Icons.Filled.VisibilityOff
IconButton(onClick = { apiKeyVisible = !apiKeyVisible }) {
Icon(imageVector = image, contentDescription = null)
}
}
)
Spacer(modifier = Modifier.height(8.dp))
}
},
confirmText = stringResource(R.string.ok),
onConfirm = {
onIntent(AiProviderEditIntent.UpdateApiKey(apiKeyDraft))
showApiKeyDialog = false
},
dismissText = stringResource(R.string.cancel),
onDismiss = { showApiKeyDialog = false }
)
AppAlertDialog(
data = state.editingModel,
onDismissRequest = { onIntent(AiProviderEditIntent.DismissModelEditor) },
title = stringResource(R.string.ai_model_edit),
content = { model ->
Column {
AppTextField(
value = model.modelName,
onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingModelName(it)) },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_model_name)
)
Spacer(modifier = Modifier.height(8.dp))
AppTextField(
value = model.modelId,
onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingModelId(it)) },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_model_id)
)
Spacer(modifier = Modifier.height(8.dp))
AppTextField(
value = model.contextWindow,
onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingContextWindow(it)) },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_context_window),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(8.dp))
AppTextField(
value = model.maxOutputTokens,
onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingMaxOutputTokens(it)) },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_max_output_tokens),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
Spacer(modifier = Modifier.height(8.dp))
AppTextField(
value = model.temperature,
onValueChange = { onIntent(AiProviderEditIntent.UpdateEditingTemperature(it)) },
modifier = Modifier.fillMaxWidth(),
backgroundColor = LegadoTheme.colorScheme.surface,
label = stringResource(R.string.ai_temperature),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
)
}
},
confirmText = stringResource(R.string.ai_save_model),
onConfirm = { onIntent(AiProviderEditIntent.SaveEditingModel) },
dismissText = stringResource(R.string.cancel),
onDismiss = { onIntent(AiProviderEditIntent.DismissModelEditor) }
)
}
private fun formatFetchedLimit(contextWindow: Int, maxOutputTokens: Int): String? {
return when {
contextWindow > 0 && maxOutputTokens > 0 -> "${formatTokenLimit(contextWindow)} / ${formatTokenLimit(maxOutputTokens)}"
contextWindow > 0 -> formatTokenLimit(contextWindow)
maxOutputTokens > 0 -> formatTokenLimit(maxOutputTokens)
else -> null
}
}
@@ -0,0 +1,283 @@
package io.legado.app.ui.config.ai
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import io.legado.app.domain.gateway.AiProfileGateway
import io.legado.app.domain.gateway.AiTextGateway
import io.legado.app.domain.model.AiAvailableModel
import io.legado.app.domain.model.AiGenerationParams
import io.legado.app.domain.model.AiModelDraft
import io.legado.app.domain.model.AiProviderConfig
import io.legado.app.domain.model.AiProviderDraft
import io.legado.app.domain.model.AiProviderPresets
import io.legado.app.domain.model.TranslationConstants
import io.legado.app.utils.GSON
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
class AiProviderEditViewModel(
private val initialProviderId: String?,
private val aiProfileGateway: AiProfileGateway,
private val aiTextGateway: AiTextGateway
) : ViewModel() {
private val _uiState = MutableStateFlow(
AiProviderEditUiState(
providerPresets = AiProviderPresets.items.map {
AiProviderPresetUi(
id = it.id,
name = it.name,
protocol = it.protocol,
baseUrl = it.baseUrl,
modelsUrl = it.modelsUrl,
modelName = it.modelName,
modelId = it.modelId
)
}.toImmutableList(),
providerId = initialProviderId
)
)
val uiState = _uiState.asStateFlow()
private val _effects = MutableSharedFlow<AiProviderEditEffect>(extraBufferCapacity = 16)
val effects = _effects.asSharedFlow()
init {
viewModelScope.launch {
combine(
aiProfileGateway.observeProviders(),
aiProfileGateway.observeModels()
) { providers, models ->
providers to models
}.collect { (providers, models) ->
_uiState.update { current ->
val providerId = current.providerId ?: initialProviderId
val provider = providerId?.let { id -> providers.firstOrNull { it.id == id } }
val providerModels = models
.filter { it.providerId == providerId }
.map { model ->
val params = parseParams(model.defaultParamsJson)
AiProviderModelUi(
modelProfileId = model.id,
providerId = model.providerId,
modelName = model.displayName,
modelId = model.modelId,
contextWindow = model.contextWindow,
maxOutputTokens = model.maxOutputTokens,
temperature = params.temperature ?: TranslationConstants.DEFAULT_TEMPERATURE
)
}
.toImmutableList()
if (current.initialized) {
current.copy(providerModels = providerModels)
} else {
current.copy(
providerId = provider?.id ?: current.providerId,
providerName = provider?.name ?: current.providerName,
protocol = provider?.protocol ?: current.protocol,
baseUrl = provider?.baseUrl.orEmpty(),
modelsUrl = provider?.modelsUrl.orEmpty(),
apiKey = provider?.apiKey.orEmpty(),
providerModels = providerModels,
initialized = true
)
}
}
}
}
}
fun onIntent(intent: AiProviderEditIntent) {
when (intent) {
is AiProviderEditIntent.ApplyProviderPreset -> applyProviderPreset(intent.id)
is AiProviderEditIntent.UpdateProviderName -> _uiState.update { it.copy(providerName = intent.value) }
is AiProviderEditIntent.UpdateProtocol -> _uiState.update {
it.copy(protocol = intent.value, selectedProviderPresetId = "", fetchedModels = emptyList<AiFetchedModelUi>().toImmutableList())
}
is AiProviderEditIntent.UpdateBaseUrl -> _uiState.update { it.copy(baseUrl = intent.value, selectedProviderPresetId = "") }
is AiProviderEditIntent.UpdateModelsUrl -> _uiState.update { it.copy(modelsUrl = intent.value, selectedProviderPresetId = "") }
is AiProviderEditIntent.UpdateApiKey -> _uiState.update { it.copy(apiKey = intent.value) }
AiProviderEditIntent.AddModel -> _uiState.update {
it.copy(editingModel = AiProviderModelEditorUi(temperature = TranslationConstants.DEFAULT_TEMPERATURE.toString()))
}
is AiProviderEditIntent.EditModel -> editModel(intent.modelProfileId)
AiProviderEditIntent.DismissModelEditor -> _uiState.update { it.copy(editingModel = null) }
is AiProviderEditIntent.UpdateEditingModelName -> updateEditingModel { copy(modelName = intent.value) }
is AiProviderEditIntent.UpdateEditingModelId -> updateEditingModel { copy(modelId = intent.value) }
is AiProviderEditIntent.UpdateEditingContextWindow -> updateEditingModel { copy(contextWindow = intent.value) }
is AiProviderEditIntent.UpdateEditingMaxOutputTokens -> updateEditingModel { copy(maxOutputTokens = intent.value) }
is AiProviderEditIntent.UpdateEditingTemperature -> updateEditingModel { copy(temperature = intent.value) }
AiProviderEditIntent.SaveEditingModel -> saveEditingModel()
AiProviderEditIntent.SaveProvider -> saveProvider()
AiProviderEditIntent.SyncModels -> syncModels()
}
}
private fun applyProviderPreset(id: String) {
if (id.isBlank()) {
_uiState.update { it.copy(selectedProviderPresetId = "") }
return
}
val preset = AiProviderPresets.items.firstOrNull { it.id == id } ?: return
_uiState.update {
it.copy(
selectedProviderPresetId = preset.id,
providerName = preset.name,
protocol = preset.protocol,
baseUrl = preset.baseUrl,
modelsUrl = preset.modelsUrl,
fetchedModels = emptyList<AiFetchedModelUi>().toImmutableList()
)
}
}
private fun editModel(modelProfileId: String) {
val model = _uiState.value.providerModels.firstOrNull { it.modelProfileId == modelProfileId } ?: return
_uiState.update {
it.copy(
editingModel = AiProviderModelEditorUi(
modelProfileId = model.modelProfileId,
modelName = model.modelName,
modelId = model.modelId,
contextWindow = model.contextWindow.takeIf { value -> value > 0 }?.toString().orEmpty(),
maxOutputTokens = model.maxOutputTokens.takeIf { value -> value > 0 }?.toString().orEmpty(),
temperature = model.temperature.toString()
)
)
}
}
private fun updateEditingModel(update: AiProviderModelEditorUi.() -> AiProviderModelEditorUi) {
_uiState.update {
it.copy(editingModel = it.editingModel?.update())
}
}
private fun saveEditingModel() {
viewModelScope.launch {
val editor = _uiState.value.editingModel ?: return@launch
_uiState.update { it.copy(isSaving = true) }
runCatching {
val providerId = _uiState.value.providerId
?: aiProfileGateway.saveProvider(_uiState.value.toDraft()).id
aiProfileGateway.saveModel(
AiModelDraft(
modelProfileId = editor.modelProfileId,
providerId = providerId,
modelName = editor.modelName.trim(),
modelId = editor.modelId.trim(),
contextWindow = editor.contextWindow.toIntOrNull() ?: 0,
maxOutputTokens = editor.maxOutputTokens.toIntOrNull() ?: 0,
temperature = editor.temperature.toFloatOrNull()
?: TranslationConstants.DEFAULT_TEMPERATURE
)
)
}.onSuccess { model ->
_uiState.update { it.copy(editingModel = null, providerId = model.providerId) }
_effects.tryEmit(AiProviderEditEffect.ShowMessage("AI model saved"))
}.onFailure { error ->
_effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI model"))
}
_uiState.update { it.copy(isSaving = false) }
}
}
private fun saveProvider() {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true) }
runCatching {
aiProfileGateway.saveProvider(_uiState.value.toDraft())
}.onSuccess { provider ->
_uiState.update { it.copy(providerId = provider.id) }
_effects.tryEmit(AiProviderEditEffect.ShowMessage("AI provider saved"))
_effects.tryEmit(AiProviderEditEffect.NavigateBack)
}.onFailure { error ->
_effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI provider"))
}
_uiState.update { it.copy(isSaving = false) }
}
}
private fun syncModels() {
viewModelScope.launch {
_uiState.update { it.copy(isSaving = true, isFetchingModels = true) }
runCatching {
aiProfileGateway.saveProvider(_uiState.value.toDraft())
}.onSuccess { provider ->
_uiState.update { it.copy(providerId = provider.id) }
val state = _uiState.value
runCatching {
aiTextGateway.fetchModels(
AiProviderConfig(
id = provider.id,
name = state.providerName,
protocol = state.protocol,
baseUrl = state.baseUrl,
apiKey = state.apiKey,
modelsUrl = state.modelsUrl.ifBlank { null }
)
).getOrThrow()
}.onSuccess { models ->
aiProfileGateway.importProviderModels(provider.id, models)
applyFetchedModels(provider.id, models)
}.onFailure { error ->
_effects.tryEmit(
AiProviderEditEffect.ShowMessage(
error.message ?: "AI provider saved, but failed to fetch models"
)
)
}
}.onFailure { error ->
_effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to save AI provider"))
}
_uiState.update { it.copy(isSaving = false, isFetchingModels = false) }
}
}
private fun applyFetchedModels(providerId: String, models: List<AiAvailableModel>) {
val options = models.map {
AiFetchedModelUi(
id = it.id,
name = it.name,
contextWindow = it.contextWindow,
maxOutputTokens = it.maxOutputTokens
)
}.toImmutableList()
_uiState.update {
it.copy(
providerId = providerId,
fetchedModels = options
)
}
val message = if (options.isEmpty()) {
"No models found"
} else {
"Fetched and saved ${options.size} models"
}
_effects.tryEmit(AiProviderEditEffect.ShowMessage(message))
}
private fun AiProviderEditUiState.toDraft(): AiProviderDraft {
return AiProviderDraft(
providerId = providerId,
providerName = providerName,
protocol = protocol,
baseUrl = baseUrl,
modelsUrl = modelsUrl,
apiKey = apiKey
)
}
private fun parseParams(json: String?): AiGenerationParams {
if (json.isNullOrBlank()) return AiGenerationParams()
return runCatching {
GSON.fromJson(json, AiGenerationParams::class.java)
}.getOrDefault(AiGenerationParams())
}
}
@@ -11,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
@@ -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
)
}
}
@@ -24,6 +24,7 @@ import io.legado.app.model.Download
import io.legado.app.ui.about.AboutEffect
import io.legado.app.ui.about.AboutScreen
import io.legado.app.ui.about.AboutViewModel
import io.legado.app.ui.ai.chat.AiChatRouteScreen
import io.legado.app.ui.book.cache.manage.BookCacheManageRouteScreen
import io.legado.app.ui.book.explore.ExploreShowIntent
import io.legado.app.ui.book.explore.ExploreShowScreen
@@ -46,6 +47,9 @@ import io.legado.app.ui.book.searchContent.SearchContentScreen
import io.legado.app.ui.book.searchContent.SearchContentViewModel
import io.legado.app.ui.book.source.manage.BookSourceActivity
import io.legado.app.ui.config.ConfigNavScreen
import io.legado.app.ui.config.ai.AiConfigRouteScreen
import io.legado.app.ui.config.ai.AiModelEditRouteScreen
import io.legado.app.ui.config.ai.AiProviderEditRouteScreen
import io.legado.app.ui.config.backupConfig.BackupConfigScreen
import io.legado.app.ui.config.coverConfig.CoverConfigScreen
import io.legado.app.ui.config.customTheme.CustomThemeScreen
@@ -174,6 +178,7 @@ fun MainActivity.mainEntryProvider(
onNavigateToCover = { backStack.add(MainRouteSettingsCover) },
onNavigateToTheme = { backStack.add(MainRouteSettingsTheme) },
onNavigateToBackup = { backStack.add(MainRouteSettingsBackup) },
onNavigateToAi = { backStack.add(MainRouteSettingsAi) },
onNavigateToDownloadCache = { backStack.add(MainRouteSettingsDownloadCache) },
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) },
onNavigateToLab = { backStack.add(MainRouteSettingsLabConfig) }
@@ -204,12 +209,66 @@ fun MainActivity.mainEntryProvider(
BackupConfigScreen(onBackClick = { onNavigateBack() })
}
entry<MainRouteSettingsAi> {
AiConfigRouteScreen(
onBackClick = { onNavigateBack() },
onNavigateToProviderEdit = { providerId ->
backStack.add(MainRouteSettingsAiProviderEdit(providerId = providerId))
},
onNavigateToModelEdit = { providerId, modelProfileId ->
backStack.add(
MainRouteSettingsAiModelEdit(
providerId = providerId,
modelProfileId = modelProfileId
)
)
},
onNavigateToChat = { backStack.add(MainRouteAiChat) },
onNavigateToTranslation = { backStack.add(MainRouteSettingsTranslation) }
)
}
entry<MainRouteSettingsAiProviderEdit> { route ->
AiProviderEditRouteScreen(
providerId = route.providerId,
onBackClick = { onNavigateBack() }
)
}
entry<MainRouteAiChat> {
AiChatRouteScreen(
onBackClick = { onNavigateBack() },
onOpenBookInfo = { book ->
onNavigateToRoute(
MainRouteBookInfo(
name = book.name,
author = book.author,
bookUrl = book.bookUrl,
origin = book.origin,
coverPath = book.coverPath
)
)
}
)
}
entry<MainRouteSettingsAiModelEdit> { route ->
AiModelEditRouteScreen(
providerId = route.providerId,
modelProfileId = route.modelProfileId,
onBackClick = { onNavigateBack() }
)
}
entry<MainRouteSettingsDownloadCache> {
DownloadCacheConfigScreen(onBackClick = { onNavigateBack() })
}
entry<MainRouteSettingsTranslation> {
TranslationConfigScreen(onBackClick = { onNavigateBack() })
TranslationConfigScreen(
onBackClick = { onNavigateBack() },
onNavigateToAi = { backStack.add(MainRouteSettingsAi) }
)
}
entry<MainRouteSettingsLabConfig> {
@@ -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"
@@ -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
@@ -0,0 +1,782 @@
package io.legado.app.ui.widget.components.text
import android.content.Intent
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.LocalTextStyle
import androidx.compose.material3.ProvideTextStyle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withLink
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import androidx.core.net.toUri
import io.legado.app.ui.theme.LegadoTheme
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.mapLatest
import org.intellij.markdown.IElementType
import org.intellij.markdown.MarkdownElementTypes
import org.intellij.markdown.MarkdownTokenTypes
import org.intellij.markdown.ast.ASTNode
import org.intellij.markdown.ast.LeafASTNode
import org.intellij.markdown.flavours.gfm.GFMElementTypes
import org.intellij.markdown.flavours.gfm.GFMFlavourDescriptor
import org.intellij.markdown.flavours.gfm.GFMTokenTypes
import org.intellij.markdown.parser.MarkdownParser
// ---- Markdown parser (lazy singleton) ----
private val flavour by lazy {
GFMFlavourDescriptor(makeHttpsAutoLinks = true, useSafeLinks = true)
}
private val parser by lazy {
MarkdownParser(flavour)
}
private data class MarkdownParseResult(
val content: String,
val astTree: ASTNode,
)
private fun parseMarkdown(content: String): MarkdownParseResult {
return MarkdownParseResult(content, parser.buildMarkdownTreeFromString(content))
}
// ---- Main composable ----
/**
* Markdown renderer using JetBrains intellij-markdown AST parser.
* Parses on a background thread to avoid blocking the UI during streaming.
* Uses AppText for proper theme integration.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
fun MarkdownBlock(
content: String,
modifier: Modifier = Modifier,
style: TextStyle = LegadoTheme.typography.bodyLarge,
onClickLink: ((String) -> Unit)? = null,
) {
var data by remember { mutableStateOf(parseMarkdown(content)) }
val updatedContent by rememberUpdatedState(content)
LaunchedEffect(Unit) {
snapshotFlow { updatedContent }
.distinctUntilChanged()
.mapLatest { parseMarkdown(it) }
.catch { it.printStackTrace() }
.flowOn(Dispatchers.Default)
.collect { data = it }
}
ProvideTextStyle(style) {
Column(modifier = modifier.padding(horizontal = 4.dp)) {
data.astTree.children.fastForEach { child ->
MarkdownNode(
node = child,
content = data.content,
onClickLink = onClickLink,
)
}
}
}
}
// ---- AST node dispatcher ----
@Composable
private fun MarkdownNode(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
listLevel: Int = 0,
) {
when (node.type) {
MarkdownElementTypes.MARKDOWN_FILE -> {
node.children.fastForEach { child ->
MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
}
}
MarkdownElementTypes.PARAGRAPH -> {
MarkdownParagraph(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
}
MarkdownElementTypes.ATX_1, MarkdownElementTypes.ATX_2, MarkdownElementTypes.ATX_3,
MarkdownElementTypes.ATX_4, MarkdownElementTypes.ATX_5, MarkdownElementTypes.ATX_6 -> {
MarkdownHeading(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
}
MarkdownElementTypes.UNORDERED_LIST -> {
MarkdownUnorderedList(node = node, content = content, modifier = modifier, onClickLink = onClickLink, level = listLevel)
}
MarkdownElementTypes.ORDERED_LIST -> {
MarkdownOrderedList(node = node, content = content, modifier = modifier, onClickLink = onClickLink, level = listLevel)
}
GFMTokenTypes.CHECK_BOX -> {
val isChecked = node.getTextInNode(content).trim() == "[x]"
AppText(
text = if (isChecked) "" else "",
color = LegadoTheme.colorScheme.primary,
)
}
MarkdownElementTypes.BLOCK_QUOTE -> {
MarkdownBlockquote(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
}
MarkdownElementTypes.INLINE_LINK -> {
MarkdownInlineLink(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
}
MarkdownElementTypes.EMPH -> {
ProvideTextStyle(TextStyle(fontStyle = FontStyle.Italic)) {
node.children.fastForEach { child ->
MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
}
}
}
MarkdownElementTypes.STRONG -> {
ProvideTextStyle(TextStyle(fontWeight = FontWeight.SemiBold)) {
node.children.fastForEach { child ->
MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
}
}
}
GFMElementTypes.STRIKETHROUGH -> {
AppText(
text = node.getTextInNode(content),
style = LocalTextStyle.current.copy(textDecoration = TextDecoration.LineThrough),
modifier = modifier,
)
}
GFMElementTypes.TABLE -> {
MarkdownTable(node = node, content = content, modifier = modifier, onClickLink = onClickLink)
}
MarkdownTokenTypes.HORIZONTAL_RULE -> {
HorizontalDivider(
modifier = Modifier.padding(vertical = 16.dp),
color = LegadoTheme.colorScheme.outlineVariant,
thickness = 0.5.dp,
)
}
MarkdownElementTypes.IMAGE -> {
val altText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content) ?: ""
val imageUrl = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
if (imageUrl.isNotBlank()) {
val context = LocalContext.current
AppText(
text = "🖼 $altText",
style = LegadoTheme.typography.bodySmall,
color = LegadoTheme.colorScheme.primary,
modifier = modifier.clickable {
context.startActivity(Intent(Intent.ACTION_VIEW, imageUrl.toUri()))
}
)
}
}
GFMElementTypes.INLINE_MATH -> {
val formula = node.getTextInNode(content)
AppText(
text = formula,
style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace, fontSize = 0.9.em),
modifier = modifier.padding(horizontal = 1.dp),
)
}
GFMElementTypes.BLOCK_MATH -> {
val formula = node.getTextInNode(content)
AppText(
text = formula,
style = LocalTextStyle.current.copy(fontFamily = FontFamily.Monospace),
modifier = modifier
.fillMaxWidth()
.padding(vertical = 8.dp)
.clip(RoundedCornerShape(4.dp))
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.padding(8.dp),
)
}
MarkdownElementTypes.CODE_SPAN -> {
val code = node.getTextInNode(content).trim('`')
AppText(
text = code,
style = LocalTextStyle.current.copy(
fontFamily = FontFamily.Monospace,
fontSize = 0.9.em,
),
color = LegadoTheme.colorScheme.primary,
)
}
MarkdownElementTypes.CODE_FENCE -> {
MarkdownCodeFence(node = node, content = content, modifier = modifier)
}
MarkdownElementTypes.CODE_BLOCK -> {
val code = node.getTextInNode(content)
MarkdownCodeBlock(code = code, language = null, modifier = modifier)
}
MarkdownTokenTypes.TEXT -> {
AppText(text = node.getTextInNode(content), modifier = modifier)
}
else -> {
node.children.fastForEach { child ->
MarkdownNode(node = child, content = content, modifier = modifier, onClickLink = onClickLink)
}
}
}
}
// ---- Block renderers ----
@Composable
private fun MarkdownParagraph(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
) {
val colorScheme = LegadoTheme.colorScheme
val textStyle = LocalTextStyle.current
FlowRow(
modifier = modifier.then(
if (node.nextSibling() != null) Modifier.padding(bottom = with(LocalDensity.current) { LocalTextStyle.current.fontSize.toDp() })
else Modifier
)
) {
val annotatedString = remember(content) {
buildAnnotatedString {
node.children.fastForEach { child ->
appendMarkdownInline(
node = child,
content = content,
colorScheme = colorScheme,
onClickLink = onClickLink,
)
}
}
}
AppText(
text = annotatedString,
style = textStyle,
overflow = TextOverflow.Visible,
)
}
}
@Composable
private fun MarkdownHeading(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
) {
val level = when (node.type) {
MarkdownElementTypes.ATX_1 -> 1
MarkdownElementTypes.ATX_2 -> 2
MarkdownElementTypes.ATX_3 -> 3
MarkdownElementTypes.ATX_4 -> 4
MarkdownElementTypes.ATX_5 -> 5
else -> 6
}
val fontSize = when (level) {
1 -> 24.sp; 2 -> 22.sp; 3 -> 20.sp; 4 -> 18.sp; 5 -> 16.sp; else -> 14.sp
}
val verticalPadding = when (level) {
1 -> 16.dp; 2 -> 14.dp; 3 -> 12.dp; 4 -> 10.dp; 5 -> 8.dp; else -> 6.dp
}
val headingStyle = TextStyle(
fontWeight = FontWeight.Bold,
fontSize = fontSize,
lineHeight = fontSize * 1.25f,
)
ProvideTextStyle(headingStyle) {
FlowRow(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
node.children.fastForEach { child ->
if (child.type == MarkdownTokenTypes.ATX_CONTENT) {
MarkdownParagraph(
node = child,
content = content,
modifier = modifier.padding(vertical = verticalPadding),
onClickLink = onClickLink,
)
}
}
}
}
}
@Composable
private fun MarkdownUnorderedList(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
level: Int = 0,
) {
val bullet = when (level % 3) {
0 -> ""; 1 -> ""; else -> ""
}
Column(modifier = modifier.padding(start = (level * 8).dp)) {
node.children.fastForEach { child ->
if (child.type == MarkdownElementTypes.LIST_ITEM) {
MarkdownListItem(
node = child,
content = content,
bulletText = bullet,
onClickLink = onClickLink,
level = level,
)
}
}
}
}
@Composable
private fun MarkdownOrderedList(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
level: Int = 0,
) {
Column(modifier = modifier.padding(start = (level * 8).dp)) {
var index = 1
node.children.fastForEach { child ->
if (child.type == MarkdownElementTypes.LIST_ITEM) {
val numberText = child.findChildOfTypeRecursive(MarkdownTokenTypes.LIST_NUMBER)
?.getTextInNode(content) ?: "$index. "
MarkdownListItem(
node = child,
content = content,
bulletText = numberText,
onClickLink = onClickLink,
level = level,
)
index++
}
}
}
}
@Composable
private fun MarkdownListItem(
node: ASTNode,
content: String,
bulletText: String,
onClickLink: ((String) -> Unit)? = null,
level: Int,
) {
Column {
val (directContent, nestedLists) = separateContentAndLists(node)
if (directContent.isNotEmpty()) {
Row {
AppText(
text = bulletText,
color = LegadoTheme.colorScheme.primary,
modifier = Modifier.alignByBaseline(),
)
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
itemVerticalAlignment = Alignment.CenterVertically,
) {
directContent.fastForEach { contentChild ->
MarkdownNode(
node = contentChild,
content = content,
onClickLink = onClickLink,
listLevel = level,
)
}
}
}
}
nestedLists.fastForEach { nestedList ->
MarkdownNode(
node = nestedList,
content = content,
onClickLink = onClickLink,
listLevel = level + 1,
)
}
}
}
private fun separateContentAndLists(listItemNode: ASTNode): Pair<List<ASTNode>, List<ASTNode>> {
val directContent = mutableListOf<ASTNode>()
val nestedLists = mutableListOf<ASTNode>()
listItemNode.children.fastForEach { child ->
when (child.type) {
MarkdownElementTypes.UNORDERED_LIST, MarkdownElementTypes.ORDERED_LIST -> nestedLists.add(child)
else -> directContent.add(child)
}
}
return directContent to nestedLists
}
@Composable
private fun MarkdownBlockquote(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
) {
ProvideTextStyle(LocalTextStyle.current.copy(fontStyle = FontStyle.Italic)) {
val borderColor = LegadoTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.3f)
val bgColor = LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.2f)
Column(
modifier = Modifier
.drawWithContent {
drawRect(color = bgColor, size = size)
drawContent()
drawRect(color = borderColor, size = Size(10f, size.height))
}
.padding(8.dp)
) {
node.children.fastForEach { child ->
MarkdownNode(node = child, content = content, onClickLink = onClickLink)
}
}
}
}
@Composable
private fun MarkdownInlineLink(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
) {
val linkDest = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
val linkText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content) ?: linkDest
val context = LocalContext.current
AppText(
text = linkText,
color = LegadoTheme.colorScheme.primary,
textDecoration = TextDecoration.Underline,
modifier = modifier.clickable {
if (onClickLink != null) {
onClickLink(linkDest)
} else {
context.startActivity(Intent(Intent.ACTION_VIEW, linkDest.toUri()))
}
}
)
}
@Composable
private fun MarkdownCodeFence(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
) {
val contentStartIndex = node.children.indexOfFirst { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }
if (contentStartIndex == -1) return
val eolElement = node.children.subList(0, contentStartIndex).findLast { it.type == MarkdownTokenTypes.EOL } ?: return
val codeContentStartOffset = eolElement.endOffset
val codeContentEndOffset = node.children.findLast { it.type == MarkdownTokenTypes.CODE_FENCE_CONTENT }?.endOffset ?: return
val code = content.substring(codeContentStartOffset, codeContentEndOffset).trimIndent()
val language = node.findChildOfTypeRecursive(MarkdownTokenTypes.FENCE_LANG)?.getTextInNode(content)
MarkdownCodeBlock(code = code, language = language, modifier = modifier)
}
@Composable
private fun MarkdownCodeBlock(
code: String,
language: String?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
.clip(RoundedCornerShape(8.dp))
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f))
.border(
BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant),
RoundedCornerShape(8.dp)
)
) {
if (!language.isNullOrBlank()) {
AppText(
text = language,
style = LegadoTheme.typography.labelSmall,
color = LegadoTheme.colorScheme.onSurfaceVariant,
modifier = Modifier
.fillMaxWidth()
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.padding(horizontal = 12.dp, vertical = 4.dp),
)
}
AppText(
text = code,
style = LegadoTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace, fontSize = 0.85.em),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
)
}
}
@Composable
private fun MarkdownTable(
node: ASTNode,
content: String,
modifier: Modifier = Modifier,
onClickLink: ((String) -> Unit)? = null,
) {
val headerNode = node.children.find { it.type == GFMElementTypes.HEADER }
val rowNodes = node.children.filter { it.type == GFMElementTypes.ROW }
val columnCount = headerNode?.children?.count { it.type == GFMTokenTypes.CELL } ?: 0
if (columnCount == 0) return
val headerCells = headerNode?.children
?.filter { it.type == GFMTokenTypes.CELL }
?.map { it.getTextInNode(content).trim() } ?: emptyList()
val rows = rowNodes.map { rowNode ->
rowNode.children.filter { it.type == GFMTokenTypes.CELL }.map { it.getTextInNode(content).trim() }
}
Column(
modifier = modifier
.padding(vertical = 8.dp)
.clip(RoundedCornerShape(8.dp))
.border(BorderStroke(0.5.dp, LegadoTheme.colorScheme.outlineVariant), RoundedCornerShape(8.dp))
) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.background(LegadoTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f))
.padding(horizontal = 8.dp, vertical = 6.dp),
) {
headerCells.forEach { cell ->
AppText(
text = cell,
style = LegadoTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
// Rows
rows.forEach { row ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 4.dp),
) {
row.forEachIndexed { index, cell ->
AppText(
text = cell,
style = LegadoTheme.typography.bodySmall,
modifier = Modifier.weight(1f),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
}
HorizontalDivider(
thickness = 0.25.dp,
color = LegadoTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
}
}
}
// ---- Inline AnnotatedString builder ----
private fun androidx.compose.ui.text.AnnotatedString.Builder.appendMarkdownInline(
node: ASTNode,
content: String,
colorScheme: io.legado.app.ui.theme.LegadoColorScheme,
onClickLink: ((String) -> Unit)? = null,
) {
when {
node is LeafASTNode -> {
append(node.getTextInNode(content))
}
node.type == MarkdownElementTypes.EMPH -> {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
node.children.trimSurrounding(MarkdownTokenTypes.EMPH, 1).fastForEach {
appendMarkdownInline(it, content, colorScheme, onClickLink)
}
}
}
node.type == MarkdownElementTypes.STRONG -> {
withStyle(SpanStyle(fontWeight = FontWeight.SemiBold)) {
node.children.trimSurrounding(MarkdownTokenTypes.EMPH, 2).fastForEach {
appendMarkdownInline(it, content, colorScheme, onClickLink)
}
}
}
node.type == GFMElementTypes.STRIKETHROUGH -> {
withStyle(SpanStyle(textDecoration = TextDecoration.LineThrough)) {
node.children.trimSurrounding(GFMTokenTypes.TILDE, 2).fastForEach {
appendMarkdownInline(it, content, colorScheme, onClickLink)
}
}
}
node.type == MarkdownElementTypes.INLINE_LINK -> {
val linkDest = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_DESTINATION)?.getTextInNode(content) ?: ""
val linkText = node.findChildOfTypeRecursive(MarkdownElementTypes.LINK_TEXT)?.getTextInNode(content)
?.trim { it == '[' || it == ']' } ?: linkDest
withLink(LinkAnnotation.Url(linkDest)) {
withStyle(SpanStyle(color = colorScheme.primary, textDecoration = TextDecoration.Underline)) {
append(linkText)
}
}
}
node.type == MarkdownElementTypes.AUTOLINK -> {
val link = node.children.trimSurrounding(MarkdownTokenTypes.LT, 1).trimSurrounding(MarkdownTokenTypes.GT, 1)
link.fastForEach { l ->
withLink(LinkAnnotation.Url(l.getTextInNode(content))) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(l.getTextInNode(content))
}
}
}
}
node.type == MarkdownElementTypes.CODE_SPAN -> {
val code = node.getTextInNode(content).trim('`')
withStyle(
SpanStyle(
fontFamily = FontFamily.Monospace,
fontSize = 0.9.em,
color = colorScheme.primary,
)
) {
append(' ')
append(code)
append(' ')
}
}
node.type == GFMElementTypes.INLINE_MATH -> {
val formula = node.getTextInNode(content)
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, fontSize = 0.95.em)) {
append(formula)
}
}
node.type == GFMTokenTypes.GFM_AUTOLINK -> {
val link = node.getTextInNode(content)
withLink(LinkAnnotation.Url(link)) {
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) {
append(link)
}
}
}
else -> {
node.children.fastForEach { child ->
appendMarkdownInline(child, content, colorScheme, onClickLink)
}
}
}
}
// ---- Utility extensions ----
private fun ASTNode.getTextInNode(text: String): String {
return text.substring(startOffset, endOffset)
}
private fun ASTNode.nextSibling(): ASTNode? {
val siblings = this.parent?.children ?: return null
for (i in siblings.indices) {
if (siblings[i] == this && i + 1 < siblings.size) {
return siblings[i + 1]
}
}
return null
}
private fun ASTNode.findChildOfTypeRecursive(vararg types: IElementType): ASTNode? {
if (this.type in types) return this
for (child in children) {
val result = child.findChildOfTypeRecursive(*types)
if (result != null) return result
}
return null
}
private fun List<ASTNode>.trimSurrounding(type: IElementType, size: Int): List<ASTNode> {
if (isEmpty() || size <= 0) return this
var start = 0
var end = this.size
var trimmed = 0
while (start < end && trimmed < size && this[start].type == type) { start++; trimmed++ }
trimmed = 0
while (end > start && trimmed < size && this[end - 1].type == type) { end--; trimmed++ }
return this.subList(start, end)
}
/** Fast forEach without inline overhead for List<ASTNode>. */
private inline fun <T> List<T>.fastForEach(action: (T) -> Unit) {
for (element in this) action(element)
}
@@ -1877,6 +1877,96 @@
<string name="return_to_original">返回原文</string>
<string name="translation_progress">翻译中 %1$d%%</string>
<string name="translation_started">开始进行翻译,请耐心等待……</string>
<string name="translation_app_ai_provider">使用应用 AI 接口</string>
<string name="translation_app_ai_provider_summary">模型、API Key 和协议在 AI 设置中配置。</string>
<string name="ai_config">AI 设置</string>
<string name="ai_profile_database">配置库</string>
<string name="ai_model_database">模型</string>
<string name="ai_provider_database">供应商</string>
<string name="ai_tasks">任务</string>
<string name="ai_chapter_summary">章节梗概</string>
<string name="ai_skills">Skills</string>
<string name="ai_new_skill">新建 Skill</string>
<string name="ai_chat">AI 对话</string>
<string name="ai_recent_chats">最近对话</string>
<string name="ai_new_chat">新建对话</string>
<string name="ai_replying">AI 正在回复…</string>
<string name="ai_send">发送</string>
<string name="ai_chat_input_hint">输入问题…</string>
<string name="ai_chat_empty">开始一段对话</string>
<string name="ai_regenerate">重新生成</string>
<string name="ai_thinking_mode">思考模式</string>
<string name="ai_thinking_strength">强度</string>
<string name="ai_thinking_off">关闭</string>
<string name="ai_thinking_auto">自动</string>
<string name="ai_thinking_deep">深度</string>
<string name="ai_you"></string>
<string name="ai_assistant">助手</string>
<string name="ai_reasoning_trace">思考</string>
<string name="ai_reasoning_streaming">思考中</string>
<string name="ai_reasoning_done">已思考</string>
<string name="ai_tool_trace">工具</string>
<string name="ai_tool_confirmation">确认工具操作</string>
<string name="ai_book_results">相关书籍</string>
<string name="ai_scroll_to_bottom">回到底部</string>
<string name="ai_thinking_seconds">思考中 %.1f秒</string>
<string name="ai_thinking_show_more">展开剩余 %d 步</string>
<string name="ai_thinking_collapse">收起</string>
<string name="ai_tool_done">完成</string>
<string name="ai_tool_pending">等待确认</string>
<string name="ai_tool_running">执行中</string>
<string name="ai_tool_input">输入:</string>
<string name="ai_tool_output">输出:</string>
<string name="ai_current_provider">当前供应商</string>
<string name="ai_current_model">当前模型</string>
<string name="ai_current">当前</string>
<string name="ai_model_not_configured">未配置模型</string>
<string name="ai_new_provider">新建供应商</string>
<string name="ai_new_model">新建模型</string>
<string name="ai_new_profile">新建配置</string>
<string name="ai_provider">供应商</string>
<string name="ai_provider_edit">供应商配置</string>
<string name="ai_provider_name">供应商名称</string>
<string name="ai_protocol">协议</string>
<string name="ai_provider_preset">供应商预设</string>
<string name="ai_custom_provider">自定义供应商</string>
<string name="ai_base_url">基础 URL</string>
<string name="ai_models_url">模型列表 URL</string>
<string name="ai_api_key">API Key</string>
<string name="ai_api_key_summary">保存在本地。留空会保留已保存的密钥。</string>
<string name="ai_model_config">模型</string>
<string name="ai_model_edit">模型配置</string>
<string name="ai_provider_models">供应商模型</string>
<string name="ai_fetch_models">获取模型列表</string>
<string name="ai_fetch_and_save_models">获取并保存模型列表</string>
<string name="ai_no_provider_configured">请先新建供应商</string>
<string name="ai_no_models_imported">未导入模型</string>
<string name="ai_select_model">选择模型</string>
<string name="ai_add_model_manually">手动添加模型</string>
<string name="ai_not_set">未设置</string>
<string name="ai_current_value">当前:%1$s</string>
<string name="ai_model_name">模型名称</string>
<string name="ai_model_id">模型 ID</string>
<string name="ai_context_window">上下文长度</string>
<string name="ai_max_output_tokens">最大输出 Tokens</string>
<string name="ai_temperature">温度</string>
<string name="ai_model_actions">操作</string>
<string name="ai_save_default_model">保存默认模型</string>
<string name="ai_save_provider">保存供应商</string>
<string name="ai_save_and_sync_models">保存并同步模型</string>
<string name="ai_save_model">保存模型</string>
<string name="ai_advanced">高级</string>
<string name="ai_models_url_summary">可选。留空时使用该协议默认的模型列表端点。</string>
<string name="ai_translation_task">翻译任务</string>
<string name="ai_translation_target_language">目标语言</string>
<string name="ai_max_input_chars">最大输入字符数</string>
<string name="ai_concurrent_requests">并发请求数</string>
<string name="ai_retry_count">重试次数</string>
<string name="ai_profile_status">档案</string>
<string name="ai_save_profile">保存默认档案</string>
<string name="ai_test_connection">测试连接</string>
<string name="ai_profile_counts">供应商:%1$d,模型:%2$d,预设:%3$d</string>
<string name="read_menu_bg_color_summary">阅读顶栏和底栏背景色</string>
<string name="read_menu_color_source">色板来源</string>
<string name="container_background_color">容器背景色</string>
+90
View File
@@ -1819,6 +1819,96 @@
<string name="return_to_original">Return to Original</string>
<string name="translation_progress">Translating %1$d%%</string>
<string name="translation_started">Translation started, please wait…</string>
<string name="translation_app_ai_provider">Use App AI Interface</string>
<string name="translation_app_ai_provider_summary">Model, API key, and protocol are configured in AI Settings.</string>
<string name="ai_config">AI Settings</string>
<string name="ai_profile_database">Profile Database</string>
<string name="ai_model_database">Models</string>
<string name="ai_provider_database">Providers</string>
<string name="ai_tasks">Tasks</string>
<string name="ai_chapter_summary">Chapter Summary</string>
<string name="ai_skills">Skills</string>
<string name="ai_new_skill">New Skill</string>
<string name="ai_chat">AI Chat</string>
<string name="ai_recent_chats">Recent Chats</string>
<string name="ai_new_chat">New Chat</string>
<string name="ai_replying">AI is replying…</string>
<string name="ai_send">Send</string>
<string name="ai_chat_input_hint">Ask anything…</string>
<string name="ai_chat_empty">Start a conversation</string>
<string name="ai_regenerate">Regenerate</string>
<string name="ai_thinking_mode">Thinking</string>
<string name="ai_thinking_strength">Strength</string>
<string name="ai_thinking_off">Off</string>
<string name="ai_thinking_auto">Auto</string>
<string name="ai_thinking_deep">Deep</string>
<string name="ai_you">You</string>
<string name="ai_assistant">Assistant</string>
<string name="ai_reasoning_trace">Reasoning</string>
<string name="ai_reasoning_streaming">Thinking</string>
<string name="ai_reasoning_done">Thought</string>
<string name="ai_tool_trace">Tools</string>
<string name="ai_tool_confirmation">Confirm tool action</string>
<string name="ai_book_results">Related books</string>
<string name="ai_scroll_to_bottom">Scroll to bottom</string>
<string name="ai_thinking_seconds">Thinking %.1fs</string>
<string name="ai_thinking_show_more">Show %d more steps</string>
<string name="ai_thinking_collapse">Collapse</string>
<string name="ai_tool_done">Done</string>
<string name="ai_tool_pending">Pending</string>
<string name="ai_tool_running">Running</string>
<string name="ai_tool_input">Input:</string>
<string name="ai_tool_output">Output:</string>
<string name="ai_current_provider">Current Provider</string>
<string name="ai_current_model">Current Model</string>
<string name="ai_current">Current</string>
<string name="ai_model_not_configured">No model configured</string>
<string name="ai_new_provider">New Provider</string>
<string name="ai_new_model">New Model</string>
<string name="ai_new_profile">New Profile</string>
<string name="ai_provider">Provider</string>
<string name="ai_provider_edit">Provider Configuration</string>
<string name="ai_provider_name">Provider Name</string>
<string name="ai_protocol">Protocol</string>
<string name="ai_provider_preset">Provider Preset</string>
<string name="ai_custom_provider">Custom Provider</string>
<string name="ai_base_url">Base URL</string>
<string name="ai_models_url">Models URL</string>
<string name="ai_api_key">API Key</string>
<string name="ai_api_key_summary">Stored locally. Leave blank to keep the saved key.</string>
<string name="ai_model_config">Model</string>
<string name="ai_model_edit">Model Configuration</string>
<string name="ai_provider_models">Provider Models</string>
<string name="ai_fetch_models">Fetch Models</string>
<string name="ai_fetch_and_save_models">Fetch And Save Models</string>
<string name="ai_no_provider_configured">Create a provider first</string>
<string name="ai_no_models_imported">No models imported</string>
<string name="ai_select_model">Select Model</string>
<string name="ai_add_model_manually">Add Model Manually</string>
<string name="ai_not_set">Not Set</string>
<string name="ai_current_value">Current: %1$s</string>
<string name="ai_model_name">Model Name</string>
<string name="ai_model_id">Model ID</string>
<string name="ai_context_window">Context Window</string>
<string name="ai_max_output_tokens">Max Output Tokens</string>
<string name="ai_temperature">Temperature</string>
<string name="ai_model_actions">Actions</string>
<string name="ai_save_default_model">Save Default Model</string>
<string name="ai_save_provider">Save Provider</string>
<string name="ai_save_and_sync_models">Save And Sync Models</string>
<string name="ai_save_model">Save Model</string>
<string name="ai_advanced">Advanced</string>
<string name="ai_models_url_summary">Optional. Leave blank to use the protocol default models endpoint.</string>
<string name="ai_translation_task">Translation Task</string>
<string name="ai_translation_target_language">Target Language</string>
<string name="ai_max_input_chars">Max Input Characters</string>
<string name="ai_concurrent_requests">Concurrent Requests</string>
<string name="ai_retry_count">Retry Count</string>
<string name="ai_profile_status">Profile</string>
<string name="ai_save_profile">Save Default Profile</string>
<string name="ai_test_connection">Test Connection</string>
<string name="ai_profile_counts">Providers: %1$d, models: %2$d, presets: %3$d</string>
<string name="homepage_load_more_failed">Failed to load more: %1$s</string>
<string name="homepage_module_duplicate_infinite">This group already has an infinite loading module</string>
<string name="homepage_quick_actions">Quick Actions</string>
+2 -2
View File
@@ -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" }