feat: ai底座
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user