feat(proxy): CN/INTL 配置与模型列表完全分离
- 模型 Provider 改为按区域独立列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取,不再互相覆盖 - 拉模型按区域取账号:未配置该区域账号时返回失败并保留内置兜底 - WorkBuddy 凭据表单补充区域(CN/INTL)选择,解决国际版无处配置的问题 - 账号管理列表显示 CN/INTL 区域徽标,一眼区分 - 版本升至 1.7.8
This commit is contained in:
parent
1a96bcb105
commit
eb4451c4de
@ -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 {
|
||||
|
||||
@ -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<List<TraeModel>> = _models.asStateFlow()
|
||||
private val _modelsByRegion = MutableStateFlow(
|
||||
ProviderRegion.entries.associateWith { defaultModels() }
|
||||
)
|
||||
|
||||
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
|
||||
fun modelsFor(region: ProviderRegion): List<TraeModel> =
|
||||
_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<TraeModel>? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
|
||||
@ -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<List<TraeModelProvider.TraeModel>> = _models.asStateFlow()
|
||||
private val _modelsByRegion = MutableStateFlow(
|
||||
ProviderRegion.entries.associateWith { defaultModels() }
|
||||
)
|
||||
|
||||
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
|
||||
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
|
||||
_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<TraeModelProvider.TraeModel>? {
|
||||
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
|
||||
?: return null
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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<ServiceAccountInfo> = emptyList(),
|
||||
val accounts: List<AccountItem> = emptyList(),
|
||||
val message: UiText? = null
|
||||
)
|
||||
@ -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,
|
||||
|
||||
@ -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 = "",
|
||||
|
||||
@ -84,7 +84,7 @@ class SettingsViewModel @Inject constructor(
|
||||
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
|
||||
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()
|
||||
|
||||
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 = "模型列表更新失败(当前显示内置列表)"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user