From 0241840f9cdbb7fa757e07908abed6d09d84e98e Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:03:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(proxy):=20Trae=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E5=9C=A8=E7=BA=BF=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置页新增模型下拉选择,内置参考映射兜底 - TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models) - 选中模型持久化,作为反代缺省模型 - 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型 - 修复探针测试请求体混入反斜杠导致 JSON 无效的问题 - 版本升至 1.7.5 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 58 ++++++- .../token/data/proxy/TraeModelProvider.kt | 161 ++++++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 6 +- .../rainy/token/ui/settings/SettingsScreen.kt | 47 +++++ .../token/ui/settings/SettingsViewModel.kt | 30 +++- 6 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72370b8..5f53740 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 = 24 - versionName = "1.7.4" + versionCode = 25 + versionName = "1.7.5" 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 beb034b..54907fd 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 @@ -35,6 +35,7 @@ import okhttp3.RequestBody.Companion.toRequestBody class TraeChatProxy @Inject constructor( private val okHttpClient: OkHttpClient, private val credentialRepository: CredentialRepository, + private val modelProvider: TraeModelProvider, ) { private val json = Json { ignoreUnknownKeys = true } @@ -228,6 +229,13 @@ class TraeChatProxy @Inject constructor( return null } + /** 外部模型名 -> 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 + return MODEL_ALIASES[id.lowercase()] ?: id + } + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { val src = try { json.parseToJsonElement(raw) as? JsonObject @@ -235,8 +243,8 @@ class TraeChatProxy @Inject constructor( null } ?: return raw - val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - ?: "glm-5.2" + val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + val model = normalizeModel(rawModel) val messages = convertNativeMessages(src["messages"]) val sessionId = UUID.randomUUID().toString().replace("-", "") @@ -255,4 +263,50 @@ class TraeChatProxy @Inject constructor( } }.toString() } + + companion object { + /** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */ + private val MODEL_ALIASES = mapOf( + "auto" to "glm-5.2", + "gpt-4o" to "DeepSeek-V4-Pro", + "gpt-4o-latest" to "DeepSeek-V4-Pro", + "gpt-4.1" to "DeepSeek-V4-Pro", + "deepseek-v3" to "DeepSeek-V4-Pro", + "deepseek-r1" to "DeepSeek-V4-Pro", + "gpt-4o-mini" to "DeepSeek-V4-Flash", + "claude-opus-4-7" to "glm-5.2", + "claude-opus-4-6" to "glm-5.2", + "claude-opus-4-5" to "glm-5.2", + "claude-sonnet-4-6" to "glm-5.2", + "claude-sonnet-4-5" to "glm-5.2", + "claude-sonnet-4" to "glm-5.2", + "claude-3.7-sonnet" to "glm-5.2", + "claude-3-7-sonnet" to "glm-5.2", + "claude-3.5-sonnet" to "glm-5.2", + "claude-haiku-4-5" to "glm-5.1", + "deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official", + "deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official", + "doubao-seed-evolving" to "Doubao-Seed-Evolving", + "deepseek-v4-pro" to "DeepSeek-V4-Pro", + "deepseek-v4-flash" to "DeepSeek-V4-Flash", + "seed-2.1-pro" to "Doubao-Seed-2.1-Pro", + "seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo", + "seed-code" to "Doubao-Seed-Code", + "seed-evolving" to "Doubao-Seed-Evolving", + "qwen3.7-plus" to "qwen-3.7-plus", + "kimi-k2.6" to "kimi-k2.6", + "kimi-k3" to "kimi-k3", + "kimi-k2.7-code" to "kimi-k2.7-code", + "kimi-k2" to "kimi-k2", + "kimi-k2.5" to "kimi-k2.5", + "minimax-m3" to "minimax-m3", + "minimax-m2.7" to "minimax-m2.7", + "minimax-m2.1" to "minimax-m2.1", + "mimo-v2.5" to "mimo-v2.5", + "mimo-v2.5-pro" to "mimo-v2.5-pro", + "minimax-m25" to "minimax-m25", + "qwen36-35b" to "qwen36-35b", + ) + } + } 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 new file mode 100644 index 0000000..c84bae3 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -0,0 +1,161 @@ +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 + +/** + * Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。 + * 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。 + */ +@Singleton +class TraeModelProvider @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + @ApplicationContext private val context: Context, +) { + + data class TraeModel(val id: String, val label: String? = null) + + private val prefs: SharedPreferences = + context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) + + 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) { + if (id.isBlank()) return + selectedModel = id + prefs.edit().putString(KEY_MODEL, id).apply() + } + + /** 拉取账号在线模型列表;失败时保留内置列表并返回 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 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" + val url = base + "/api/remote/v1/models" + + "?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" + + "&show_custom_model=true" + + val headers = linkedMapOf( + "Authorization" to "Cloud-IDE-JWT $jwt", + "Content-Type" to "application/json", + "X-Trae-Client-Type" to "web", + "X-Preferenced-Language" to "zh-CN", + "x-user-region" to if (intl) "SG" else "CN", + "Origin" to origin, + "Referer" to origin + "/", + "User-Agent" to ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + ), + ) + 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 list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null + val seen = LinkedHashMap() + for (groupEl in list) { + val group = groupEl as? JsonObject ?: continue + val models = group["models"] as? JsonArray ?: continue + for (rawEl in models) { + val raw = rawEl as? JsonObject ?: continue + val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim() + ?.takeIf { it.isNotEmpty() } ?: continue + val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull + ?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull + ?: id + seen[id] = TraeModel(id, label) + } + } + return seen.values.toList().ifEmpty { null } + } + + companion object { + private const val KEY_MODEL = "trae_default_model" + + /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */ + fun defaultModels(): List = listOf( + TraeModel("glm-5.2", "GLM-5.2"), + TraeModel("glm-5.3", "GLM-5.3"), + TraeModel("glm-5.1", "GLM-5.1"), + TraeModel("glm-5", "GLM-5"), + TraeModel("glm-4.7", "GLM-4.7"), + TraeModel("glm-4.6", "GLM-4.6"), + TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"), + TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"), + TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"), + TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"), + TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"), + TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"), + TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"), + TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"), + TraeModel("qwen-3.7-plus", "qwen-3.7-plus"), + TraeModel("qwen-3.6-plus", "qwen-3.6-plus"), + TraeModel("qwen3.8-max", "qwen3.8-max"), + TraeModel("qwen-3.5", "qwen-3.5"), + TraeModel("qwen3-coder", "qwen3-coder"), + TraeModel("kimi-k2.6", "kimi-k2.6"), + TraeModel("kimi-k3", "kimi-k3"), + TraeModel("kimi-k2.7-code", "kimi-k2.7-code"), + TraeModel("kimi-k2", "kimi-k2"), + TraeModel("kimi-k2.5", "kimi-k2.5"), + TraeModel("minimax-m3", "minimax-m3"), + TraeModel("minimax-m2.7", "minimax-m2.7"), + TraeModel("minimax-m2.1", "minimax-m2.1"), + TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"), + TraeModel("mimo-v2.5", "mimo-v2.5"), + TraeModel("minimax-m25", "minimax-m25"), + TraeModel("qwen36-35b", "qwen36-35b"), + TraeModel("work", "Work(200K 档)"), + ) + } +} diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 3c3124a..7c8bb96 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -26,6 +26,7 @@ import com.rainy.token.data.proxy.KtorLocalProxyServer import com.rainy.token.data.proxy.LocalProxyServer import com.rainy.token.data.proxy.Sub2ApiChatProxy import com.rainy.token.data.proxy.TraeChatProxy +import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module @@ -250,8 +251,9 @@ object NetworkModule { @Singleton fun provideTraeChatProxy( okHttpClient: OkHttpClient, - credentialRepository: CredentialRepository - ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository) + credentialRepository: CredentialRepository, + traeModelProvider: TraeModelProvider, + ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider) @Provides @Singleton 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 6440d04..a579963 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 @@ -33,6 +33,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -60,6 +62,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip @@ -105,6 +108,8 @@ fun SettingsScreen( val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() + val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle() + val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -182,6 +187,10 @@ fun SettingsScreen( error = proxyError, debug = proxyDebug, debugRaw = proxyDebugRaw, + models = proxyModels, + selectedModel = proxyModel, + onSelectModel = viewModel::selectProxyModel, + onRefreshModels = viewModel::refreshProxyModels, onToggle = viewModel::setProxyEnabled, onTestTrae = viewModel::testTraeStream, onTestWorkBuddy = viewModel::testWorkBuddyStream @@ -216,11 +225,16 @@ private fun ProxyGatewayCard( error: String?, debug: String?, debugRaw: String?, + models: List, + selectedModel: String, + onSelectModel: (String) -> Unit, + onRefreshModels: () -> Unit, onToggle: (Boolean) -> Unit, onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit ) { val context = LocalContext.current + var modelMenuExpanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -270,6 +284,39 @@ 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 = { modelMenuExpanded = true }) { + Text(selectedModel + " ▾") + } + DropdownMenu( + expanded = modelMenuExpanded, + onDismissRequest = { modelMenuExpanded = false } + ) { + models.forEach { m -> + DropdownMenuItem( + text = { Text(m.label ?: m.id) }, + onClick = { + onSelectModel(m.id) + modelMenuExpanded = false + } + ) + } + } + } + TextButton(onClick = onRefreshModels) { + Text("刷新") + } + } if (debug != null) { Spacer(modifier = Modifier.padding(top = 8.dp)) Row(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 167f30f..8170b38 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 @@ -7,6 +7,7 @@ import com.rainy.token.data.proxy.ProviderRegion 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.WorkBuddyChatProxy import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus @@ -38,7 +39,8 @@ class SettingsViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val localProxyServer: LocalProxyServer, private val traeChatProxy: TraeChatProxy, - private val workBuddyChatProxy: WorkBuddyChatProxy + private val workBuddyChatProxy: WorkBuddyChatProxy, + private val traeModelProvider: TraeModelProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -74,12 +76,34 @@ class SettingsViewModel @Inject constructor( private val _proxyDebugRaw = MutableStateFlow(null) val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() + /** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */ + val proxyModels: StateFlow> = traeModelProvider.models + + /** 当前选择的 Trae 默认模型。 */ + private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel) + val proxyModel: StateFlow = _proxyModel.asStateFlow() + + /** 切换 Trae 默认模型并持久化。 */ + fun selectProxyModel(id: String) { + traeModelProvider.setSelectedModel(id) + _proxyModel.value = id + } + + /** 拉取当前账号可用的 Trae 模型列表。 */ + fun refreshProxyModels() { + viewModelScope.launch { + val ok = traeModelProvider.refreshFor(ProviderRegion.CN) + if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" + } + } + /** 用 Trae CN 默认账号发一条 stream=true 测试。 */ fun testTraeStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." _proxyDebugRaw.value = null - val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" + val model = traeModelProvider.selectedModel + val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { @@ -108,7 +132,7 @@ 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 body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) {