fix: allow deleting desktop providers

This commit is contained in:
boos
2026-06-29 15:22:40 +08:00
parent 7dd099b2e9
commit 0515b90b6c
4 changed files with 122 additions and 17 deletions
@@ -5,9 +5,12 @@ use std::collections::HashSet;
use crate::crypto::{decrypt_key, encrypt_key};
use crate::entity::{builtin_model_deletions, models, provider_keys, providers};
use crate::error::{BoosApiError, Result};
use crate::repo::settings;
use crate::types::*;
use crate::utils::{gen_id, now_ts};
const DELETED_BUILTIN_PROVIDERS_SETTING: &str = "deleted_builtin_providers";
fn parse_provider_type(s: &str) -> ProviderType {
match s {
"openai" => ProviderType::OpenAI,
@@ -286,6 +289,47 @@ fn key_prefix(raw_key: &str) -> String {
}
}
async fn list_deleted_builtin_provider_ids(db: &DatabaseConnection) -> Result<HashSet<String>> {
let Some(raw) = settings::get_setting(db, DELETED_BUILTIN_PROVIDERS_SETTING).await? else {
return Ok(HashSet::new());
};
Ok(serde_json::from_str::<Vec<String>>(&raw)
.unwrap_or_default()
.into_iter()
.filter(|id| !id.trim().is_empty())
.collect())
}
async fn save_deleted_builtin_provider_ids(
db: &DatabaseConnection,
deleted_ids: &HashSet<String>,
) -> Result<()> {
let mut values: Vec<String> = deleted_ids.iter().cloned().collect();
values.sort();
let raw = serde_json::to_string(&values).unwrap_or_else(|_| "[]".to_string());
settings::set_setting(db, DELETED_BUILTIN_PROVIDERS_SETTING, &raw).await
}
pub async fn mark_builtin_provider_deleted(
db: &DatabaseConnection,
builtin_id: &str,
) -> Result<()> {
if !crate::db::get_builtin_providers()
.iter()
.any(|provider| provider.builtin_id == builtin_id)
{
return Err(BoosApiError::NotFound(format!(
"Built-in provider {}",
builtin_id
)));
}
let mut deleted_ids = list_deleted_builtin_provider_ids(db).await?;
deleted_ids.insert(builtin_id.to_string());
save_deleted_builtin_provider_ids(db, &deleted_ids).await
}
pub async fn import_provider_from_deep_link(
db: &DatabaseConnection,
master_key: &[u8; 32],
@@ -354,11 +398,20 @@ pub async fn import_provider_from_deep_link(
}
pub async fn delete_provider(db: &DatabaseConnection, id: &str) -> Result<()> {
let builtin_id = providers::Entity::find_by_id(id)
.one(db)
.await?
.ok_or_else(|| BoosApiError::NotFound(format!("Provider {}", id)))?
.builtin_id;
let result = providers::Entity::delete_by_id(id).exec(db).await?;
if result.rows_affected == 0 {
return Err(BoosApiError::NotFound(format!("Provider {}", id)));
}
if let Some(builtin_id) = builtin_id {
mark_builtin_provider_deleted(db, &builtin_id).await?;
}
Ok(())
}
@@ -833,6 +886,7 @@ async fn sync_missing_builtin_models(
pub async fn list_providers_merged(db: &DatabaseConnection) -> Result<Vec<ProviderConfig>> {
let mut db_providers = list_providers(db).await?;
let builtins = crate::db::get_builtin_providers();
let deleted_builtin_ids = list_deleted_builtin_provider_ids(db).await?;
if sync_missing_builtin_models(db, &builtins, &db_providers).await? {
db_providers = list_providers(db).await?;
}
@@ -840,6 +894,10 @@ pub async fn list_providers_merged(db: &DatabaseConnection) -> Result<Vec<Provid
let mut result = Vec::new();
for bp in &builtins {
if deleted_builtin_ids.contains(bp.builtin_id) {
continue;
}
if let Some(db_prov) = db_providers
.iter()
.find(|p| p.builtin_id.as_deref() == Some(bp.builtin_id))
@@ -1106,6 +1164,27 @@ mod tests {
);
}
#[tokio::test]
async fn deleted_builtin_provider_stays_hidden_from_merged_list() {
let h = create_test_pool().await.unwrap();
let db = &h.conn;
let providers = list_providers_merged(db).await.unwrap();
assert!(providers
.iter()
.any(|provider| provider.builtin_id.as_deref() == Some("boosapi")));
mark_builtin_provider_deleted(db, "boosapi").await.unwrap();
let providers = list_providers_merged(db).await.unwrap();
assert!(
!providers
.iter()
.any(|provider| provider.builtin_id.as_deref() == Some("boosapi")),
"deleted built-in provider should not reappear after refresh"
);
}
#[tokio::test]
async fn provider_deep_link_import_creates_provider_and_key() {
let h = create_test_pool().await.unwrap();
+7 -3
View File
@@ -77,9 +77,13 @@ pub async fn update_provider(
#[tauri::command]
pub async fn delete_provider(state: State<'_, AppState>, id: String) -> Result<(), String> {
// Virtual built-in providers have no DB row — deletion is a no-op (they'll reappear)
if id.starts_with("builtin_") {
return Ok(());
if let Some(builtin_id) = id.strip_prefix("builtin_") {
return boosapi_core::repo::provider::mark_builtin_provider_deleted(
&state.sea_db,
builtin_id,
)
.await
.map_err(|e| e.to_string());
}
boosapi_core::repo::provider::delete_provider(&state.sea_db, &id)
.await
@@ -1107,7 +1107,6 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
checkedChildren={t('common.enabled')}
unCheckedChildren={t('common.disabled')}
/>
{!provider.builtin_id && (
<Popconfirm
title={t('settings.deleteProviderConfirm')}
onConfirm={async () => {
@@ -1118,9 +1117,15 @@ export function ProviderDetail({ providerId }: ProviderDetailProps) {
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
>
<Button type="text" size="small" danger icon={<Trash2 size={14} />} />
<Button
type="text"
size="small"
danger
aria-label={t('settings.deleteProvider')}
title={t('settings.deleteProvider')}
icon={<Trash2 size={14} />}
/>
</Popconfirm>
)}
</Space>
</div>
@@ -231,6 +231,23 @@ describe('ProviderDetail', () => {
expect(screen.getByText('settings.urlPreviewLabelhttps://open.bigmodel.cn/api/paas/v4/chat/completions')).toBeInTheDocument();
});
it('allows deleting built-in providers from the detail header', async () => {
provider.builtin_id = 'boosapi';
mocks.deleteProvider.mockResolvedValue(undefined);
render(
<App>
<ProviderDetail providerId="provider-1" />
</App>,
);
await userEvent.click(screen.getByRole('button', { name: 'settings.deleteProvider' }));
await userEvent.click(await screen.findByText('common.confirm'));
await waitFor(() => expect(mocks.deleteProvider).toHaveBeenCalledWith('provider-1'));
expect(mocks.setSelectedProviderId).toHaveBeenCalledWith(null);
});
it('adds a model from the card-level action and derives the default group from the model id', async () => {
render(
<App>