From eb4451c4de6d4d45e1d6b7e35f56fa2c040af9cd Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 12:26:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20CN/INTL=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E4=B8=8E=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=AE=8C=E5=85=A8?= =?UTF-8?q?=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型 Provider 改为按区域独立列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取,不再互相覆盖 - 拉模型按区域取账号:未配置该区域账号时返回失败并保留内置兜底 - WorkBuddy 凭据表单补充区域(CN/INTL)选择,解决国际版无处配置的问题 - 账号管理列表显示 CN/INTL 区域徽标,一眼区分 - 版本升至 1.7.8 --- app/build.gradle.kts | 4 +-- .../token/data/proxy/TraeModelProvider.kt | 35 +++++++++++++------ .../data/proxy/WorkBuddyModelProvider.kt | 29 +++++++++++---- .../ui/settings/AccountManagementScreen.kt | 27 ++++++++++++-- .../ui/settings/AccountManagementViewModel.kt | 21 +++++++++-- .../token/ui/settings/CredentialEditScreen.kt | 21 +++++++++++ .../ui/settings/CredentialEditViewModel.kt | 12 +++++-- .../token/ui/settings/SettingsViewModel.kt | 10 +++--- 8 files changed, 127 insertions(+), 32 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acdca91..fcac958 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 27 - versionName = "1.7.7" + versionCode = 28 + versionName = "1.7.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index b54b258..86f0f48 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -38,8 +38,13 @@ class TraeModelProvider @Inject constructor( private val prefs: SharedPreferences = context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) - private val _models = MutableStateFlow(defaultModels()) - val models: StateFlow> = _models.asStateFlow() + private val _modelsByRegion = MutableStateFlow( + ProviderRegion.entries.associateWith { defaultModels() } + ) + + /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ + fun modelsFor(region: ProviderRegion): List = + _modelsByRegion.value[region] ?: defaultModels() private val json = Json { ignoreUnknownKeys = true } @@ -61,20 +66,16 @@ class TraeModelProvider @Inject constructor( fun supports(modelId: String, region: ProviderRegion): Boolean { val want = modelId.trim().lowercase() if (want.isBlank() || want == "auto") return true - if (models.value.any { it.id.equals(modelId, ignoreCase = true) }) return true + if (modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }) return true return TraeChatProxy.resolveAlias(modelId) != null } - /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ + /** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */ suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.TRAE, null) - ?: return@withContext false - if (credential !is Credential.TraeCredential) return@withContext false + val effectiveRegion = forcedRegion ?: ProviderRegion.CN + val credential = pickCredential(effectiveRegion) ?: return@withContext false val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false - val effectiveRegion = forcedRegion ?: runCatching { - ProviderRegion.valueOf(credential.region.uppercase()) - }.getOrNull() val intl = effectiveRegion == ProviderRegion.INTL val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn" @@ -106,10 +107,22 @@ class TraeModelProvider @Inject constructor( null } if (parsed.isNullOrEmpty()) return@withContext false - _models.value = parsed + _modelsByRegion.value = _modelsByRegion.value + (effectiveRegion to parsed) true } + private suspend fun pickCredential(region: ProviderRegion): Credential.TraeCredential? { + val accounts = credentialRepository.accountsFor(ServiceType.TRAE) + if (accounts.isEmpty()) { + val legacy = credentialRepository.get(ServiceType.TRAE, null) + return (legacy as? Credential.TraeCredential) + ?.takeIf { it.region.equals(region.name, ignoreCase = true) } + } + return accounts.mapNotNull { account -> + credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential + }.firstOrNull { it.region.equals(region.name, ignoreCase = true) } + } + private fun parseModelList(body: String): List? { val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt index 517b331..f89e1f1 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -36,8 +36,13 @@ class WorkBuddyModelProvider @Inject constructor( private val prefs: SharedPreferences = context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) - private val _models = MutableStateFlow(defaultModels()) - val models: StateFlow> = _models.asStateFlow() + private val _modelsByRegion = MutableStateFlow( + ProviderRegion.entries.associateWith { defaultModels() } + ) + + /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ + fun modelsFor(region: ProviderRegion): List = + _modelsByRegion.value[region] ?: defaultModels() private val json = Json { ignoreUnknownKeys = true } @@ -56,13 +61,11 @@ class WorkBuddyModelProvider @Inject constructor( fun supports(modelId: String, region: ProviderRegion): Boolean { val want = modelId.trim().lowercase() if (want.isBlank() || want == "auto") return true - return models.value.any { it.id.equals(modelId, ignoreCase = true) } + return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) } } suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.WORKBUDDY, null) - ?: return@withContext false - if (credential !is Credential.WorkBuddyCredential) return@withContext false + val credential = pickCredential(region) ?: return@withContext false val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } ?: return@withContext false val intl = region == ProviderRegion.INTL @@ -90,10 +93,22 @@ class WorkBuddyModelProvider @Inject constructor( null } if (parsed.isNullOrEmpty()) return@withContext false - _models.value = parsed + _modelsByRegion.value = _modelsByRegion.value + (region to parsed) true } + private suspend fun pickCredential(region: ProviderRegion): Credential.WorkBuddyCredential? { + val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY) + if (accounts.isEmpty()) { + val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null) + return (legacy as? Credential.WorkBuddyCredential) + ?.takeIf { it.region.equals(region.name, ignoreCase = true) } + } + return accounts.mapNotNull { account -> + credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential + }.firstOrNull { it.region.equals(region.name, ignoreCase = true) } + } + private fun parseModelList(body: String): List? { val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt index 76674ec..99ed039 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt @@ -156,10 +156,12 @@ fun AccountManagementScreen( } } } - items(uiState.accounts, key = { it.id }) { account -> - val ordinal = accountOrdinal(uiState.accounts, account) + items(uiState.accounts, key = { it.account.id }) { item -> + val account = item.account + val ordinal = accountOrdinal(uiState.accounts.map { it.account }, account) AccountRow( account = account, + region = item.region, displayName = account.label?.takeIf { it.isNotBlank() } ?: stringResource(R.string.account_fallback_name, ordinal), menuExpanded = menuAccountId == account.id, @@ -262,6 +264,7 @@ fun AccountManagementScreen( @Composable private fun AccountRow( account: ServiceAccountInfo, + region: String?, displayName: String, menuExpanded: Boolean, onMenuToggle: () -> Unit, @@ -306,6 +309,26 @@ private fun AccountRow( ) ) } + if (region != null) { + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = if (region.equals("INTL", ignoreCase = true)) { + stringResource(R.string.option_region_intl) + } else { + stringResource(R.string.option_region_cn) + }, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.outline, + maxLines = 1, + softWrap = false, + modifier = Modifier + .padding(horizontal = 6.dp, vertical = 2.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = RoundedCornerShape(8.dp) + ) + ) + } } } Box { diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt index c1ec7a7..bbe9c50 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.rainy.token.R import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.ServiceAccountInfo import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.UiText @@ -46,13 +47,22 @@ class AccountManagementViewModel @Inject constructor( viewModelScope.launch { _uiState.update { it.copy(loading = true) } val accounts = credentialRepository.accountsFor(type) + val items = accounts.mapNotNull { account -> + val region = credentialRepository.get(type, account.id) + ?.let { c -> when (c) { + is Credential.TraeCredential -> c.region.uppercase() + is Credential.WorkBuddyCredential -> c.region.uppercase() + else -> null + } } + AccountItem(account = account, region = region) + } if (generation != loadGeneration) return@launch _uiState.update { it.copy( loading = false, - accounts = accounts, + accounts = items, // 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉 - message = if (accounts.isEmpty()) null else it.message + message = if (items.isEmpty()) null else it.message ) } } @@ -90,8 +100,13 @@ class AccountManagementViewModel @Inject constructor( } } +data class AccountItem( + val account: ServiceAccountInfo, + val region: String?, +) + data class AccountManagementUiState( val loading: Boolean = false, - val accounts: List = emptyList(), + val accounts: List = emptyList(), val message: UiText? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 86aab52..f968da7 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -169,6 +169,8 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onAccessChange = viewModel::updateWorkBuddyAccess, onRefreshChange = viewModel::updateWorkBuddyRefresh, + region = uiState.workBuddyRegion, + onRegionChange = viewModel::updateWorkBuddyRegion, onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, onSave = viewModel::saveWorkBuddyCredential ) @@ -916,9 +918,11 @@ private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit) private fun WorkBuddyCredentialForm( access: String, refresh: String, + region: String, hasExisting: Boolean, onAccessChange: (String) -> Unit, onRefreshChange: (String) -> Unit, + onRegionChange: (String) -> Unit, onStartInAppLogin: () -> Unit, onSave: () -> Unit ) { @@ -934,6 +938,23 @@ private fun WorkBuddyCredentialForm( ) { Text(stringResource(R.string.action_login_in_app)) } + Text( + text = stringResource(R.string.field_trae_region), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RegionButton( + label = stringResource(R.string.option_region_cn), + selected = region.uppercase() != "INTL", + onSelect = { onRegionChange("CN") } + ) + RegionButton( + label = stringResource(R.string.option_region_intl), + selected = region.uppercase() == "INTL", + onSelect = { onRegionChange("INTL") } + ) + } OutlinedTextField( value = access, onValueChange = onAccessChange, diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index 82f0ce2..5c727d3 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -126,6 +126,7 @@ class CredentialEditViewModel @Inject constructor( traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(), workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), + workBuddyRegion = (existing as? Credential.WorkBuddyCredential)?.region ?: "CN", sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon, sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(), @@ -353,6 +354,10 @@ class CredentialEditViewModel @Inject constructor( _uiState.update { it.copy(workBuddyRefresh = value) } } + fun updateWorkBuddyRegion(value: String) { + _uiState.update { it.copy(workBuddyRegion = value) } + } + fun saveWorkBuddyCredential() { val type = serviceType ?: return val current = _uiState.value @@ -371,10 +376,11 @@ class CredentialEditViewModel @Inject constructor( credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential } val effectiveId = credentialRepository.save( - (existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh)) + (existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh, region = "CN")) .copy( accessToken = access, refreshToken = refresh, + region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN", expiresAt = existing?.expiresAt ?: 0L, lastVerifiedAt = existing?.lastVerifiedAt ?: 0L ), @@ -852,6 +858,7 @@ class CredentialEditViewModel @Inject constructor( traeCheckinDeviceId = "", workBuddyAccess = "", workBuddyRefresh = "", + workBuddyRegion = "CN", sub2BaseUrl = "", sub2Email = "", sub2Password = "", @@ -974,9 +981,10 @@ data class CredentialEditUiState( val traeRegion: String = "CN", /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ val traeCheckinDeviceId: String = "", - /** WorkBuddy:OAuth tokens */ + /** WorkBuddy:OAuth tokens + 区域 */ val workBuddyAccess: String = "", val workBuddyRefresh: String = "", + val workBuddyRegion: String = "CN", /** Sub2API:自托管实例连接 */ val sub2BaseUrl: String = "", val sub2Email: String = "", diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 9f512f7..b8c1357 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -84,7 +84,7 @@ class SettingsViewModel @Inject constructor( private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN) val selectedProxy: StateFlow = _selectedProxy.asStateFlow() - private val _proxyModels = MutableStateFlow>(traeModelProvider.models.value) + private val _proxyModels = MutableStateFlow>(traeModelProvider.modelsFor(ProviderRegion.CN)) val proxyModels: StateFlow> = _proxyModels.asStateFlow() private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN)) @@ -95,8 +95,8 @@ class SettingsViewModel @Inject constructor( _selectedProxy.value = target val region = target.region() _proxyModels.value = when (target) { - ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value - else -> workBuddyModelProvider.models.value + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region) + else -> workBuddyModelProvider.modelsFor(region) } _proxyModel.value = when (target) { ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region) @@ -125,8 +125,8 @@ class SettingsViewModel @Inject constructor( else -> workBuddyModelProvider.refreshFor(region) } _proxyModels.value = when (target) { - ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value - else -> workBuddyModelProvider.models.value + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region) + else -> workBuddyModelProvider.modelsFor(region) } if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" }