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

feat(ai): 添加AI对话、供应商、模型的删除功能

- AI对话:在最近对话抽屉中长按触发删除,带确认框
- AI供应商:配置页底部添加删除选项,删除后级联删除关联模型
- AI模型:模型编辑弹窗底部添加删除选项
- 数据层:AiProfileDao/Gateway/Repository新增deleteProvider/deleteModel方法
- 补充中英文字符串资源
HapeLee 4 месяцев назад
Родитель
Сommit
a30f61bfd9

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

@@ -59,6 +59,15 @@ interface AiProfileDao {
     @Update(onConflict = OnConflictStrategy.REPLACE)
     @Update(onConflict = OnConflictStrategy.REPLACE)
     suspend fun updatePreset(preset: AiTaskPreset)
     suspend fun updatePreset(preset: AiTaskPreset)
 
 
+    @Query("DELETE FROM ai_provider_profiles WHERE id = :providerId")
+    suspend fun deleteProvider(providerId: String)
+
+    @Query("DELETE FROM ai_model_profiles WHERE id = :modelId")
+    suspend fun deleteModel(modelId: String)
+
+    @Query("DELETE FROM ai_model_profiles WHERE providerId = :providerId")
+    suspend fun deleteModelsByProvider(providerId: String)
+
     @Transaction
     @Transaction
     suspend fun upsertProfile(
     suspend fun upsertProfile(
         provider: AiProviderProfile,
         provider: AiProviderProfile,

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

@@ -150,6 +150,15 @@ class AiProfileRepository(
             ?: error("Failed to save default model")
             ?: error("Failed to save default model")
     }
     }
 
 
+    override suspend fun deleteProvider(providerId: String) = withContext(Dispatchers.IO) {
+        aiProfileDao.deleteModelsByProvider(providerId)
+        aiProfileDao.deleteProvider(providerId)
+    }
+
+    override suspend fun deleteModel(modelId: String) = withContext(Dispatchers.IO) {
+        aiProfileDao.deleteModel(modelId)
+    }
+
     override suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig = withContext(Dispatchers.IO) {
     override suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig = withContext(Dispatchers.IO) {
         require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
         require(draft.baseUrl.isNotBlank()) { "Base URL is required" }
         require(draft.modelId.isNotBlank()) { "Model is required" }
         require(draft.modelId.isNotBlank()) { "Model is required" }

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

@@ -23,4 +23,6 @@ interface AiProfileGateway {
     suspend fun importProviderModels(providerId: String, models: List<AiAvailableModel>): List<AiModelProfile>
     suspend fun importProviderModels(providerId: String, models: List<AiAvailableModel>): List<AiModelProfile>
     suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig
     suspend fun setDefaultModel(modelProfileId: String): AiTaskPresetConfig
     suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig
     suspend fun saveDefaultChatProfile(draft: AiProfileDraft): AiTaskPresetConfig
+    suspend fun deleteProvider(providerId: String)
+    suspend fun deleteModel(modelId: String)
 }
 }

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

@@ -91,6 +91,7 @@ import io.legado.app.domain.model.AiReasoningLevel
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.theme.LegadoTheme
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppScaffold
 import io.legado.app.ui.widget.components.AppTextField
 import io.legado.app.ui.widget.components.AppTextField
+import io.legado.app.ui.widget.components.alert.AppAlertDialog
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.MediumTonalButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.button.series.SmallPlainButton
 import io.legado.app.ui.widget.components.card.GlassCard
 import io.legado.app.ui.widget.components.card.GlassCard
@@ -203,6 +204,9 @@ fun AiChatScreen(
                 onSelectConversation = {
                 onSelectConversation = {
                     onIntent(AiChatIntent.SelectConversation(it))
                     onIntent(AiChatIntent.SelectConversation(it))
                     scope.launch { drawerState.close() }
                     scope.launch { drawerState.close() }
+                },
+                onDeleteConversation = {
+                    onIntent(AiChatIntent.DeleteConversation(it))
                 }
                 }
             )
             )
         }
         }
@@ -612,10 +616,12 @@ private fun PendingToolConfirmationCard(
 private fun RecentChatsDrawer(
 private fun RecentChatsDrawer(
     conversations: List<AiChatConversationUi>,
     conversations: List<AiChatConversationUi>,
     onNewChat: () -> Unit,
     onNewChat: () -> Unit,
-    onSelectConversation: (String) -> Unit
+    onSelectConversation: (String) -> Unit,
+    onDeleteConversation: (String) -> Unit
 ) {
 ) {
     var searchQuery by rememberSaveable { mutableStateOf("") }
     var searchQuery by rememberSaveable { mutableStateOf("") }
     var showSearch by rememberSaveable { mutableStateOf(false) }
     var showSearch by rememberSaveable { mutableStateOf(false) }
+    var conversationToDelete by remember { mutableStateOf<AiChatConversationUi?>(null) }
     val searchFocusRequester = remember { FocusRequester() }
     val searchFocusRequester = remember { FocusRequester() }
     val filteredConversations = remember(conversations, searchQuery) {
     val filteredConversations = remember(conversations, searchQuery) {
         if (searchQuery.isBlank()) {
         if (searchQuery.isBlank()) {
@@ -692,6 +698,7 @@ private fun RecentChatsDrawer(
                 NormalCard(
                 NormalCard(
                     modifier = Modifier.fillMaxWidth(),
                     modifier = Modifier.fillMaxWidth(),
                     onClick = { onSelectConversation(conversation.id) },
                     onClick = { onSelectConversation(conversation.id) },
+                    onLongClick = { conversationToDelete = conversation },
                     containerColor = if (isSelected) {
                     containerColor = if (isSelected) {
                         LegadoTheme.colorScheme.primaryContainer
                         LegadoTheme.colorScheme.primaryContainer
                     } else {
                     } else {
@@ -724,6 +731,20 @@ private fun RecentChatsDrawer(
             }
             }
         }
         }
     }
     }
+
+    AppAlertDialog(
+        show = conversationToDelete != null,
+        onDismissRequest = { conversationToDelete = null },
+        title = stringResource(R.string.delete),
+        text = stringResource(R.string.sure_del),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = {
+            conversationToDelete?.let { onDeleteConversation(it.id) }
+            conversationToDelete = null
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { conversationToDelete = null }
+    )
 }
 }
 
 
 private fun formatRelativeTime(timestamp: Long): String {
 private fun formatRelativeTime(timestamp: Long): String {

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

@@ -81,9 +81,12 @@ sealed interface AiProviderEditIntent {
     data object SaveEditingModel : AiProviderEditIntent
     data object SaveEditingModel : AiProviderEditIntent
     data object SaveProvider : AiProviderEditIntent
     data object SaveProvider : AiProviderEditIntent
     data object SyncModels : AiProviderEditIntent
     data object SyncModels : AiProviderEditIntent
+    data object DeleteProvider : AiProviderEditIntent
+    data class DeleteModel(val modelProfileId: String) : AiProviderEditIntent
 }
 }
 
 
 sealed interface AiProviderEditEffect {
 sealed interface AiProviderEditEffect {
     data class ShowMessage(val message: String) : AiProviderEditEffect
     data class ShowMessage(val message: String) : AiProviderEditEffect
     data object NavigateBack : AiProviderEditEffect
     data object NavigateBack : AiProviderEditEffect
+    data object NavigateBackAfterDelete : AiProviderEditEffect
 }
 }

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

@@ -90,12 +90,15 @@ fun AiProviderEditScreen(
     var showApiKeyDialog by remember { mutableStateOf(false) }
     var showApiKeyDialog by remember { mutableStateOf(false) }
     var apiKeyDraft by remember { mutableStateOf("") }
     var apiKeyDraft by remember { mutableStateOf("") }
     var apiKeyVisible by remember { mutableStateOf(false) }
     var apiKeyVisible by remember { mutableStateOf(false) }
+    var showDeleteProviderDialog by remember { mutableStateOf(false) }
+    var showDeleteModelDialog by remember { mutableStateOf<String?>(null) }
 
 
     LaunchedEffect(Unit) {
     LaunchedEffect(Unit) {
         effects.collectLatest { effect ->
         effects.collectLatest { effect ->
             when (effect) {
             when (effect) {
                 is AiProviderEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
                 is AiProviderEditEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.message)
                 AiProviderEditEffect.NavigateBack -> onBackClick()
                 AiProviderEditEffect.NavigateBack -> onBackClick()
+                AiProviderEditEffect.NavigateBackAfterDelete -> onBackClick()
             }
             }
         }
         }
     }
     }
@@ -200,6 +203,15 @@ fun AiProviderEditScreen(
                     )
                     )
                 }
                 }
             }
             }
+
+            if (state.providerId != null) {
+                item {
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_delete_provider),
+                        onClick = { showDeleteProviderDialog = true }
+                    )
+                }
+            }
         }
         }
     }
     }
 
 
@@ -284,6 +296,15 @@ fun AiProviderEditScreen(
                     label = stringResource(R.string.ai_temperature),
                     label = stringResource(R.string.ai_temperature),
                     keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
                     keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal)
                 )
                 )
+                if (model.modelProfileId != null) {
+                    Spacer(modifier = Modifier.height(16.dp))
+                    ClickableSettingItem(
+                        title = stringResource(R.string.ai_delete_model),
+                        onClick = {
+                            showDeleteModelDialog = model.modelProfileId
+                        }
+                    )
+                }
             }
             }
         },
         },
         confirmText = stringResource(R.string.ai_save_model),
         confirmText = stringResource(R.string.ai_save_model),
@@ -291,6 +312,34 @@ fun AiProviderEditScreen(
         dismissText = stringResource(R.string.cancel),
         dismissText = stringResource(R.string.cancel),
         onDismiss = { onIntent(AiProviderEditIntent.DismissModelEditor) }
         onDismiss = { onIntent(AiProviderEditIntent.DismissModelEditor) }
     )
     )
+
+    AppAlertDialog(
+        show = showDeleteProviderDialog,
+        onDismissRequest = { showDeleteProviderDialog = false },
+        title = stringResource(R.string.ai_delete_provider),
+        text = stringResource(R.string.ai_delete_provider_confirm),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = {
+            onIntent(AiProviderEditIntent.DeleteProvider)
+            showDeleteProviderDialog = false
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showDeleteProviderDialog = false }
+    )
+
+    AppAlertDialog(
+        show = showDeleteModelDialog != null,
+        onDismissRequest = { showDeleteModelDialog = null },
+        title = stringResource(R.string.ai_delete_model),
+        text = stringResource(R.string.ai_delete_model_confirm),
+        confirmText = stringResource(R.string.delete),
+        onConfirm = {
+            showDeleteModelDialog?.let { onIntent(AiProviderEditIntent.DeleteModel(it)) }
+            showDeleteModelDialog = null
+        },
+        dismissText = stringResource(R.string.cancel),
+        onDismiss = { showDeleteModelDialog = null }
+    )
 }
 }
 
 
 private fun formatFetchedLimit(contextWindow: Int, maxOutputTokens: Int): String? {
 private fun formatFetchedLimit(contextWindow: Int, maxOutputTokens: Int): String? {

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

@@ -116,6 +116,8 @@ class AiProviderEditViewModel(
             AiProviderEditIntent.SaveEditingModel -> saveEditingModel()
             AiProviderEditIntent.SaveEditingModel -> saveEditingModel()
             AiProviderEditIntent.SaveProvider -> saveProvider()
             AiProviderEditIntent.SaveProvider -> saveProvider()
             AiProviderEditIntent.SyncModels -> syncModels()
             AiProviderEditIntent.SyncModels -> syncModels()
+            AiProviderEditIntent.DeleteProvider -> deleteProvider()
+            is AiProviderEditIntent.DeleteModel -> deleteModel(intent.modelProfileId)
         }
         }
     }
     }
 
 
@@ -240,6 +242,33 @@ class AiProviderEditViewModel(
         }
         }
     }
     }
 
 
+    private fun deleteProvider() {
+        val providerId = _uiState.value.providerId ?: return
+        viewModelScope.launch {
+            runCatching {
+                aiProfileGateway.deleteProvider(providerId)
+            }.onSuccess {
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage("AI provider deleted"))
+                _effects.tryEmit(AiProviderEditEffect.NavigateBackAfterDelete)
+            }.onFailure { error ->
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to delete AI provider"))
+            }
+        }
+    }
+
+    private fun deleteModel(modelProfileId: String) {
+        viewModelScope.launch {
+            runCatching {
+                aiProfileGateway.deleteModel(modelProfileId)
+            }.onSuccess {
+                _uiState.update { it.copy(editingModel = null) }
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage("AI model deleted"))
+            }.onFailure { error ->
+                _effects.tryEmit(AiProviderEditEffect.ShowMessage(error.message ?: "Failed to delete AI model"))
+            }
+        }
+    }
+
     private fun applyFetchedModels(providerId: String, models: List<AiAvailableModel>) {
     private fun applyFetchedModels(providerId: String, models: List<AiAvailableModel>) {
         val options = models.map {
         val options = models.map {
             AiFetchedModelUi(
             AiFetchedModelUi(

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

@@ -1994,6 +1994,10 @@
     <string name="ai_save_and_sync_models">保存并同步模型</string>
     <string name="ai_save_and_sync_models">保存并同步模型</string>
     <string name="ai_save_model">保存模型</string>
     <string name="ai_save_model">保存模型</string>
     <string name="ai_advanced">高级</string>
     <string name="ai_advanced">高级</string>
+    <string name="ai_delete_provider">删除供应商</string>
+    <string name="ai_delete_provider_confirm">确定要删除此供应商及其所有模型吗?</string>
+    <string name="ai_delete_model">删除模型</string>
+    <string name="ai_delete_model_confirm">确定要删除此模型吗?</string>
     <string name="ai_models_url_summary">可选。留空时使用该协议默认的模型列表端点。</string>
     <string name="ai_models_url_summary">可选。留空时使用该协议默认的模型列表端点。</string>
     <string name="ai_translation_task">翻译任务</string>
     <string name="ai_translation_task">翻译任务</string>
     <string name="ai_translation_target_language">目标语言</string>
     <string name="ai_translation_target_language">目标语言</string>

+ 4 - 0
app/src/main/res/values-zh-rHK/strings.xml

@@ -1423,6 +1423,10 @@
     <string name="add_tag">添加標籤</string>
     <string name="add_tag">添加標籤</string>
     <string name="ai_add_model_manually">手動添加模型</string>
     <string name="ai_add_model_manually">手動添加模型</string>
     <string name="ai_advanced">進階設定</string>
     <string name="ai_advanced">進階設定</string>
+    <string name="ai_delete_provider">刪除供應商</string>
+    <string name="ai_delete_provider_confirm">確定要刪除此供應商及其所有模型嗎?</string>
+    <string name="ai_delete_model">刪除模型</string>
+    <string name="ai_delete_model_confirm">確定要刪除此模型嗎?</string>
     <string name="ai_api_key">API Key</string>
     <string name="ai_api_key">API Key</string>
     <string name="ai_api_key_summary">儲存在本機。留空則保留已儲存的金鑰。</string>
     <string name="ai_api_key_summary">儲存在本機。留空則保留已儲存的金鑰。</string>
     <string name="ai_assistant">助手</string>
     <string name="ai_assistant">助手</string>

+ 4 - 0
app/src/main/res/values-zh-rTW/strings.xml

@@ -2000,6 +2000,10 @@
     <string name="ai_save_and_sync_models">儲存並同步模型</string>
     <string name="ai_save_and_sync_models">儲存並同步模型</string>
     <string name="ai_save_model">儲存模型</string>
     <string name="ai_save_model">儲存模型</string>
     <string name="ai_advanced">進階</string>
     <string name="ai_advanced">進階</string>
+    <string name="ai_delete_provider">刪除供應商</string>
+    <string name="ai_delete_provider_confirm">確定要刪除此供應商及其所有模型嗎?</string>
+    <string name="ai_delete_model">刪除模型</string>
+    <string name="ai_delete_model_confirm">確定要刪除此模型嗎?</string>
     <string name="ai_models_url_summary">選填。留空則使用協定預設的模型端點。</string>
     <string name="ai_models_url_summary">選填。留空則使用協定預設的模型端點。</string>
     <string name="ai_translation_task">翻譯任務</string>
     <string name="ai_translation_task">翻譯任務</string>
     <string name="ai_translation_target_language">目標語言</string>
     <string name="ai_translation_target_language">目標語言</string>

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

@@ -1925,6 +1925,10 @@
     <string name="ai_save_model">Save Model</string>
     <string name="ai_save_model">Save Model</string>
     <string name="ai_advanced">Advanced</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_models_url_summary">Optional. Leave blank to use the protocol default models endpoint.</string>
+    <string name="ai_delete_provider">Delete Provider</string>
+    <string name="ai_delete_provider_confirm">Are you sure you want to delete this provider and all its models?</string>
+    <string name="ai_delete_model">Delete Model</string>
+    <string name="ai_delete_model_confirm">Are you sure you want to delete this model?</string>
     <string name="ai_translation_task">Translation Task</string>
     <string name="ai_translation_task">Translation Task</string>
     <string name="ai_translation_target_language">Target Language</string>
     <string name="ai_translation_target_language">Target Language</string>
     <string name="ai_max_input_chars">Max Input Characters</string>
     <string name="ai_max_input_chars">Max Input Characters</string>