feat(proxy): CN/INTL 配置与模型列表完全分离

- 模型 Provider 改为按区域独立列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取,不再互相覆盖
- 拉模型按区域取账号:未配置该区域账号时返回失败并保留内置兜底
- WorkBuddy 凭据表单补充区域(CN/INTL)选择,解决国际版无处配置的问题
- 账号管理列表显示 CN/INTL 区域徽标,一眼区分
- 版本升至 1.7.8
This commit is contained in:
Liuxinyu176 2026-10-09 12:26:12 +08:00
parent 1a96bcb105
commit eb4451c4de
8 changed files with 127 additions and 32 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 27 versionCode = 28
versionName = "1.7.7" versionName = "1.7.8"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {

View File

@ -38,8 +38,13 @@ class TraeModelProvider @Inject constructor(
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels()) private val _modelsByRegion = MutableStateFlow(
val models: StateFlow<List<TraeModel>> = _models.asStateFlow() ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@ -61,20 +66,16 @@ class TraeModelProvider @Inject constructor(
fun supports(modelId: String, region: ProviderRegion): Boolean { fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase() val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true 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 return TraeChatProxy.resolveAlias(modelId) != null
} }
/** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ /** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) { suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, null) val effectiveRegion = forcedRegion ?: ProviderRegion.CN
?: return@withContext false val credential = pickCredential(effectiveRegion) ?: return@withContext false
if (credential !is Credential.TraeCredential) return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: 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 intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" 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" val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
@ -106,10 +107,22 @@ class TraeModelProvider @Inject constructor(
null null
} }
if (parsed.isNullOrEmpty()) return@withContext false if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed _modelsByRegion.value = _modelsByRegion.value + (effectiveRegion to parsed)
true 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<TraeModel>? { private fun parseModelList(body: String): List<TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null ?: return null

View File

@ -36,8 +36,13 @@ class WorkBuddyModelProvider @Inject constructor(
private val prefs: SharedPreferences = private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels()) private val _modelsByRegion = MutableStateFlow(
val models: StateFlow<List<TraeModelProvider.TraeModel>> = _models.asStateFlow() ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
@ -56,13 +61,11 @@ class WorkBuddyModelProvider @Inject constructor(
fun supports(modelId: String, region: ProviderRegion): Boolean { fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase() val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true 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) { suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, null) val credential = pickCredential(region) ?: return@withContext false
?: return@withContext false
if (credential !is Credential.WorkBuddyCredential) return@withContext false
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext false ?: return@withContext false
val intl = region == ProviderRegion.INTL val intl = region == ProviderRegion.INTL
@ -90,10 +93,22 @@ class WorkBuddyModelProvider @Inject constructor(
null null
} }
if (parsed.isNullOrEmpty()) return@withContext false if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed _modelsByRegion.value = _modelsByRegion.value + (region to parsed)
true 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<TraeModelProvider.TraeModel>? { private fun parseModelList(body: String): List<TraeModelProvider.TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null ?: return null

View File

@ -156,10 +156,12 @@ fun AccountManagementScreen(
} }
} }
} }
items(uiState.accounts, key = { it.id }) { account -> items(uiState.accounts, key = { it.account.id }) { item ->
val ordinal = accountOrdinal(uiState.accounts, account) val account = item.account
val ordinal = accountOrdinal(uiState.accounts.map { it.account }, account)
AccountRow( AccountRow(
account = account, account = account,
region = item.region,
displayName = account.label?.takeIf { it.isNotBlank() } displayName = account.label?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, ordinal), ?: stringResource(R.string.account_fallback_name, ordinal),
menuExpanded = menuAccountId == account.id, menuExpanded = menuAccountId == account.id,
@ -262,6 +264,7 @@ fun AccountManagementScreen(
@Composable @Composable
private fun AccountRow( private fun AccountRow(
account: ServiceAccountInfo, account: ServiceAccountInfo,
region: String?,
displayName: String, displayName: String,
menuExpanded: Boolean, menuExpanded: Boolean,
onMenuToggle: () -> Unit, 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 { Box {

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository 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.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText import com.rainy.token.ui.components.UiText
@ -46,13 +47,22 @@ class AccountManagementViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(loading = true) } _uiState.update { it.copy(loading = true) }
val accounts = credentialRepository.accountsFor(type) 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 if (generation != loadGeneration) return@launch
_uiState.update { _uiState.update {
it.copy( it.copy(
loading = false, 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( data class AccountManagementUiState(
val loading: Boolean = false, val loading: Boolean = false,
val accounts: List<ServiceAccountInfo> = emptyList(), val accounts: List<AccountItem> = emptyList(),
val message: UiText? = null val message: UiText? = null
) )

View File

@ -169,6 +169,8 @@ fun CredentialEditScreen(
hasExisting = uiState.hasExisting, hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess, onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh, onRefreshChange = viewModel::updateWorkBuddyRefresh,
region = uiState.workBuddyRegion,
onRegionChange = viewModel::updateWorkBuddyRegion,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
onSave = viewModel::saveWorkBuddyCredential onSave = viewModel::saveWorkBuddyCredential
) )
@ -916,9 +918,11 @@ private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit)
private fun WorkBuddyCredentialForm( private fun WorkBuddyCredentialForm(
access: String, access: String,
refresh: String, refresh: String,
region: String,
hasExisting: Boolean, hasExisting: Boolean,
onAccessChange: (String) -> Unit, onAccessChange: (String) -> Unit,
onRefreshChange: (String) -> Unit, onRefreshChange: (String) -> Unit,
onRegionChange: (String) -> Unit,
onStartInAppLogin: () -> Unit, onStartInAppLogin: () -> Unit,
onSave: () -> Unit onSave: () -> Unit
) { ) {
@ -934,6 +938,23 @@ private fun WorkBuddyCredentialForm(
) { ) {
Text(stringResource(R.string.action_login_in_app)) 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( OutlinedTextField(
value = access, value = access,
onValueChange = onAccessChange, onValueChange = onAccessChange,

View File

@ -126,6 +126,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(), traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
workBuddyRegion = (existing as? Credential.WorkBuddyCredential)?.region ?: "CN",
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon, sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(), sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
@ -353,6 +354,10 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(workBuddyRefresh = value) } _uiState.update { it.copy(workBuddyRefresh = value) }
} }
fun updateWorkBuddyRegion(value: String) {
_uiState.update { it.copy(workBuddyRegion = value) }
}
fun saveWorkBuddyCredential() { fun saveWorkBuddyCredential() {
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value val current = _uiState.value
@ -371,10 +376,11 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
} }
val effectiveId = credentialRepository.save( 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( .copy(
accessToken = access, accessToken = access,
refreshToken = refresh, refreshToken = refresh,
region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN",
expiresAt = existing?.expiresAt ?: 0L, expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
), ),
@ -852,6 +858,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = "", traeCheckinDeviceId = "",
workBuddyAccess = "", workBuddyAccess = "",
workBuddyRefresh = "", workBuddyRefresh = "",
workBuddyRegion = "CN",
sub2BaseUrl = "", sub2BaseUrl = "",
sub2Email = "", sub2Email = "",
sub2Password = "", sub2Password = "",
@ -974,9 +981,10 @@ data class CredentialEditUiState(
val traeRegion: String = "CN", val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "", val traeCheckinDeviceId: String = "",
/** WorkBuddy:OAuth tokens */ /** WorkBuddy:OAuth tokens + 区域 */
val workBuddyAccess: String = "", val workBuddyAccess: String = "",
val workBuddyRefresh: String = "", val workBuddyRefresh: String = "",
val workBuddyRegion: String = "CN",
/** Sub2API:自托管实例连接 */ /** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "", val sub2BaseUrl: String = "",
val sub2Email: String = "", val sub2Email: String = "",

View File

@ -84,7 +84,7 @@ class SettingsViewModel @Inject constructor(
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN) private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow() val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.models.value) private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.modelsFor(ProviderRegion.CN))
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow() val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow()
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN)) private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
@ -95,8 +95,8 @@ class SettingsViewModel @Inject constructor(
_selectedProxy.value = target _selectedProxy.value = target
val region = target.region() val region = target.region()
_proxyModels.value = when (target) { _proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.models.value else -> workBuddyModelProvider.modelsFor(region)
} }
_proxyModel.value = when (target) { _proxyModel.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region) ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
@ -125,8 +125,8 @@ class SettingsViewModel @Inject constructor(
else -> workBuddyModelProvider.refreshFor(region) else -> workBuddyModelProvider.refreshFor(region)
} }
_proxyModels.value = when (target) { _proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.models.value else -> workBuddyModelProvider.modelsFor(region)
} }
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
} }