diff --git a/.gitignore b/.gitignore
index 02f92f9..8554bca 100644
--- a/.gitignore
+++ b/.gitignore
@@ -23,3 +23,7 @@ release.jks
tmp_cc/
goal.md
/reference/
+
+# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
+_tmp_*
+build_compile.log
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 077c966..5ba02f2 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
- versionCode = 14
- versionName = "1.6.4"
+ versionCode = 43
+ versionName = "1.7.23"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -214,6 +214,12 @@ 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)
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 9cd6552..83aafee 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools">
+
diff --git a/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt b/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
new file mode 100644
index 0000000..4de1876
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt
@@ -0,0 +1,87 @@
+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()
+ private val cursor = mutableMapOf()
+
+ 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 {
+ 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
+ }
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt
new file mode 100644
index 0000000..0f26125
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt
@@ -0,0 +1,243 @@
+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 = _isRunning.asStateFlow()
+
+ override fun start(config: ProxyServerConfig): Result {
+ 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 =
+ 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
+ }
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt b/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt
new file mode 100644
index 0000000..441d361
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt
@@ -0,0 +1,27 @@
+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
+
+ fun start(config: ProxyServerConfig): Result
+
+ fun stop()
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt b/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt
new file mode 100644
index 0000000..74886a7
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt
@@ -0,0 +1,70 @@
+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:/authorize?...`,
+ * 这里把整个回调 URL 交给 ViewModel 解析并保存凭据(Sub2API 同款体验)。
+ *
+ * 只绑 loopback;授权完成/超时/退出登录页时调用 [stop] 释放端口。
+ */
+object LoginCallbackServer {
+
+ private const val PATH = "/authorize"
+
+ private var server: EmbeddedServer<*, *>? = null
+ private var pending: CompletableDeferred? = 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
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProviderAdapter.kt b/app/src/main/java/com/rainy/token/data/proxy/ProviderAdapter.kt
new file mode 100644
index 0000000..28aaee0
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderAdapter.kt
@@ -0,0 +1,21 @@
+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
+
+ /** 发起对话,内部处理流式/非流式。 */
+ fun chat(request: ProxyChatRequest, accountId: String?): Flow
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt b/app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
new file mode 100644
index 0000000..21ae760
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt
@@ -0,0 +1,33 @@
+package com.rainy.token.data.proxy
+
+/**
+ * 网关支持的 Provider 清单。
+ *
+ * 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
+ * supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
+ */
+enum class ProviderKind(
+ val displayName: String,
+ val regions: Set = 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()),
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProviderRegion.kt b/app/src/main/java/com/rainy/token/data/proxy/ProviderRegion.kt
new file mode 100644
index 0000000..c8183fa
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderRegion.kt
@@ -0,0 +1,6 @@
+package com.rainy.token.data.proxy
+
+/**
+ * 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
+ */
+enum class ProviderRegion { CN, INTL }
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt b/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt
new file mode 100644
index 0000000..bc99dd0
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt
@@ -0,0 +1,293 @@
+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()
+ private val cooldownUntil = ConcurrentHashMap()
+ private val failCount = ConcurrentHashMap()
+
+ 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): List = input.sortedWith(
+ compareBy(
+ { 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 {
+ 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 {
+ 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()
+ 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() ?: ""
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt b/app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
new file mode 100644
index 0000000..8ae399a
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt
@@ -0,0 +1,56 @@
+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 = emptyList(),
+ val stream: Boolean = false,
+ val tools: List? = 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? = 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? = 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,
+)
diff --git a/app/src/main/java/com/rainy/token/data/proxy/ProxyUpstreamStream.kt b/app/src/main/java/com/rainy/token/data/proxy/ProxyUpstreamStream.kt
new file mode 100644
index 0000000..2718439
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/ProxyUpstreamStream.kt
@@ -0,0 +1,15 @@
+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,
+)
diff --git a/app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt b/app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
new file mode 100644
index 0000000..5c26f16
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt
@@ -0,0 +1,30 @@
+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")
+ }
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
new file mode 100644
index 0000000..9087cec
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt
@@ -0,0 +1,135 @@
+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,
+)
diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
new file mode 100644
index 0000000..3f791a4
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt
@@ -0,0 +1,319 @@
+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 = 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 {
+ 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",
+ )
+ }
+
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt
new file mode 100644
index 0000000..51a931c
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt
@@ -0,0 +1,191 @@
+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 =
+ _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? {
+ val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
+ ?: return null
+ val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
+ val seen = LinkedHashMap()
+ for (groupEl in list) {
+ val group = groupEl as? JsonObject ?: continue
+ val models = group["models"] as? JsonArray ?: continue
+ for (rawEl in models) {
+ val raw = rawEl as? JsonObject ?: continue
+ val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim()
+ ?.takeIf { it.isNotEmpty() } ?: continue
+ val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull
+ ?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull
+ ?: id
+ seen[id] = TraeModel(id, label)
+ }
+ }
+ return seen.values.toList().ifEmpty { null }
+ }
+
+ companion object {
+ private const val KEY_MODEL_CN = "trae_default_model_cn"
+ private const val KEY_MODEL_INTL = "trae_default_model_intl"
+ private const val DEFAULT_MODEL_INTL = "glm-5.2"
+
+ /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
+ fun defaultModels(): List = listOf(
+ TraeModel("glm-5.2", "GLM-5.2"),
+ TraeModel("glm-5.3", "GLM-5.3"),
+ TraeModel("glm-5.1", "GLM-5.1"),
+ TraeModel("glm-5", "GLM-5"),
+ TraeModel("glm-4.7", "GLM-4.7"),
+ TraeModel("glm-4.6", "GLM-4.6"),
+ TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"),
+ TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"),
+ TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"),
+ TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"),
+ TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"),
+ TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"),
+ TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"),
+ TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"),
+ TraeModel("qwen-3.7-plus", "qwen-3.7-plus"),
+ TraeModel("qwen-3.6-plus", "qwen-3.6-plus"),
+ TraeModel("qwen3.8-max", "qwen3.8-max"),
+ TraeModel("qwen-3.5", "qwen-3.5"),
+ TraeModel("qwen3-coder", "qwen3-coder"),
+ TraeModel("kimi-k2.6", "kimi-k2.6"),
+ TraeModel("kimi-k3", "kimi-k3"),
+ TraeModel("kimi-k2.7-code", "kimi-k2.7-code"),
+ TraeModel("kimi-k2", "kimi-k2"),
+ TraeModel("kimi-k2.5", "kimi-k2.5"),
+ TraeModel("minimax-m3", "minimax-m3"),
+ TraeModel("minimax-m2.7", "minimax-m2.7"),
+ TraeModel("minimax-m2.1", "minimax-m2.1"),
+ TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"),
+ TraeModel("mimo-v2.5", "mimo-v2.5"),
+ TraeModel("minimax-m25", "minimax-m25"),
+ TraeModel("qwen36-35b", "qwen36-35b"),
+ TraeModel("work", "Work(200K 档)"),
+ )
+ }
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt
new file mode 100644
index 0000000..e72c758
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt
@@ -0,0 +1,252 @@
+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
+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
+
+/**
+ * WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。
+ *
+ * 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。
+ * 这里补齐桌面端请求头与上游 body 归一化,国际版先走 console 路由再退回 v2。
+ */
+@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 upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL)
+ val intl = effectiveRegion == ProviderRegion.INTL
+ val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions")
+ for (path in paths) {
+ val request = buildChatRequest(base, path, upstreamBody, accessToken, intl)
+ val response = try {
+ okHttpClient.newCall(request).execute()
+ } catch (e: java.io.IOException) {
+ throw e
+ }
+ val status = response.code
+ // 国际站 console 路由返回 404/405 时再退回共享 v2 路径
+ if (status == 404 || status == 405) {
+ response.close()
+ continue
+ }
+ val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
+ val contentType = response.header("Content-Type") ?: "application/json"
+ response.close()
+ return@withContext ProxyUpstreamResponse(status, contentType, bytes)
+ }
+ return@withContext null
+ }
+ 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 upstreamBody = prepareChatBody(requestBody, effectiveRegion == ProviderRegion.INTL)
+ val intl = effectiveRegion == ProviderRegion.INTL
+ val paths = if (intl) listOf("/console/chat/completions", "/v2/chat/completions") else listOf("/v2/chat/completions")
+ for (path in paths) {
+ val request = buildChatRequest(base, path, upstreamBody, accessToken, intl)
+ val response = try {
+ okHttpClient.newCall(request).execute()
+ } catch (e: java.io.IOException) {
+ throw e
+ }
+ if (response.code == 404 || response.code == 405) {
+ response.close()
+ continue
+ }
+ val input = response.body?.byteStream() ?: run {
+ response.close()
+ return@withContext null
+ }
+ return@withContext ProxyUpstreamStream(
+ status = response.code,
+ contentType = response.header("Content-Type") ?: "text/event-stream",
+ input = input,
+ close = { response.close() },
+ )
+ }
+ return@withContext null
+ }
+
+ private fun buildChatRequest(
+ base: String,
+ path: String,
+ upstreamBody: String,
+ accessToken: String,
+ intl: Boolean,
+ ): Request {
+ val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
+ val requestId = UUID.randomUUID().toString()
+ val builder = Request.Builder()
+ .url(base + path)
+ .addHeader("Content-Type", "application/json")
+ .addHeader("Accept", "application/json, text/event-stream")
+ .addHeader("Authorization", "Bearer $accessToken")
+ .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
+ .addHeader("X-Requested-With", "XMLHttpRequest")
+ .addHeader("Origin", origin)
+ .addHeader("Referer", origin + "/")
+ .addHeader("X-CodeBuddy-Request", "1")
+ .addHeader("Accept-Language", if (intl) "en-US" else "zh-CN")
+ .addHeader("X-No-User-Id", "1")
+ .addHeader("X-Agent-Purpose", "conversation")
+ .addHeader("X-IDE-Name", "WorkBuddy")
+ .addHeader("X-IDE-Type", "WorkBuddy")
+ .addHeader("X-IDE-Version", "2.63.2")
+ .addHeader("X-Product", "WorkBuddy")
+ .addHeader("X-Request-ID", requestId)
+ .addHeader("X-Conv-Request-ID", requestId)
+ if (intl) {
+ // 国际站强制声明个人客户端:不携带 enterprise,域固定为 www.workbuddy.ai
+ builder.addHeader("X-No-Enterprise-Id", "1")
+ builder.addHeader("X-Domain", "www.workbuddy.ai")
+ } else {
+ builder.addHeader("X-No-Enterprise-Id", "1")
+ builder.addHeader("X-No-Department-Info", "1")
+ }
+ return builder.post(upstreamBody.toRequestBody("application/json".toMediaType())).build()
+ }
+
+ /**
+ * 对齐 dsh-workbuddy-connect 的上游归一化:
+ * - 上游拒绝非流式,强制 stream=true;
+ * - developer -> system(国内站直接拒绝 developer role);
+ * - 国际站首条必须 system,缺省则前置空 system(避免 11128);
+ * - max_completion_tokens 翻译成 max_tokens;
+ * - tool_choice 只接受字符串,对象形式拍平成 auto/工具名/none。
+ */
+ private fun prepareChatBody(body: String, intl: Boolean): String {
+ val root = runCatching { Json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return body
+ if (root.isEmpty()) return body
+ val out = LinkedHashMap(root)
+ out["stream"] = JsonPrimitive(true)
+ if (!out.containsKey("stream_options")) {
+ out["stream_options"] = JsonObject(
+ mapOf("include_usage" to JsonPrimitive(true))
+ )
+ }
+ val maxCompletion = (root["max_completion_tokens"] as? JsonPrimitive)?.contentOrNull
+ ?.toLongOrNull()
+ if (maxCompletion != null && !out.containsKey("max_tokens")) {
+ out["max_tokens"] = JsonPrimitive(maxCompletion)
+ }
+ val messages = root["messages"] as? JsonArray
+ if (messages != null) {
+ var list = messages.map { msg ->
+ val obj = msg as? JsonObject ?: return@map msg
+ if ((obj["role"] as? JsonPrimitive)?.contentOrNull == "developer") {
+ JsonObject(obj.entries.associate { (key, value) ->
+ key to (if (key == "role") JsonPrimitive("system") else value)
+ })
+ } else {
+ obj
+ }
+ }.toMutableList()
+ if (intl) {
+ val firstRole = (list.firstOrNull() as? JsonObject)?.get("role") as? JsonPrimitive
+ if (firstRole?.contentOrNull != "system") {
+ val fallback = JsonObject(
+ mapOf(
+ "role" to JsonPrimitive("system"),
+ "content" to JsonPrimitive("You are a helpful assistant."),
+ )
+ )
+ list.add(0, fallback)
+ }
+ }
+ out["messages"] = JsonArray(list)
+ }
+ normalizeToolChoice(out)
+ return JsonObject(out).toString()
+ }
+
+ private fun normalizeToolChoice(out: MutableMap) {
+ val choice = out["tool_choice"] ?: return
+ val suppress = {
+ out.remove("tools")
+ out.remove("functions")
+ }
+ if (choice is JsonPrimitive) {
+ val s = choice.contentOrNull?.trim()?.lowercase()
+ if (s == "none") {
+ out.remove("tool_choice")
+ suppress()
+ }
+ return
+ }
+ val obj = choice as? JsonObject
+ if (obj != null) {
+ val type = (obj["type"] as? JsonPrimitive)?.contentOrNull?.trim()?.lowercase()
+ when (type) {
+ "none" -> {
+ out.remove("tool_choice")
+ suppress()
+ }
+ "auto", "required" -> out["tool_choice"] = JsonPrimitive(type)
+ "function" -> {
+ val fn = obj["function"] as? JsonObject
+ val name = ((fn?.get("name") as? JsonPrimitive)?.contentOrNull
+ ?: (obj["name"] as? JsonPrimitive)?.contentOrNull
+ ?: "").trim()
+ out["tool_choice"] = JsonPrimitive(if (name.isNotEmpty()) name else "auto")
+ }
+ else -> out.remove("tool_choice")
+ }
+ return
+ }
+ out.remove("tool_choice")
+ }
+
+}
diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt
new file mode 100644
index 0000000..d3f4948
--- /dev/null
+++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt
@@ -0,0 +1,177 @@
+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(it) }
+ )
+
+ /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
+ fun modelsFor(region: ProviderRegion): List =
+ _modelsByRegion.value[region] ?: defaultModels(region)
+
+ 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"
+ // 国内站官方 CLI 吃 /console/enterprises/personal/models;国际站产品目录在 /v3/config,
+ // 且必须用 App 形态 UA,否则网关拒发(实测 code 12403)。
+ val url = base + if (intl) "/v3/config" else "/console/enterprises/personal/models"
+ val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
+ val headers = linkedMapOf(
+ "Authorization" to "Bearer $accessToken",
+ "Content-Type" to "application/json",
+ "Accept" to "application/json",
+ "X-Requested-With" to "XMLHttpRequest",
+ "Origin" to origin,
+ "Referer" to "$origin/",
+ "X-CodeBuddy-Request" to "1",
+ "Accept-Language" to (if (intl) "en-US" else "zh-CN"),
+ "X-Client-ID" to "codebuddy-cli",
+ "X-Client-Version" to "2.63.2",
+ "X-Product" to (if (intl) "SaaS" else "WorkBuddy"),
+ "User-Agent" to (if (intl) "WorkBuddyAI/5.5.2" else "CLI/2.63.2 CodeBuddy/2.63.2"),
+ )
+ 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? {
+ val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
+ ?: return null
+ // 国内站永远是 {code,msg,data} 包络;国际站 /v3/config 有时裸返回产品文档。
+ val data = (root["data"] as? JsonObject) ?: root
+ val rawModels = data["models"] as? JsonArray ?: return null
+ val agents = data["agents"] as? JsonArray
+ // 只暴露 cli agent 可用的模型(国际站 App 目录里 agents[].name=="cli" 限定模型名单)
+ val cliIds = agents?.mapNotNull { it as? JsonObject }?.firstOrNull { agent ->
+ (agent["name"] as? JsonPrimitive)?.contentOrNull == "cli"
+ }?.get("models") as? JsonArray
+ // 有 agents 文档但找不到 cli 列表时,不猜模型,避免把 App 只读模型塞给 CLI 反代
+ if (agents != null && cliIds == null) return null
+ val allowed = cliIds?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }?.toSet()
+ val seen = LinkedHashMap()
+ for (rawEl in rawModels) {
+ val raw = rawEl as? JsonObject ?: continue
+ val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
+ ?.takeIf { it.isNotEmpty() } ?: continue
+ if (allowed != null && id !in allowed) continue
+ if (raw["disabled"] as? JsonPrimitive == JsonPrimitive(true)) 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"
+
+ /** 内置兜底(在线列表成功后会被替换)。 */
+ fun defaultModels(region: ProviderRegion): List =
+ if (region == ProviderRegion.INTL) intlDefaultModels() else cnDefaultModels()
+
+ /** 国内站 CLI 模型兜底(参考 workbuddy-gateway 模型目录)。 */
+ private fun cnDefaultModels(): List = listOf(
+ TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"),
+ TraeModelProvider.TraeModel("hy3", "hy3"),
+ TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"),
+ TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"),
+ )
+
+ /** 国际站 App 模型兜底(参考 cpa-multi-plugins 实测目录,避免刷新前误用国内模型名)。 */
+ private fun intlDefaultModels(): List = listOf(
+ TraeModelProvider.TraeModel("default-model", "Auto"),
+ TraeModelProvider.TraeModel("fast-model", "Fast"),
+ TraeModelProvider.TraeModel("balanced-model", "Balanced"),
+ TraeModelProvider.TraeModel("primary-model", "Primary"),
+ TraeModelProvider.TraeModel("deep-model", "Deep"),
+ TraeModelProvider.TraeModel("hy4-preview-f", "Hy4 preview"),
+ TraeModelProvider.TraeModel("hy3", "Hy3"),
+ TraeModelProvider.TraeModel("deepseek-v4.1-flash", "Deepseek-V4.1-Flash"),
+ TraeModelProvider.TraeModel("gpt-6-astra", "GPT-6-Astra"),
+ TraeModelProvider.TraeModel("gpt-5.6-luna", "GPT-5.6-Luna"),
+ TraeModelProvider.TraeModel("gemini-3.5-flash", "Gemini-3.5-Flash"),
+ )
+ }
+}
diff --git a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt
index 99c9187..9b6c909 100644
--- a/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/TraeRepository.kt
@@ -58,6 +58,79 @@ 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> = 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 = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@@ -66,9 +139,12 @@ class TraeRepository constructor(
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
- 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 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 request = Request.Builder()
.url(url)
@@ -124,6 +200,8 @@ 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) 权威汇总
@@ -156,6 +234,28 @@ 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()
@@ -186,7 +286,7 @@ class TraeRepository constructor(
}
}
}
- if (packCount > 0) {
+ if (packCount > 0 && !isDollarBilling) {
extras["pack_count"] = packCount.toString()
extras["general_credits"] = trimNum(generalSum)
extras["work_credits"] = trimNum(workSum)
@@ -736,3 +836,16 @@ 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
+)
diff --git a/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt b/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt
index 32b8151..921f23c 100644
--- a/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/UpdateRepository.kt
@@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
}
}
- /** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
+ /** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
suspend fun downloadApk(
info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit
@@ -64,6 +64,11 @@ 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) {
@@ -71,7 +76,7 @@ class UpdateRepository @Inject constructor(
}
val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength()
- file.outputStream().buffered().use { output ->
+ part.outputStream().buffered().use { output ->
body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L
@@ -84,6 +89,8 @@ class UpdateRepository @Inject constructor(
}
}
}
+ if (file.exists()) file.delete()
+ part.renameTo(file)
Result.success(file)
} catch (e: Exception) {
Result.failure(e)
diff --git a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt
index 7d3c344..bd04cba 100644
--- a/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt
+++ b/app/src/main/java/com/rainy/token/data/repository/WorkBuddyRepository.kt
@@ -53,6 +53,26 @@ 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)
@@ -79,7 +99,7 @@ class WorkBuddyRepository constructor(
var lastBody = ""
while (attempts < 3) {
attempts++
- val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
+ val request = if (hostMode == 0) summaryRequest(credential, access) else codebuddyRequest(credential, access)
val resp = try {
okHttpClient.newCall(request).execute()
} catch (e: IOException) {
@@ -101,7 +121,7 @@ class WorkBuddyRepository constructor(
return@withContext Result.failure(mapHttpError(code, body))
}
401, 403 -> {
- val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
+ val refreshed = tryRefesh(credential, refresh) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential()
)
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
@@ -141,7 +161,7 @@ class WorkBuddyRepository constructor(
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
- val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
+ val detailExtras = runCatching { fetchResourceDetailExtras(credential, access) }.getOrNull()
val mergedExtras = if (detailExtras != null) {
parsed.extras + detailExtras
} else {
@@ -166,9 +186,9 @@ class WorkBuddyRepository constructor(
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。
*/
- private fun fetchResourceDetailExtras(accessToken: String): Map? {
+ private fun fetchResourceDetailExtras(credential: Credential.WorkBuddyCredential, accessToken: String): Map? {
val request = Request.Builder()
- .url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
+ .url(credential.resourceUrl())
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json")
@@ -187,18 +207,18 @@ class WorkBuddyRepository constructor(
}
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
- private fun summaryRequest(accessToken: String): Request =
+ private fun summaryRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
Request.Builder()
- .url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
+ .url(credential.summaryUrl())
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia))
.build()
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
- private fun codebuddyRequest(accessToken: String): Request =
+ private fun codebuddyRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
Request.Builder()
- .url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
+ .url(credential.resourceUrl())
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia))
@@ -208,9 +228,9 @@ class WorkBuddyRepository constructor(
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
*/
- private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
+ private fun tryRefesh(credential: Credential.WorkBuddyCredential, refreshToken: String): RefreshedWorkBuddy? {
val request = Request.Builder()
- .url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
+ .url(credential.refreshUrl())
.addHeader("X-Refresh-Token", refreshToken)
.post("{}".toRequestBody(jsonMedia))
.build()
@@ -439,7 +459,9 @@ private fun parseResourceDetail(body: String): Map? {
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
- FAILED
+ FAILED,
+ /** 国际站无签到活动(活动未开启/未上线,非错误) */
+ NO_CAMPAIGN
}
/**
@@ -466,6 +488,12 @@ private fun parseResourceDetail(body: String): Map? {
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(
@@ -497,6 +525,12 @@ private fun parseResourceDetail(body: String): Map? {
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)
@@ -530,8 +564,15 @@ private fun parseResourceDetail(body: String): Map? {
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
}.getOrNull().orEmpty()
val status = fetchCheckinStatus(accountId).getOrNull()
- // 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
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 already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
lowerMsg.contains("claimed")
@@ -546,6 +587,12 @@ private fun parseResourceDetail(body: String): Map? {
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
@@ -595,10 +642,23 @@ private fun parseResourceDetail(body: String): Map? {
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt ->
val request = Request.Builder()
- .url("https://www.codebuddy.cn" + path)
+ .url(credential.originBase() + path)
.addHeader("Authorization", "Bearer " + access)
- .addHeader("Accept", "application/json")
+ .addHeader("Accept", "application/json, text/plain, */*")
.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 {
@@ -615,7 +675,7 @@ private fun parseResourceDetail(body: String): Map? {
when {
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> {
- val refreshed = tryRefesh(refresh) ?: return AuthedPostResult.Failed(rotated)
+ val refreshed = tryRefesh(credential, refresh) ?: return AuthedPostResult.Failed(rotated)
val expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000
} else {
diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt
index c89cf21..4f9219d 100644
--- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt
+++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt
@@ -20,6 +20,15 @@ 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
@@ -223,6 +232,70 @@ 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
diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt
index 760b281..faf63ec 100644
--- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt
+++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt
@@ -79,8 +79,10 @@ sealed class Credential {
override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT ` 的 JWT 值 */
val jwt: String,
- /** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
+ /** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai / api-us-east.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=旧凭据未保存 */
@@ -106,6 +108,8 @@ 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
diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt
index fd16a14..a77f9a7 100644
--- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt
+++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt
@@ -102,5 +102,20 @@ 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 = ServiceType.entries.map { get(it) }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
index b5a42a8..a1bf129 100644
--- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
+++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt
@@ -1,5 +1,8 @@
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
@@ -63,7 +66,6 @@ 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
/**
* 应用导航图。
@@ -90,13 +92,16 @@ object Routes {
}
const val ACCOUNTS = "accounts/{type}"
fun accounts(type: ServiceType) = "accounts/${type.name}"
- 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_LOGIN = "webview_login/{type}?account={account}®ion={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_PAGE = "webview_page?url={url}&title={title}"
fun webviewPage(url: String, title: String) =
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
@@ -137,6 +142,13 @@ 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()
@@ -226,6 +238,7 @@ private fun CompactNavHost() {
}
) {
composable(Routes.DASHBOARD) {
+ val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
DashboardScreen(
onOpenSettings = { navController.navigate(Routes.SETTINGS) },
@@ -236,21 +249,26 @@ private fun CompactNavHost() {
onOpenUsageDetail = { navController.navigate(Routes.USAGE_DETAIL) },
onOpenCcgoUsageDetail = { navController.navigate(Routes.CCGO_USAGE_DETAIL) },
onOpenHeatmap = { navController.navigate(Routes.HEATMAP) },
- onOpenRecharge = { type ->
- ServiceConfigProvider.get(type).externalUrl?.let { url ->
- navController.navigate(Routes.webviewPage(url, rechargeLabel))
+ 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))
}
}
)
}
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 ->
- ServiceConfigProvider.get(type).externalUrl?.let { url ->
- navController.navigate(Routes.webviewPage(url, rechargeLabel))
+ 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))
}
}
)
@@ -356,7 +374,7 @@ private fun CompactNavHost() {
service = type,
accountId = accountId,
onBack = guardedPop,
- onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
+ onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
onWebViewLoginSuccess = { },
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
)
@@ -369,14 +387,21 @@ 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 回传给上一层(凭据编辑页),新建账号模式用于回填
@@ -405,6 +430,7 @@ 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,
@@ -417,10 +443,12 @@ private fun CompactNavHost() {
}
},
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
- onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
- onOpenRecharge = { svc ->
- ServiceConfigProvider.get(svc).externalUrl?.let { url ->
- navController.navigate(Routes.webviewPage(url, rechargeLabel))
+ 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))
}
}
)
@@ -461,6 +489,7 @@ private fun ExpandedLayout() {
prevPane = detailPane
}
+ val context = LocalContext.current
val rechargeLabel = stringResource(R.string.action_recharge)
Row(modifier = Modifier.fillMaxSize()) {
@@ -478,9 +507,11 @@ private fun ExpandedLayout() {
onOpenUsageDetail = { detailPane = DetailPane.OCGOUsage },
onOpenCcgoUsageDetail = { detailPane = DetailPane.CCGOUsage },
onOpenHeatmap = { detailPane = DetailPane.Heatmap },
- onOpenRecharge = { type ->
- ServiceConfigProvider.get(type).externalUrl?.let { url ->
- detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
+ 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)
}
},
viewModel = dashboardVm
@@ -536,6 +567,7 @@ 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,
@@ -543,10 +575,12 @@ private fun ExpandedDetailPane(
onBack = onClose,
onSwitchAccount = onSwitchAccount,
onConfigureCredential = { onClose() },
- onStartWebViewLogin = { _, _ -> },
- onOpenRecharge = { svc ->
- ServiceConfigProvider.get(svc).externalUrl?.let { url ->
- navController.navigate(Routes.webviewPage(url, rechargeLabel))
+ 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))
}
}
)
@@ -654,13 +688,16 @@ 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 ->
- ServiceConfigProvider.get(type).externalUrl?.let { url ->
- apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
+ 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))
}
}
)
@@ -692,8 +729,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { apiNavController.popBackStack() },
- onStartWebViewLogin = { svc, acc ->
- apiNavController.navigate(Routes.webviewLogin(svc, acc))
+ onStartWebViewLogin = { svc, acc, region ->
+ apiNavController.navigate(Routes.webviewLogin(svc, acc, region))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
@@ -707,14 +744,21 @@ 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()) {
@@ -817,8 +861,8 @@ private fun ExpandedDetailPane(
service = type,
accountId = accountId,
onBack = { settingsNavController.popBackStack() },
- onStartWebViewLogin = { svc, acc ->
- settingsNavController.navigate(Routes.webviewLogin(svc, acc))
+ onStartWebViewLogin = { svc, acc, region ->
+ settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
},
onWebViewLoginSuccess = { },
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
@@ -832,14 +876,21 @@ 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()) {
diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
index 903d253..f82fe15 100644
--- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementScreen.kt
@@ -85,7 +85,7 @@ import java.util.Locale
fun ApiManagementScreen(
onBack: () -> Unit,
onConfigure: (ServiceType) -> Unit,
- onRecharge: (ServiceType) -> Unit,
+ onRecharge: (ServiceType, String?) -> 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) }
+ onRecharge = { onRecharge(row.service, row.region) }
)
}
}
@@ -316,9 +316,9 @@ private fun ApiRowCard(
onDelete: () -> Unit,
onRecharge: () -> Unit
) {
- // 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
- val rechargeUrl = remember(row.service) {
- runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
+ // 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
+ val rechargeUrl = remember(row.service, row.region) {
+ runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull()
}
Card(
modifier = Modifier
diff --git a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt
index abe5c80..097d831 100644
--- a/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/account/ApiManagementViewModel.kt
@@ -63,6 +63,15 @@ 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
}
)
}
@@ -158,7 +167,9 @@ data class ApiRowUi(
val cachedBalance: CachedBalance?,
val maskedCredential: String?,
/** 多账号服务的账号个数;单账号服务为 0。 */
- val accountCount: Int = 0
+ val accountCount: Int = 0,
+ /** Trae 账号区域(用于选择官方充值/用量页)。 */
+ val region: String? = null
) {
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt
index 63e1ccd..6d57694 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt
@@ -138,7 +138,7 @@ fun DashboardScreen(
onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {},
onOpenHeatmap: () -> Unit = {},
- onOpenRecharge: (ServiceType) -> Unit = {},
+ onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
viewModel: DashboardViewModel = hiltViewModel()
) {
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
@@ -549,7 +549,7 @@ private fun rememberDashboardItems(
onOpenService: (ServiceType, String?) -> Unit,
onOpenHeatmap: () -> Unit,
onOpenSub2PanelManagement: (String?) -> Unit,
- onOpenRecharge: (ServiceType) -> Unit,
+ onOpenRecharge: (ServiceType, String?) -> Unit,
refreshTrigger: Int
): List {
val defaultItems = buildList {
@@ -607,8 +607,8 @@ private fun rememberDashboardItems(
null
},
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
- onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
- { onOpenRecharge(card.service) }
+ onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) {
+ { onOpenRecharge(card.service, card.region) }
} else {
null
},
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
index c9fe07f..903db1c 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardViewModel.kt
@@ -287,7 +287,8 @@ 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)
+ checkinStatusStore.isCheckedToday(local.service, local.account?.id),
+ region = (local.credential as? Credential.TraeCredential)?.region
)
private fun newerOf(
@@ -463,7 +464,9 @@ data class DashboardCardUi(
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
val siteIcon: String? = null,
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
- val checkedInToday: Boolean = false
+ val checkedInToday: Boolean = false,
+ /** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
+ val region: String? = null
) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt
index 37f912e..a1b9e1e 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt
@@ -146,6 +146,10 @@ 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) {
@@ -176,7 +180,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) {
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
@Composable
-private fun TraeSplitLine(label: String, value: Double?) {
+private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom,
@@ -196,7 +200,7 @@ private fun TraeSplitLine(label: String, value: Double?) {
)
Spacer(modifier = Modifier.width(4.dp))
Text(
- text = "Credits",
+ text = unit,
style = MaterialTheme.typography.titleMedium,
color = inkMuted(),
modifier = Modifier.padding(bottom = 2.dp)
@@ -205,6 +209,42 @@ private fun TraeSplitLine(label: String, value: Double?) {
}
}
+@Composable
+private fun TraeDollarPlanSummary(extras: Map) {
+ 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) {
diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
index 515d0ae..ab39c65 100644
--- a/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
+++ b/app/src/main/java/com/rainy/token/ui/dashboard/autocheckin/AutoCheckinManager.kt
@@ -214,6 +214,9 @@ class AutoCheckinManager @Inject constructor(
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY ->
ServiceResult(service, label, false, true, 0, "", false)
+ WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN ->
+ // 国际站没有签到活动不是账号失败:静默跳过,不记失败日志
+ ServiceResult(service, label, false, false, 0, s.message, false)
WorkBuddyRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, s.message, failed = true)
diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
index 1a1d1d5..544a946 100644
--- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt
@@ -57,6 +57,7 @@ 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
@@ -66,6 +67,7 @@ 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
@@ -114,8 +116,8 @@ fun ServiceDetailScreen(
onBack: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onConfigureCredential: (ServiceType) -> Unit,
- onStartWebViewLogin: (ServiceType, String?) -> Unit,
- onOpenRecharge: (ServiceType) -> Unit = { },
+ onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
+ onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
viewModel: ServiceDetailViewModel = hiltViewModel()
) {
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
@@ -129,6 +131,7 @@ 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()
@@ -235,37 +238,50 @@ fun ServiceDetailScreen(
item { OllamaUsageCard(uiState.state) }
}
ServiceType.TRAE -> {
- // 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
- item {
- if (uiState.hasCredential) {
- CheckinCard(
- state = checkinState,
- todayCheckedIn = checkinInfo?.todayCheckedIn == true,
- streakDays = checkinInfo?.streakDays ?: 0L,
- isTrae = true,
- onCheckin = { viewModel.checkin() }
- )
- } else {
- CheckinNeedCredentialCard()
+ 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) {
+ CheckinCard(
+ state = checkinState,
+ todayCheckedIn = checkinInfo?.todayCheckedIn == true,
+ streakDays = checkinInfo?.streakDays ?: 0L,
+ isTrae = true,
+ onCheckin = { viewModel.checkin() }
+ )
+ } else {
+ CheckinNeedCredentialCard()
+ }
}
+ // 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
+ item { TraePacksCard(uiState.state) }
+ // 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
+ item { TraeAccountInfoCard(uiState.state) }
}
- // 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
- item { TraePacksCard(uiState.state) }
- // 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
- item { TraeAccountInfoCard(uiState.state) }
}
ServiceType.WORKBUDDY -> {
- item {
- if (uiState.hasCredential) {
- CheckinCard(
- state = checkinState,
- todayCheckedIn = checkinInfo?.todayCheckedIn == true,
- streakDays = checkinInfo?.streakDays ?: 0L,
- isTrae = false,
- onCheckin = { viewModel.checkin() }
- )
- } else {
- CheckinNeedCredentialCard()
+ val wbIntl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
+ if (wbIntl) {
+ // 国际版没有独立签到体系:积分来自网页/客户端活跃使用,直接展示说明卡
+ item { WorkBuddyIntlNoCheckinCard() }
+ } else {
+ item {
+ if (uiState.hasCredential) {
+ CheckinCard(
+ state = checkinState,
+ todayCheckedIn = checkinInfo?.todayCheckedIn == true,
+ streakDays = checkinInfo?.streakDays ?: 0L,
+ isTrae = false,
+ onCheckin = { viewModel.checkin() }
+ )
+ } else {
+ CheckinNeedCredentialCard()
+ }
}
}
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
@@ -309,14 +325,17 @@ 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) },
- onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
+ onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
+ onOpenRecharge = if (config.externalUrl != null || service == ServiceType.TRAE) {
+ { onOpenRecharge(service, uiState.region) }
+ } else null,
onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) }
@@ -1091,6 +1110,41 @@ private fun MainBalanceCard(state: State, service: ServiceType) {
}
}
+@Composable
+private fun TraeIntlBigNumber(extras: Map) {
+ 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) {
@@ -1101,20 +1155,24 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
)
return
}
- Row(verticalAlignment = Alignment.Bottom) {
- Text(
- text = formatAmount(balance.amount),
- style = MaterialTheme.typography.displayLarge,
- fontWeight = FontWeight.Bold,
- color = MaterialTheme.colorScheme.onSurface
- )
- Spacer(modifier = Modifier.width(6.dp))
- Text(
- text = balance.unit,
- style = MaterialTheme.typography.titleLarge,
- color = inkMuted(),
- modifier = Modifier.padding(bottom = 8.dp)
- )
+ if (service == ServiceType.TRAE && balance.extras?.get("billing_mode") == "dollar") {
+ TraeIntlBigNumber(balance.extras)
+ } else {
+ Row(verticalAlignment = Alignment.Bottom) {
+ Text(
+ text = formatAmount(balance.amount),
+ style = MaterialTheme.typography.displayLarge,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ Spacer(modifier = Modifier.width(6.dp))
+ Text(
+ text = balance.unit,
+ style = MaterialTheme.typography.titleLarge,
+ color = inkMuted(),
+ modifier = Modifier.padding(bottom = 8.dp)
+ )
+ }
}
if (!balance.isAvailable) {
Spacer(modifier = Modifier.height(4.dp))
@@ -1504,6 +1562,37 @@ 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)
@@ -1531,6 +1620,216 @@ 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) {
@@ -1836,6 +2135,7 @@ private fun ActionButtons(
hasCredential: Boolean,
isManualMode: Boolean,
service: ServiceType,
+ region: String?,
triggerState: TriggerState,
models: List,
selectedModel: String?,
@@ -1956,6 +2256,18 @@ 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,
diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
index 31fd4f4..1771620 100644
--- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt
@@ -8,6 +8,7 @@ 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
@@ -36,7 +37,8 @@ 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 sub2ApiRepository: com.rainy.token.data.repository.Sub2ApiRepository,
+ private val traeRepository: TraeRepository
) : ViewModel() {
private val _serviceType = MutableStateFlow(null)
@@ -75,6 +77,10 @@ class ServiceDetailViewModel @Inject constructor(
private val _checkinInfo = MutableStateFlow(null)
val checkinInfo: StateFlow = _checkinInfo.asStateFlow()
+ /** Trae 国际版 Usage Events 列表(详情页直接展示,不跳网页)。 */
+ private val _traeUsageEvents = MutableStateFlow(TraeUsageEventsState.Idle)
+ val traeUsageEvents: StateFlow = _traeUsageEvents.asStateFlow()
+
/** 今日签到信息摘要(仅字段已确认的才填)。 */
data class CheckinInfo(val todayCheckedIn: Boolean, val streakDays: Long)
@@ -112,6 +118,34 @@ 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
@@ -150,6 +184,8 @@ class ServiceDetailViewModel @Inject constructor(
if (s.message.isNotBlank()) UiText.Dynamic(s.message)
else UiText.Resource(R.string.checkin_failed)
)
+ WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN ->
+ CheckinState.Notice(UiText.Resource(R.string.checkin_wb_no_campaign))
// QUERY 不该出现在签到动作结果里,兜底为通用成功
WorkBuddyRepository.CheckinOutcome.QUERY ->
CheckinState.Done(UiText.Resource(R.string.checkin_success))
@@ -415,6 +451,7 @@ 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 }
@@ -454,6 +491,16 @@ 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 -> {
@@ -583,6 +630,9 @@ class ServiceDetailViewModel @Inject constructor(
)
}
}
+ if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
+ preloadUsageEvents(type, accountId)
+ }
}
}
@@ -1005,10 +1055,20 @@ 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) : TraeUsageEventsState()
+ data class Error(val message: UiText) : TraeUsageEventsState()
+}
+
data class ServiceDetailUiState(
val state: State = State.Loading,
val hasCredential: Boolean = false,
- val cached: CachedBalance? = null
+ val cached: CachedBalance? = null,
+ /** 当前账号区域(Trae/WorkBuddy):CN / INTL;其他服务为 null。 */
+ val region: String? = null
)
sealed class TriggerState {
diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt
index 76674ec..99ed039 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementScreen.kt
@@ -156,10 +156,12 @@ fun AccountManagementScreen(
}
}
}
- items(uiState.accounts, key = { it.id }) { account ->
- val ordinal = accountOrdinal(uiState.accounts, account)
+ items(uiState.accounts, key = { it.account.id }) { item ->
+ val account = item.account
+ val ordinal = accountOrdinal(uiState.accounts.map { it.account }, account)
AccountRow(
account = account,
+ region = item.region,
displayName = account.label?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, ordinal),
menuExpanded = menuAccountId == account.id,
@@ -262,6 +264,7 @@ fun AccountManagementScreen(
@Composable
private fun AccountRow(
account: ServiceAccountInfo,
+ region: String?,
displayName: String,
menuExpanded: Boolean,
onMenuToggle: () -> Unit,
@@ -306,6 +309,26 @@ private fun AccountRow(
)
)
}
+ if (region != null) {
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = if (region.equals("INTL", ignoreCase = true)) {
+ stringResource(R.string.option_region_intl)
+ } else {
+ stringResource(R.string.option_region_cn)
+ },
+ style = MaterialTheme.typography.labelSmall,
+ color = MaterialTheme.colorScheme.outline,
+ maxLines = 1,
+ softWrap = false,
+ modifier = Modifier
+ .padding(horizontal = 6.dp, vertical = 2.dp)
+ .background(
+ color = MaterialTheme.colorScheme.surfaceVariant,
+ shape = RoundedCornerShape(8.dp)
+ )
+ )
+ }
}
}
Box {
diff --git a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt
index c1ec7a7..bbe9c50 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/AccountManagementViewModel.kt
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository
+import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText
@@ -46,13 +47,22 @@ class AccountManagementViewModel @Inject constructor(
viewModelScope.launch {
_uiState.update { it.copy(loading = true) }
val accounts = credentialRepository.accountsFor(type)
+ val items = accounts.mapNotNull { account ->
+ val region = credentialRepository.get(type, account.id)
+ ?.let { c -> when (c) {
+ is Credential.TraeCredential -> c.region.uppercase()
+ is Credential.WorkBuddyCredential -> c.region.uppercase()
+ else -> null
+ } }
+ AccountItem(account = account, region = region)
+ }
if (generation != loadGeneration) return@launch
_uiState.update {
it.copy(
loading = false,
- accounts = accounts,
+ accounts = items,
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
- message = if (accounts.isEmpty()) null else it.message
+ message = if (items.isEmpty()) null else it.message
)
}
}
@@ -90,8 +100,13 @@ class AccountManagementViewModel @Inject constructor(
}
}
+data class AccountItem(
+ val account: ServiceAccountInfo,
+ val region: String?,
+)
+
data class AccountManagementUiState(
val loading: Boolean = false,
- val accounts: List = emptyList(),
+ val accounts: List = emptyList(),
val message: UiText? = null
)
\ No newline at end of file
diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
index 86aab52..e2a3b9a 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt
@@ -82,7 +82,7 @@ fun CredentialEditScreen(
service: ServiceType,
accountId: String? = null,
onBack: () -> Unit,
- onStartWebViewLogin: (ServiceType, String?) -> Unit,
+ onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel()
@@ -153,13 +153,31 @@ 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) },
+ 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)
+ )
+ }
+ },
onSave = viewModel::saveTraeCredential
)
} else if (service == ServiceType.WORKBUDDY) {
@@ -169,7 +187,9 @@ fun CredentialEditScreen(
hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh,
- onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
+ region = uiState.workBuddyRegion,
+ onRegionChange = viewModel::updateWorkBuddyRegion,
+ onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) },
onSave = viewModel::saveWorkBuddyCredential
)
} else if (service == ServiceType.SUB2API) {
@@ -267,7 +287,7 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
- onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
+ onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId, null) },
onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }
@@ -777,13 +797,17 @@ 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)
@@ -792,11 +816,37 @@ private fun TraeCredentialForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
- 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_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,
@@ -823,6 +873,12 @@ private fun TraeCredentialForm(
onSelect = { onRegionChange("INTL") }
)
}
+ OutlinedButton(
+ onClick = onRecharge,
+ modifier = Modifier.fillMaxWidth()
+ ) {
+ Text(stringResource(R.string.action_trae_recharge))
+ }
OutlinedTextField(
value = checkinDeviceId,
onValueChange = onCheckinDeviceIdChange,
@@ -916,9 +972,11 @@ private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit)
private fun WorkBuddyCredentialForm(
access: String,
refresh: String,
+ region: String,
hasExisting: Boolean,
onAccessChange: (String) -> Unit,
onRefreshChange: (String) -> Unit,
+ onRegionChange: (String) -> Unit,
onStartInAppLogin: () -> Unit,
onSave: () -> Unit
) {
@@ -928,11 +986,37 @@ private fun WorkBuddyCredentialForm(
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
- 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))
+ }
+ } 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,
diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
index 82f0ce2..77aecd7 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt
@@ -26,6 +26,7 @@ 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
@@ -126,6 +127,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
+ workBuddyRegion = (existing as? Credential.WorkBuddyCredential)?.region ?: "CN",
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
@@ -298,6 +300,82 @@ 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) }
@@ -310,7 +388,11 @@ class CredentialEditViewModel @Inject constructor(
fun saveTraeCredential() {
val type = serviceType ?: return
val current = _uiState.value
- val jwt = current.traeJwt.trim()
+ var jwt = current.traeJwt.trim()
+ val parsedCallback = if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) {
+ parseTraeCallback(current.traeCallbackUrl)
+ } else null
+ if (parsedCallback != null) jwt = parsedCallback.jwt
if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return
@@ -326,6 +408,9 @@ 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,
@@ -353,6 +438,10 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(workBuddyRefresh = value) }
}
+ fun updateWorkBuddyRegion(value: String) {
+ _uiState.update { it.copy(workBuddyRegion = value) }
+ }
+
fun saveWorkBuddyCredential() {
val type = serviceType ?: return
val current = _uiState.value
@@ -371,10 +460,11 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
}
val effectiveId = credentialRepository.save(
- (existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
+ (existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh, region = "CN"))
.copy(
accessToken = access,
refreshToken = refresh,
+ region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN",
expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
),
@@ -852,6 +942,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = "",
workBuddyAccess = "",
workBuddyRefresh = "",
+ workBuddyRegion = "CN",
sub2BaseUrl = "",
sub2Email = "",
sub2Password = "",
@@ -971,12 +1062,17 @@ 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 = "",
- /** WorkBuddy:OAuth tokens */
+ /** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
+ val traeApiHost: String = "",
+ /** WorkBuddy:OAuth tokens + 区域 */
val workBuddyAccess: String = "",
val workBuddyRefresh: String = "",
+ val workBuddyRegion: String = "CN",
/** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "",
val sub2Email: String = "",
diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
index b2f740f..6aaeb32 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt
@@ -2,7 +2,17 @@ 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
@@ -23,11 +33,14 @@ 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
@@ -49,6 +62,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus
+import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip
@@ -89,6 +103,14 @@ 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(
@@ -159,6 +181,25 @@ 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() })
}
@@ -181,6 +222,181 @@ fun SettingsScreen(
}
}
+@Composable
+private fun ProxyGatewayCard(
+ running: Boolean,
+ apiKey: String,
+ error: String?,
+ debug: String?,
+ debugRaw: String?,
+ proxyTargets: List,
+ selectedProxy: ProxyTarget,
+ onSelectProxy: (ProxyTarget) -> Unit,
+ models: List,
+ 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
@@ -416,9 +632,23 @@ 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(stringResource(R.string.update_install))
+ Text(
+ if (phase.error == null) {
+ stringResource(R.string.update_install)
+ } else {
+ "重试安装"
+ }
+ )
}
}
is UpdateViewModel.Phase.NeedInstallPermission -> {
@@ -437,6 +667,9 @@ private fun UpdateCard(
}) {
Text(stringResource(R.string.update_enable_permission))
}
+ TextButton(onClick = { viewModel.install(phase.file) }) {
+ Text("已开启,继续安装")
+ }
}
is UpdateViewModel.Phase.Error -> {
Text(
diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt
index a742351..b8c1357 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt
@@ -2,15 +2,33 @@ 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
/**
@@ -19,12 +37,295 @@ import javax.inject.Inject
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
- private val credentialRepository: CredentialRepository
+ private val credentialRepository: CredentialRepository,
+ private val localProxyServer: LocalProxyServer,
+ private val traeChatProxy: TraeChatProxy,
+ private val workBuddyChatProxy: WorkBuddyChatProxy,
+ private val traeModelProvider: TraeModelProvider,
+ private val workBuddyModelProvider: WorkBuddyModelProvider,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow = _uiState.asStateFlow()
+ /** 本地反代服务运行状态。 */
+ val proxyRunning: StateFlow = localProxyServer.isRunning
+
+ /** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
+ val proxyApiKey: StateFlow = MutableStateFlow(generateProxyKey())
+
+ /** 启动失败时的错误信息。 */
+ private val _proxyError = MutableStateFlow(null)
+ val proxyError: StateFlow = _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(null)
+ val proxyDebug: StateFlow = _proxyDebug.asStateFlow()
+
+ /** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
+ private val _proxyDebugRaw = MutableStateFlow(null)
+ val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow()
+
+ /** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
+ val proxyTargets: List = ProxyTarget.entries
+
+ private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
+ val selectedProxy: StateFlow = _selectedProxy.asStateFlow()
+
+ private val _proxyModels = MutableStateFlow>(traeModelProvider.modelsFor(ProviderRegion.CN))
+ val proxyModels: StateFlow> = _proxyModels.asStateFlow()
+
+ private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
+ val proxyModel: StateFlow = _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()
+ 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 {
+ 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()
+ val models = linkedSetOf()
+ val usage = mutableListOf()
+ val errors = mutableListOf()
+ 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) {
+ 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) {
+ 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) {
+ 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()
}
@@ -48,6 +349,23 @@ 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 = emptyList()
diff --git a/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt
index 6cd9d95..6b56e0e 100644
--- a/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt
@@ -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) : Phase
- object NeedInstallPermission : Phase
+ data class Ready(val file: File, val error: String? = null) : Phase
+ data class NeedInstallPermission(val file: File) : Phase
data class Error(val message: String) : Phase
}
@@ -80,19 +80,33 @@ class UpdateViewModel @Inject constructor(
}
fun install(file: File) {
- if (context.packageManager.canRequestPackageInstalls()) {
- _uiState.update { it.copy(phase = Phase.Ready(file)) }
- val authority = context.packageName + ".fileprovider"
- val uri = FileProvider.getUriForFile(context, authority, file)
- val intent = Intent(Intent.ACTION_VIEW).apply {
+ val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
+ .getOrDefault(false)
+ if (!canInstall) {
+ _uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
+ return
+ }
+ _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 {
+ 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(intent) }.onFailure { e ->
- _uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
+ runCatching { context.startActivity(actionInstall) }.onFailure { e ->
+ _uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
}
- } else {
- _uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
}
}
}
diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
index 52cd313..19102cc 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginScreen.kt
@@ -1,5 +1,7 @@
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
@@ -10,12 +12,15 @@ 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
@@ -29,8 +34,12 @@ 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
@@ -54,6 +63,7 @@ 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)。 */
@@ -62,10 +72,14 @@ fun WebViewLoginScreen(
) {
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl, accountId) {
- viewModel.bind(service, configuredBaseUrl, accountId)
+ viewModel.bind(service, configuredBaseUrl, accountId, region)
}
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(null) }
// 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) {
@@ -97,6 +111,17 @@ 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))
+ }
}
)
}
@@ -105,6 +130,22 @@ 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 {
@@ -152,8 +193,9 @@ fun WebViewLoginScreen(
}
fun handleOverrideUrl(url: String): Boolean {
- // Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
- if (url.startsWith("http://127.0.0.1:18080/authorize")) {
+ // Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
+ // (部分流程会从 https 跳到 http,或把参数放 fragment)
+ if (isTraeLoginCallback(url)) {
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
viewModel.onTraeCallback(url)
return true
@@ -161,8 +203,24 @@ 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)
}
@@ -173,13 +231,70 @@ 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(
diff --git a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
index 80534f0..6656e7b 100644
--- a/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
+++ b/app/src/main/java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt
@@ -21,6 +21,7 @@ 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
@@ -57,14 +58,25 @@ 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) {
+ fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: String? = null, region: 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 {
@@ -77,6 +89,11 @@ class WebViewLoginViewModel @Inject constructor(
)
}
+ // 每次进入登录页强制清掉 WebView 全局登录态,避免沿用上一个账号的 Cookie
+ if (service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) {
+ clearWebViewSession()
+ }
+
when (service) {
ServiceType.TRAE -> bindTrae()
ServiceType.WORKBUDDY -> bindWorkBuddy()
@@ -133,9 +150,24 @@ class WebViewLoginViewModel @Inject constructor(
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId()
- val callbackUrl = "http://127.0.0.1:18080/authorize"
+ 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 authUrl = buildString {
- append("https://www.trae.cn/authorization?")
+ append(authRoot).append("?")
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&")
@@ -148,8 +180,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", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
- _uiState.update { it.copy(loginUrl = authUrl) }
+ DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
+ return authUrl
}
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
@@ -174,56 +206,95 @@ 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 值,避免把 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
- } 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: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
- return
- }
- 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 userId = parsed.queryParameter("userInfo")?.let { raw ->
- runCatching {
- val u = Json.parseToJsonElement(raw).jsonObject
- u["userId"]?.jsonPrimitive?.contentOrNull
- ?: u["user_id"]?.jsonPrimitive?.contentOrNull
- ?: u["id"]?.jsonPrimitive?.contentOrNull
- ?: u["accountId"]?.jsonPrimitive?.contentOrNull
- }.getOrNull()
- }
- DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
-
+ // 只记录参数名(不含 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 {
- // 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
- // 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
+ val userJwt = runCatching {
+ 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})")
+ }
+ }
+
+ if (token.isNullOrBlank()) {
+ DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
+ _uiState.update {
+ it.copy(error = UiText.Resource(R.string.error_login_token_missing))
+ }
+ return@launch
+ }
+
+ // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
+ val deviceId = traeDeviceId()
+ // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
+ val parsedUserId = param("userInfo")?.let { raw ->
+ runCatching {
+ val u = Json.parseToJsonElement(raw).jsonObject
+ u["userId"]?.jsonPrimitive?.contentOrNull
+ ?: u["user_id"]?.jsonPrimitive?.contentOrNull
+ ?: u["id"]?.jsonPrimitive?.contentOrNull
+ ?: u["accountId"]?.jsonPrimitive?.contentOrNull
+ }.getOrNull()
+ }
+ DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
+
val credential = Credential.TraeCredential(
service = ServiceType.TRAE,
jwt = token,
- region = "CN",
+ region = if (region == "INTL") "INTL" else "CN",
+ apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
deviceId = deviceId,
- userId = userId?.takeIf { it.isNotBlank() },
+ userId = parsedUserId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis()
)
val effectiveId = credentialRepository.save(credential, accountId)
@@ -234,14 +305,76 @@ 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("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
- .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
+ .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")
.addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
.build()
@@ -261,6 +394,7 @@ 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))
@@ -269,6 +403,10 @@ 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))
@@ -277,16 +415,24 @@ class WebViewLoginViewModel @Inject constructor(
}
}
- /** 每 2 秒轮询一次 token 接口,120 秒超时。 */
+ /** 每 2 秒轮询一次 token 接口,超时按站点 Profile(CN 5 分钟 / INTL 15 分钟)。 */
private suspend fun pollWorkBuddyToken(state: String) {
- val timeoutMs = 120_000L
+ val timeoutMs = workBuddyPollTimeoutMs
val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) {
if (_uiState.value.loginSucceeded) return
try {
val request = Request.Builder()
- .url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
- .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
+ .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")
.get()
.build()
val body = withContext(Dispatchers.IO) {
@@ -295,16 +441,34 @@ 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 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
+ 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
+ }
if (!accessToken.isNullOrBlank()) {
val expiresAt = System.currentTimeMillis() + expiresIn * 1000
// 刚成功登录的凭据视为已验证(见 Trae 同款注释)
@@ -312,6 +476,7 @@ class WebViewLoginViewModel @Inject constructor(
service = ServiceType.WORKBUDDY,
accessToken = accessToken,
refreshToken = refreshToken ?: "",
+ region = if (region == "INTL") "INTL" else "CN",
expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis()
)
@@ -324,11 +489,13 @@ 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 (_: Exception) {
- // 网络异常时继续轮询,直到超时
+ } catch (e: Exception) {
+ DebugLog.w("WorkBuddyLogin", "poll: 异常=${e.message ?: "unknown"}")
}
delay(2000)
}
@@ -361,6 +528,77 @@ 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) {
@@ -515,6 +753,10 @@ 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 {
@@ -545,5 +787,7 @@ data class WebViewLoginUiState(
val pendingManualConfirm: Boolean = false,
val error: UiText? = null,
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */
- val loginAccountId: String? = null
+ val loginAccountId: String? = null,
+ /** 已在手机浏览器打开登录页,等待回调/轮询自动带回凭证。 */
+ val externalBrowserWaiting: Boolean = false
)
diff --git a/app/src/main/res/values-b+zh+Hans/strings.xml b/app/src/main/res/values-b+zh+Hans/strings.xml
index 97f00ea..51663ff 100644
--- a/app/src/main/res/values-b+zh+Hans/strings.xml
+++ b/app/src/main/res/values-b+zh+Hans/strings.xml
@@ -19,6 +19,7 @@
清除
保存
软件内登录
+ 手机浏览器登录
跳转
◀ 上一页
下一页 ▶
@@ -227,6 +228,7 @@
签到成功
今日已签到
今日已签到(连签 %1$d 天)
+ 暂无可领取的签到活动
签到失败,请检查凭据
该服务不支持签到
今日已签到
@@ -423,6 +425,13 @@
请先选择模型
未抓到 Cookie,请确认已登录
未配置登录 URL
+ 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴
+ Trae 登录回调链接
+ 在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...)
+ 解析并填入 JWT
+ 无法解析这条回调链接,请完整复制地址栏内容
+ 本地回调服务启动失败,请改用应用内登录
+ 已在浏览器打开,登录完成后会自动回到雨晴Token
登录超时,请重试
URL 应以 %1$s 开头
授权失败: %1$s
@@ -447,6 +456,7 @@
token 刷新失败,请重新登录或重新导入 auth.json
+ 清除登录
登录 %1$s
完成登录了吗?
如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。
@@ -784,4 +794,24 @@
需要开启「安装未知应用」权限
去开启
更新失败:%1$s
+ Premium 快速
+ Premium 慢速
+ 高级额度
+ 自动补全
+ 次/月
+ 充值 / 升级
+ 套餐
+ 基础用量已用
+国际版无独立签到
+ WorkBuddy 国际版积分来自网页/客户端活跃使用,没有独立签到活动。
+ 国际版没有每日签到
+ Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。
+ 月配额
+ 美元用量额度
+ 用量事件
+ Dollar Usage
+ 用量事件
+ 正在加载用量事件…
+ 最近 30 天暂无用量事件
+ 用量事件加载失败:%1$s
\ No newline at end of file
diff --git a/app/src/main/res/values-b+zh+Hant/strings.xml b/app/src/main/res/values-b+zh+Hant/strings.xml
index c99722d..237ae7b 100644
--- a/app/src/main/res/values-b+zh+Hant/strings.xml
+++ b/app/src/main/res/values-b+zh+Hant/strings.xml
@@ -19,6 +19,7 @@
清除
儲存
軟體內登入
+ 手機瀏覽器登入
跳轉
◀ 上一頁
下一頁 ▶
@@ -227,6 +228,7 @@
簽到成功
今日已簽到
今日已簽到(連簽 %1$d 天)
+ 暫無可領取的簽到活動
簽到失敗,請檢查憑證
該服務不支援簽到
今日已簽到
@@ -423,6 +425,13 @@
請先選擇模型
未抓到 Cookie,請確認已登入
未配置登入 URL
+ 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上
+ Trae 登入回呼連結
+ 在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...)
+ 解析並填入 JWT
+ 無法解析這條回呼連結,請完整複製網址列內容
+ 本機回呼服務啟動失敗,請改用應用內登入
+ 已在瀏覽器開啟,登入完成後會自動回到雨晴Token
登入逾時,請重試
URL 應以 %1$s 開頭
授權失敗: %1$s
@@ -447,6 +456,7 @@
token 重新整理失敗,請重新登入或重新匯入 auth.json
+ 清除登入
登入 %1$s
完成登入了嗎?
如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。
@@ -784,4 +794,24 @@
需要開啟「安裝未知應用」權限
前往開啟
更新失敗:%1$s
+ Premium 快速
+ Premium 慢速
+ 進階額度
+ 自動補全
+ 次/月
+ 儲值 / 升級
+ 套餐
+ 基礎用量已用
+國際版無獨立簽到
+ WorkBuddy 國際版積分來自網頁/客戶端活躍使用,沒有獨立簽到活動。
+ 國際版沒有每日簽到
+ Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。
+ 月額度
+ 美元用量額度
+ 用量事件
+ Dollar Usage
+ 用量事件
+ 正在載入用量事件…
+ 最近 30 天暫無用量事件
+ 用量事件載入失敗:%1$s
\ No newline at end of file
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 15d0bee..8e5150b 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -23,6 +23,7 @@
Clear
Save
Sign in within app
+ Sign in via mobile browser
Go
◀ Prev
Next ▶
@@ -230,6 +231,7 @@
Check-in successful
Already checked in today
Already checked in (streak %1$d days)
+ No active check-in campaign
Check-in failed, please check credentials
This service does not support check-in
Checked in today
@@ -426,6 +428,13 @@
Please select a model first
No cookie captured, please make sure you\'re logged in
No login URL configured
+ Login succeeded but no token was received. Copy userJwt from the callback URL and paste it manually.
+ Trae login callback URL
+ Paste the whole callback URL after logging in the browser (http://127.0.0.1:18080/authorize?...).
+ Parse and fill JWT
+ Could not parse this callback URL. Please copy the address bar URL completely.
+ Unable to start local callback server. Please use in-app login instead.
+ Opened in browser. After login it will return to RainyToken automatically.
Login timed out, please retry
URL should start with %1$s
Authorization failed: %1$s
@@ -450,6 +459,7 @@
Token refresh failed. Please log in again or import auth.json.
+ Clear login
Sign in to %1$s
Done logging in?
If you finished signing in but the page didn\'t redirect, tap the button below to grab the cookie and save your session.
@@ -789,4 +799,24 @@
Please allow installing apps from unknown sources
Open settings
Update failed: %1$s
+ Premium fast
+ Premium slow
+ Advanced requests
+ Auto completion
+ /mo
+ Upgrade / Top up
+ Plan
+ Basic usage used
+No daily check-in on international
+ WorkBuddy international credits are earned through active usage, not a check-in campaign.
+ International edition has no daily check-in
+ TRAE International uses USD billing with monthly request quotas instead of credit check-ins.
+ Monthly quota
+ Dollar allowance
+ Usage Events
+ Dollar Usage
+ Usage Events
+ Loading usage events…
+ No usage events in the last 30 days
+ Usage events load failed: %1$s
\ No newline at end of file
diff --git a/docs/PROXY-ROADMAP.md b/docs/PROXY-ROADMAP.md
new file mode 100644
index 0000000..abd7ca9
--- /dev/null
+++ b/docs/PROXY-ROADMAP.md
@@ -0,0 +1,143 @@
+# 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
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 90a634c..cd10e79 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -15,6 +15,9 @@ 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"
@@ -67,6 +70,10 @@ 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" }