Compare commits

..

33 Commits

Author SHA1 Message Date
618eecf676 Merge pull request '_tmp_update' (#11) from _tmp_update into dev 2026-10-10 02:39:06 +08:00
8207285b23 update 1.7.22 final2
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:39:01 +08:00
82bd04597d Merge pull request '_tmp_update' (#3) from _tmp_update into dev 2026-10-10 02:00:16 +08:00
5850fc452c update 1.7.22 final
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:00:15 +08:00
c6a75f8fce Merge pull request '_tmp_update' (#2) from _tmp_update into dev 2026-10-10 01:47:32 +08:00
a384309689 update 1.7.22
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 01:47:31 +08:00
8692014340 Merge pull request 'chore(update): publish 1.7.21 manifest' (#1) from _tmp_update into dev
Some checks are pending
Release / build (push) Waiting to run
2026-10-10 00:25:54 +08:00
15c6bc24c4 chore(update): 发布 1.7.21 更新清单
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 00:25:12 +08:00
460bed7279 chore(update): 发布 1.7.20 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 23:16:10 +08:00
ecbc86fdf2 chore(update): 发布 1.7.19 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:43:53 +08:00
05b5107f46 chore(update): 发布 1.7.18 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:32:24 +08:00
bccd7bad83 chore(update): 发布 1.7.17 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:14:31 +08:00
c5120c5952 chore(update): 发布 1.7.16 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:39:17 +08:00
2c5a3b2055 chore(update): 发布 1.7.15 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:02:12 +08:00
c76193e892 chore(update): 发布 1.7.14 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:22:54 +08:00
6edd68f80e chore(update): 发布 1.7.13 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:12:43 +08:00
750b9d9f90 chore(update): 发布 1.7.12 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:42:53 +08:00
0f4d68033e chore(update): 发布 1.7.11 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:33:50 +08:00
7c7a23f097 chore(update): 发布 1.7.10 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:36:25 +08:00
7c3809e879 chore(update): 发布 1.7.9 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:29:08 +08:00
7ad49e994d chore(update): 发布 1.7.8 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 12:27:18 +08:00
8f40360b9f chore(update): 发布 1.7.7 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 11:51:52 +08:00
08a3b42a82 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:23:01 +08:00
aab50bbe78 chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:35 +08:00
faf7f40524 chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:27 +08:00
342788056b chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:48 +08:00
b479cb2a7a chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:57 +08:00
6e0d67afb7 chore(update): 发布 1.7.1 更新清单 2026-10-09 10:13:03 +08:00
8d68c3b280 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:32 +08:00
71f3f8d554 chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:13 +08:00
dfd91a5355 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:40:01 +08:00
149fc5f125 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:31:01 +08:00
95cde916ca chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:15 +08:00
48 changed files with 226 additions and 4160 deletions

4
.gitignore vendored
View File

@ -23,7 +23,3 @@ release.jks
tmp_cc/
goal.md
/reference/
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
_tmp_*
build_compile.log

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 42
versionName = "1.7.22"
versionCode = 14
versionName = "1.6.4"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@ -214,12 +214,6 @@ dependencies {
implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter)
// Ktor 本地反代 HTTP 服务
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// DataStore
implementation(libs.androidx.datastore.preferences)

View File

@ -3,7 +3,6 @@
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

View File

@ -1,87 +0,0 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。
*
* - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组;
* - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱;
* - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。
*/
@Singleton
class AccountPool @Inject constructor(
private val credentialRepository: CredentialRepository,
) {
data class PooledAccount(
val kind: ProviderKind,
val region: ProviderRegion? = null,
val accountId: String? = null,
val label: String? = null,
)
private val mutex = Mutex()
private val sessionSticky = mutableMapOf<String, String>()
private val cursor = mutableMapOf<ProviderKind, Int>()
suspend fun next(
kind: ProviderKind,
region: ProviderRegion? = null,
sessionKey: String? = null,
): PooledAccount? = mutex.withLock {
val service = serviceFor(kind) ?: return null
val accounts = credentialRepository.accountsFor(service)
val allIds = accounts.map { it.id }
val matchedIds = if (region == null) {
allIds
} else {
allIds.filter { accountId -> regionMatches(service, accountId, region) }
.ifEmpty { allIds }
}
if (matchedIds.isEmpty()) return null
val accountId = if (sessionKey != null) {
sessionSticky[sessionKey]
?.takeIf { it in matchedIds }
?: pickRoundRobin(kind, matchedIds).also { sessionSticky[sessionKey] = it }
} else {
pickRoundRobin(kind, matchedIds)
}
PooledAccount(
kind = kind,
region = region,
accountId = accountId,
label = accounts.firstOrNull { it.id == accountId }?.label,
)
}
private suspend fun regionMatches(service: ServiceType, accountId: String, region: ProviderRegion): Boolean {
val credential = credentialRepository.get(service, accountId) ?: return false
return when (credential) {
is Credential.TraeCredential -> credential.region.equals(region.name, ignoreCase = true)
is Credential.WorkBuddyCredential -> credential.region.equals(region.name, ignoreCase = true)
else -> true
}
}
private fun pickRoundRobin(kind: ProviderKind, ids: List<String>): String {
val idx = cursor[kind] ?: 0
cursor[kind] = (idx + 1) % ids.size
return ids[idx]
}
private fun serviceFor(kind: ProviderKind): ServiceType? = when (kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> ServiceType.WORKBUDDY
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> ServiceType.TRAE
ProviderKind.OPENAI_COMPATIBLE -> ServiceType.SUB2API
else -> null
}
}

View File

@ -1,243 +0,0 @@
package com.rainy.token.data.proxy
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.buildJsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 基于 Ktor CIO 的本地 HTTP 反代服务。
*
* M1b 能力:
* - GET /health
* - GET /v1/models
* - POST /v1/chat/completions
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 安全:
* - 只绑定 127.0.0.1
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
*/
@Singleton
class KtorLocalProxyServer @Inject constructor(
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeChatProxy: TraeChatProxy,
private val providerRouter: ProviderRouter,
private val accountPool: AccountPool,
) : LocalProxyServer {
private val lock = Any()
@Volatile
private var server: EmbeddedServer<*, *>? = null
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
override fun start(config: ProxyServerConfig): Result<Unit> {
synchronized(lock) {
if (_isRunning.value) return Result.success(Unit)
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) {
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
}
engine.start(wait = false)
server = engine
_isRunning.value = true
Result.success(Unit)
} catch (e: Throwable) {
server = null
_isRunning.value = false
Result.failure(e)
}
}
}
override fun stop() {
synchronized(lock) {
runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) }
server = null
_isRunning.value = false
}
}
private fun Application.proxyModule(
apiKey: String?,
sub2Api: Sub2ApiChatProxy,
workBuddy: WorkBuddyChatProxy,
trae: TraeChatProxy,
router: ProviderRouter,
pool: AccountPool,
) {
install(ContentNegotiation) {
json()
}
routing {
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/v1/models") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@get
}
val result = sub2Api.forwardModels()
if (result == null) {
call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"))
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
post("/v1/chat/completions") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@post
}
val rawBody = call.receiveText()
if (rawBody.length > MAX_REQUEST_BYTES) {
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
return@post
}
val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody)
try {
if (extractStream(rawBody)) {
val stream = router.openStreamingChat(rawBody, conversationId)
if (stream == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("所有上游均不可用或未配置,请检查设置")
)
} else {
call.respondOutputStream(
contentType = contentTypeOf(stream.contentType),
status = HttpStatusCode(stream.status, "")
) {
try {
val buffer = ByteArray(8192)
val input = stream.input
while (true) {
val read = input.read(buffer)
if (read < 0) break
write(buffer, 0, read)
flush()
}
} finally {
stream.close()
}
}
}
} else {
val result = router.forwardChat(rawBody, conversationId)
if (result == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("所有上游均不可用或未配置,请检查设置")
)
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
} catch (e: IOException) {
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.BadGateway,
errorBody("上游网络错误:$detail")
)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.InternalServerError,
errorBody("网关内部错误:$detail")
)
}
}
}
}
private fun authorized(call: ApplicationCall, apiKey: String?): Boolean {
if (apiKey.isNullOrBlank()) return true
val header = call.request.headers[HttpHeaders.Authorization] ?: return false
val expect = "Bearer $apiKey"
return header.trim() == expect
}
private fun errorBody(message: String): Map<String, Any> =
mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error"))
private fun contentTypeOf(raw: String): ContentType =
runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json)
private fun extractStream(body: String): Boolean = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("stream")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.contentOrNull?.toBooleanStrictOrNull() else null }
}.getOrNull() ?: false
private fun stripModelPrefix(model: String): String {
val m = model.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) }
return if (prefix != null) m.substring(prefix.length).ifBlank { m } else m
}
private fun rewriteModelBody(body: String, newModel: String): String = try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body)
as? kotlinx.serialization.json.JsonObject ?: return body
kotlinx.serialization.json.buildJsonObject {
obj.forEach { (key, value) ->
put(key, if (key == "model") kotlinx.serialization.json.JsonPrimitive(newModel) else value)
}
}.toString()
} catch (_: Throwable) {
body
}
private fun extractModel(body: String): String = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("model")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
private fun extractUser(body: String): String? = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("user")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull()
companion object {
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
}
}

View File

@ -1,27 +0,0 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* 本地 HTTP 反代服务配置。
*/
data class ProxyServerConfig(
val port: Int = 8787,
/** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */
val apiKey: String? = null,
)
/**
* 本地 HTTP 反代服务。
*
* 具体实现建议用 Ktor CIO(协程友好,方便 SSE 流式转发);
* 若 APK 体积敏感,可换 NanoHTTPD,但流式转发需要额外桥接。
*/
interface LocalProxyServer {
val isRunning: StateFlow<Boolean>
fun start(config: ProxyServerConfig): Result<Unit>
fun stop()
}

View File

@ -1,70 +0,0 @@
package com.rainy.token.data.proxy
import io.ktor.http.ContentType
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.withTimeoutOrNull
/**
* 本地登录回调服务器:在 App 内起一个 127.0.0.1 端口,
* 手机浏览器完成 Trae OAuth 后跳回 `http://127.0.0.1:<port>/authorize?...`,
* 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。
*
* 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。
*/
object LoginCallbackServer {
private const val PATH = "/authorize"
private var server: EmbeddedServer<*, *>? = null
private var pending: CompletableDeferred<String>? = null
/** 启动(已启动则只重置等待任务)。返回是否可用。 */
@Synchronized
fun start(port: Int): Boolean {
if (server != null) {
pending = CompletableDeferred()
return true
}
pending = CompletableDeferred()
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = port) {
routing {
get(PATH) {
val callbackUrl = "http://127.0.0.1:$port" + call.request.local.uri
pending?.complete(callbackUrl)
call.respondText(
"登录成功,可以返回雨晴Token了。",
ContentType.Text.Html
)
}
}
}
engine.start(wait = false)
server = engine
true
} catch (t: Throwable) {
server = null
pending = null
false
}
}
/** 等待登录回调;[timeoutMs] 内没等到返回 null。 */
suspend fun await(timeoutMs: Long): String? {
val deferred = pending ?: return null
return withTimeoutOrNull(timeoutMs) { deferred.await() }
}
@Synchronized
fun stop() {
runCatching { server?.stop(gracePeriodMillis = 200, timeoutMillis = 1000) }
server = null
pending = null
}
}

View File

@ -1,21 +0,0 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.Flow
/**
* 统一的上游 Provider 适配器。
*
* 实现类负责把 [ProxyChatRequest] 翻译成各家私有协议
* (llm_utils_chat / v2/plugin / ACP 等),并把上游响应/SSE 流翻译回 [Flow]。
*
* 账号池、熔断、会话粘性由网关上层统一处理,不在适配器内重复实现。
*/
interface ProviderAdapter {
val kind: ProviderKind
/** 该适配器支持的模型列表;空列表表示暂未加载/不支持。 */
suspend fun models(): List<ProxyModel>
/** 发起对话,内部处理流式/非流式。 */
fun chat(request: ProxyChatRequest, accountId: String?): Flow<ProxyChatChunk>
}

View File

@ -1,33 +0,0 @@
package com.rainy.token.data.proxy
/**
* 网关支持的 Provider 清单。
*
* 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
* supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
*/
enum class ProviderKind(
val displayName: String,
val regions: Set<ProviderRegion> = ProviderRegion.entries.toSet(),
val supportsChat: Boolean = true,
val supportsCheckin: Boolean = false,
) {
WORKBUDDY_CN("WorkBuddy 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
WORKBUDDY_INTL("WorkBuddy 国际版", setOf(ProviderRegion.INTL)),
TRAE_CN("Trae CN", setOf(ProviderRegion.CN), supportsCheckin = true),
TRAE_INTL("Trae INTL", setOf(ProviderRegion.INTL)),
TRAE_WORK("TraeWork 桌面版", setOf(ProviderRegion.CN)),
QODER_CN("Qoder CN", setOf(ProviderRegion.CN), supportsCheckin = true),
RACCOON_CN("小浣熊(商汤)", setOf(ProviderRegion.CN), supportsCheckin = true),
CATPAW_CN("CatPaw(美团)", setOf(ProviderRegion.CN)),
AUTOCLAW_CN("AutoClaw 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
AUTOCLAW_INTL("AutoClaw 国际版", setOf(ProviderRegion.INTL)),
CLINE("Cline", setOf(ProviderRegion.INTL)),
ACCIO_CN("Accio 国内版", setOf(ProviderRegion.CN)),
ACCIO_INTL("Accio 国际版", setOf(ProviderRegion.INTL)),
ZCODE_CN("ZCode 国内版", setOf(ProviderRegion.CN)),
ZCODE_INTL("ZCode 国际版", setOf(ProviderRegion.INTL)),
CODEARTS_CN("CodeArts(华为云)", setOf(ProviderRegion.CN)),
LOOMY_CN("Loomy(讯飞)", setOf(ProviderRegion.CN), supportsCheckin = true),
OPENAI_COMPATIBLE("OpenAI 兼容自定义", ProviderRegion.entries.toSet()),
}

View File

@ -1,6 +0,0 @@
package com.rainy.token.data.proxy
/**
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
*/
enum class ProviderRegion { CN, INTL }

View File

@ -1,293 +0,0 @@
package com.rainy.token.data.proxy
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* 聚合路由:一个本地 API Key 通吃所有供应商。
*
* 规则:
* 1. 模型名带显式前缀(traecn- / wbintl- 等)→ 强制指定供应商;
* 2. 否则按【模型名】找所有支持的供应商,按历史速度排序逐个尝试;
* 3. 上游不可用/HTTP>=400/抛错 → 自动切换到下一个供应商;
* 4. Sub2API 作为通用兜底(配置了账号时)。
*/
@Singleton
class ProviderRouter @Inject constructor(
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val traeModelProvider: TraeModelProvider,
private val workBuddyModelProvider: WorkBuddyModelProvider,
private val accountPool: AccountPool,
) {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion?,
)
/** 网关内供应商目标。 */
enum class ProviderTarget(
val kind: ProviderKind,
val region: ProviderRegion?,
val displayName: String,
) {
TRAE_CN(ProviderKind.TRAE_CN, ProviderRegion.CN, "Trae CN"),
TRAE_INTL(ProviderKind.TRAE_INTL, ProviderRegion.INTL, "Trae INTL"),
WORKBUDDY_CN(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN, "WorkBuddy CN"),
WORKBUDDY_INTL(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL, "WorkBuddy INTL"),
SUB2API(ProviderKind.OPENAI_COMPATIBLE, null, "Sub2API"),
}
private data class Candidate(
val target: ProviderTarget,
val accountId: String?,
)
// ---- 速度 / 健康度统计(内存态) ----
private val avgLatency = ConcurrentHashMap<ProviderTarget, Long>()
private val cooldownUntil = ConcurrentHashMap<ProviderTarget, Long>()
private val failCount = ConcurrentHashMap<ProviderTarget, Int>()
private fun recordSuccess(target: ProviderTarget, startedMs: Long, status: Int) {
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
val old = avgLatency[target]
avgLatency[target] = if (old == null) ms else (old * 3 + ms) / 4
cooldownUntil.remove(target)
failCount.remove(target)
}
private fun recordFail(target: ProviderTarget, startedMs: Long) {
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
val old = avgLatency[target]
avgLatency[target] = if (old == null) ms + 5000L else old + 5000L
val fails = (failCount[target] ?: 0) + 1
failCount[target] = fails
cooldownUntil[target] = System.currentTimeMillis() + 30_000L * fails
}
private fun cooling(target: ProviderTarget): Boolean {
val until = cooldownUntil[target] ?: return false
if (System.currentTimeMillis() >= until) {
cooldownUntil.remove(target)
return false
}
return true
}
private fun orderBySpeed(input: List<Candidate>): List<Candidate> = input.sortedWith(
compareBy<Candidate>(
{ cooling(it.target) },
{ avgLatency[it.target] ?: Long.MAX_VALUE },
{ if (it.target == ProviderTarget.SUB2API) 1 else 0 },
)
)
// ---- 流式转发(带失败切换) ----
suspend fun openStreamingChat(
requestBody: String,
conversationId: String? = null,
): ProxyUpstreamStream? {
val model = extractModel(requestBody)
val forced = explicitTarget(model)
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
val candidates = candidatesFor(model, conversationId, forced)
for (candidate in candidates) {
val started = System.currentTimeMillis()
val stream = try {
when (candidate.target.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddyChatProxy.openStreamingChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
traeChatProxy.openStreamingChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
else ->
sub2ApiChatProxy.openStreamingChat(upstreamBody, candidate.accountId)
}
} catch (e: java.io.IOException) {
recordFail(candidate.target, started)
null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
recordFail(candidate.target, started)
null
}
if (stream == null) {
recordFail(candidate.target, started)
continue
}
if (stream.status >= 400) {
stream.close()
recordFail(candidate.target, started)
continue
}
recordSuccess(candidate.target, started, stream.status)
return stream
}
return null
}
// ---- 非流式转发(带失败切换) ----
suspend fun forwardChat(
requestBody: String,
conversationId: String? = null,
): ProxyUpstreamResponse? {
val model = extractModel(requestBody)
val forced = explicitTarget(model)
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
val candidates = candidatesFor(model, conversationId, forced)
for (candidate in candidates) {
val started = System.currentTimeMillis()
val result = try {
when (candidate.target.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddyChatProxy.forwardChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
traeChatProxy.forwardChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
else ->
sub2ApiChatProxy.forwardChat(upstreamBody, candidate.accountId)
}
} catch (e: java.io.IOException) {
recordFail(candidate.target, started)
null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
recordFail(candidate.target, started)
null
}
if (result == null) {
recordFail(candidate.target, started)
continue
}
if (result.status >= 400) {
recordFail(candidate.target, started)
continue
}
recordSuccess(candidate.target, started, result.status)
return result
}
return null
}
// ---- 候选构建 ----
private suspend fun candidatesFor(
model: String,
conversationId: String?,
forced: ProviderTarget?,
): List<Candidate> {
val targets = if (forced != null) {
listOf(forced)
} else {
capableTargets(model)
}
val built = targets.mapNotNull { target ->
val pooled = accountPool.next(target.kind, target.region, conversationId)
pooled?.accountId?.let { Candidate(target, it) }
}
return orderBySpeed(built)
}
private fun capableTargets(model: String): List<ProviderTarget> {
val m = model.trim().lowercase()
if (m.isBlank() || m == "auto") {
return listOf(
ProviderTarget.TRAE_CN,
ProviderTarget.WORKBUDDY_CN,
ProviderTarget.TRAE_INTL,
ProviderTarget.WORKBUDDY_INTL,
ProviderTarget.SUB2API,
)
}
val out = mutableListOf<ProviderTarget>()
if (traeModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.TRAE_CN
if (traeModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.TRAE_INTL
if (workBuddyModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.WORKBUDDY_CN
if (workBuddyModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.WORKBUDDY_INTL
// 通用兜底:Sub2API 是 OpenAI 兼容实例,什么模型都可能支持
out += ProviderTarget.SUB2API
return out.distinct().ifEmpty { listOf(ProviderTarget.TRAE_CN) }
}
private fun explicitTarget(model: String): ProviderTarget? {
val m = model.trim().lowercase()
return when {
m.startsWith("wbcn-") || m.startsWith("workbuddy-cn") || m.startsWith("codebuddy-") ->
ProviderTarget.WORKBUDDY_CN
m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") ->
ProviderTarget.WORKBUDDY_INTL
m.startsWith("traeintl-") || m.startsWith("trae-intl") ->
ProviderTarget.TRAE_INTL
m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") ->
ProviderTarget.TRAE_CN
m.startsWith("sub2api-") || m.startsWith("openai-") ->
ProviderTarget.SUB2API
else -> null
}
}
/** 兼容旧调用:只返回模型名前缀对应的路由。 */
fun route(model: String): Route {
val target = explicitTarget(model)
return if (target == null) {
Route(ProviderKind.OPENAI_COMPATIBLE, null)
} else {
Route(target.kind, target.region)
}
}
private fun stripModelInBody(body: String, originalModel: String): String {
val m = originalModel.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) } ?: return body
val newModel = m.substring(prefix.length).ifBlank { m }
if (newModel == m) return body
return runCatching {
val obj = Json.parseToJsonElement(body) as? JsonObject ?: return@runCatching body
JsonObject(
obj.entries.associate { (key, value) ->
key to (if (key == "model") JsonPrimitive(newModel) else value)
}
).toString()
}.getOrDefault(body)
}
private fun extractModel(body: String): String = runCatching {
val el = Json.parseToJsonElement(body)
(el as? JsonObject)
?.get("model")
?.let { if (it is JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
}

View File

@ -1,56 +0,0 @@
package com.rainy.token.data.proxy
import kotlinx.serialization.json.JsonElement
/**
* 网关对外的最小 OpenAI 兼容模型。
* 后续按协议(Chat Completions / Responses / Anthropic)扩展。
*/
data class ProxyChatRequest(
val model: String,
val messages: List<ProxyChatMessage> = emptyList(),
val stream: Boolean = false,
val tools: List<JsonElement>? = null,
val toolChoice: JsonElement? = null,
val reasoningEffort: String? = null,
val maxTokens: Int? = null,
)
data class ProxyChatMessage(
val role: String,
val content: String? = null,
val toolCalls: List<JsonElement>? = null,
val toolCallId: String? = null,
val name: String? = null,
)
data class ProxyModel(
val id: String,
val provider: ProviderKind,
val region: ProviderRegion? = null,
)
/** 流式/非流式统一输出块,由 StreamNormalizer 归一。 */
sealed interface ProxyChatChunk {
data class Done(
val text: String? = null,
val usage: ProxyUsage? = null,
) : ProxyChatChunk
data class Delta(
val text: String? = null,
val reasoning: String? = null,
val toolCalls: List<JsonElement>? = null,
) : ProxyChatChunk
data class Error(
val message: String,
val code: Int? = null,
) : ProxyChatChunk
}
data class ProxyUsage(
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
)

View File

@ -1,15 +0,0 @@
package com.rainy.token.data.proxy
import java.io.InputStream
/**
* 上游流式响应句柄:由本地网关在收到客户端 stream=true 请求时创建,
* 把上游 SSE/原始字节流实时转发给本地客户端。
* 使用方必须在 finally 中调用 [close]。
*/
class ProxyUpstreamStream(
val status: Int,
val contentType: String,
val input: InputStream,
val close: () -> Unit,
)

View File

@ -1,30 +0,0 @@
package com.rainy.token.data.proxy
/**
* 模型名 → Provider + Region 路由。
*
* 骨架策略:按模型家族前缀映射;后续可用 agent2api / proxy-hub 的远端模型目录替换。
*/
class RegionRouter {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion,
)
fun route(model: String): Route? {
val normalized = model.lowercase()
return when {
// 国内/字节/智谱系模型默认 CN
CN_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.CN)
// 国际模型默认 INTL
INTL_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.INTL)
else -> null
}
}
companion object {
private val CN_MODEL_FAMILIES = listOf("glm", "deepseek", "kimi", "qwen", "doubao", "hunyuan", "minimax")
private val INTL_MODEL_FAMILIES = listOf("gpt", "o1", "o3", "o4", "claude", "gemini", "grok")
}
}

View File

@ -1,135 +0,0 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Sub2API Chat 透传代理。
*
* Sub2API 实例本身暴露 OpenAI 兼容接口,因此这里不做协议转换:
* 取出用户保存的 Sub2ApiCredential(优先 sk- API Key,其次面板 authToken),
* 把客户端请求原样转发到 {base}/v1/chat/completions,
* 并把上游响应(含 SSE 流式内容)原样返回给本地客户端。
*
* M1a 阶段:先做整包透传(流式也先缓冲),后续由 StreamNormalizer 升级为逐块转发。
*/
@Singleton
class Sub2ApiChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
/** 转发 POST /v1/chat/completions。 */
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/chat/completions", requestBody = requestBody, accountId = accountId)
}
/** 转发流式 POST /v1/chat/completions(上游 SSE 原样转发)。 */
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val base = normalizeBase(credential.baseUrl) ?: return@withContext null
val builder = Request.Builder()
.url(base + "/v1/chat/completions")
.addHeader("Authorization", auth)
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = okHttpClient.newCall(builder.build()).execute()
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
/** 转发 GET /v1/models。 */
suspend fun forwardModels(accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/models", requestBody = null, accountId = accountId)
}
private suspend fun resolveBase(accountId: String?): String? {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return null
if (credential !is Credential.Sub2ApiCredential) return null
if (resolveAuth(credential) == null) return null
return normalizeBase(credential.baseUrl)
}
private suspend fun forward(
base: String,
path: String,
requestBody: String?,
accountId: String?,
): ProxyUpstreamResponse? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val builder = Request.Builder()
.url(base + path)
.addHeader("Authorization", auth)
val body = requestBody?.takeIf { it.isNotBlank() }
if (body != null) {
builder
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
}
val response = okHttpClient.newCall(builder.build()).execute()
val bytes = response.body?.bytes() ?: ByteArray(0)
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
private fun resolveAuth(credential: Credential.Sub2ApiCredential): String? {
credential.apiKey?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
credential.authToken?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
return null
}
private fun normalizeBase(raw: String): String? {
var s = raw.trim()
while (s.endsWith("/")) s = s.dropLast(1)
return s.takeIf { it.isNotBlank() }
}
/** 是否已配置 Sub2API 凭据(用于聚合网关兜底)。 */
suspend fun hasCredential(): Boolean {
val credential = credentialRepository.get(ServiceType.SUB2API, null)
return credential != null
}
}
/**
* 上游 HTTP 响应(透传用)。
*/
data class ProxyUpstreamResponse(
val status: Int,
val contentType: String,
val body: ByteArray,
)

View File

@ -1,319 +0,0 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae CN 上游 Chat 代理。
*
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat。
* CN 模型网关固定走 trae-api-cn.mchost.guru(api.trae.cn 只是账号/OAuth 主机,返回 404)。
* 请求头/body 对齐 trae2api-cn 参考实现的 SOLO 协议。
*/
@Singleton
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val modelProvider: TraeModelProvider,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
private fun soloHeaders(
jwt: String,
credential: Credential.TraeCredential,
requestId: String,
): Map<String, String> = linkedMapOf(
"Content-Type" to "application/json",
"Accept" to "text/event-stream",
"Connection" to "keep-alive",
"Authorization" to "Cloud-IDE-JWT $jwt",
"X-Cloudide-Token" to jwt,
"x-ide-token" to jwt,
"x-uid" to (credential.userId ?: ""),
"x-app-id" to "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
"x-device-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-machine-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-request-id" to requestId,
"x-ide-version" to "0.1.52",
"x-ide-version-code" to "20260811",
"x-ide-version-type" to "stable",
"x-app-version" to "default",
"x-app-version-code" to "20260811",
"x-version-code" to "20260811",
"x-device-cpu" to "AMD",
"x-device-brand" to "83DG",
"x-device-type" to "windows",
"x-device-platform" to "windows",
"x-os-version" to "Windows 11 Pro",
"x-system-type" to "Windows",
"package-type" to "stable_cn",
"x-lscbd-aid" to "787976",
"x-lscbd-platform" to "windows",
"x-ss-dp" to "787976",
"x-plugin-channel" to "icube-ai",
"app-version" to "0.1.52",
"x-bridge-transport" to "aha",
"x-ahanet-timeout" to "86400",
"x-lgw-req-sdk-type" to "3",
"x-net-sdk-domain-dispatch" to "1",
"x-ttnet-bypass-decompression" to "1",
"x-ttnet-bypass-cookie" to "0",
"request-traffic-type" to "prod",
"User-Agent" to "Trae/0.1.52",
)
private fun convertNativeMessages(src: JsonElement?): List<JsonElement> {
val arr = src as? JsonArray ?: return emptyList()
return arr.mapNotNull { el ->
val m = el as? JsonObject ?: return@mapNotNull null
val rawRole = (m["role"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: "user"
val role = when (rawRole) {
"developer" -> "system"
"system", "user", "assistant", "tool", "function" -> rawRole
else -> "user"
}
buildJsonObject {
put("role", role)
val content = nativeContent(m["content"])
if (content != null) put("content", content)
m["name"]?.let { put("name", it) }
m["tool_call_id"]?.let { put("tool_call_id", it) }
m["tool_calls"]?.let { put("tool_calls", it) }
}
}
}
private fun nativeContent(content: JsonElement?): JsonElement? {
if (content == null || content is JsonNull) return null
if (content is JsonPrimitive) {
return buildJsonArray {
add(buildJsonObject {
put("type", "text")
put("text", content.content)
})
}
}
if (content is JsonArray) {
return buildJsonArray {
content.forEach { block ->
when (block) {
is JsonPrimitive -> add(buildJsonObject {
put("type", "text")
put("text", block.content)
})
is JsonObject -> {
val type = (block["type"] as? JsonPrimitive)?.contentOrNull?.lowercase()
if (type == "text" || type == "input_text") {
val text = (block["text"] as? JsonPrimitive)?.contentOrNull
?: (block["content"] as? JsonPrimitive)?.contentOrNull
?: ""
add(buildJsonObject {
put("type", "text")
put("text", text)
})
} else {
add(block)
}
}
else -> add(block)
}
}
}
}
return null
}
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
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
}
/**
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
*/
private fun chatBase(region: ProviderRegion?): String =
if (region == ProviderRegion.INTL) {
"https://core-normal.trae.ai"
} else {
"https://trae-api-cn.mchost.guru"
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
val model = normalizeModel(rawModel, region)
val messages = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "")
return buildJsonObject {
put("messages", JsonArray(messages))
put("config_name", model)
put("model", model)
put("function", "solo_work_lite")
put("stream", true)
put("request_id", sessionId)
put("session_id", sessionId)
src["tools"]?.let { put("tools", it) }
src["tool_choice"]?.let { put("tool_choice", it) }
(src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let {
put("max_tokens", it)
}
}.toString()
}
companion object {
/** 供路由判断模型是否可能由 Trae 消化。 */
internal fun resolveAlias(modelName: String): String? =
MODEL_ALIASES[modelName.trim().lowercase()]
/** 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",
)
}
}

View File

@ -1,191 +0,0 @@
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 _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true }
/** 当前区域选择的默认模型(未设置时 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
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(key, id).apply()
}
/** 该区域是否可能支持此模型(内置列表/在线列表/别名)。auto 视为支持。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
if (modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }) return true
return TraeChatProxy.resolveAlias(modelId) != null
}
/** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
val effectiveRegion = forcedRegion ?: ProviderRegion.CN
val credential = pickCredential(effectiveRegion) ?: return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://core-normal.trae.ai" else "https://trae-api-cn.mchost.guru"
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
val userRegion = if (intl) {
if (credential.apiHost?.contains("us", ignoreCase = true) == true) "US" else "SG"
} else "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 userRegion,
"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
_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
val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModel>()
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_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<TraeModel> = 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 档)"),
)
}
}

View File

@ -1,127 +0,0 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。
*
* 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。
* 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。
*/
@Singleton
class WorkBuddyChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://www.workbuddy.ai"
} else {
"https://copilot.tencent.com"
}
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/v2/chat/completions")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("User-Agent", "RainyToken/1.0")
.addHeader("X-IDE-Type", "VSCode")
.addHeader("X-IDE-Name", "CodeBuddy")
.addHeader("X-IDE-Version", "3.0.0")
.addHeader("X-Product", "CodeBuddy")
.addHeader("X-Agent-Intent", "craft")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://www.workbuddy.ai"
} else {
"https://copilot.tencent.com"
}
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/v2/chat/completions")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("User-Agent", "RainyToken/1.0")
.addHeader("X-IDE-Type", "VSCode")
.addHeader("X-IDE-Name", "CodeBuddy")
.addHeader("X-IDE-Version", "3.0.0")
.addHeader("X-Product", "CodeBuddy")
.addHeader("X-Agent-Intent", "craft")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
}

View File

@ -1,141 +0,0 @@
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 _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
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()
}
/** 该区域是否可能支持此模型(在线/内置列表)。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }
}
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = pickCredential(region) ?: 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
_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
val data = root["data"] as? JsonObject ?: return null
val models = data["models"] as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModelProvider.TraeModel>()
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<TraeModelProvider.TraeModel> = 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"),
)
}
}

View File

@ -58,79 +58,6 @@ class TraeRepository constructor(
private val json = Json { ignoreUnknownKeys = true }
/** 拉取近 30 天按会话聚合的用量事件(官方 Usage Events 数据)。 */
suspend fun fetchUsageEvents(
accountId: String? = null,
startSec: Long = System.currentTimeMillis() / 1000L - 30 * 86400L,
endSec: Long = System.currentTimeMillis() / 1000L,
pageNum: Int = 1,
pageSize: Int = 20
): Result<List<TraeUsageEvent>> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = if (regionKey == "INTL") {
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
?: "https://grow-normal.trae.ai"
host + "/trae/api/v1/pay/query_user_usage_group_by_session"
} else {
"https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"
}
val body =
"{\"start_time\":$startSec,\"end_time\":$endSec,\"page_size\":$pageSize,\"page_num\":$pageNum,\"usage_type\":[7]}"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim())
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
val text = try {
okHttpClient.newCall(request).execute().use { resp ->
val t = resp.body?.string().orEmpty()
DebugLog.i(TAG, "fetchUsageEvents: http=${resp.code} len=${t.length}")
if (!resp.isSuccessful) {
return@withContext Result.failure(
if (resp.code == 401 || resp.code == 403) authErrorFor(credential.jwt.trim())
else mapHttpError(resp.code)
)
}
t
}
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchUsageEvents exception: " + e)
return@withContext Result.failure(RepositoryError.Network(e))
}
val events = try {
val root = json.parseToJsonElement(text).jsonObject
root["user_usage_group_by_sessions"]?.jsonArray?.mapNotNull { el ->
val obj = el.jsonObject
val extra = obj["extra_info"]?.jsonObject
TraeUsageEvent(
usageTime = obj.scalarOr("usage_time")?.toLongOrNull() ?: 0L,
modelName = obj.scalarOr("model_name").orEmpty(),
mode = obj.scalarOr("mode").orEmpty(),
userInputPreview = obj.scalarOr("user_input_preview").orEmpty(),
creditsFloat = obj.numberOr("credits_float", "amount_float") ?: 0.0,
costMoneyFloat = obj.numberOr("cost_money_float") ?: 0.0,
inputToken = extra?.numberOr("input_token")?.toLong() ?: 0L,
outputToken = extra?.numberOr("output_token")?.toLong() ?: 0L,
cacheReadToken = extra?.numberOr("cache_read_token")?.toLong() ?: 0L,
cacheWriteToken = extra?.numberOr("cache_write_token")?.toLong() ?: 0L
)
} ?: emptyList()
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchUsageEvents parse error: " + e)
emptyList()
}
Result.success(events.sortedByDescending { it.usageTime }.take(50))
}
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -139,12 +66,9 @@ class TraeRepository constructor(
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = if (regionKey == "INTL") {
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
?: "https://grow-normal.trae.ai"
host + "/trae/api/v1/pay/ide_user_ent_usage"
} else {
"https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
val url = when (regionKey) {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
}
val request = Request.Builder()
.url(url)
@ -200,8 +124,6 @@ class TraeRepository constructor(
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it }
root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
val isDollarBilling = root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.equals("true", true) == true
if (isDollarBilling) extras["billing_mode"] = "dollar"
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总
@ -234,28 +156,6 @@ class TraeRepository constructor(
?: base?.get("product_extra")?.jsonObject
?.get("package_extra")?.jsonObject?.getString("package_name")
?: "pack[$index]"
if (isDollarBilling) {
packCount++
if (packShown == 0) {
extras["intl.plan_name"] = name
extras["intl.billing_version"] = root["billing_version"]?.jsonPrimitive?.contentOrNull ?: ""
packShown++
}
quota?.let { q ->
q.getNumber("premium_model_fast_request_limit")?.let { extras["intl.premium_fast_limit"] = trimNum(it) }
q.getNumber("premium_model_slow_request_limit")?.let { extras["intl.premium_slow_limit"] = trimNum(it) }
q.getNumber("advanced_model_request_limit")?.let { extras["intl.advanced_limit"] = trimNum(it) }
q.getNumber("auto_completion_limit")?.let { extras["intl.auto_completion_limit"] = trimNum(it) }
q.getNumber("basic_usage_limit")?.let { extras["intl.basic_usage_limit"] = trimNum(it) }
q.getNumber("basic_usage_limit")?.let { extras["intl.dollar_limit"] = it.toString() }
q.getNumber("bonus_usage_limit")?.let { extras["intl.bonus_usage_limit"] = trimNum(it) }
}
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.basic_used"] = trimNum(it) }
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.dollar_used"] = it.toString() }
pack.getNumber("usage", "bonus_usage_amount")?.let { extras["intl.bonus_used"] = trimNum(it) }
pack.getNumber("usage", "credits_amount")?.let { extras["intl.credits_used"] = trimNum(it) }
return@forEachIndexed
}
val limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
@ -286,7 +186,7 @@ class TraeRepository constructor(
}
}
}
if (packCount > 0 && !isDollarBilling) {
if (packCount > 0) {
extras["pack_count"] = packCount.toString()
extras["general_credits"] = trimNum(generalSum)
extras["work_credits"] = trimNum(workSum)
@ -836,16 +736,3 @@ class TraeRepository constructor(
}
}
}
/** 官方 Usage Events 按会话聚合的一条用量记录。 */
data class TraeUsageEvent(
val usageTime: Long,
val modelName: String,
val mode: String,
val userInputPreview: String,
val creditsFloat: Double,
val costMoneyFloat: Double,
val inputToken: Long,
val outputToken: Long,
val cacheReadToken: Long,
val cacheWriteToken: Long
)

View File

@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
}
}
/** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
suspend fun downloadApk(
info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit
@ -64,11 +64,6 @@ class UpdateRepository @Inject constructor(
try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk")
if (file.exists() && file.length() > 0L) {
return@withContext Result.success(file)
}
// 先写 .part,下载完整后再替换正式文件名,避免半截文件被当成完整包
val part = File(dir, file.name + ".part")
val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
@ -76,7 +71,7 @@ class UpdateRepository @Inject constructor(
}
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength()
part.outputStream().buffered().use { output ->
file.outputStream().buffered().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L
@ -89,8 +84,6 @@ class UpdateRepository @Inject constructor(
}
}
}
if (file.exists()) file.delete()
part.renameTo(file)
Result.success(file)
} catch (e: Exception) {
Result.failure(e)

View File

@ -53,26 +53,6 @@ class WorkBuddyRepository constructor(
private val jsonMedia = "application/json".toMediaType()
private fun Credential.WorkBuddyCredential.isIntlRegion(): Boolean =
region.equals("INTL", ignoreCase = true)
private fun Credential.WorkBuddyCredential.apiBase(): String =
if (isIntlRegion()) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
private fun Credential.WorkBuddyCredential.originBase(): String =
if (isIntlRegion()) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
private fun Credential.WorkBuddyCredential.summaryUrl(): String =
if (isIntlRegion()) "https://www.workbuddy.ai/billing/meter/get-user-resource-summary"
else "https://www.workbuddy.cn/billing/meter/get-user-resource-summary"
private fun Credential.WorkBuddyCredential.resourceUrl(): String =
if (isIntlRegion()) "https://www.workbuddy.ai/billing/meter/get-user-resource"
else "https://www.codebuddy.cn/v2/billing/meter/get-user-resource"
private fun Credential.WorkBuddyCredential.refreshUrl(): String =
apiBase() + "/v2/plugin/auth/token/refresh"
private val codebuddyBody = buildJsonObject {
put("PageNumber", 1)
put("PageSize", 100)
@ -99,7 +79,7 @@ class WorkBuddyRepository constructor(
var lastBody = ""
while (attempts < 3) {
attempts++
val request = if (hostMode == 0) summaryRequest(credential, access) else codebuddyRequest(credential, access)
val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
val resp = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
@ -121,7 +101,7 @@ class WorkBuddyRepository constructor(
return@withContext Result.failure(mapHttpError(code, body))
}
401, 403 -> {
val refreshed = tryRefesh(credential, refresh) ?: return@withContext Result.failure(
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential()
)
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
@ -161,7 +141,7 @@ class WorkBuddyRepository constructor(
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
val detailExtras = runCatching { fetchResourceDetailExtras(credential, access) }.getOrNull()
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
val mergedExtras = if (detailExtras != null) {
parsed.extras + detailExtras
} else {
@ -186,9 +166,9 @@ class WorkBuddyRepository constructor(
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。
*/
private fun fetchResourceDetailExtras(credential: Credential.WorkBuddyCredential, accessToken: String): Map<String, String>? {
private fun fetchResourceDetailExtras(accessToken: String): Map<String, String>? {
val request = Request.Builder()
.url(credential.resourceUrl())
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
@ -207,18 +187,18 @@ class WorkBuddyRepository constructor(
}
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
private fun summaryRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
private fun summaryRequest(accessToken: String): Request =
Request.Builder()
.url(credential.summaryUrl())
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
private fun codebuddyRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
private fun codebuddyRequest(accessToken: String): Request =
Request.Builder()
.url(credential.resourceUrl())
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia))
@ -228,9 +208,9 @@ class WorkBuddyRepository constructor(
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
*/
private fun tryRefesh(credential: Credential.WorkBuddyCredential, refreshToken: String): RefreshedWorkBuddy? {
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
val request = Request.Builder()
.url(credential.refreshUrl())
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
.addHeader("X-Refresh-Token", refreshToken)
.post("{}".toRequestBody(jsonMedia))
.build()
@ -459,9 +439,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
FAILED,
/** 国际站无签到活动(活动未开启/未上线,非错误) */
NO_CAMPAIGN
FAILED
}
/**
@ -488,12 +466,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 国际版没有独立签到档案:积分来自网页/客户端活跃使用,直接返回“无活动”。
if (credential.isIntlRegion()) {
return@withContext Result.success(
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
)
}
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
val body = authedPostFirstSuccessful(
@ -525,12 +497,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 国际版不调用 daily-checkin(无签到营业),直接按无活动处理,避免每次刷新都报错。
if (credential.isIntlRegion()) {
return@withContext Result.success(
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
)
}
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
@ -564,15 +530,8 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
}.getOrNull().orEmpty()
val status = fetchCheckinStatus(accountId).getOrNull()
val lowerMsg = msg.lowercase()
// 国际站无签到活动:接口可能返回 data.active=false 或「活动未开启/已过期」。
val inactive = runCatching {
val r = json.parseToJsonElement(body).jsonObject
((r["data"] as? JsonObject)?.get("active") as? JsonPrimitive)?.contentOrNull?.equals("false", true) == true
}.getOrDefault(false)
val noCampaign = inactive || msg.contains("活动未开启") || msg.contains("已过期") ||
lowerMsg.contains("not active")
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
val lowerMsg = msg.lowercase()
val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
lowerMsg.contains("claimed")
@ -587,12 +546,6 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
outcome = CheckinOutcome.SUCCESS
)
)
noCampaign -> Result.success(
(status ?: CheckinStatus(false, false, 0, 0, 0, "")).copy(
message = msg,
outcome = CheckinOutcome.NO_CAMPAIGN
)
)
already -> Result.success(
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
outcome = CheckinOutcome.ALREADY
@ -642,23 +595,10 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url(credential.originBase() + path)
.url("https://www.codebuddy.cn" + path)
.addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json, text/plain, */*")
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.addHeader("Origin", credential.originBase())
.addHeader("Referer", credential.originBase() + "/profile/growth-center")
.addHeader("X-CodeBuddy-Request", "1")
.addHeader("Accept-Language", if (credential.isIntlRegion()) "en-US" else "zh-CN")
.apply {
if (credential.isIntlRegion()) {
addHeader("X-Domain", "www.workbuddy.ai")
addHeader("X-No-Enterprise-Id", "1")
} else {
addHeader("X-No-Enterprise-Id", "1")
}
}
.post("{}".toRequestBody(jsonMedia))
.build()
val resp = try {
@ -675,7 +615,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
when {
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> {
val refreshed = tryRefesh(credential, refresh) ?: return AuthedPostResult.Failed(rotated)
val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
val expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {

View File

@ -20,15 +20,6 @@ import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository
import com.rainy.token.data.proxy.AccountPool
import com.rainy.token.data.proxy.ProviderRouter
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.proxy.WorkBuddyModelProvider
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -232,70 +223,6 @@ object NetworkModule {
balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
// ---- 本地反代网关 ----
@Provides
@Singleton
fun provideSub2ApiChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
traeModelProvider: TraeModelProvider,
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
@Provides
@Singleton
fun provideProviderRouter(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
traeModelProvider: TraeModelProvider,
workBuddyModelProvider: WorkBuddyModelProvider,
accountPool: AccountPool,
): ProviderRouter = ProviderRouter(
traeChatProxy,
workBuddyChatProxy,
sub2ApiChatProxy,
traeModelProvider,
workBuddyModelProvider,
accountPool,
)
@Provides
@Singleton
fun provideAccountPool(
credentialRepository: CredentialRepository
): AccountPool = AccountPool(credentialRepository)
@Provides
@Singleton
fun provideLocalProxyServer(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
providerRouter: ProviderRouter,
accountPool: AccountPool
): LocalProxyServer = KtorLocalProxyServer(
sub2ApiChatProxy,
workBuddyChatProxy,
traeChatProxy,
providerRouter,
accountPool
)
/** 余额缓存 DataStore(计划 7.1) */
@Provides
@Singleton

View File

@ -79,10 +79,8 @@ sealed class Credential {
override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String,
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai / api-us-east.trae.ai) */
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
val region: String = "CN",
/** 登录回调携带的账号 API 主机(如 https://api-us-east.trae.ai);null=旧凭据用区域默认 */
val apiHost: String? = null,
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
val deviceId: String? = null,
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */
@ -108,8 +106,6 @@ sealed class Credential {
override val service: ServiceType,
val accessToken: String,
val refreshToken: String,
/** 区域:"CN"(copilot.tencent.com / codebuddy.cn)/ "INTL"(workbuddy.ai) */
val region: String = "CN",
/** 过期时间(epoch millis),0=未知 */
val expiresAt: Long = 0L,
override val lastVerifiedAt: Long = 0L

View File

@ -102,20 +102,5 @@ object ServiceConfigProvider {
fun get(type: ServiceType): ServiceConfig =
configs.getValue(type)
/**
* 根据账号区域选择外部操作页:Trae 国际版用 trae.ai,国内版用 trae.cn。
* 其余服务仍走静态配置。
*/
fun externalUrlFor(type: ServiceType, region: String? = null): String? =
if (type == ServiceType.TRAE) {
if (region.equals("INTL", ignoreCase = true)) {
"https://www.trae.ai/pricing"
} else {
"https://www.trae.cn/pricing"
}
} else {
get(type).externalUrl
}
fun all(): List<ServiceConfig> = ServiceType.entries.map { get(it) }
}

View File

@ -1,8 +1,5 @@
package com.rainy.token.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import androidx.compose.animation.AnimatedContentTransitionScope
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.tween
@ -66,6 +63,7 @@ import com.rainy.token.ui.theme.StrawberryPink
import com.rainy.token.ui.webview.CodexOAuthScreen
import com.rainy.token.ui.webview.WebViewLoginScreen
import com.rainy.token.ui.webview.WebViewPageScreen
import android.net.Uri
/**
* 应用导航图。
@ -92,15 +90,12 @@ object Routes {
}
const val ACCOUNTS = "accounts/{type}"
fun accounts(type: ServiceType) = "accounts/${type.name}"
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}&region={region}"
fun webviewLogin(type: ServiceType, accountId: String? = null, region: String? = null): String = buildString {
append("webview_login/").append(type.name)
if (!accountId.isNullOrBlank()) {
append("?account=").append(Uri.encode(accountId))
}
if (!region.isNullOrBlank()) {
append(if (accountId.isNullOrBlank()) "?" else "&").append("region=").append(Uri.encode(region))
}
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
if (accountId.isNullOrBlank()) {
"webview_login/${type.name}"
} else {
"webview_login/${type.name}?account=${Uri.encode(accountId)}"
}
const val WEBVIEW_PAGE = "webview_page?url={url}&title={title}"
fun webviewPage(url: String, title: String) =
@ -142,13 +137,6 @@ private sealed class DetailPane {
data class WebPage(val url: String, val title: String) : DetailPane()
}
private fun Context.openTraeUrl(url: String) {
try {
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
} catch (_: Exception) {
}
}
@Composable
fun RainyTokenNavHost() {
val windowSize = rememberWindowSizeClass()
@ -238,7 +226,6 @@ private fun CompactNavHost() {
}
) {
composable(Routes.DASHBOARD) {
val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
@ -249,26 +236,21 @@ private fun CompactNavHost() {
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
onOpenRecharge = { type, region ->
val url = ServiceConfigProvider.externalUrlFor(type, region)
if (url != null) {
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
}
composable(Routes.API_MANAGEMENT) {
val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
ApiManagementScreen(
onBack = guardedPop,
onConfigure = { type -> navController.navigate(credentialEntry(type)) },
onRecharge = { type, region ->
val url = ServiceConfigProvider.externalUrlFor(type, region)
if (url != null) {
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
@ -374,7 +356,7 @@ private fun CompactNavHost() {
service = type,
accountId = accountId,
onBack = guardedPop,
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
@ -387,21 +369,14 @@ private fun CompactNavHost() {
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
val region = backStackEntry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = guardedPop,
onLoginSucceeded = { _, effectiveId ->
// 把实际写入的账号 ID 回传给上一层(凭据编辑页),新建账号模式用于回填
@ -430,7 +405,6 @@ private fun CompactNavHost() {
) { backStackEntry ->
val type = parseServiceType(backStackEntry.arguments?.getString("type"))
val accountId = backStackEntry.arguments?.getString("account")
val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
ServiceDetailScreen(
service = type,
@ -443,12 +417,10 @@ private fun CompactNavHost() {
}
},
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
onOpenRecharge = { svc, region ->
val url = ServiceConfigProvider.externalUrlFor(svc, region)
if (url != null) {
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
@ -489,7 +461,6 @@ private fun ExpandedLayout() {
prevPane = detailPane
}
val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
Row(modifier = Modifier.fillMaxSize()) {
@ -507,11 +478,9 @@ private fun ExpandedLayout() {
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
onOpenRecharge = { type, region ->
val url = ServiceConfigProvider.externalUrlFor(type, region)
if (url != null) {
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
onOpenRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
}
},
viewModel = dashboardVm
@ -567,7 +536,6 @@ private fun ExpandedDetailPane(
popExitTransition = { ExitTransition.None }
) {
composable("detail") {
val context = LocalContext.current
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ServiceDetailScreen(
service = pane.type,
@ -575,12 +543,10 @@ private fun ExpandedDetailPane(
onBack = onClose,
onSwitchAccount = onSwitchAccount,
onConfigureCredential = { onClose() },
onStartWebViewLogin = { _, _, _ -> },
onOpenRecharge = { svc, region ->
val url = ServiceConfigProvider.externalUrlFor(svc, region)
if (url != null) {
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
onStartWebViewLogin = { _, _ -> },
onOpenRecharge = { svc ->
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
navController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
@ -688,16 +654,13 @@ private fun ExpandedDetailPane(
popExitTransition = { ExitTransition.None }
) {
composable("api_mgmt_main") {
val context = LocalContext.current
val rechargeLabel = LocalContext.current.getString(R.string.action_recharge)
ApiManagementScreen(
onBack = onClose,
onConfigure = { type -> apiNavController.navigate(credentialEntry(type)) },
onRecharge = { type, region ->
val url = ServiceConfigProvider.externalUrlFor(type, region)
if (url != null) {
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
if (intlTrae) context.openTraeUrl(url) else apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
onRecharge = { type ->
ServiceConfigProvider.get(type).externalUrl?.let { url ->
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
}
}
)
@ -729,8 +692,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
onStartWebViewLogin = { svc, acc, region ->
apiNavController.navigate(Routes.webviewLogin(svc, acc, region))
onStartWebViewLogin = { svc, acc ->
apiNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
@ -744,21 +707,14 @@ private fun ExpandedDetailPane(
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
val region = entry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = { apiNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {
@ -861,8 +817,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
onStartWebViewLogin = { svc, acc, region ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
onStartWebViewLogin = { svc, acc ->
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
@ -876,21 +832,14 @@ private fun ExpandedDetailPane(
type = NavType.StringType
nullable = true
defaultValue = null
},
navArgument("region") {
type = NavType.StringType
nullable = true
defaultValue = null
}
)
) { entry ->
val type = parseServiceType(entry.arguments?.getString("type"))
val accountId = entry.arguments?.getString("account")
val region = entry.arguments?.getString("region")
WebViewLoginScreen(
service = type,
accountId = accountId,
region = region,
onBack = { settingsNavController.popBackStack() },
onLoginSucceeded = { _, effectiveId ->
if (!effectiveId.isNullOrBlank()) {

View File

@ -85,7 +85,7 @@ import java.util.Locale
fun ApiManagementScreen(
onBack: () -> Unit,
onConfigure: (ServiceType) -> Unit,
onRecharge: (ServiceType, String?) -> Unit,
onRecharge: (ServiceType) -> Unit,
viewModel: ApiManagementViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -150,7 +150,7 @@ fun ApiManagementScreen(
onEdit = { onConfigure(row.service) },
onTest = { viewModel.test(row.service) },
onDelete = { pendingDelete = row.service },
onRecharge = { onRecharge(row.service, row.region) }
onRecharge = { onRecharge(row.service) }
)
}
}
@ -316,9 +316,9 @@ private fun ApiRowCard(
onDelete: () -> Unit,
onRecharge: () -> Unit
) {
// 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
val rechargeUrl = remember(row.service, row.region) {
runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull()
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
val rechargeUrl = remember(row.service) {
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
}
Card(
modifier = Modifier

View File

@ -63,15 +63,6 @@ class ApiManagementViewModel @Inject constructor(
credentialRepository.accountsFor(type).size
} else {
0
},
region = when (type) {
ServiceType.TRAE -> {
(cred as? Credential.TraeCredential)?.region
?: credentialRepository.accountsFor(type)
.firstOrNull()
?.let { (credentialRepository.get(type, it.id) as? Credential.TraeCredential)?.region }
}
else -> null
}
)
}
@ -167,9 +158,7 @@ data class ApiRowUi(
val cachedBalance: CachedBalance?,
val maskedCredential: String?,
/** 多账号服务的账号个数;单账号服务为 0。 */
val accountCount: Int = 0,
/** Trae 账号区域(用于选择官方充值/用量页)。 */
val region: String? = null
val accountCount: Int = 0
) {
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED

View File

@ -138,7 +138,7 @@ fun DashboardScreen(
onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {},
onOpenHeatmap: () -> Unit = {},
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
onOpenRecharge: (ServiceType) -> Unit = {},
viewModel: DashboardViewModel = hiltViewModel()
) {
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
@ -549,7 +549,7 @@ private fun rememberDashboardItems(
onOpenService: (ServiceType, String?) -> Unit,
onOpenHeatmap: () -> Unit,
onOpenSub2PanelManagement: (String?) -> Unit,
onOpenRecharge: (ServiceType, String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit,
refreshTrigger: Int
): List<DashboardHomeItem> {
val defaultItems = buildList {
@ -607,8 +607,8 @@ private fun rememberDashboardItems(
null
},
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) {
{ onOpenRecharge(card.service, card.region) }
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
{ onOpenRecharge(card.service) }
} else {
null
},

View File

@ -287,8 +287,7 @@ class DashboardViewModel @Inject constructor(
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
checkedInToday = local.service in CHECKIN_SERVICES &&
checkinStatusStore.isCheckedToday(local.service, local.account?.id),
region = (local.credential as? Credential.TraeCredential)?.region
checkinStatusStore.isCheckedToday(local.service, local.account?.id)
)
private fun newerOf(
@ -464,9 +463,7 @@ data class DashboardCardUi(
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
val siteIcon: String? = null,
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
val checkedInToday: Boolean = false,
/** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
val region: String? = null
val checkedInToday: Boolean = false
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance

View File

@ -146,10 +146,6 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
@Composable
private fun TraeSplitMainBalance(balance: ServiceBalance) {
val extras = balance.extras
if (extras?.get("billing_mode") == "dollar") {
TraeDollarPlanSummary(extras)
return
}
val general = extras?.get("general_credits")?.toDoubleOrNull()
val work = extras?.get("work_credits")?.toDoubleOrNull()
if (general == null && work == null) {
@ -180,7 +176,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) {
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
@Composable
private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") {
private fun TraeSplitLine(label: String, value: Double?) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
@ -200,7 +196,7 @@ private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits
)
Spacer(modifier = Modifier.width(4.dp))
Text(
text = unit,
text = "Credits",
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 2.dp)
@ -209,42 +205,6 @@ private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits
}
}
@Composable
private fun TraeDollarPlanSummary(extras: Map<String, String>) {
val planName = extras["intl.plan_name"] ?: "Free plan"
Text(
text = planName,
style = MaterialTheme.typography.titleMedium,
color = inkMuted()
)
Spacer(modifier = Modifier.height(8.dp))
val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull()
val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull()
if (dollarLimit != null) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
text = stringResource(R.string.trae_intl_dollar_usage_title),
style = MaterialTheme.typography.titleMedium,
color = inkMuted()
)
Text(
text = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit),
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
}
}
TraeSplitLine(stringResource(R.string.trae_intl_premium_fast_title), extras["intl.premium_fast_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_premium_slow_title), extras["intl.premium_slow_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_advanced_title), extras["intl.advanced_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
TraeSplitLine(stringResource(R.string.trae_intl_auto_completion_title), extras["intl.auto_completion_limit"]?.toDoubleOrNull(), stringResource(R.string.trae_intl_quota_unit))
}
@Composable
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
Row(verticalAlignment = Alignment.Bottom) {

View File

@ -57,7 +57,6 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
@ -67,7 +66,6 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R
import com.rainy.token.data.repository.ModelUsageRow
import com.rainy.token.data.repository.OpenCodeGoRepository
import com.rainy.token.data.repository.TraeUsageEvent
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.WindowModelUsage
import com.rainy.token.domain.model.ServiceBalance
@ -116,8 +114,8 @@ fun ServiceDetailScreen(
onBack: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onConfigureCredential: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit = { },
viewModel: ServiceDetailViewModel = hiltViewModel()
) {
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
@ -131,7 +129,6 @@ fun ServiceDetailScreen(
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
val traeUsageEvents by viewModel.traeUsageEvents.collectAsStateWithLifecycle()
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle()
@ -238,12 +235,6 @@ fun ServiceDetailScreen(
item { OllamaUsageCard(uiState.state) }
}
ServiceType.TRAE -> {
val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
if (intl) {
// 国际版:单张用量卡(套餐/美元额度/月请求配额),不再重复展示签到卡和积分账户卡
item { TraeIntlUsageCard(uiState.state) }
item { TraeUsageEventsCard(traeUsageEvents) }
} else {
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
item {
if (uiState.hasCredential) {
@ -263,13 +254,7 @@ fun ServiceDetailScreen(
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
item { TraeAccountInfoCard(uiState.state) }
}
}
ServiceType.WORKBUDDY -> {
val wbIntl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
if (wbIntl) {
// 国际版没有独立签到体系:积分来自网页/客户端活跃使用,直接展示说明卡
item { WorkBuddyIntlNoCheckinCard() }
} else {
item {
if (uiState.hasCredential) {
CheckinCard(
@ -283,7 +268,6 @@ fun ServiceDetailScreen(
CheckinNeedCredentialCard()
}
}
}
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
item { WorkBuddyResourcesCard(uiState.state) }
}
@ -325,17 +309,14 @@ fun ServiceDetailScreen(
hasCredential = uiState.hasCredential,
isManualMode = isManualMode,
service = service,
region = uiState.region,
triggerState = triggerState,
models = models,
selectedModel = selectedModel,
modelsLoading = modelsLoading,
onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
onOpenRecharge = if (config.externalUrl != null || service == ServiceType.TRAE) {
{ onOpenRecharge(service, uiState.region) }
} else null,
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) },
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) }
@ -1110,41 +1091,6 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
}
}
@Composable
private fun TraeIntlBigNumber(extras: Map<String, String>) {
val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull()
val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull()
Text(
text = if (dollarLimit != null) {
String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit)
} else {
extras["intl.plan_name"] ?: "Free plan"
},
style = MaterialTheme.typography.displayLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text = if (dollarLimit != null) {
stringResource(R.string.trae_intl_dollar_usage_title)
} else {
stringResource(R.string.trae_intl_monthly_quota)
},
style = MaterialTheme.typography.titleLarge,
color = inkMuted(),
modifier = Modifier.padding(bottom = 8.dp)
)
if (dollarLimit != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = (extras["intl.plan_name"] ?: "Free plan") + " · " + stringResource(R.string.trae_intl_monthly_quota),
style = MaterialTheme.typography.bodyMedium,
color = inkMuted()
)
}
}
@Composable
private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
if (balance == null) {
@ -1155,9 +1101,6 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
)
return
}
if (service == ServiceType.TRAE && balance.extras?.get("billing_mode") == "dollar") {
TraeIntlBigNumber(balance.extras)
} else {
Row(verticalAlignment = Alignment.Bottom) {
Text(
text = formatAmount(balance.amount),
@ -1173,7 +1116,6 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
modifier = Modifier.padding(bottom = 8.dp)
)
}
}
if (!balance.isAvailable) {
Spacer(modifier = Modifier.height(4.dp))
Text(
@ -1562,37 +1504,6 @@ internal fun TraeAccountInfoCard(state: State) {
extras["pack_count"]?.toIntOrNull()?.takeIf { it > 0 }?.let {
add(stringResource(R.string.extra_label_pack_count) to it.toString())
}
if (extras["billing_mode"] == "dollar") {
extras["intl.dollar_limit"]?.toDoubleOrNull()?.let { limit ->
add(
stringResource(R.string.trae_intl_dollar_usage_title) to
String.format(
Locale.US,
"$%.2f / $%.2f",
extras["intl.dollar_used"]?.toDoubleOrNull() ?: 0.0,
limit
)
)
}
extras["intl.plan_name"]?.let {
add(stringResource(R.string.trae_intl_plan_title) to it)
}
extras["intl.premium_fast_limit"]?.let {
add(stringResource(R.string.trae_intl_premium_fast_title) to "$it " + stringResource(R.string.trae_intl_quota_unit))
}
extras["intl.premium_slow_limit"]?.let {
add(stringResource(R.string.trae_intl_premium_slow_title) to "$it " + stringResource(R.string.trae_intl_quota_unit))
}
extras["intl.advanced_limit"]?.let {
add(stringResource(R.string.trae_intl_advanced_title) to "$it " + stringResource(R.string.trae_intl_quota_unit))
}
extras["intl.auto_completion_limit"]?.let {
add(stringResource(R.string.trae_intl_auto_completion_title) to "$it " + stringResource(R.string.trae_intl_quota_unit))
}
extras["intl.basic_used"]?.let {
add(stringResource(R.string.trae_intl_basic_used_title) to it)
}
}
// 计费方式:两个英文布尔键合并为一行自然语言
val billingLabel = when {
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
@ -1620,216 +1531,6 @@ internal fun TraeAccountInfoCard(state: State) {
*
* 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。
*/
@Composable
private fun TraeUsageEventsCard(state: TraeUsageEventsState) {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = stringResource(R.string.trae_usage_events_title),
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Spacer(modifier = Modifier.height(8.dp))
when (state) {
is TraeUsageEventsState.Idle,
is TraeUsageEventsState.Loading -> Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
CircularProgressIndicator(
modifier = Modifier.height(16.dp).width(16.dp),
color = StrawberryPink,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = stringResource(R.string.trae_usage_events_loading),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
is TraeUsageEventsState.Error -> Text(
text = state.message.asString(),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
is TraeUsageEventsState.Success -> {
if (state.events.isEmpty()) {
Text(
text = stringResource(R.string.trae_usage_events_empty),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
} else {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
state.events.forEach { event ->
Row(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = buildString {
append(event.modelName.ifBlank { "Unknown" })
if (event.mode.isNotBlank()) append("(${event.mode})")
},
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = sdf.format(Date(event.usageTime * 1000L)),
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
if (event.userInputPreview.isNotBlank()) {
Text(
text = event.userInputPreview.take(48),
style = MaterialTheme.typography.bodySmall,
color = inkMuted(),
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
Column(horizontalAlignment = Alignment.End) {
val quotaText = if (event.costMoneyFloat > 0) {
String.format(Locale.US, "\$%.4f", event.costMoneyFloat)
} else {
String.format(Locale.US, "%.2f 积分", event.creditsFloat)
}
Text(
text = quotaText,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.SemiBold
)
val tokens = buildString {
if (event.inputToken > 0) append("入 ${event.inputToken}")
if (event.outputToken > 0) {
if (isNotEmpty()) append(" / ")
append("出 ${event.outputToken}")
}
if (event.cacheReadToken > 0) append(" / 缓存 ${event.cacheReadToken}")
}
if (tokens.isNotBlank()) {
Text(
text = tokens,
style = MaterialTheme.typography.bodySmall,
color = inkMuted()
)
}
}
}
}
}
}
}
}
}
}
@Composable
internal fun TraeIntlUsageCard(state: State) {
val balance = when (state) {
is State.Fresh -> state.data
is State.Stale -> state.data
is State.Error -> state.cached
else -> null
}
val extras = balance?.extras ?: return
val rows = buildList {
extras["intl.plan_name"]?.let {
add(stringResource(R.string.trae_intl_plan_title) to it)
}
extras["intl.dollar_limit"]?.toDoubleOrNull()?.let { limit ->
add(
stringResource(R.string.trae_intl_dollar_usage_title) to String.format(
Locale.US,
"\$%.2f / \$%.2f",
extras["intl.dollar_used"]?.toDoubleOrNull() ?: 0.0,
limit
)
)
}
val quotaUnit = stringResource(R.string.trae_intl_quota_unit)
extras["intl.premium_fast_limit"]?.let {
add(stringResource(R.string.trae_intl_premium_fast_title) to "$it $quotaUnit")
}
extras["intl.premium_slow_limit"]?.let {
add(stringResource(R.string.trae_intl_premium_slow_title) to "$it $quotaUnit")
}
extras["intl.advanced_limit"]?.let {
add(stringResource(R.string.trae_intl_advanced_title) to "$it $quotaUnit")
}
extras["intl.auto_completion_limit"]?.let {
add(stringResource(R.string.trae_intl_auto_completion_title) to "$it $quotaUnit")
}
val billingLabel = when {
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits)
extras["is_dollar_usage_billing"] == "true" -> stringResource(R.string.extra_value_billing_dollar)
else -> null
}
billingLabel?.let {
add(stringResource(R.string.extra_label_billing_mode) to it)
}
}
if (rows.isEmpty()) return
AccountInfoCard(title = stringResource(R.string.trae_intl_usage_card_title), rows = rows)
}
@Composable
private fun TraeIntlNoCheckinCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.trae_intl_no_checkin_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.trae_intl_no_checkin_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
private fun WorkBuddyIntlNoCheckinCard() {
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
Text(
text = stringResource(R.string.wb_intl_no_checkin_title),
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold
)
Text(
text = stringResource(R.string.wb_intl_no_checkin_desc),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
}
}
}
@Composable
internal fun WorkBuddyResourcesCard(state: State) {
val balance = when (state) {
@ -2135,7 +1836,6 @@ private fun ActionButtons(
hasCredential: Boolean,
isManualMode: Boolean,
service: ServiceType,
region: String?,
triggerState: TriggerState,
models: List<String>,
selectedModel: String?,
@ -2256,18 +1956,6 @@ private fun ActionButtons(
Spacer(modifier = Modifier.height(4.dp))
}
// Trae/WorkBuddy 国际版:直接应用内登录(回调解析凭据)
if ((service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) &&
region != null && region.equals("INTL", ignoreCase = true)
) {
OutlinedButton(
onClick = onStartWebViewLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_mobile_browser_login))
}
}
if (hasCredential && !isManualMode) {
Button(
onClick = onRefresh,

View File

@ -8,7 +8,6 @@ import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.TraeUsageEvent
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.data.repository.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason
@ -37,8 +36,7 @@ class ServiceDetailViewModel @Inject constructor(
private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase,
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore,
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository,
private val traeRepository: TraeRepository
private val sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository
) : ViewModel() {
private val _serviceType = MutableStateFlow<ServiceType?>(null)
@ -77,10 +75,6 @@ class ServiceDetailViewModel @Inject constructor(
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow()
/** Trae 国际版 Usage Events 列表(详情页直接展示,不跳网页)。 */
private val _traeUsageEvents = MutableStateFlow<TraeUsageEventsState>(TraeUsageEventsState.Idle)
val traeUsageEvents: StateFlow<TraeUsageEventsState> = _traeUsageEvents.asStateFlow()
/** 今日签到信息摘要(仅字段已确认的才填)。 */
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
@ -118,34 +112,6 @@ class ServiceDetailViewModel @Inject constructor(
}
}
/** 预取 Trae 国际版用量事件(仅 INTL 有 Usage Events;CN 仍走积分体系)。 */
private fun preloadUsageEvents(service: ServiceType, accountId: String?) {
if (service != ServiceType.TRAE) return
if (_traeUsageEvents.value is TraeUsageEventsState.Loading) return
val currentRegion = _uiState.value.region
if (!currentRegion.equals("INTL", ignoreCase = true)) return
_traeUsageEvents.value = TraeUsageEventsState.Loading
val generation = serviceGeneration
viewModelScope.launch {
val result = traeRepository.fetchUsageEvents(accountId)
if (generation != serviceGeneration || _serviceType.value != service) return@launch
_traeUsageEvents.value = result.fold(
onSuccess = { events ->
if (events.isEmpty()) TraeUsageEventsState.Success(emptyList())
else TraeUsageEventsState.Success(events)
},
onFailure = { e ->
TraeUsageEventsState.Error(
UiText.Resource(
R.string.trae_usage_events_load_failed,
listOf(e.message ?: "")
)
)
}
)
}
}
/** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
fun checkin() {
val service = _serviceType.value ?: return
@ -449,7 +415,6 @@ class ServiceDetailViewModel @Inject constructor(
_triggerState.value = TriggerState.Idle
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
_checkinState.value = CheckinState.Idle
_traeUsageEvents.value = TraeUsageEventsState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
loadSelectedModel(service)?.let { _selectedModel.value = it }
@ -489,16 +454,6 @@ class ServiceDetailViewModel @Inject constructor(
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
currentCredentialFingerprint = newFingerprint
val currentCredential = credentialRepository.get(type, accountId)
val currentRegion = when (currentCredential) {
is Credential.TraeCredential -> currentCredential.region.uppercase()
is Credential.WorkBuddyCredential -> currentCredential.region.uppercase()
else -> null
}
_uiState.update { it.copy(region = currentRegion) }
if (type == ServiceType.TRAE && newHasCredential) {
preloadUsageEvents(type, accountId)
}
when (change) {
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
@ -628,9 +583,6 @@ class ServiceDetailViewModel @Inject constructor(
)
}
}
if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
preloadUsageEvents(type, accountId)
}
}
}
@ -1053,20 +1005,10 @@ sealed class State {
data object ManualModeHint : State()
}
/** Trae 国际版 Usage Events 加载状态。 */
sealed class TraeUsageEventsState {
data object Idle : TraeUsageEventsState()
data object Loading : TraeUsageEventsState()
data class Success(val events: List<TraeUsageEvent>) : TraeUsageEventsState()
data class Error(val message: UiText) : TraeUsageEventsState()
}
data class ServiceDetailUiState(
val state: State = State.Loading,
val hasCredential: Boolean = false,
val cached: CachedBalance? = null,
/** 当前账号区域(Trae/WorkBuddy):CN / INTL;其他服务为 null。 */
val region: String? = null
val cached: CachedBalance? = null
)
sealed class TriggerState {

View File

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

View File

@ -4,7 +4,6 @@ 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
@ -47,22 +46,13 @@ 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 = items,
accounts = accounts,
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
message = if (items.isEmpty()) null else it.message
message = if (accounts.isEmpty()) null else it.message
)
}
}
@ -100,13 +90,8 @@ class AccountManagementViewModel @Inject constructor(
}
}
data class AccountItem(
val account: ServiceAccountInfo,
val region: String?,
)
data class AccountManagementUiState(
val loading: Boolean = false,
val accounts: List<AccountItem> = emptyList(),
val accounts: List<ServiceAccountInfo> = emptyList(),
val message: UiText? = null
)

View File

@ -82,7 +82,7 @@ fun CredentialEditScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel()
@ -153,31 +153,13 @@ fun CredentialEditScreen(
if (service == ServiceType.TRAE) {
TraeCredentialForm(
jwt = uiState.traeJwt,
callbackUrl = uiState.traeCallbackUrl,
region = uiState.traeRegion,
checkinDeviceId = uiState.traeCheckinDeviceId,
hasExisting = uiState.hasExisting,
onJwtChange = viewModel::updateTraeJwt,
onCallbackUrlChange = viewModel::updateTraeCallbackUrl,
onImportCallback = { viewModel.importTraeCallbackFromUrl(it) },
onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) },
onRecharge = {
val url = if (uiState.traeRegion.uppercase() == "INTL") {
"https://www.trae.ai/pricing"
} else {
"https://www.trae.cn/subscription"
}
runCatching {
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url)
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
},
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) },
onSave = viewModel::saveTraeCredential
)
} else if (service == ServiceType.WORKBUDDY) {
@ -187,9 +169,7 @@ fun CredentialEditScreen(
hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh,
region = uiState.workBuddyRegion,
onRegionChange = viewModel::updateWorkBuddyRegion,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
onSave = viewModel::saveWorkBuddyCredential
)
} else if (service == ServiceType.SUB2API) {
@ -287,7 +267,7 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId, null) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }
@ -797,17 +777,13 @@ private fun ManualCookieForm(
@Composable
private fun TraeCredentialForm(
jwt: String,
callbackUrl: String,
region: String,
checkinDeviceId: String,
hasExisting: Boolean,
onJwtChange: (String) -> Unit,
onCallbackUrlChange: (String) -> Unit,
onImportCallback: (String) -> Unit,
onRegionChange: (String) -> Unit,
onCheckinDeviceIdChange: (String) -> Unit,
onStartInAppLogin: () -> Unit,
onRecharge: () -> Unit,
onSave: () -> Unit
) {
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
@ -816,38 +792,12 @@ private fun TraeCredentialForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
if (region.uppercase() != "INTL") {
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_login_in_app))
}
}
if (region.uppercase() == "INTL") {
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_mobile_browser_login))
}
OutlinedTextField(
value = callbackUrl,
onValueChange = onCallbackUrlChange,
label = { Text(stringResource(R.string.field_trae_callback_url)) },
supportingText = { Text(stringResource(R.string.field_trae_callback_hint)) },
minLines = 1,
maxLines = 3,
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = { onImportCallback(callbackUrl) },
enabled = callbackUrl.isNotBlank(),
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_parse_callback))
}
}
OutlinedTextField(
value = jwt,
onValueChange = onJwtChange,
@ -873,12 +823,6 @@ private fun TraeCredentialForm(
onSelect = { onRegionChange("INTL") }
)
}
OutlinedButton(
onClick = onRecharge,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_trae_recharge))
}
OutlinedTextField(
value = checkinDeviceId,
onValueChange = onCheckinDeviceIdChange,
@ -972,11 +916,9 @@ 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
) {
@ -986,38 +928,12 @@ private fun WorkBuddyCredentialForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
if (region.uppercase() == "INTL") {
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_mobile_browser_login))
}
} else {
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
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,

View File

@ -26,7 +26,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
@ -127,7 +126,6 @@ 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(),
@ -300,82 +298,6 @@ class CredentialEditViewModel @Inject constructor(
fun updateTraeJwt(value: String) {
_uiState.update { it.copy(traeJwt = value) }
}
fun updateTraeCallbackUrl(value: String) {
_uiState.update { it.copy(traeCallbackUrl = value) }
}
/** 解析浏览器登录回调链接,填入 JWT;解析失败给提示。 */
fun importTraeCallbackFromUrl(raw: String) {
val input = raw.trim()
if (input.isEmpty()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
}
val parsed = parseTraeCallback(input)
if (parsed == null) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_trae_callback_invalid)) }
return
}
_uiState.update {
it.copy(
traeJwt = parsed.jwt,
traeRegion = parsed.region,
traeApiHost = parsed.apiHost,
traeCallbackUrl = input
)
}
}
private data class TraeParsedCallback(
val jwt: String,
val region: String,
val apiHost: String
)
private fun parseTraeCallback(input: String): TraeParsedCallback? {
val raw = input.trim()
val uri = runCatching { android.net.Uri.parse(raw) }.getOrNull() ?: return null
fun query(name: String): String? {
uri.getQueryParameter(name)?.let { return it }
val frag = uri.fragment
if (!frag.isNullOrBlank()) {
frag.split("&").forEach { pair ->
val kv = pair.split("=", limit = 2)
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
if (kv.size == 1 && kv[0] == name) return ""
}
}
return null
}
val userJwtRaw = query("userJwt") ?: query("user_jwt") ?: ""
val userJwt = runCatching {
kotlinx.serialization.json.Json.parseToJsonElement(userJwtRaw).jsonObject
}.getOrNull() ?: kotlinx.serialization.json.JsonObject(emptyMap())
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
?: if (raw.startsWith("{")) userJwt["jwt"]?.jsonPrimitive?.contentOrNull else null
if (token.isNullOrBlank()) return null
// 区域推断:回调显式 userRegion/us/INTL,或 userInfo.Region 非 CN 域
val userRegion = query("userRegion")?.lowercase()
val userInfoRegion = query("userInfo")?.let { infoRaw ->
runCatching {
val info = kotlinx.serialization.json.Json.parseToJsonElement(infoRaw).jsonObject
info["Region"]?.jsonPrimitive?.contentOrNull
?: info["region"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
}
val intl = userRegion?.let { it != "cn" } == true ||
userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true ||
query("userRegion")?.equals("us", true) == true
val apiHost = query("host")?.takeIf { it.startsWith("http") }?.trimEnd('/') ?: ""
return TraeParsedCallback(
jwt = token,
region = if (intl) "INTL" else "CN",
apiHost = apiHost
)
}
fun updateTraeRegion(value: String) {
_uiState.update { it.copy(traeRegion = value) }
@ -388,11 +310,7 @@ class CredentialEditViewModel @Inject constructor(
fun saveTraeCredential() {
val type = serviceType ?: return
val current = _uiState.value
var jwt = current.traeJwt.trim()
val parsedCallback = if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) {
parseTraeCallback(current.traeCallbackUrl)
} else null
if (parsedCallback != null) jwt = parsedCallback.jwt
val jwt = current.traeJwt.trim()
if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
@ -408,9 +326,6 @@ class CredentialEditViewModel @Inject constructor(
val effectiveId = credentialRepository.save(
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
jwt = jwt,
apiHost = current.traeApiHost.takeIf { it.isNotBlank() }?.trimEnd('/')
?: parsedCallback?.apiHost?.takeIf { it.isNotBlank() }?.trimEnd('/')
?: existing?.apiHost,
region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null,
@ -438,10 +353,6 @@ 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
@ -460,11 +371,10 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
}
val effectiveId = credentialRepository.save(
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh, region = "CN"))
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
.copy(
accessToken = access,
refreshToken = refresh,
region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN",
expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
),
@ -942,7 +852,6 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = "",
workBuddyAccess = "",
workBuddyRefresh = "",
workBuddyRegion = "CN",
sub2BaseUrl = "",
sub2Email = "",
sub2Password = "",
@ -1062,17 +971,12 @@ data class CredentialEditUiState(
val triggerApiKey: String = "",
/** Trae:Cloud-IDE-JWT + 区域 */
val traeJwt: String = "",
/** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */
val traeCallbackUrl: String = "",
val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "",
/** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
val traeApiHost: String = "",
/** WorkBuddy:OAuth tokens + 区域 */
/** WorkBuddy:OAuth tokens */
val workBuddyAccess: String = "",
val workBuddyRefresh: String = "",
val workBuddyRegion: String = "CN",
/** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "",
val sub2Email: String = "",

View File

@ -2,17 +2,7 @@ package com.rainy.token.ui.settings
import android.app.Activity
import android.os.Build
import android.widget.Toast
import android.content.ClipboardManager
import android.content.ClipData
import android.content.Context
import androidx.compose.foundation.clickable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -33,14 +23,11 @@ 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
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@ -62,7 +49,6 @@ 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
@ -103,14 +89,6 @@ fun SettingsScreen(
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle()
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 selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
@ -181,25 +159,6 @@ fun SettingsScreen(
item {
LanguageCard(onClick = { showLanguageDialog = true })
}
item {
ProxyGatewayCard(
running = proxyRunning,
apiKey = proxyApiKey,
error = proxyError,
debug = proxyDebug,
debugRaw = proxyDebugRaw,
proxyTargets = viewModel.proxyTargets,
selectedProxy = selectedProxy,
onSelectProxy = viewModel::selectProxy,
models = proxyModels,
selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
)
}
item {
TipsCard(onClick = { onOpenTips() })
}
@ -222,181 +181,6 @@ fun SettingsScreen(
}
}
@Composable
private fun ProxyGatewayCard(
running: Boolean,
apiKey: String,
error: String?,
debug: String?,
debugRaw: String?,
proxyTargets: List<ProxyTarget>,
selectedProxy: ProxyTarget,
onSelectProxy: (ProxyTarget) -> Unit,
models: List<TraeModelProvider.TraeModel>,
selectedModel: String,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
val context = LocalContext.current
var proxyMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🛰️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "代理网关",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
Text(
text = "API Key:$apiKey",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp)
)
}
Row(modifier = Modifier.padding(top = 4.dp)) {
TextButton(onClick = onTestTrae) {
Text("测试 Trae 流")
}
TextButton(onClick = onTestWorkBuddy) {
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
) {
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) {
Text(
text = "探针摘要",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
modifier = Modifier.weight(1f)
)
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug))
Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show()
}) {
Text("复制原始")
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
.verticalScroll(rememberScrollState())
.padding(10.dp)
) {
Text(
text = debug,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = InkMuted
)
}
}
}
Switch(
checked = running,
onCheckedChange = onToggle
)
}
}
}
@Composable
private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current
@ -632,23 +416,9 @@ private fun UpdateCard(
style = MaterialTheme.typography.bodyMedium,
color = InkMuted
)
if (phase.error != null) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) {
Text(
if (phase.error == null) {
stringResource(R.string.update_install)
} else {
"重试安装"
}
)
Text(stringResource(R.string.update_install))
}
}
is UpdateViewModel.Phase.NeedInstallPermission -> {
@ -667,9 +437,6 @@ private fun UpdateCard(
}) {
Text(stringResource(R.string.update_enable_permission))
}
TextButton(onClick = { viewModel.install(phase.file) }) {
Text("已开启,继续安装")
}
}
is UpdateViewModel.Phase.Error -> {
Text(

View File

@ -2,33 +2,15 @@ package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.proxy.LocalProxyServer
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.WorkBuddyModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import javax.inject.Inject
/**
@ -37,295 +19,12 @@ import javax.inject.Inject
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer,
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeModelProvider: TraeModelProvider,
private val workBuddyModelProvider: WorkBuddyModelProvider,
private val credentialRepository: CredentialRepository
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
/** 本地反代服务运行状态。 */
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
/** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
val proxyApiKey: StateFlow<String> = MutableStateFlow(generateProxyKey())
/** 启动失败时的错误信息。 */
private val _proxyError = MutableStateFlow<String?>(null)
val proxyError: StateFlow<String?> = _proxyError.asStateFlow()
/** 启动/停止本地反代服务。 */
fun setProxyEnabled(enabled: Boolean) {
if (enabled) {
localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value))
.onSuccess { _proxyError.value = null }
.onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" }
} else {
localProxyServer.stop()
_proxyError.value = null
}
}
/** 代理调试输出(结构化摘要)。 */
private val _proxyDebug = MutableStateFlow<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
/** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
val proxyTargets: List<ProxyTarget> = ProxyTarget.entries
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
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))
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换当前测试目标代理。 */
fun selectProxy(target: ProxyTarget) {
_selectedProxy.value = target
val region = target.region()
_proxyModels.value = when (target) {
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)
else -> workBuddyModelProvider.selectedFor(region)
}
}
/** 切换当前代理的测试模型并持久化。 */
fun selectProxyModel(id: String) {
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
}
/** 拉取当前代理可用模型;失败保留内置列表。 */
fun refreshProxyModels() {
val target = _selectedProxy.value
val region = target.region()
viewModelScope.launch {
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.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
fun testTraeStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
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)
if (stream == null) {
_proxyDebug.value = "Trae CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"Trae CN",
body,
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
/** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */
fun testWorkBuddyStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebugRaw.value = null
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) {
_proxyDebug.value = "WorkBuddy CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"WorkBuddy CN",
body,
"POST https://copilot.tencent.com/v2/chat/completions"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
private suspend fun readStreamSample(
stream: ProxyUpstreamStream,
label: String,
requestBody: String,
requestLine: String,
): StreamProbeResult = withContext(Dispatchers.IO) {
val lines = mutableListOf<String>()
val raw = StringBuilder()
raw.append(label).append(" HTTP ").append(stream.status)
raw.append(" Content-Type=").append(stream.contentType).append("\n")
raw.append("请求: ").append(requestLine).append("\n")
raw.append("请求体: ").append(requestBody).append("\n")
raw.append("---- 上游响应原始帧 ----\n")
try {
val reader = stream.input.bufferedReader()
repeat(120) {
val line = reader.readLine() ?: return@repeat
lines.add(line)
raw.append(line).append("\n")
if (line.trim() == "data: [DONE]") return@repeat
if (raw.length > 12000) return@repeat
}
} finally {
stream.close()
}
StreamProbeResult(
summary = summarizeProbe(label, stream.status, stream.contentType, requestBody, requestLine, lines),
raw = raw.toString()
)
}
private fun summarizeProbe(
label: String,
status: Int,
contentType: String,
requestBody: String,
requestLine: String,
lines: List<String>,
): String {
val sb = StringBuilder()
sb.append(label).append(" 探针摘要")
sb.append("\n请求: ").append(requestLine)
runCatching {
val body = json.parseToJsonElement(requestBody) as? JsonObject
if (body != null) {
val model = (body["model"] as? JsonPrimitive)?.contentOrNull ?: "?"
val count = (body["messages"] as? JsonArray)?.size ?: 0
val stream = (body["stream"] as? JsonPrimitive)?.contentOrNull ?: "?"
sb.append("\n 模型=").append(model)
sb.append(" · messages=").append(count)
sb.append(" · stream=").append(stream)
(body["tools"] as? JsonArray)?.let { sb.append(" · tools=").append(it.size) }
}
}
sb.append("\n回复: HTTP ").append(status).append(" · ").append(contentType)
var events = 0
val texts = mutableListOf<String>()
val models = linkedSetOf<String>()
val usage = mutableListOf<String>()
val errors = mutableListOf<String>()
for (line in lines) {
val trimmed = line.trim()
if (!trimmed.startsWith("data:")) continue
val payload = trimmed.removePrefix("data:").trim()
if (payload == "[DONE]") continue
val el = runCatching { json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: continue
events++
collectProbeText(el, texts)
collectProbeModel(el, models)
collectProbeUsage(el, usage)
(el["error"] as? JsonObject)?.let { e ->
val msg = (e["message"] as? JsonPrimitive)?.contentOrNull ?: e.toString()
errors.add(msg)
}
}
if (events > 0) sb.append("\n事件帧: ").append(events)
if (models.isNotEmpty()) sb.append("\n模型: ").append(models.joinToString(" / "))
if (texts.isNotEmpty()) {
val joined = texts.joinToString("").trim().replace("\n", " ").take(200)
sb.append("\n文本(截断): ").append(if (joined.isEmpty()) "(空)" else joined)
}
if (usage.isNotEmpty()) sb.append("\n用量: ").append(usage.distinct().joinToString(" · "))
if (errors.isNotEmpty()) sb.append("\n错误: ").append(errors.take(2).joinToString(" | "))
return sb.toString()
}
private fun collectProbeText(el: JsonObject, out: MutableList<String>) {
fun add(v: JsonElement?) {
when (v) {
is JsonPrimitive -> if (v.isString) out.add(v.content)
is JsonObject -> {
(v["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["text"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["delta"] as? JsonObject)?.let { add(it) }
}
is JsonArray -> v.forEach { add(it) }
else -> Unit
}
}
(el["data"] as? JsonObject)?.let { add(it) }
(el["content"] as? JsonElement)?.let { add(it) }
(el["text"] as? JsonElement)?.let { add(it) }
(el["message"] as? JsonObject)?.let { add(it) }
(el["choices"] as? JsonArray)?.forEach { c ->
val co = c as? JsonObject ?: return@forEach
(co["delta"] as? JsonObject)?.let { add(it) }
(co["message"] as? JsonObject)?.let { add(it) }
}
}
private fun collectProbeModel(el: JsonObject, out: MutableSet<String>) {
for (key in listOf("model_name", "modelName", "config_name", "model", "provider_model_name", "modelProviderName")) {
(el[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { out.add(it) }
}
(el["data"] as? JsonObject)?.let { collectProbeModel(it, out) }
(el["message"] as? JsonObject)?.let { collectProbeModel(it, out) }
}
private fun collectProbeUsage(el: JsonObject, out: MutableList<String>) {
val usage = (el["usage"] as? JsonObject) ?: (el["token_usage"] as? JsonObject) ?: return
fun num(key: String, label: String) {
(usage[key] as? JsonPrimitive)?.let { out.add(label + "=" + it.content) }
}
num("prompt_tokens", "输入")
num("completion_tokens", "输出")
num("total_tokens", "合计")
}
private val json = Json { ignoreUnknownKeys = true }
private fun generateProxyKey(): String =
"rainy-" + UUID.randomUUID().toString().replace("-", "").take(16)
init {
refresh()
}
@ -349,23 +48,6 @@ 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,
)
data class SettingsUiState(
val loading: Boolean = false,
val credentialStatuses: List<CredentialStatusLine> = emptyList()

View File

@ -30,8 +30,8 @@ class UpdateViewModel @Inject constructor(
object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase
data class Ready(val file: File, val error: String? = null) : Phase
data class NeedInstallPermission(val file: File) : Phase
data class Ready(val file: File) : Phase
object NeedInstallPermission : Phase
data class Error(val message: String) : Phase
}
@ -80,33 +80,19 @@ class UpdateViewModel @Inject constructor(
}
fun install(file: File) {
val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
.getOrDefault(false)
if (!canInstall) {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
return
}
if (context.packageManager.canRequestPackageInstalls()) {
_uiState.update { it.copy(phase = Phase.Ready(file)) }
val authority = context.packageName + ".fileprovider"
val uri = try {
FileProvider.getUriForFile(context, authority, file)
} catch (e: Exception) {
_uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
return
}
val actionView = Intent(Intent.ACTION_VIEW).apply {
val uri = FileProvider.getUriForFile(context, authority, file)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
val started = runCatching { context.startActivity(actionView) }
if (started.isFailure) {
val actionInstall = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(actionInstall) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
runCatching { context.startActivity(intent) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
}
} else {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
}
}
}

View File

@ -1,7 +1,5 @@
package com.rainy.token.ui.webview
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebChromeClient
@ -12,15 +10,12 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@ -34,12 +29,8 @@ import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
@ -63,7 +54,6 @@ import com.rainy.token.ui.components.asString
fun WebViewLoginScreen(
service: ServiceType,
accountId: String? = null,
region: String? = null,
onBack: () -> Unit,
onLoginSucceeded: (ServiceType, String?) -> Unit,
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
@ -72,14 +62,10 @@ fun WebViewLoginScreen(
) {
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl, accountId) {
viewModel.bind(service, configuredBaseUrl, accountId, region)
viewModel.bind(service, configuredBaseUrl, accountId)
}
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
val context = LocalContext.current
var webViewGeneration by remember { mutableStateOf(0) }
var pageLoading by remember { mutableStateOf(true) }
var pageError by remember { mutableStateOf<String?>(null) }
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) {
@ -111,17 +97,6 @@ fun WebViewLoginScreen(
IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back))
}
},
actions = {
TextButton(onClick = { viewModel.openExternalBrowser() }) {
Text(stringResource(R.string.action_open_login_entry))
}
TextButton(onClick = {
viewModel.clearWebViewSession()
webViewGeneration++
}) {
Text(stringResource(R.string.webview_clear_login))
}
}
)
}
@ -130,22 +105,6 @@ fun WebViewLoginScreen(
.fillMaxSize()
.padding(innerPadding)) {
if (uiState.loginUrl.isNotEmpty()) {
if (pageLoading) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
if (uiState.externalBrowserWaiting) {
androidx.compose.material3.Surface(
color = androidx.compose.material3.MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.fillMaxWidth()
) {
Text(
text = stringResource(R.string.webview_browser_waiting),
style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(12.dp)
)
}
}
key(webViewGeneration) {
AndroidView(
factory = { context ->
WebView(context).apply {
@ -193,9 +152,8 @@ fun WebViewLoginScreen(
}
fun handleOverrideUrl(url: String): Boolean {
// Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
// (部分流程会从 https 跳到 http,或把参数放 fragment)
if (isTraeLoginCallback(url)) {
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
viewModel.onTraeCallback(url)
return true
@ -203,24 +161,8 @@ fun WebViewLoginScreen(
return false
}
private fun isTraeLoginCallback(url: String): Boolean {
val u = runCatching { android.net.Uri.parse(url) }.getOrNull()
return u?.host in setOf("127.0.0.1", "localhost") &&
u?.port == 18080 &&
u?.path == "/authorize"
}
override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
pageLoading = true
pageError = null
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}")
// 保险:某些重定向不一定走 shouldOverrideUrlLoading,
// 在 pageStarted 也拦一次回调地址,避免页面停在 Authenticating。
if (url != null && isTraeLoginCallback(url)) {
viewModel.onTraeCallback(url)
view?.stopLoading()
return
}
super.onPageStarted(view, url, favicon)
}
@ -231,70 +173,13 @@ fun WebViewLoginScreen(
viewModel.onPageFinished(url)
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}")
}
override fun onReceivedError(
view: WebView?,
request: android.webkit.WebResourceRequest?,
error: android.webkit.WebResourceError?
) {
// 只把“主框架”加载失败当成整页失败;图片/脚本/统计等子资源
// ERR_CONNECTION_CLOSED 很常见,页面仍在加载,不能误报。
if (request?.isForMainFrame != true) {
DebugLog.i("TraeLogin", "subresourceError(ignored): ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}")
super.onReceivedError(view, request, error)
return
}
pageLoading = false
pageError = error?.description?.toString() ?: request?.url?.toString().orEmpty()
DebugLog.w("TraeLogin", "pageError(main): ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}")
super.onReceivedError(view, request, error)
}
}
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
pageLoading = newProgress < 100
// 页面仍在加载(子资源逐渐完成)时,自动清掉之前的瞬时网络错误提示
if (newProgress > 0 && pageError != null && newProgress < 100) {
pageError = null
}
super.onProgressChanged(view, newProgress)
}
}
webChromeClient = WebChromeClient()
loadUrl(uiState.loginUrl)
}
},
modifier = Modifier.fillMaxSize()
)
}
pageError?.let { err ->
Card(modifier = Modifier.fillMaxWidth().padding(12.dp)) {
Column(Modifier.padding(12.dp)) {
Text(
text = stringResource(R.string.error_network) + ": " + err,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
Row(horizontalArrangement = androidx.compose.foundation.layout.Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
pageError = null
webViewGeneration++
}) {
Text(stringResource(R.string.action_retry))
}
TextButton(onClick = {
val url = uiState.loginUrl
runCatching {
context.startActivity(
Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}) {
Text(stringResource(R.string.action_open_login_entry))
}
}
}
}
}
} else {
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box(

View File

@ -21,7 +21,6 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.proxy.LoginCallbackServer
import com.rainy.token.data.repository.WebViewSessionSaver
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
@ -58,25 +57,14 @@ class WebViewLoginViewModel @Inject constructor(
/** WorkBuddy 轮询协程,bind 新服务时取消。 */
private var workBuddyPollingJob: Job? = null
/** WorkBuddy 当前基址(决定 auth state/token 用 CN 还是 INTL)。 */
private var workBuddyBase = "https://copilot.tencent.com"
/** WorkBuddy 伪装头:Origin/Referer/Platform/UA 必须与官方客户端一致。 */
private var workBuddyOrigin = "https://www.codebuddy.cn"
private var workBuddyPlatform = "VSCode"
private var workBuddyUserAgent = "CLI/2.143.1 CodeBuddy/2.143.1"
/** WorkBuddy 授权等待超时:国内 5 分钟(扫码),INTL 15 分钟(邮箱/验证码/SSO)。 */
private var workBuddyPollTimeoutMs = 300_000L
/** 目标账号(多账号服务);null = 新建账号。 */
private var accountId: String? = null
/** 当前登录目标区域(Trae/WorkBuddy);null = 其他服务。 */
private var region: String? = null
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null, region: String? = null) {
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null) {
workBuddyPollingJob?.cancel()
workBuddyPollingJob = null
this.accountId = accountId?.takeIf { it.isNotBlank() }
this.region = region?.takeIf { it.isNotBlank() }?.uppercase()
val config = ServiceConfigProvider.get(service)
_uiState.update {
@ -89,11 +77,6 @@ class WebViewLoginViewModel @Inject constructor(
)
}
// 每次进入登录页强制清掉 WebView 全局登录态,避免沿用上一个账号的 Cookie
if (service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) {
clearWebViewSession()
}
when (service) {
ServiceType.TRAE -> bindTrae()
ServiceType.WORKBUDDY -> bindWorkBuddy()
@ -150,24 +133,9 @@ class WebViewLoginViewModel @Inject constructor(
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId()
val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT)
_uiState.update { it.copy(loginUrl = callbackUrl) }
if (region == "INTL") {
// 国际版登录页在系统浏览器完成,回调由本地服务器自动接收并回填
openExternalBrowser()
}
return
}
/** 构建 Trae 授权 URL;WebView 走 18080,外部浏览器走 18081(本地回调服务器)。 */
private fun buildTraeAuthUrl(callbackPort: Int): String {
val traceId = randomHex(16)
val machineId = randomHex(32)
val deviceId = traeDeviceId()
val callbackUrl = "http://127.0.0.1:$callbackPort/authorize"
val authRoot = if (region == "INTL") "https://www.trae.ai/authorization" else "https://www.trae.cn/authorization"
val callbackUrl = "http://127.0.0.1:18080/authorize"
val authUrl = buildString {
append(authRoot).append("?")
append("https://www.trae.cn/authorization?")
append("login_version=1&auth_from=solo&login_channel=native_ide&")
append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&")
append("login_trace_id=$traceId&")
@ -180,8 +148,8 @@ class WebViewLoginViewModel @Inject constructor(
append("x_os_version=1.0&x_app_version=0.1.43&")
append("x_app_type=stable&plugin_version=2.3.62834")
}
DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
return authUrl
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
_uiState.update { it.copy(loginUrl = authUrl) }
}
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
@ -206,78 +174,37 @@ class WebViewLoginViewModel @Inject constructor(
if (service != ServiceType.TRAE) return
if (_uiState.value.loginSucceeded) return
val callbackUri = android.net.Uri.parse(url)
// 参数同时支持 query 和 fragment(部分 OAuth 回调用 # 而非 ?)
fun param(name: String): String? {
callbackUri.getQueryParameter(name)?.let { return it }
val frag = callbackUri.fragment
if (!frag.isNullOrBlank()) {
frag.split("&").forEach { pair ->
val kv = pair.split("=", limit = 2)
if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1])
if (kv.size == 1 && kv[0] == name) return ""
}
}
return null
}
val parsed = url.toHttpUrlOrNull() ?: run {
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return
}
// 只记录参数名(不含 token 值,避免把 token 明文落调试日志);query+both
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}")
// Trae 回调可能走两套:
// 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
// 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
val userJwtRaw = param("userJwt")
?: param("user_jwt")
?: ""
viewModelScope.launch {
val userJwt = runCatching {
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
val userJwtRaw = parsed.queryParameter("userJwt")
?: parsed.queryParameter("user_jwt")
?: run {
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
return
}
val userJwt = try {
Json.parseToJsonElement(userJwtRaw).jsonObject
}.getOrNull() ?: JsonObject(emptyMap())
var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
?: param("refreshToken")
?: param("refresh_token")
?: param("data")
val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
?: param("clientId")
?: param("client_id")
?: "en1oxy7wnw8j9n"
// 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
val exchangeHost = param("host")?.takeIf { it.startsWith("http") }
?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
if (exchanged != null) {
token = exchanged.token
refresh = exchanged.refreshToken
DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})")
} else {
DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})")
} catch (e: Exception) {
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
return
}
}
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_login_token_missing))
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
return
}
return@launch
}
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val parsedUserId = param("userInfo")?.let { raw ->
val userId = parsed.queryParameter("userInfo")?.let { raw ->
runCatching {
val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull
@ -286,15 +213,17 @@ class WebViewLoginViewModel @Inject constructor(
?: u["accountId"]?.jsonPrimitive?.contentOrNull
}.getOrNull()
}
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
viewModelScope.launch {
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
region = if (region == "INTL") "INTL" else "CN",
apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
region = "CN",
deviceId = deviceId,
userId = parsedUserId?.takeIf { it.isNotBlank() },
userId = userId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
@ -305,76 +234,14 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/** 使用 refreshToken 向 Trae OAuth 兑换 Cloud-IDE-JWT(老流程回调没有 userJwt)。 */
private data class TraeExchangeResult(val token: String, val refreshToken: String)
private suspend fun exchangeTraeRefreshToken(
refreshToken: String,
clientId: String,
host: String
): TraeExchangeResult? = withContext(Dispatchers.IO) {
try {
val payload = buildString {
append("{\"ClientID\":\"").append(clientId)
.append("\",\"RefreshToken\":\"").append(refreshToken)
.append("\",\"ClientSecret\":\"-\",\"UserID\":\"\"}")
}
val request = Request.Builder()
.url(host.trimEnd('/') + "/cloudide/api/v3/trae/oauth/ExchangeToken")
.addHeader("Content-Type", "application/json")
.post(payload.toRequestBody("application/json".toMediaTypeOrNull()))
.build()
okHttpClient.newCall(request).execute().use { resp ->
val body = resp.body?.string().orEmpty()
DebugLog.i("TraeLogin", "ExchangeToken: http=${resp.code} bodyLen=${body.length}")
val root = runCatching { Json.parseToJsonElement(body).jsonObject }.getOrNull() ?: return@use null
val data = root["Result"]?.jsonObject ?: root["result"]?.jsonObject ?: root
val token = data["Token"]?.jsonPrimitive?.contentOrNull
?: data["token"]?.jsonPrimitive?.contentOrNull
?: data["AccessToken"]?.jsonPrimitive?.contentOrNull
?: data["accessToken"]?.jsonPrimitive?.contentOrNull
if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "ExchangeToken: 返回无 Token(keys=${data.keys})")
return@use null
}
val newRefresh = data["RefreshToken"]?.jsonPrimitive?.contentOrNull
?: data["refreshToken"]?.jsonPrimitive?.contentOrNull
?: refreshToken
TraeExchangeResult(token, newRefresh)
}
} catch (e: Exception) {
DebugLog.w("TraeLogin", "ExchangeToken: 异常=" + (e.message ?: "unknown"))
null
}
}
// ─── WorkBuddy ─────────────────────────────────────────────────────────
private fun bindWorkBuddy() {
viewModelScope.launch {
try {
if (region == "INTL") {
workBuddyBase = "https://www.workbuddy.ai"
workBuddyOrigin = "https://www.workbuddy.ai"
workBuddyPlatform = "workbuddy-ai"
workBuddyPollTimeoutMs = 900_000L
} else {
workBuddyBase = "https://copilot.tencent.com"
workBuddyOrigin = "https://www.codebuddy.cn"
workBuddyPlatform = "VSCode"
workBuddyPollTimeoutMs = 300_000L
}
workBuddyUserAgent = "CLI/2.143.1 CodeBuddy/2.143.1"
val request = Request.Builder()
.url(workBuddyBase + "/v2/plugin/auth/state?platform=" + workBuddyPlatform)
.addHeader("User-Agent", workBuddyUserAgent)
.addHeader("Origin", workBuddyOrigin)
.addHeader("Referer", workBuddyOrigin + "/")
.addHeader("X-Client-ID", "codebuddy-cli")
.addHeader("X-Client-Version", "2.143.1")
.addHeader("X-Product", "SaaS")
.addHeader("Accept", "application/json, text/plain, */*")
.addHeader("X-Requested-With", "XMLHttpRequest")
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
.build()
@ -394,7 +261,6 @@ class WebViewLoginViewModel @Inject constructor(
val state = data?.get("state")?.jsonPrimitive?.contentOrNull
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
?: data?.get("authURL")?.jsonPrimitive?.contentOrNull
DebugLog.i("WorkBuddyLogin", "bind: host=${workBuddyBase} bodyKeys=${json.keys} dataKeys=${data?.keys ?: emptySet()}")
if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic))
@ -403,10 +269,6 @@ class WebViewLoginViewModel @Inject constructor(
}
_uiState.update { it.copy(loginUrl = authURL) }
workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) }
if (region == "INTL") {
// 国际版登录页在系统浏览器完成,凭据由轮询自动回填
openExternalBrowser()
}
} catch (_: Exception) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic))
@ -415,24 +277,16 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/** 每 2 秒轮询一次 token 接口,超时按站点 Profile(CN 5 分钟 / INTL 15 分钟)。 */
/** 每 2 秒轮询一次 token 接口,120 秒超时。 */
private suspend fun pollWorkBuddyToken(state: String) {
val timeoutMs = workBuddyPollTimeoutMs
val timeoutMs = 120_000L
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
if (_uiState.value.loginSucceeded) return
try {
val request = Request.Builder()
.url(workBuddyBase + "/v2/plugin/auth/token?state=$state")
.addHeader("User-Agent", workBuddyUserAgent)
.addHeader("Origin", workBuddyOrigin)
.addHeader("Referer", workBuddyOrigin + "/")
.addHeader("X-Client-ID", "codebuddy-cli")
.addHeader("X-Client-Version", "2.143.1")
.addHeader("X-Product", "SaaS")
.addHeader("X-No-Authorization", "1")
.addHeader("Accept", "application/json, text/plain, */*")
.addHeader("X-Requested-With", "XMLHttpRequest")
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.get()
.build()
val body = withContext(Dispatchers.IO) {
@ -441,34 +295,16 @@ class WebViewLoginViewModel @Inject constructor(
resp.close()
s
}
DebugLog.i("WorkBuddyLogin", "poll: state=${state.take(6)}… bodyLen=${body.length}")
val json = Json.parseToJsonElement(body).jsonObject
val codeText = json["code"]?.jsonPrimitive?.contentOrNull
val code = codeText?.toIntOrNull() ?: json["code"]?.jsonPrimitive?.intOrNull ?: -1
val rawData = json["data"]?.jsonObject ?: json.jsonObject
DebugLog.i(
"WorkBuddyLogin",
"poll: code=$codeText bodyKeys=${json.keys} dataKeys=${rawData.keys}"
)
if (code == 0 || codeText == "0") {
// 兼容多种字段:accessToken/access_token/token/authToken,refreshToken/refresh_token,expiresIn/expires_at
val accessToken = rawData.let { obj ->
obj["accessToken"]?.jsonPrimitive?.contentOrNull
?: obj["access_token"]?.jsonPrimitive?.contentOrNull
?: obj["token"]?.jsonPrimitive?.contentOrNull
?: obj["authToken"]?.jsonPrimitive?.contentOrNull
}
val refreshToken = rawData.let { obj ->
obj["refreshToken"]?.jsonPrimitive?.contentOrNull
?: obj["refresh_token"]?.jsonPrimitive?.contentOrNull
?: obj["refresh"]?.jsonPrimitive?.contentOrNull
}
val expiresIn = rawData.let { obj ->
obj["expiresIn"]?.jsonPrimitive?.longOrNull
?: obj["expires_in"]?.jsonPrimitive?.longOrNull
?: obj["expiresAt"]?.jsonPrimitive?.longOrNull
?: 7200L
}
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
if (code == 0) {
val data = json["data"]?.jsonObject
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
if (!accessToken.isNullOrBlank()) {
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
@ -476,7 +312,6 @@ class WebViewLoginViewModel @Inject constructor(
service = ServiceType.WORKBUDDY,
accessToken = accessToken,
refreshToken = refreshToken ?: "",
region = if (region == "INTL") "INTL" else "CN",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
@ -489,13 +324,11 @@ class WebViewLoginViewModel @Inject constructor(
loginAccountId = effectiveId.ifBlank { null }
)
}
DebugLog.i("WorkBuddyLogin", "poll: 凭证已保存,登录完成")
return
}
DebugLog.w("WorkBuddyLogin", "poll: code=0 但 data 无 accessToken(dataKeys=${rawData.keys})")
}
} catch (e: Exception) {
DebugLog.w("WorkBuddyLogin", "poll: 异常=${e.message ?: "unknown"}")
} catch (_: Exception) {
// 网络异常时继续轮询,直到超时
}
delay(2000)
}
@ -528,77 +361,6 @@ class WebViewLoginViewModel @Inject constructor(
}
}
/**
* 「在浏览器中打开登录入口」:Trae 起本地回调服务器,登录完自动回传凭证;
* WorkBuddy 直接打开浏览器并继续原有轮询(官方登录本身就在浏览器完成)。
*/
fun openExternalBrowser() {
val service = _uiState.value.service ?: return
val currentUrl = _uiState.value.loginUrl
when (service) {
ServiceType.TRAE -> {
if (!LoginCallbackServer.start(LOGIN_EXTERNAL_CALLBACK_PORT)) {
_uiState.update {
it.copy(error = UiText.Resource(R.string.error_login_callback_server))
}
return
}
val authUrl = buildTraeAuthUrl(LOGIN_EXTERNAL_CALLBACK_PORT)
_uiState.update {
it.copy(loginUrl = authUrl, externalBrowserWaiting = true, error = null)
}
launchExternalBrowser(authUrl)
viewModelScope.launch {
val callback = LoginCallbackServer.await(LOGIN_EXTERNAL_TIMEOUT_MS)
if (callback != null) {
onTraeCallback(callback)
} else if (!_uiState.value.loginSucceeded) {
_uiState.update {
it.copy(
error = UiText.Resource(R.string.error_login_timeout),
externalBrowserWaiting = false
)
}
}
}
}
ServiceType.WORKBUDDY -> {
if (currentUrl.isNotBlank()) {
_uiState.update {
it.copy(loginUrl = currentUrl, externalBrowserWaiting = true, error = null)
}
launchExternalBrowser(currentUrl)
}
}
else -> {
if (currentUrl.isNotBlank()) {
launchExternalBrowser(currentUrl)
}
}
}
}
private fun launchExternalBrowser(url: String) {
runCatching {
val ctx = com.rainy.token.RainyTokenApplication.appContext
val intent = android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url)
).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK)
ctx.startActivity(intent)
}
}
/** 清除 WebView 登录缓存(Cookie/Storage),用于更换账号登录。 */
fun clearWebViewSession() {
runCatching {
val cm = android.webkit.CookieManager.getInstance()
cm.removeAllCookies(null)
cm.removeSessionCookies(null)
android.webkit.WebStorage.getInstance().deleteAllData()
}
}
// ─── Page lifecycle ────────────────────────────────────────────────────
fun onPageFinished(url: String) {
@ -753,10 +515,6 @@ private const val SUB2_NONCE_KEY = "nonce"
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/go""")
private const val LOGIN_WEBVIEW_CALLBACK_PORT = 18080
private const val LOGIN_EXTERNAL_CALLBACK_PORT = 18081
private const val LOGIN_EXTERNAL_TIMEOUT_MS = 20 * 60 * 1000L
internal fun buildProbeScript(nonce: String): String = """
(function () {
try {
@ -787,7 +545,5 @@ data class WebViewLoginUiState(
val pendingManualConfirm: Boolean = false,
val error: UiText? = null,
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
val loginAccountId: String? = null,
/** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */
val externalBrowserWaiting: Boolean = false
val loginAccountId: String? = null
)

View File

@ -19,7 +19,6 @@
<string name="action_clear">清除</string>
<string name="action_save">保存</string>
<string name="action_login_in_app">软件内登录</string>
<string name="action_mobile_browser_login">手机浏览器登录</string>
<string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string>
@ -228,7 +227,6 @@
<string name="checkin_success">签到成功</string>
<string name="checkin_already">今日已签到</string>
<string name="checkin_already_streak">今日已签到(连签 %1$d 天)</string>
<string name="checkin_wb_no_campaign">暂无可领取的签到活动</string>
<string name="checkin_failed">签到失败,请检查凭据</string>
<string name="checkin_unsupported">该服务不支持签到</string>
<string name="checkin_action_done">今日已签到</string>
@ -425,13 +423,6 @@
<string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</string>
<string name="error_login_token_missing">登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴</string>
<string name="field_trae_callback_url">Trae 登录回调链接</string>
<string name="field_trae_callback_hint">在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...)</string>
<string name="action_parse_callback">解析并填入 JWT</string>
<string name="error_trae_callback_invalid">无法解析这条回调链接,请完整复制地址栏内容</string>
<string name="error_login_callback_server">本地回调服务启动失败,请改用应用内登录</string>
<string name="webview_browser_waiting">已在浏览器打开,登录完成后会自动回到雨晴Token</string>
<string name="error_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %1$s</string>
@ -456,7 +447,6 @@
<string name="error_token_refresh_generic">token 刷新失败,请重新登录或重新导入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登录</string>
<string name="title_webview_login">登录 %1$s</string>
<string name="dialog_login_done_title">完成登录了吗?</string>
<string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string>
@ -794,24 +784,4 @@
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
<string name="update_enable_permission">去开启</string>
<string name="update_failed">更新失败:%1$s</string>
<string name="trae_intl_premium_fast_title">Premium 快速</string>
<string name="trae_intl_premium_slow_title">Premium 慢速</string>
<string name="trae_intl_advanced_title">高级额度</string>
<string name="trae_intl_auto_completion_title">自动补全</string>
<string name="trae_intl_quota_unit">次/月</string>
<string name="action_trae_recharge">充值 / 升级</string>
<string name="trae_intl_plan_title">套餐</string>
<string name="trae_intl_basic_used_title">基础用量已用</string>
<string name="wb_intl_no_checkin_title">国际版无独立签到</string>
<string name="wb_intl_no_checkin_desc">WorkBuddy 国际版积分来自网页/客户端活跃使用,没有独立签到活动。</string>
<string name="trae_intl_no_checkin_title">国际版没有每日签到</string>
<string name="trae_intl_no_checkin_desc">Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。</string>
<string name="trae_intl_monthly_quota">月配额</string>
<string name="trae_intl_dollar_usage_title">美元用量额度</string>
<string name="action_usage_events">用量事件</string>
<string name="trae_intl_usage_card_title">Dollar Usage</string>
<string name="trae_usage_events_title">用量事件</string>
<string name="trae_usage_events_loading">正在加载用量事件…</string>
<string name="trae_usage_events_empty">最近 30 天暂无用量事件</string>
<string name="trae_usage_events_load_failed">用量事件加载失败:%1$s</string>
</resources>

View File

@ -19,7 +19,6 @@
<string name="action_clear">清除</string>
<string name="action_save">儲存</string>
<string name="action_login_in_app">軟體內登入</string>
<string name="action_mobile_browser_login">手機瀏覽器登入</string>
<string name="action_jump">跳轉</string>
<string name="action_prev_page">◀ 上一頁</string>
<string name="action_next_page">下一頁 ▶</string>
@ -228,7 +227,6 @@
<string name="checkin_success">簽到成功</string>
<string name="checkin_already">今日已簽到</string>
<string name="checkin_already_streak">今日已簽到(連簽 %1$d 天)</string>
<string name="checkin_wb_no_campaign">暫無可領取的簽到活動</string>
<string name="checkin_failed">簽到失敗,請檢查憑證</string>
<string name="checkin_unsupported">該服務不支援簽到</string>
<string name="checkin_action_done">今日已簽到</string>
@ -425,13 +423,6 @@
<string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</string>
<string name="error_login_token_missing">登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上</string>
<string name="field_trae_callback_url">Trae 登入回呼連結</string>
<string name="field_trae_callback_hint">在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...)</string>
<string name="action_parse_callback">解析並填入 JWT</string>
<string name="error_trae_callback_invalid">無法解析這條回呼連結,請完整複製網址列內容</string>
<string name="error_login_callback_server">本機回呼服務啟動失敗,請改用應用內登入</string>
<string name="webview_browser_waiting">已在瀏覽器開啟,登入完成後會自動回到雨晴Token</string>
<string name="error_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %1$s</string>
@ -456,7 +447,6 @@
<string name="error_token_refresh_generic">token 重新整理失敗,請重新登入或重新匯入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登入</string>
<string name="title_webview_login">登入 %1$s</string>
<string name="dialog_login_done_title">完成登入了嗎?</string>
<string name="dialog_login_done_body">如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。</string>
@ -794,24 +784,4 @@
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
<string name="update_enable_permission">前往開啟</string>
<string name="update_failed">更新失敗:%1$s</string>
<string name="trae_intl_premium_fast_title">Premium 快速</string>
<string name="trae_intl_premium_slow_title">Premium 慢速</string>
<string name="trae_intl_advanced_title">進階額度</string>
<string name="trae_intl_auto_completion_title">自動補全</string>
<string name="trae_intl_quota_unit">次/月</string>
<string name="action_trae_recharge">儲值 / 升級</string>
<string name="trae_intl_plan_title">套餐</string>
<string name="trae_intl_basic_used_title">基礎用量已用</string>
<string name="wb_intl_no_checkin_title">國際版無獨立簽到</string>
<string name="wb_intl_no_checkin_desc">WorkBuddy 國際版積分來自網頁/客戶端活躍使用,沒有獨立簽到活動。</string>
<string name="trae_intl_no_checkin_title">國際版沒有每日簽到</string>
<string name="trae_intl_no_checkin_desc">Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。</string>
<string name="trae_intl_monthly_quota">月額度</string>
<string name="trae_intl_dollar_usage_title">美元用量額度</string>
<string name="action_usage_events">用量事件</string>
<string name="trae_intl_usage_card_title">Dollar Usage</string>
<string name="trae_usage_events_title">用量事件</string>
<string name="trae_usage_events_loading">正在載入用量事件…</string>
<string name="trae_usage_events_empty">最近 30 天暫無用量事件</string>
<string name="trae_usage_events_load_failed">用量事件載入失敗:%1$s</string>
</resources>

View File

@ -23,7 +23,6 @@
<string name="action_clear">Clear</string>
<string name="action_save">Save</string>
<string name="action_login_in_app">Sign in within app</string>
<string name="action_mobile_browser_login">Sign in via mobile browser</string>
<string name="action_jump">Go</string>
<string name="action_prev_page">◀ Prev</string>
<string name="action_next_page">Next ▶</string>
@ -231,7 +230,6 @@
<string name="checkin_success">Check-in successful</string>
<string name="checkin_already">Already checked in today</string>
<string name="checkin_already_streak">Already checked in (streak %1$d days)</string>
<string name="checkin_wb_no_campaign">No active check-in campaign</string>
<string name="checkin_failed">Check-in failed, please check credentials</string>
<string name="checkin_unsupported">This service does not support check-in</string>
<string name="checkin_action_done">Checked in today</string>
@ -428,13 +426,6 @@
<string name="error_select_model_first">Please select a model first</string>
<string name="error_cookie_not_found">No cookie captured, please make sure you\'re logged in</string>
<string name="error_login_url_missing">No login URL configured</string>
<string name="error_login_token_missing">Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.</string>
<string name="field_trae_callback_url">Trae login callback URL</string>
<string name="field_trae_callback_hint">Paste the whole callback URL after logging in the browser (http://127.0.0.1:18080/authorize?...).</string>
<string name="action_parse_callback">Parse and fill JWT</string>
<string name="error_trae_callback_invalid">Could not parse this callback URL. Please copy the address bar URL completely.</string>
<string name="error_login_callback_server">Unable to start local callback server. Please use in-app login instead.</string>
<string name="webview_browser_waiting">Opened in browser. After login it will return to RainyToken automatically.</string>
<string name="error_login_timeout">Login timed out, please retry</string>
<string name="error_oauth_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %1$s</string>
@ -459,7 +450,6 @@
<string name="error_token_refresh_generic">Token refresh failed. Please log in again or import auth.json.</string>
<!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">Clear login</string>
<string name="title_webview_login">Sign in to %1$s</string>
<string name="dialog_login_done_title">Done logging in?</string>
<string name="dialog_login_done_body">If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session.</string>
@ -799,24 +789,4 @@
<string name="update_install_permission_tip">Please allow installing apps from unknown sources</string>
<string name="update_enable_permission">Open settings</string>
<string name="update_failed">Update failed: %1$s</string>
<string name="trae_intl_premium_fast_title">Premium fast</string>
<string name="trae_intl_premium_slow_title">Premium slow</string>
<string name="trae_intl_advanced_title">Advanced requests</string>
<string name="trae_intl_auto_completion_title">Auto completion</string>
<string name="trae_intl_quota_unit">/mo</string>
<string name="action_trae_recharge">Upgrade / Top up</string>
<string name="trae_intl_plan_title">Plan</string>
<string name="trae_intl_basic_used_title">Basic usage used</string>
<string name="wb_intl_no_checkin_title">No daily check-in on international</string>
<string name="wb_intl_no_checkin_desc">WorkBuddy international credits are earned through active usage, not a check-in campaign.</string>
<string name="trae_intl_no_checkin_title">International edition has no daily check-in</string>
<string name="trae_intl_no_checkin_desc">TRAE International uses USD billing with monthly request quotas instead of credit check-ins.</string>
<string name="trae_intl_monthly_quota">Monthly quota</string>
<string name="trae_intl_dollar_usage_title">Dollar allowance</string>
<string name="action_usage_events">Usage Events</string>
<string name="trae_intl_usage_card_title">Dollar Usage</string>
<string name="trae_usage_events_title">Usage Events</string>
<string name="trae_usage_events_loading">Loading usage events…</string>
<string name="trae_usage_events_empty">No usage events in the last 30 days</string>
<string name="trae_usage_events_load_failed">Usage events load failed: %1$s</string>
</resources>

View File

@ -1,143 +0,0 @@
# RainyToken 反代网关开发路线图(PROXY-ROADMAP)
> 分支:dev-proxy
> 目标:在雨晴Token App 内嵌一个多提供商 OpenAI 兼容反代网关,支持双区、签到、反代、多账号池,并尽量覆盖 agent2api / fleet-kit 等开源项目已支持的平台。
---
## 1. 最终目标
App 内启动一个本地 HTTP 服务(默认 `127.0.0.1:8787`),对外暴露:
- `GET /v1/models`
- `POST /v1/chat/completions`(OpenAI 兼容,流式/非流式)
- `POST /v1/responses`(Codex Responses API)
- `POST /v1/messages`(Anthropic Messages,Claude Code 直连)
请求按「模型名 → Provider + Region + 账号」路由,复用现有 `CredentialRepository` / `SecureStorage` / 各 Repository。
## 2. 架构
```
LocalProxyServer (127.0.0.1:8787)
│
├── /v1/chat/completions
├── /v1/responses
├── /v1/messages
└── /v1/models
│
▼
ProviderRouter(模型名 → Provider + Region + Account)
│
┌──────┼──────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough
(CN/INTL)(CN/INTL)(CN) (CN) (DeepSeek/Gemini/Kimi…)
│
▼
AccountPool(多账号轮询 + 熔断冷却 + 会话粘性)
│
▼
StreamNormalizer(各家上游 SSE 分片 → 标准 OpenAI/Anthropic 流)
```
## 3. 已编码骨架(data/proxy)
- `ProviderKind.kt`:全部计划支持的 Provider 枚举
- `ProviderRegion.kt`:CN / INTL
- `ProxyModels.kt`:统一 Chat 请求 / 响应 / 模型 / 用量
- `ProviderAdapter.kt`:统一 Provider 适配器接口
- `AccountPool.kt`:账号池骨架(round-robin,后续加权)
- `RegionRouter.kt`:模型 → Provider/Region 路由骨架
- `LocalProxyServer.kt`:本地服务接口 + 配置
## 3.5 当前进度(dev-proxy 分支)
| 提交 | 内容 |
|---|---|
| 85c6cf4 | M1a:Ktor 本地服务 + Sub2API 透传 |
| 11b9065 | M1b:WorkBuddy CN / Trae CN 适配 + 设置页开关 |
| a1605e6 | M2:账号池轮询 + 会话粘性 + 双区域路由 |
| 1609e21 | 审计修复:鉴权头/401/模型前缀剥离 + 流式入口骨架 |
**已打通**:
- 设置页「代理网关」开关,默认 127.0.0.1:8787,本机 Key:rainytoken
- 路由前缀:wbcn- / workbuddy-cn / codebuddy- → WorkBuddy CN;wbintl- / workbuddy-intl → WorkBuddy INTL;traecn- / trae-cn / trae- → Trae CN;traeintl- / trae-intl → Trae INTL;其余 → Sub2API
- 账号池按区内匹配 + round-robin + 会话粘性(X-Conversation-Id / user)
- stream=true 已接实时字节流转发(respondOutputStream);各上游私有 SSE 信封仍待归一
**未完成**:
- 各家私有 SSE 信封 → 标准 OpenAI/Anthropic 流的 StreamNormalizer 仍待实现
- WorkBuddy INTL / Trae SOLO/Work 实际协议归一化
- M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy)
- Qoder 需 COSY 请求签名/编码(ed25519 类原语),已列为攻坚项
- M4 长尾 + 官方透传 + 网关配置 UI 完善(已做:随机 API Key / 启动失败提示;待做:端口配置 / 状态持久化)
## 4. 里程碑
### M0:地基(当前)
- 新增 `data/proxy` 骨架,能编译
- 新增本路线图
- 验收:`./gradlew :app:assembleDebug` 绿
### M1:先打通“已经有凭据体系”的三家
- WorkBuddy 国内版 Chat 适配器(参考 workbuddy2api / workbuddy-gateway)
- Trae CN Chat 适配器(参考 Trae2api-cn / trae2api)
- Sub2API 透传(本身是 OpenAI 兼容协议)
- 本地 HTTP 服务可用,任意 OpenAI 客户端可连
- 验收:真机 + 真凭据,WorkBuddy / Trae / Sub2API 各能回复一条消息
### M2:双区 + 多账号治理
- WorkBuddy 国际版、Trae INTL/Solo/Work 适配器
- RegionRouter 按模型家族自动选区(glm/deepseek/kimi → CN,gpt/claude/gemini → INTL)
- AccountPool 加权轮询 + 429 熔断 + 会话粘性
- 验收:国内/国际账号混挂,同模型自动走对应区,单号故障自动换号
### M3:覆盖 agent2api 全家桶
- Qoder CN、小浣熊、CatPaw、AutoClaw CN/INTL、Cline、Accio CN/INTL、ZCode CN/INTL、CodeArts、Loomy
- 每家一个 Adapter,抄 agent2api `providers/` 目录
- 验收:每家至少能在有凭据的情况下完成一次对话;签到/余额尽量复用现有页
### M4:长尾 + 官方直连
- 灵犀、团结AI、TokenDance、Kimi Code、MiniMax、Gemini
- 官方 OpenAI 兼容平台统一走 `OfficialPassthrough`(配置 base_url + key)
- 设置页新增「代理网关」管理:端口、启动/停止、API Key、运行状态
- 验收:所有支持平台可在 UI 中启用/禁用
## 5. Provider 支持矩阵(规划)
| Provider | 区域 | Chat | 签到 | 参考实现 |
|---|---|---|---|---|
| WorkBuddy | CN / INTL | ✅ | CN 有 | agent2api / fleet-kit / proxy-hub |
| Trae / SOLO / Work | CN / INTL | ✅ | CN 有 | agent2api / proxy-hub |
| Qoder | CN | ✅ | CN 有 | agent2api / proxy-hub |
| 小浣熊 | CN | ✅ | 桌面登录积分 | agent2api / fleet-kit |
| CatPaw | CN | ✅ | - | agent2api |
| AutoClaw | CN / INTL | ✅ | CN 有 | agent2api |
| Cline | INTL | ✅ | - | agent2api |
| Accio | CN / INTL | ✅ | - | agent2api |
| ZCode | CN / INTL | ✅ | - | agent2api |
| CodeArts | CN | ✅ | 每日福利 | agent2api |
| Loomy | CN | ✅ | 每日积分 | agent2api / fleet-kit |
| 灵犀 / 团结AI / TokenDance | CN | ✅ | 待确认 | fleet-kit |
| Kimi Code / MiniMax / Gemini | INTL/CN | ✅ | - | 官方 API 透传 |
## 6. 关键风险与对策
1. **上游协议会变**:所有解析做防御式多候选扫描(项目现有惯例);接口变动时快速适配。
2. **设备指纹/风控**:Trae 签到 9074、WorkBuddy 设备指纹;沿用现有 `checkinDeviceId` / `derive_id` 思路。
3. **Token 单次轮换**:WorkBuddy refresh 单次消费,沿用 `RefreshWriteSession` 落库再用的机制。
4. **SSE 分片差异**:各家流式事件格式不同,用统一 `StreamNormalizer` 归一。
5. **Android 后台**:若只给本机内客户端用,App 前台运行即可;若希望后台常驻,需前台服务 + 通知。
## 7. 参考仓库
- agent2api:https://github.com/aimod-cc/agent2api
- workbuddy2api-hub:https://github.com/ardeyouxipianyi/workbuddy2api-hub
- workbuddy-gateway:https://github.com/CangShui/workbuddy-gateway
- workbuddy2api-panel:https://github.com/linguo2625469/workbuddy2api-panel
- Trae2api-cn:https://github.com/autumnsentiment/Trae2api-cn
- trae2api (Go):https://github.com/JeffHu0912/trae2api
- proxy-hub:https://github.com/1416277987/proxy-hub
- fleet-kit:https://github.com/cpufreestyle/fleet-kit

View File

@ -15,9 +15,6 @@ navigationCompose = "2.9.8"
# Network (Retrofit 2.11.0 配 OkHttp 4.12.0 — 稳定组合,避开 Retrofit 3.x + OkHttp 5.x 的前沿兼容性问题)
retrofit = "2.11.0"
# Ktor (本地反代 HTTP 服务)
ktor = "3.0.3"
okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3"
retrofitKotlinxSerializationConverter = "1.0.0"
@ -70,10 +67,6 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }

View File

@ -1,6 +1,6 @@
{
"versionCode": 40,
"versionName": "1.7.20",
"apkUrl": "http://124.222.97.212:8443/attachments/28bb378e-c34a-4f55-b17a-c74f76b596a7",
"notes": "国际版 Trae 显示美元用量额度 $0.00/$1.00 + 月请求配额"
"versionCode": 42,
"versionName": "1.7.22",
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
}