From f13b38c02bdceec5b84612fb4ca475e2011cc9fb Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:21:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20=E6=8C=89=E4=BB=A3=E7=90=86/?= =?UTF-8?q?=E5=8C=BA=E5=9F=9F=E5=8C=BA=E5=88=86=E6=A8=A1=E5=9E=8B=E5=88=97?= =?UTF-8?q?=E8=A1=A8=E4=B8=8E=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增“代理”下拉:Trae CN/INTL、WorkBuddy CN/INTL - WorkBuddyModelProvider 在线拉取 /v2/enterprises/personal/models - Trae/WorkBuddy 各区域独立记住所选测试模型 - 真实代理转发仍按前缀自动路由,不受测试选择影响 - 版本升至 1.7.6 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 15 +-- .../token/data/proxy/TraeModelProvider.kt | 22 ++-- .../data/proxy/WorkBuddyModelProvider.kt | 119 ++++++++++++++++++ .../rainy/token/ui/settings/SettingsScreen.kt | 38 ++++++ .../token/ui/settings/SettingsViewModel.kt | 68 ++++++++-- 6 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f53740..5ea6e18 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 = 25 - versionName = "1.7.5" + versionCode = 26 + versionName = "1.7.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 54907fd..9d27c84 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -60,7 +60,7 @@ class TraeChatProxy @Inject constructor( } else { "https://trae-api-cn.mchost.guru" } - val upstreamBody = buildUpstreamBody(requestBody, credential) + val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() val builder = Request.Builder() @@ -99,7 +99,7 @@ class TraeChatProxy @Inject constructor( } else { "https://trae-api-cn.mchost.guru" } - val upstreamBody = buildUpstreamBody(requestBody, credential) + val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() val builder = Request.Builder() @@ -230,13 +230,14 @@ class TraeChatProxy @Inject constructor( } /** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */ - private fun normalizeModel(raw: String?): String { - val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel - if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel + private fun normalizeModel(raw: String?, region: ProviderRegion?): String { + val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN) + val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected + if (id.equals("auto", ignoreCase = true)) return selected return MODEL_ALIASES[id.lowercase()] ?: id } - private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String { val src = try { json.parseToJsonElement(raw) as? JsonObject } catch (_: Throwable) { @@ -244,7 +245,7 @@ class TraeChatProxy @Inject constructor( } ?: return raw val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - val model = normalizeModel(rawModel) + val model = normalizeModel(rawModel, region) val messages = convertNativeMessages(src["messages"]) val sessionId = UUID.randomUUID().toString().replace("-", "") 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 c84bae3..7f6fec9 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 @@ -41,16 +41,20 @@ class TraeModelProvider @Inject constructor( private val _models = MutableStateFlow(defaultModels()) val models: StateFlow> = _models.asStateFlow() - /** 当前默认模型(用户在设置页选择的 Trae 模型名)。 */ - var selectedModel: String = prefs.getString(KEY_MODEL, "glm-5.2") ?: "glm-5.2" - private set - private val json = Json { ignoreUnknownKeys = true } - fun setSelectedModel(id: String) { + /** 当前区域选择的默认模型(未设置时 CN 用 GLM-5.2)。 */ + fun selectedFor(region: ProviderRegion): String { + if (region == ProviderRegion.INTL) { + return prefs.getString(KEY_MODEL_INTL, DEFAULT_MODEL_INTL) ?: DEFAULT_MODEL_INTL + } + return prefs.getString(KEY_MODEL_CN, "glm-5.2") ?: "glm-5.2" + } + + fun setSelectedFor(region: ProviderRegion, id: String) { if (id.isBlank()) return - selectedModel = id - prefs.edit().putString(KEY_MODEL, id).apply() + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + prefs.edit().putString(key, id).apply() } /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ @@ -120,7 +124,9 @@ class TraeModelProvider @Inject constructor( } companion object { - private const val KEY_MODEL = "trae_default_model" + private const val KEY_MODEL_CN = "trae_default_model_cn" + private const val KEY_MODEL_INTL = "trae_default_model_intl" + private const val DEFAULT_MODEL_INTL = "glm-5.2" /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */ fun defaultModels(): List = listOf( 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 new file mode 100644 index 0000000..46298bb --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -0,0 +1,119 @@ +package com.rainy.token.data.proxy + +import android.content.Context +import android.content.SharedPreferences +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * WorkBuddy / CodeBuddy 模型列表: + * 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。 + */ +@Singleton +class WorkBuddyModelProvider @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + @ApplicationContext private val context: Context, +) { + + private val prefs: SharedPreferences = + context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) + + private val _models = MutableStateFlow(defaultModels()) + val models: StateFlow> = _models.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true } + + fun selectedFor(region: ProviderRegion): String { + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL + } + + fun setSelectedFor(region: ProviderRegion, id: String) { + if (id.isBlank()) return + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + prefs.edit().putString(key, id).apply() + } + + 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 accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } + ?: return@withContext false + val intl = region == ProviderRegion.INTL + val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com" + val url = base + "/v2/enterprises/personal/models" + + val headers = linkedMapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json", + "Accept" to "application/json", + "X-Client-ID" to "codebuddy-cli", + "X-Client-Version" to "2.143.1", + "X-Product" to "SaaS", + "User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1", + "Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"), + ) + val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build() + val parsed = try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val body = resp.body?.string() ?: return@use null + parseModelList(body) + } + } catch (_: Exception) { + null + } + if (parsed.isNullOrEmpty()) return@withContext false + _models.value = parsed + true + } + + private fun parseModelList(body: String): List? { + val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() + ?: return null + val data = root["data"] as? JsonObject ?: return null + val models = data["models"] as? JsonArray ?: return null + val seen = LinkedHashMap() + for (rawEl in models) { + val raw = rawEl as? JsonObject ?: continue + val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim() + ?.takeIf { it.isNotEmpty() } ?: continue + val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id + seen[id] = TraeModelProvider.TraeModel(id, label) + } + return seen.values.toList().ifEmpty { null } + } + + companion object { + private const val KEY_MODEL_CN = "wb_default_model_cn" + private const val KEY_MODEL_INTL = "wb_default_model_intl" + private const val DEFAULT_MODEL = "hy4-preview" + + /** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */ + fun defaultModels(): List = listOf( + TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"), + TraeModelProvider.TraeModel("hy3", "hy3"), + TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"), + TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"), + ) + } +} diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index a579963..6aaeb32 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -109,6 +109,7 @@ fun SettingsScreen( val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle() + val selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle() val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } @@ -187,6 +188,9 @@ fun SettingsScreen( error = proxyError, debug = proxyDebug, debugRaw = proxyDebugRaw, + proxyTargets = viewModel.proxyTargets, + selectedProxy = selectedProxy, + onSelectProxy = viewModel::selectProxy, models = proxyModels, selectedModel = proxyModel, onSelectModel = viewModel::selectProxyModel, @@ -225,6 +229,9 @@ private fun ProxyGatewayCard( error: String?, debug: String?, debugRaw: String?, + proxyTargets: List, + selectedProxy: ProxyTarget, + onSelectProxy: (ProxyTarget) -> Unit, models: List, selectedModel: String, onSelectModel: (String) -> Unit, @@ -234,6 +241,7 @@ private fun ProxyGatewayCard( onTestWorkBuddy: () -> Unit ) { val context = LocalContext.current + var proxyMenuExpanded by remember { mutableStateOf(false) } var modelMenuExpanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), @@ -284,6 +292,36 @@ private fun ProxyGatewayCard( Text("测试 WorkBuddy 流") } } + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "代理", + style = MaterialTheme.typography.labelSmall, + color = InkMuted + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + TextButton(onClick = { proxyMenuExpanded = true }) { + Text(selectedProxy.label + " ▾") + } + DropdownMenu( + expanded = proxyMenuExpanded, + onDismissRequest = { proxyMenuExpanded = false } + ) { + proxyTargets.forEach { t -> + DropdownMenuItem( + text = { Text(t.label) }, + onClick = { + onSelectProxy(t) + proxyMenuExpanded = false + } + ) + } + } + } + } Row( modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically 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 8170b38..9f512f7 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 @@ -8,6 +8,7 @@ import com.rainy.token.data.proxy.ProxyUpstreamStream import com.rainy.token.data.proxy.ProxyServerConfig import com.rainy.token.data.proxy.TraeChatProxy import com.rainy.token.data.proxy.TraeModelProvider +import com.rainy.token.data.proxy.WorkBuddyModelProvider import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus @@ -41,6 +42,7 @@ class SettingsViewModel @Inject constructor( private val traeChatProxy: TraeChatProxy, private val workBuddyChatProxy: WorkBuddyChatProxy, private val traeModelProvider: TraeModelProvider, + private val workBuddyModelProvider: WorkBuddyModelProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -76,23 +78,56 @@ class SettingsViewModel @Inject constructor( private val _proxyDebugRaw = MutableStateFlow(null) val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() - /** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */ - val proxyModels: StateFlow> = traeModelProvider.models + /** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */ + val proxyTargets: List = ProxyTarget.entries - /** 当前选择的 Trae 默认模型。 */ - private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel) + private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN) + val selectedProxy: StateFlow = _selectedProxy.asStateFlow() + + private val _proxyModels = MutableStateFlow>(traeModelProvider.models.value) + val proxyModels: StateFlow> = _proxyModels.asStateFlow() + + private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN)) val proxyModel: StateFlow = _proxyModel.asStateFlow() - /** 切换 Trae 默认模型并持久化。 */ + /** 切换当前测试目标代理。 */ + fun selectProxy(target: ProxyTarget) { + _selectedProxy.value = target + val region = target.region() + _proxyModels.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value + else -> workBuddyModelProvider.models.value + } + _proxyModel.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region) + else -> workBuddyModelProvider.selectedFor(region) + } + } + + /** 切换当前代理的测试模型并持久化。 */ fun selectProxyModel(id: String) { - traeModelProvider.setSelectedModel(id) + val target = _selectedProxy.value + val region = target.region() + when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id) + else -> workBuddyModelProvider.setSelectedFor(region, id) + } _proxyModel.value = id } - /** 拉取当前账号可用的 Trae 模型列表。 */ + /** 拉取当前代理可用模型;失败保留内置列表。 */ fun refreshProxyModels() { + val target = _selectedProxy.value + val region = target.region() viewModelScope.launch { - val ok = traeModelProvider.refreshFor(ProviderRegion.CN) + val ok = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.refreshFor(region) + else -> workBuddyModelProvider.refreshFor(region) + } + _proxyModels.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value + else -> workBuddyModelProvider.models.value + } if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" } } @@ -102,7 +137,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." _proxyDebugRaw.value = null - val model = traeModelProvider.selectedModel + val model = traeModelProvider.selectedFor(ProviderRegion.CN) val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) @@ -132,7 +167,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _proxyDebug.value = "正在请求 WorkBuddy CN 流..." _proxyDebugRaw.value = null - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + val model = workBuddyModelProvider.selectedFor(ProviderRegion.CN) + val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { @@ -313,6 +349,18 @@ class SettingsViewModel @Inject constructor( } } +enum class ProxyTarget(val key: String, val label: String) { + TRAE_CN("trae_cn", "Trae CN"), + TRAE_INTL("trae_intl", "Trae INTL"), + WORKBUDDY_CN("wb_cn", "WorkBuddy CN"), + WORKBUDDY_INTL("wb_intl", "WorkBuddy INTL"); + + fun region(): ProviderRegion = when (this) { + TRAE_CN, WORKBUDDY_CN -> ProviderRegion.CN + else -> ProviderRegion.INTL + } +} + data class StreamProbeResult( val summary: String, val raw: String,