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" }