From 9d3c00461751380702a402268d151ac9f2da00c8 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 22:22:45 +0800 Subject: [PATCH 01/69] =?UTF-8?q?feat(proxy):=20=E6=96=B0=E5=A2=9E?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3=E7=BD=91=E5=85=B3=E9=AA=A8=E6=9E=B6=E4=B8=8E?= =?UTF-8?q?=E5=BC=80=E5=8F=91=E8=B7=AF=E7=BA=BF=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/PROXY-ROADMAP.md:M0-M4 里程碑、Provider 矩阵、风险与参考仓库 - data/proxy/:ProviderKind/ProviderRegion/ProxyModels/ProviderAdapter/AccountPool/RegionRouter/LocalProxyServer 接口骨架 - .gitignore:忽略 _tmp_* 调试文件,防止真实接口响应/账号数据入库 --- .gitignore | 3 + .../com/rainy/token/data/proxy/AccountPool.kt | 44 +++++++ .../token/data/proxy/LocalProxyServer.kt | 27 ++++ .../rainy/token/data/proxy/ProviderAdapter.kt | 21 +++ .../rainy/token/data/proxy/ProviderKind.kt | 33 +++++ .../rainy/token/data/proxy/ProviderRegion.kt | 6 + .../com/rainy/token/data/proxy/ProxyModels.kt | 56 ++++++++ .../rainy/token/data/proxy/RegionRouter.kt | 30 +++++ docs/PROXY-ROADMAP.md | 120 ++++++++++++++++++ 9 files changed, 340 insertions(+) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/LocalProxyServer.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProviderAdapter.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProviderKind.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProviderRegion.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProxyModels.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/RegionRouter.kt create mode 100644 docs/PROXY-ROADMAP.md diff --git a/.gitignore b/.gitignore index 02f92f9..27e6c16 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,6 @@ release.jks tmp_cc/ goal.md /reference/ + +# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交) +_tmp_* 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..ae71135 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt @@ -0,0 +1,44 @@ +package com.rainy.token.data.proxy + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * 账号池:按 Provider 保存账号,负责轮询/冷却/会话粘性。 + * + * 当前为骨架(round-robin)。后续把 workbuddy2api-panel 的 + * 三因子加权、429 熔断冷却、会话粘性搬进来。 + */ +class AccountPool { + + data class PooledAccount( + val kind: ProviderKind, + val region: ProviderRegion? = null, + val accountId: String? = null, + val label: String? = null, + ) + + private val mutex = Mutex() + private val accounts = mutableMapOf>() + private val cursor = mutableMapOf() + + fun register(account: PooledAccount) { + accounts.getOrPut(account.kind) { mutableListOf() }.add(account) + } + + fun registerAll(list: List) { + list.forEach(::register) + } + + suspend fun next(kind: ProviderKind, sessionKey: String? = null): PooledAccount? = mutex.withLock { + val list = accounts[kind]?.takeIf { it.isNotEmpty() } ?: return null + // TODO: 会话粘性:命中 sessionKey 对应账号时优先返回 + val idx = cursor[kind] ?: 0 + cursor[kind] = (idx + 1) % list.size + list[idx] + } + + suspend fun snapshot(): Map> = mutex.withLock { + accounts.mapValues { it.value.toList() } + } +} 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/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/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/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/docs/PROXY-ROADMAP.md b/docs/PROXY-ROADMAP.md new file mode 100644 index 0000000..bb4715f --- /dev/null +++ b/docs/PROXY-ROADMAP.md @@ -0,0 +1,120 @@ +# 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`:本地服务接口 + 配置 + +## 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 From 85c6cf428331eb62c70ef03e655332afc373ca8b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 22:58:33 +0800 Subject: [PATCH 02/69] =?UTF-8?q?feat(proxy):=20M1a=20=E6=9C=AC=E5=9C=B0?= =?UTF-8?q?=20HTTP=20=E6=9C=8D=E5=8A=A1=20+=20Sub2API=20=E9=80=8F=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 引入 Ktor 3.0.3(server-core / cio / content-negotiation / kotlinx-json) - Sub2ApiChatProxy:把 /v1/chat/completions 与 /v1/models 透传到用户配置的 Sub2API 实例 - KtorLocalProxyServer:绑定 127.0.0.1:8787,提供 /health、/v1/models、/v1/chat/completions - NetworkModule 提供 Hilt 注入 --- app/build.gradle.kts | 6 + .../token/data/proxy/KtorLocalProxyServer.kt | 109 ++++++++++++++++++ .../token/data/proxy/Sub2ApiChatProxy.kt | 103 +++++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 18 +++ gradle/libs.versions.toml | 7 ++ 5 files changed, 243 insertions(+) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 077c966..1726179 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -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/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..fe90253 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -0,0 +1,109 @@ +package com.rainy.token.data.proxy + +import io.ktor.http.ContentType +import io.ktor.http.HttpStatusCode +import io.ktor.serialization.kotlinx.json.json +import io.ktor.server.application.Application +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.routing.get +import io.ktor.server.routing.post +import io.ktor.server.routing.routing +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * 基于 Ktor CIO 的本地 HTTP 反代服务。 + * + * M1a 能力: + * - GET /health + * - GET /v1/models → Sub2API 透传 + * - POST /v1/chat/completions → Sub2API 透传 + * + * 只绑定 127.0.0.1,默认端口 8787。 + */ +@Singleton +class KtorLocalProxyServer @Inject constructor( + private val sub2ApiChatProxy: Sub2ApiChatProxy, +) : LocalProxyServer { + + private val _isRunning = MutableStateFlow(false) + override val isRunning: StateFlow = _isRunning.asStateFlow() + + private var server: EmbeddedServer<*, *>? = null + + override fun start(config: ProxyServerConfig): Result = try { + if (_isRunning.value) return Result.success(Unit) + val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) { + proxyModule(config, sub2ApiChatProxy) + } + engine.start(wait = false) + server = engine + _isRunning.value = true + Result.success(Unit) + } catch (e: Throwable) { + Result.failure(e) + } + + override fun stop() { + runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) } + server = null + _isRunning.value = false + } + + private fun Application.proxyModule( + config: ProxyServerConfig, + sub2Api: Sub2ApiChatProxy, + ) { + install(ContentNegotiation) { + json() + } + routing { + get("/health") { + call.respond(mapOf("status" to "ok")) + } + get("/v1/models") { + val result = sub2Api.forwardModels() + if (result == null) { + call.respondBytes( + errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + } else { + call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) + } + } + post("/v1/chat/completions") { + val body = call.receiveText() + val result = sub2Api.forwardChat(body) + if (result == null) { + call.respondBytes( + errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), + ContentType.Application.Json, + HttpStatusCode.BadRequest + ) + } else { + call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) + } + } + } + } + + private fun errorBody(message: String): ByteArray { + val safe = message.replace("\"", "'") + return "{\"error\":{\"message\":\"$safe\",\"type\":\"invalid_request_error\"}}".toByteArray() + } + + private fun contentTypeOf(raw: String): ContentType = + runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json) +} 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..2048d13 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt @@ -0,0 +1,103 @@ +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) + } + + /** 转发 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 = try { + okHttpClient.newCall(builder.build()).execute() + } catch (_: Throwable) { + return@withContext null + } + 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) + } + + 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() } + } +} + +/** + * 上游 HTTP 响应(透传用)。 + */ +data class ProxyUpstreamResponse( + val status: Int, + val contentType: String, + val body: ByteArray, +) 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..9af3644 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,9 @@ 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.KtorLocalProxyServer +import com.rainy.token.data.proxy.LocalProxyServer +import com.rainy.token.data.proxy.Sub2ApiChatProxy import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module import dagger.Provides @@ -223,6 +226,21 @@ object NetworkModule { balanceCache: BalanceCache ): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache) + // ---- 本地反代网关 ---- + + @Provides + @Singleton + fun provideSub2ApiChatProxy( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository + ): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository) + + @Provides + @Singleton + fun provideLocalProxyServer( + sub2ApiChatProxy: Sub2ApiChatProxy + ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy) + /** 余额缓存 DataStore(计划 7.1) */ @Provides @Singleton 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" } From 11b9065f8249627e074586ae851e0885d08be0ca Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:23:16 +0800 Subject: [PATCH 03/69] =?UTF-8?q?feat(proxy):=20M1b=20WorkBuddy=20CN/Trae?= =?UTF-8?q?=20CN=20=E9=80=82=E9=85=8D=20+=20=E8=AE=BE=E7=BD=AE=E9=A1=B5?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KtorLocalProxyServer:修复审计阻断项(start/stop 加锁、apiKey Bearer 鉴权、IOException 502、请求体上限、错误 JSON 规范化) - WorkBuddyChatProxy:转发到 copilot.tencent.com/v2/chat/completions,Bearer accessToken + IDE 请求头 - TraeChatProxy:转发到 api.trae.cn/api/agent/v3/llm_utils_chat,Cloud-IDE-JWT 身份/设备头,最小 body 转换 - Credential.WorkBuddyCredential 增加 region 字段(默认 CN,兼容旧数据) - Settings 页新增代理网关开关(默认 127.0.0.1:8787,本机 Key: rainytoken) --- .../token/data/proxy/KtorLocalProxyServer.kt | 146 +++++++++++++----- .../rainy/token/data/proxy/TraeChatProxy.kt | 117 ++++++++++++++ .../token/data/proxy/WorkBuddyChatProxy.kt | 68 ++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 22 ++- .../rainy/token/domain/model/Credential.kt | 2 + .../rainy/token/ui/settings/SettingsScreen.kt | 49 ++++++ .../token/ui/settings/SettingsViewModel.kt | 17 +- 7 files changed, 382 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt create mode 100644 app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt 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 index fe90253..4e685ec 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -1,9 +1,11 @@ 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 @@ -15,6 +17,7 @@ import io.ktor.server.response.respondBytes 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.coroutines.flow.MutableStateFlow @@ -24,45 +27,63 @@ import kotlinx.coroutines.flow.asStateFlow /** * 基于 Ktor CIO 的本地 HTTP 反代服务。 * - * M1a 能力: + * M1b 能力: * - GET /health - * - GET /v1/models → Sub2API 透传 - * - POST /v1/chat/completions → Sub2API 透传 + * - GET /v1/models + * - POST /v1/chat/completions + * 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传 * - * 只绑定 127.0.0.1,默认端口 8787。 + * 安全: + * - 只绑定 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, ) : LocalProxyServer { + private val lock = Any() + + @Volatile + private var server: EmbeddedServer<*, *>? = null + private val _isRunning = MutableStateFlow(false) override val isRunning: StateFlow = _isRunning.asStateFlow() - private var server: EmbeddedServer<*, *>? = null - - override fun start(config: ProxyServerConfig): Result = try { - if (_isRunning.value) return Result.success(Unit) - val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) { - proxyModule(config, sub2ApiChatProxy) + 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) + } + engine.start(wait = false) + server = engine + _isRunning.value = true + Result.success(Unit) + } catch (e: Throwable) { + server = null + _isRunning.value = false + Result.failure(e) + } } - engine.start(wait = false) - server = engine - _isRunning.value = true - Result.success(Unit) - } catch (e: Throwable) { - Result.failure(e) } override fun stop() { - runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) } - server = null - _isRunning.value = false + synchronized(lock) { + runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) } + server = null + _isRunning.value = false + } } private fun Application.proxyModule( - config: ProxyServerConfig, + apiKey: String?, sub2Api: Sub2ApiChatProxy, + workBuddy: WorkBuddyChatProxy, + trae: TraeChatProxy, ) { install(ContentNegotiation) { json() @@ -72,38 +93,91 @@ class KtorLocalProxyServer @Inject constructor( call.respond(mapOf("status" to "ok")) } get("/v1/models") { + if (!authorized(call, apiKey)) return@get val result = sub2Api.forwardModels() if (result == null) { - call.respondBytes( - errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), - ContentType.Application.Json, - HttpStatusCode.BadRequest - ) + 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)) return@post val body = call.receiveText() - val result = sub2Api.forwardChat(body) - if (result == null) { - call.respondBytes( - errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"), - ContentType.Application.Json, - HttpStatusCode.BadRequest + if (body.length > MAX_REQUEST_BYTES) { + call.respond(HttpStatusCode(413, ""), errorBody("请求体过大")) + return@post + } + val model = extractModel(body) + val provider = routeProvider(model) + try { + val result = when (provider) { + ChatRoute.WORKBUDDY_CN -> workBuddy.forwardChat(body) + ChatRoute.TRAE_CN -> trae.forwardChat(body) + ChatRoute.SUB2API -> sub2Api.forwardChat(body) + } + if (result == null) { + call.respond( + HttpStatusCode.BadRequest, + errorBody("${provider.displayName} 未配置或未登录,请先在设置中配置") + ) + } 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") ) - } else { - call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) } } } } - private fun errorBody(message: String): ByteArray { - val safe = message.replace("\"", "'") - return "{\"error\":{\"message\":\"$safe\",\"type\":\"invalid_request_error\"}}".toByteArray() + 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 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 routeProvider(model: String): ChatRoute { + val m = model.lowercase() + return when { + m.startsWith("wbcn-") -> ChatRoute.WORKBUDDY_CN + m.startsWith("traecn-") || m.startsWith("trae-") -> ChatRoute.TRAE_CN + else -> ChatRoute.SUB2API + } + } + + private enum class ChatRoute(val displayName: String) { + WORKBUDDY_CN("WorkBuddy 国内版"), + TRAE_CN("Trae CN"), + SUB2API("Sub2API"), + } + + companion object { + private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024 + } } 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..be4f8b8 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -0,0 +1,117 @@ +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.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.contentOrNull +import kotlinx.serialization.json.put +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, + * 请求头需要 Cloud-IDE-JWT / x-device-id / x-uid 等设备与身份指纹。 + * M1b 实现最小 body 转换:保留 messages/tools,注入 config_name/function/stream/session。 + */ +@Singleton +class TraeChatProxy @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, +) { + + private val json = Json { ignoreUnknownKeys = true } + + suspend fun forwardChat(requestBody: String, accountId: String? = 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 base = if (credential.region.equals("INTL", ignoreCase = true)) { + "https://grow-normal.trae.ai" + } else { + "https://api.trae.cn" + } + val upstreamBody = buildUpstreamBody(requestBody, credential) + val requestId = UUID.randomUUID().toString() + val sessionId = UUID.randomUUID().toString().replace("-", "") + + val builder = Request.Builder() + .url(base + "/api/agent/v3/llm_utils_chat") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Cloud-IDE-JWT \$jwt") + .addHeader("X-Cloudide-Token", jwt) + .addHeader("x-uid", credential.userId ?: "") + .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-request-id", requestId) + .addHeader("x-app-id", "trae") + .addHeader("x-ide-version", "1.0.0") + .addHeader("x-ide-version-code", "1000000") + .addHeader("x-ide-version-type", "stable") + .addHeader("x-os-version", "Android") + .addHeader("x-system-type", "Android") + .addHeader("User-Agent", "RainyToken/1.0") + .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) + } + + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { + val src = try { + json.parseToJsonElement(raw) as? JsonObject + } catch (_: Throwable) { + null + } ?: return raw + + val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + ?: "glm-5.2" + val messages = src["messages"] as? JsonArray ?: JsonArray(emptyList()) + val sessionId = UUID.randomUUID().toString().replace("-", "") + + return buildJsonObject { + put("conversation_id", "") + put("session_id", sessionId) + put("user_id", credential.userId ?: "") + put("device_id", credential.deviceId ?: credential.checkinDeviceId ?: "") + put("agent_type", "solo_work_lite") + put("model_name", model + "__dev") + put("config_name", model) + put("function", "solo_work_lite") + put("stream", true) + put("request_id", sessionId) + put("messages", messages) + 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() + } +} 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..7ad1071 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -0,0 +1,68 @@ +package com.rainy.token.data.proxy + +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import java.util.UUID +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody + +/** + * WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。 + * + * 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。 + * 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。 + */ +@Singleton +class WorkBuddyChatProxy @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, +) { + + suspend fun forwardChat(requestBody: String, accountId: String? = null): 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 base = if (credential.region.equals("INTL", ignoreCase = true)) { + "https://www.workbuddy.ai" + } else { + "https://copilot.tencent.com" + } + val requestId = UUID.randomUUID().toString() + val builder = Request.Builder() + .url(base + "/v2/chat/completions") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Bearer \$accessToken") + .addHeader("User-Agent", "RainyToken/1.0") + .addHeader("X-IDE-Type", "VSCode") + .addHeader("X-IDE-Name", "CodeBuddy") + .addHeader("X-IDE-Version", "3.0.0") + .addHeader("X-Product", "CodeBuddy") + .addHeader("X-Agent-Intent", "craft") + .addHeader("X-Request-ID", requestId) + .addHeader("X-Conv-Request-ID", requestId) + .post(requestBody.toRequestBody("application/json".toMediaType())) + + val response = try { + okHttpClient.newCall(builder.build()).execute() + } catch (e: java.io.IOException) { + throw e + } + val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) } + val contentType = response.header("Content-Type") ?: "application/json" + val status = response.code + response.close() + ProxyUpstreamResponse(status, contentType, bytes) + } +} 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 9af3644..755416f 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -23,6 +23,8 @@ import com.rainy.token.data.repository.UpdateRepository 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.WorkBuddyChatProxy import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module import dagger.Provides @@ -235,11 +237,27 @@ object NetworkModule { 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 + ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository) + @Provides @Singleton fun provideLocalProxyServer( - sub2ApiChatProxy: Sub2ApiChatProxy - ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy) + sub2ApiChatProxy: Sub2ApiChatProxy, + workBuddyChatProxy: WorkBuddyChatProxy, + traeChatProxy: TraeChatProxy + ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy) /** 余额缓存 DataStore(计划 7.1) */ @Provides 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..a2a9b1d 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 @@ -106,6 +106,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/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index b2f740f..9410cfe 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 @@ -28,6 +28,7 @@ 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 @@ -89,6 +90,7 @@ fun SettingsScreen( } val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -159,6 +161,12 @@ fun SettingsScreen( item { LanguageCard(onClick = { showLanguageDialog = true }) } + item { + ProxyGatewayCard( + running = proxyRunning, + onToggle = viewModel::setProxyEnabled + ) + } item { TipsCard(onClick = { onOpenTips() }) } @@ -181,6 +189,47 @@ fun SettingsScreen( } } +@Composable +private fun ProxyGatewayCard( + running: Boolean, + onToggle: (Boolean) -> Unit +) { + 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) + ) + } + Switch( + checked = running, + onCheckedChange = onToggle + ) + } + } +} + @Composable private fun LanguageCard(onClick: () -> Unit) { val context = LocalContext.current 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..475743c 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,6 +2,8 @@ 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.ProxyServerConfig import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.service.ServiceType @@ -19,12 +21,25 @@ import javax.inject.Inject */ @HiltViewModel class SettingsViewModel @Inject constructor( - private val credentialRepository: CredentialRepository + private val credentialRepository: CredentialRepository, + private val localProxyServer: LocalProxyServer ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + /** 本地反代服务运行状态。 */ + val proxyRunning: StateFlow = localProxyServer.isRunning + + /** 启动/停止本地反代服务。M1b 使用固定本地 Key,后续改为可配置。 */ + fun setProxyEnabled(enabled: Boolean) { + if (enabled) { + localProxyServer.start(ProxyServerConfig(apiKey = "rainytoken")) + } else { + localProxyServer.stop() + } + } + init { refresh() } From 5aa2d8f116bdd77967c01cc18557cf9093725604 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:24:06 +0800 Subject: [PATCH 04/69] =?UTF-8?q?chore:=20=E5=BF=BD=E7=95=A5=20build=5Fcom?= =?UTF-8?q?pile.log?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 27e6c16..8554bca 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ goal.md # 调试临时文件(可能包含真实接口响应/账号数据,禁止提交) _tmp_* +build_compile.log From a1605e6993a6323c3fc7c207c6a4903ca2968735 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:34:35 +0800 Subject: [PATCH 05/69] =?UTF-8?q?feat(proxy):=20M2=20=E8=B4=A6=E5=8F=B7?= =?UTF-8?q?=E6=B1=A0=E8=BD=AE=E8=AF=A2=20+=20=E4=BC=9A=E8=AF=9D=E7=B2=98?= =?UTF-8?q?=E6=80=A7=20+=20=E5=8F=8C=E5=8C=BA=E5=9F=9F=E8=B7=AF=E7=94=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderRouter:前缀路由到 WorkBuddy CN/INTL、Trae CN/INTL、OpenAI 兼容 - AccountPool:从 CredentialRepository 实时取号,round-robin + 会话粘性(X-Conversation-Id / user) - 账号按 credential.region 与路由区域匹配,找不到时回退全部账号 - WorkBuddy/Trae 代理支持 forcedRegion 覆盖 - Ktor 路由接入账号池 --- .../com/rainy/token/data/proxy/AccountPool.kt | 75 +++++++++++++++---- .../token/data/proxy/KtorLocalProxyServer.kt | 45 +++++------ .../rainy/token/data/proxy/ProviderRouter.kt | 38 ++++++++++ .../rainy/token/data/proxy/TraeChatProxy.kt | 11 ++- .../token/data/proxy/WorkBuddyChatProxy.kt | 11 ++- .../java/com/rainy/token/di/NetworkModule.kt | 24 +++++- 6 files changed, 161 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt 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 index ae71135..4de1876 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/AccountPool.kt @@ -1,15 +1,24 @@ 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 /** - * 账号池:按 Provider 保存账号,负责轮询/冷却/会话粘性。 + * 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。 * - * 当前为骨架(round-robin)。后续把 workbuddy2api-panel 的 - * 三因子加权、429 熔断冷却、会话粘性搬进来。 + * - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组; + * - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱; + * - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。 */ -class AccountPool { +@Singleton +class AccountPool @Inject constructor( + private val credentialRepository: CredentialRepository, +) { data class PooledAccount( val kind: ProviderKind, @@ -19,26 +28,60 @@ class AccountPool { ) private val mutex = Mutex() - private val accounts = mutableMapOf>() + private val sessionSticky = mutableMapOf() private val cursor = mutableMapOf() - fun register(account: PooledAccount) { - accounts.getOrPut(account.kind) { mutableListOf() }.add(account) + 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, + ) } - fun registerAll(list: List) { - list.forEach(::register) + 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 + } } - suspend fun next(kind: ProviderKind, sessionKey: String? = null): PooledAccount? = mutex.withLock { - val list = accounts[kind]?.takeIf { it.isNotEmpty() } ?: return null - // TODO: 会话粘性:命中 sessionKey 对应账号时优先返回 + private fun pickRoundRobin(kind: ProviderKind, ids: List): String { val idx = cursor[kind] ?: 0 - cursor[kind] = (idx + 1) % list.size - list[idx] + cursor[kind] = (idx + 1) % ids.size + return ids[idx] } - suspend fun snapshot(): Map> = mutex.withLock { - accounts.mapValues { it.value.toList() } + 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 index 4e685ec..778fa75 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -42,6 +42,8 @@ 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() @@ -57,7 +59,7 @@ class KtorLocalProxyServer @Inject constructor( 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) + proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool) } engine.start(wait = false) server = engine @@ -84,6 +86,8 @@ class KtorLocalProxyServer @Inject constructor( sub2Api: Sub2ApiChatProxy, workBuddy: WorkBuddyChatProxy, trae: TraeChatProxy, + router: ProviderRouter, + pool: AccountPool, ) { install(ContentNegotiation) { json() @@ -109,17 +113,24 @@ class KtorLocalProxyServer @Inject constructor( return@post } val model = extractModel(body) - val provider = routeProvider(model) + val route = router.route(model) + val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(body) + val pooled = pool.next(route.kind, route.region, conversationId) + val accountId = pooled?.accountId try { - val result = when (provider) { - ChatRoute.WORKBUDDY_CN -> workBuddy.forwardChat(body) - ChatRoute.TRAE_CN -> trae.forwardChat(body) - ChatRoute.SUB2API -> sub2Api.forwardChat(body) + val result = when (route.kind) { + ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> + workBuddy.forwardChat(body, accountId, route.region) + + ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> + trae.forwardChat(body, accountId, route.region) + + else -> sub2Api.forwardChat(body, accountId) } if (result == null) { call.respond( HttpStatusCode.BadRequest, - errorBody("${provider.displayName} 未配置或未登录,请先在设置中配置") + errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") ) } else { call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) @@ -162,20 +173,12 @@ class KtorLocalProxyServer @Inject constructor( ?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null } }.getOrNull() ?: "" - private fun routeProvider(model: String): ChatRoute { - val m = model.lowercase() - return when { - m.startsWith("wbcn-") -> ChatRoute.WORKBUDDY_CN - m.startsWith("traecn-") || m.startsWith("trae-") -> ChatRoute.TRAE_CN - else -> ChatRoute.SUB2API - } - } - - private enum class ChatRoute(val displayName: String) { - WORKBUDDY_CN("WorkBuddy 国内版"), - TRAE_CN("Trae CN"), - SUB2API("Sub2API"), - } + 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/ProviderRouter.kt b/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt new file mode 100644 index 0000000..126de77 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt @@ -0,0 +1,38 @@ +package com.rainy.token.data.proxy + +/** + * 模型名 → Provider + Region 路由。 + * + * 约定前缀: + * - wbcn- / workbuddy-cn / codebuddy- → WorkBuddy 国内版 + * - wbintl- / workbuddy-intl → WorkBuddy 国际版 + * - traecn- / trae-cn / trae- → Trae CN + * - traeintl- / trae-intl → Trae INTL + * - 其余默认 OpenAI 兼容透传(Sub2API) + */ +class ProviderRouter { + + data class Route( + val kind: ProviderKind, + val region: ProviderRegion?, + ) + + fun route(model: String): Route { + val m = model.trim().lowercase() + return when { + m.startsWith("wbcn-") || m.startsWith("workbuddy-cn") || m.startsWith("codebuddy-") -> + Route(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN) + + m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") -> + Route(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL) + + m.startsWith("traeintl-") || m.startsWith("trae-intl") -> + Route(ProviderKind.TRAE_INTL, ProviderRegion.INTL) + + m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") -> + Route(ProviderKind.TRAE_CN, ProviderRegion.CN) + + else -> Route(ProviderKind.OPENAI_COMPATIBLE, null) + } + } +} diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index be4f8b8..3dd8a19 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -35,7 +35,11 @@ class TraeChatProxy @Inject constructor( private val json = Json { ignoreUnknownKeys = true } - suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? = + 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 @@ -43,7 +47,10 @@ class TraeChatProxy @Inject constructor( val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext null - val base = if (credential.region.equals("INTL", ignoreCase = true)) { + val effectiveRegion = forcedRegion ?: runCatching { + ProviderRegion.valueOf(credential.region.uppercase()) + }.getOrNull() + val base = if (effectiveRegion == ProviderRegion.INTL) { "https://grow-normal.trae.ai" } else { "https://api.trae.cn" 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 index 7ad1071..031b363 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -25,7 +25,11 @@ class WorkBuddyChatProxy @Inject constructor( private val credentialRepository: CredentialRepository, ) { - suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? = + 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 @@ -33,7 +37,10 @@ class WorkBuddyChatProxy @Inject constructor( val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } ?: return@withContext null - val base = if (credential.region.equals("INTL", ignoreCase = true)) { + 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" 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 755416f..3c3124a 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,8 @@ 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 @@ -251,13 +253,31 @@ object NetworkModule { credentialRepository: CredentialRepository ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository) + @Provides + @Singleton + fun provideProviderRouter(): ProviderRouter = ProviderRouter() + + @Provides + @Singleton + fun provideAccountPool( + credentialRepository: CredentialRepository + ): AccountPool = AccountPool(credentialRepository) + @Provides @Singleton fun provideLocalProxyServer( sub2ApiChatProxy: Sub2ApiChatProxy, workBuddyChatProxy: WorkBuddyChatProxy, - traeChatProxy: TraeChatProxy - ): LocalProxyServer = KtorLocalProxyServer(sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy) + traeChatProxy: TraeChatProxy, + providerRouter: ProviderRouter, + accountPool: AccountPool + ): LocalProxyServer = KtorLocalProxyServer( + sub2ApiChatProxy, + workBuddyChatProxy, + traeChatProxy, + providerRouter, + accountPool + ) /** 余额缓存 DataStore(计划 7.1) */ @Provides From 1609e210bd7daf04bcf61922c0d552eb4eb69d67 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:44:44 +0800 Subject: [PATCH 06/69] =?UTF-8?q?fix(proxy):=20=E4=BF=AE=E5=A4=8D=20WorkBu?= =?UTF-8?q?ddy/Trae=20=E9=89=B4=E6=9D=83=E5=A4=B4=E3=80=81401=E3=80=81?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=89=8D=E7=BC=80=20+=20=E6=B5=81=E5=BC=8F?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E9=AA=A8=E6=9E=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkBuddyChatProxy/TraeChatProxy:Authorization 插值去掉误转义 - KtorLocalProxyServer:authorized() 失败返回 401 - 路由剥离 wbcn-/traecn- 等前缀后再转发,Trae config_name/model_name 不再带前缀 - Sub2ApiChatProxy 去掉 IOException 吞掉逻辑 - 新增 ProxyUpstreamStream 与三个代理的 openStreamingChat 入口(暂未接入实时转发) --- .../token/data/proxy/KtorLocalProxyServer.kt | 56 ++++++++++++++--- .../token/data/proxy/ProxyUpstreamStream.kt | 15 +++++ .../token/data/proxy/Sub2ApiChatProxy.kt | 38 ++++++++++-- .../rainy/token/data/proxy/TraeChatProxy.kt | 61 ++++++++++++++++++- .../token/data/proxy/WorkBuddyChatProxy.kt | 54 +++++++++++++++- 5 files changed, 209 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/ProxyUpstreamStream.kt 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 index 778fa75..aacdc13 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -20,6 +20,9 @@ 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 @@ -97,7 +100,10 @@ class KtorLocalProxyServer @Inject constructor( call.respond(mapOf("status" to "ok")) } get("/v1/models") { - if (!authorized(call, apiKey)) return@get + 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")) @@ -106,19 +112,24 @@ class KtorLocalProxyServer @Inject constructor( } } post("/v1/chat/completions") { - if (!authorized(call, apiKey)) return@post - val body = call.receiveText() - if (body.length > MAX_REQUEST_BYTES) { + 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 model = extractModel(body) + val model = extractModel(rawBody) val route = router.route(model) - val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(body) + val upstreamModel = stripModelPrefix(model) + val body = if (upstreamModel != model) rewriteModelBody(rawBody, upstreamModel) else rawBody + val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody) val pooled = pool.next(route.kind, route.region, conversationId) val accountId = pooled?.accountId try { - val result = when (route.kind) { + val result: ProxyUpstreamResponse? = when (route.kind) { ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> workBuddy.forwardChat(body, accountId, route.region) @@ -166,6 +177,37 @@ class KtorLocalProxyServer @Inject constructor( 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) 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/Sub2ApiChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt index 2048d13..34f4480 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt @@ -34,6 +34,36 @@ class Sub2ApiChatProxy @Inject constructor( 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 @@ -68,12 +98,8 @@ class Sub2ApiChatProxy @Inject constructor( .post(body.toRequestBody("application/json".toMediaType())) } - val response = try { - okHttpClient.newCall(builder.build()).execute() - } catch (_: Throwable) { - return@withContext null - } - val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) } + 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() diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 3dd8a19..8f3f8af 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -63,7 +63,7 @@ class TraeChatProxy @Inject constructor( .url(base + "/api/agent/v3/llm_utils_chat") .addHeader("Content-Type", "application/json") .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Cloud-IDE-JWT \$jwt") + .addHeader("Authorization", "Cloud-IDE-JWT $jwt") .addHeader("X-Cloudide-Token", jwt) .addHeader("x-uid", credential.userId ?: "") .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") @@ -90,6 +90,65 @@ class TraeChatProxy @Inject constructor( 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 = if (effectiveRegion == ProviderRegion.INTL) { + "https://grow-normal.trae.ai" + } else { + "https://api.trae.cn" + } + val upstreamBody = buildUpstreamBody(requestBody, credential) + val requestId = UUID.randomUUID().toString() + val sessionId = UUID.randomUUID().toString().replace("-", "") + + val builder = Request.Builder() + .url(base + "/api/agent/v3/llm_utils_chat") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Cloud-IDE-JWT $jwt") + .addHeader("X-Cloudide-Token", jwt) + .addHeader("x-uid", credential.userId ?: "") + .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") + .addHeader("x-request-id", requestId) + .addHeader("x-app-id", "trae") + .addHeader("x-ide-version", "1.0.0") + .addHeader("x-ide-version-code", "1000000") + .addHeader("x-ide-version-type", "stable") + .addHeader("x-os-version", "Android") + .addHeader("x-system-type", "Android") + .addHeader("User-Agent", "RainyToken/1.0") + .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 buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { val src = try { json.parseToJsonElement(raw) as? JsonObject 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 index 031b363..c22a7cc 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -50,7 +50,7 @@ class WorkBuddyChatProxy @Inject constructor( .url(base + "/v2/chat/completions") .addHeader("Content-Type", "application/json") .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Bearer \$accessToken") + .addHeader("Authorization", "Bearer $accessToken") .addHeader("User-Agent", "RainyToken/1.0") .addHeader("X-IDE-Type", "VSCode") .addHeader("X-IDE-Name", "CodeBuddy") @@ -72,4 +72,56 @@ class WorkBuddyChatProxy @Inject constructor( response.close() ProxyUpstreamResponse(status, contentType, bytes) } + suspend fun openStreamingChat( + requestBody: String, + accountId: String? = null, + forcedRegion: ProviderRegion? = null, + ): ProxyUpstreamStream? = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId) + ?: return@withContext null + if (credential !is Credential.WorkBuddyCredential) return@withContext null + val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } + ?: return@withContext null + + val effectiveRegion = forcedRegion ?: runCatching { + ProviderRegion.valueOf(credential.region.uppercase()) + }.getOrNull() + val base = if (effectiveRegion == ProviderRegion.INTL) { + "https://www.workbuddy.ai" + } else { + "https://copilot.tencent.com" + } + val requestId = UUID.randomUUID().toString() + val builder = Request.Builder() + .url(base + "/v2/chat/completions") + .addHeader("Content-Type", "application/json") + .addHeader("Accept", "text/event-stream") + .addHeader("Authorization", "Bearer $accessToken") + .addHeader("User-Agent", "RainyToken/1.0") + .addHeader("X-IDE-Type", "VSCode") + .addHeader("X-IDE-Name", "CodeBuddy") + .addHeader("X-IDE-Version", "3.0.0") + .addHeader("X-Product", "CodeBuddy") + .addHeader("X-Agent-Intent", "craft") + .addHeader("X-Request-ID", requestId) + .addHeader("X-Conv-Request-ID", requestId) + .post(requestBody.toRequestBody("application/json".toMediaType())) + + val response = try { + okHttpClient.newCall(builder.build()).execute() + } catch (e: java.io.IOException) { + throw e + } + val input = response.body?.byteStream() ?: run { + response.close() + return@withContext null + } + ProxyUpstreamStream( + status = response.code, + contentType = response.header("Content-Type") ?: "text/event-stream", + input = input, + close = { response.close() }, + ) + } + } From 072f7efa55bf46ac4b9c793cf056f9f506326e50 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:46:18 +0800 Subject: [PATCH 07/69] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E5=8F=8D?= =?UTF-8?q?=E4=BB=A3=E7=BD=91=E5=85=B3=E5=BC=80=E5=8F=91=E8=BF=9B=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROXY-ROADMAP.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/docs/PROXY-ROADMAP.md b/docs/PROXY-ROADMAP.md index bb4715f..ae2929d 100644 --- a/docs/PROXY-ROADMAP.md +++ b/docs/PROXY-ROADMAP.md @@ -51,6 +51,28 @@ WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough - `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 也先缓冲) + +**未完成**: +- StreamNormalizer 实时 SSE 逐块转发(openStreamingChat 入口已就绪,未接线) +- WorkBuddy INTL / Trae SOLO/Work 实际协议归一化 +- M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy) +- M4 长尾 + 官方透传 + 网关配置 UI 完善 + ## 4. 里程碑 ### M0:地基(当前) From de95013164c5dc3adaaa05950d751bc7e19aa321 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:52:12 +0800 Subject: [PATCH 08/69] =?UTF-8?q?feat(proxy):=20stream=3Dtrue=20=E5=AE=9E?= =?UTF-8?q?=E6=97=B6=E9=80=8F=E4=BC=A0=E4=B8=8A=E6=B8=B8=20SSE=20=E5=AD=97?= =?UTF-8?q?=E8=8A=82=E6=B5=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ktor respondOutputStream 逐块转发上游 body,不再整包缓冲 - 流式/非流式按客户端 body 的 stream 字段分流 - openStreamingChat 入口正式接线 --- .../token/data/proxy/KtorLocalProxyServer.kt | 63 +++++++++++++++---- 1 file changed, 50 insertions(+), 13 deletions(-) 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 index aacdc13..48be89a 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -14,6 +14,7 @@ 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 @@ -129,22 +130,58 @@ class KtorLocalProxyServer @Inject constructor( val pooled = pool.next(route.kind, route.region, conversationId) val accountId = pooled?.accountId try { - val result: ProxyUpstreamResponse? = when (route.kind) { - ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> - workBuddy.forwardChat(body, accountId, route.region) + if (extractStream(body)) { + val stream: ProxyUpstreamStream? = when (route.kind) { + ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> + workBuddy.openStreamingChat(body, accountId, route.region) - ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> - trae.forwardChat(body, accountId, route.region) + ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> + trae.openStreamingChat(body, accountId, route.region) - else -> sub2Api.forwardChat(body, accountId) - } - if (result == null) { - call.respond( - HttpStatusCode.BadRequest, - errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") - ) + else -> sub2Api.openStreamingChat(body, accountId) + } + if (stream == null) { + call.respond( + HttpStatusCode.BadRequest, + errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") + ) + } 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 { - call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) + val result: ProxyUpstreamResponse? = when (route.kind) { + ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> + workBuddy.forwardChat(body, accountId, route.region) + + ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> + trae.forwardChat(body, accountId, route.region) + + else -> sub2Api.forwardChat(body, accountId) + } + if (result == null) { + call.respond( + HttpStatusCode.BadRequest, + errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") + ) + } else { + call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) + } } } catch (e: IOException) { val detail = e.message ?: "未知错误" From f5162bbf742d53a16926c6a7198658f88f9db1d9 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Thu, 8 Oct 2026 23:53:30 +0800 Subject: [PATCH 09/69] =?UTF-8?q?docs:=20=E6=9B=B4=E6=96=B0=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E8=BD=AC=E5=8F=91=E4=B8=8E=20Qoder=20=E6=94=BB?= =?UTF-8?q?=E5=9D=9A=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/PROXY-ROADMAP.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/PROXY-ROADMAP.md b/docs/PROXY-ROADMAP.md index ae2929d..7d9ae0b 100644 --- a/docs/PROXY-ROADMAP.md +++ b/docs/PROXY-ROADMAP.md @@ -65,12 +65,13 @@ WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough - 设置页「代理网关」开关,默认 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 也先缓冲) +- stream=true 已接实时字节流转发(respondOutputStream);各上游私有 SSE 信封仍待归一 **未完成**: -- StreamNormalizer 实时 SSE 逐块转发(openStreamingChat 入口已就绪,未接线) +- 各家私有 SSE 信封 → 标准 OpenAI/Anthropic 流的 StreamNormalizer 仍待实现 - WorkBuddy INTL / Trae SOLO/Work 实际协议归一化 - M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy) + - Qoder 需 COSY 请求签名/编码(ed25519 类原语),已列为攻坚项 - M4 长尾 + 官方透传 + 网关配置 UI 完善 ## 4. 里程碑 From 67e1f060c58d0923cbd72d2005155b8a3805c138 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 00:04:55 +0800 Subject: [PATCH 10/69] =?UTF-8?q?feat(proxy):=20=E7=BD=91=E5=85=B3?= =?UTF-8?q?=E9=9A=8F=E6=9C=BA=20API=20Key=20+=20=E5=90=AF=E5=8A=A8?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SettingsViewModel 每次进程生成随机本机 Key,start 失败时暴露错误信息 - Settings 页显示 API Key 与错误提示 --- .../rainy/token/ui/settings/SettingsScreen.kt | 20 +++++++++++++++++++ .../token/ui/settings/SettingsViewModel.kt | 18 +++++++++++++++-- docs/PROXY-ROADMAP.md | 2 +- 3 files changed, 37 insertions(+), 3 deletions(-) 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 9410cfe..843a5be 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 @@ -91,6 +91,8 @@ 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() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -164,6 +166,8 @@ fun SettingsScreen( item { ProxyGatewayCard( running = proxyRunning, + apiKey = proxyApiKey, + error = proxyError, onToggle = viewModel::setProxyEnabled ) } @@ -192,6 +196,8 @@ fun SettingsScreen( @Composable private fun ProxyGatewayCard( running: Boolean, + apiKey: String, + error: String?, onToggle: (Boolean) -> Unit ) { Card( @@ -221,6 +227,20 @@ private fun ProxyGatewayCard( 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) + ) + } } Switch( checked = running, 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 475743c..dbac1d3 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -8,6 +8,7 @@ import com.rainy.token.data.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 @@ -31,15 +32,28 @@ class SettingsViewModel @Inject constructor( /** 本地反代服务运行状态。 */ val proxyRunning: StateFlow = localProxyServer.isRunning - /** 启动/停止本地反代服务。M1b 使用固定本地 Key,后续改为可配置。 */ + /** 当前本机 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 = "rainytoken")) + localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value)) + .onSuccess { _proxyError.value = null } + .onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" } } else { localProxyServer.stop() + _proxyError.value = null } } + private fun generateProxyKey(): String = + "rainy-" + UUID.randomUUID().toString().replace("-", "").take(16) + init { refresh() } diff --git a/docs/PROXY-ROADMAP.md b/docs/PROXY-ROADMAP.md index 7d9ae0b..abd7ca9 100644 --- a/docs/PROXY-ROADMAP.md +++ b/docs/PROXY-ROADMAP.md @@ -72,7 +72,7 @@ WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough - WorkBuddy INTL / Trae SOLO/Work 实际协议归一化 - M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy) - Qoder 需 COSY 请求签名/编码(ed25519 类原语),已列为攻坚项 -- M4 长尾 + 官方透传 + 网关配置 UI 完善 +- M4 长尾 + 官方透传 + 网关配置 UI 完善(已做:随机 API Key / 启动失败提示;待做:端口配置 / 状态持久化) ## 4. 里程碑 From a4d854271f0977228ac0661ce56de09c54b02261 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 08:08:47 +0800 Subject: [PATCH 11/69] =?UTF-8?q?feat(proxy):=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E6=96=B0=E5=A2=9E=20Trae/WorkBuddy=20=E6=B5=81?= =?UTF-8?q?=E5=BC=8F=E6=8E=A2=E9=92=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 用默认账号直接请求 llm_utils_chat/v2 chat,抓取上游原始 SSE 帧 - 调试输出显示 HTTP 状态、Content-Type 与最多 80 行原始帧 - 为 StreamNormalizer 提供真实协议样本 --- .../rainy/token/ui/settings/SettingsScreen.kt | 27 ++++++- .../token/ui/settings/SettingsViewModel.kt | 76 ++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) 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 843a5be..6344a8a 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 @@ -93,6 +93,7 @@ fun SettingsScreen( val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle() val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle() val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() + val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -168,7 +169,10 @@ fun SettingsScreen( running = proxyRunning, apiKey = proxyApiKey, error = proxyError, - onToggle = viewModel::setProxyEnabled + debug = proxyDebug, + onToggle = viewModel::setProxyEnabled, + onTestTrae = viewModel::testTraeStream, + onTestWorkBuddy = viewModel::testWorkBuddyStream ) } item { @@ -198,7 +202,10 @@ private fun ProxyGatewayCard( running: Boolean, apiKey: String, error: String?, - onToggle: (Boolean) -> Unit + debug: String?, + onToggle: (Boolean) -> Unit, + onTestTrae: () -> Unit, + onTestWorkBuddy: () -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), @@ -241,6 +248,22 @@ private fun ProxyGatewayCard( modifier = Modifier.padding(top = 2.dp) ) } + Row(modifier = Modifier.padding(top = 4.dp)) { + TextButton(onClick = onTestTrae) { + Text("测试 Trae 流") + } + TextButton(onClick = onTestWorkBuddy) { + Text("测试 WorkBuddy 流") + } + } + if (debug != null) { + Text( + text = debug, + style = MaterialTheme.typography.bodySmall, + color = InkMuted, + modifier = Modifier.padding(top = 4.dp) + ) + } } Switch( checked = running, 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 dbac1d3..335a600 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 @@ -3,7 +3,11 @@ 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.WorkBuddyChatProxy import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.service.ServiceType @@ -12,7 +16,9 @@ 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 javax.inject.Inject @@ -23,7 +29,9 @@ import javax.inject.Inject @HiltViewModel class SettingsViewModel @Inject constructor( private val credentialRepository: CredentialRepository, - private val localProxyServer: LocalProxyServer + private val localProxyServer: LocalProxyServer, + private val traeChatProxy: TraeChatProxy, + private val workBuddyChatProxy: WorkBuddyChatProxy ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -51,6 +59,72 @@ class SettingsViewModel @Inject constructor( } } + /** 代理调试输出(原始 SSE 帧)。 */ + private val _proxyDebug = MutableStateFlow(null) + val proxyDebug: StateFlow = _proxyDebug.asStateFlow() + + /** 用 Trae CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */ + fun testTraeStream() { + viewModelScope.launch { + _proxyDebug.value = "正在请求 Trae CN 流..." + val body = """{"model":"glm-5.2","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 + } + _proxyDebug.value = readStreamSample(stream, "Trae CN") + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误") + } + } + } + + /** 用 WorkBuddy CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */ + fun testWorkBuddyStream() { + viewModelScope.launch { + _proxyDebug.value = "正在请求 WorkBuddy CN 流..." + val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + try { + val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) + if (stream == null) { + _proxyDebug.value = "WorkBuddy CN 未配置或未登录" + return@launch + } + _proxyDebug.value = readStreamSample(stream, "WorkBuddy CN") + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } catch (e: Exception) { + _proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误") + } + } + } + + private suspend fun readStreamSample( + stream: ProxyUpstreamStream, + label: String, + ): String = withContext(Dispatchers.IO) { + val sb = StringBuilder() + sb.append(label).append(" HTTP ").append(stream.status) + sb.append(" Content-Type=").append(stream.contentType).append("\n") + try { + val reader = stream.input.bufferedReader() + repeat(80) { + val line = reader.readLine() ?: return@repeat + sb.append(line).append("\n") + if (line.trim() == "data: [DONE]") return@repeat + if (sb.length > 6000) return@repeat + } + } finally { + stream.close() + } + sb.toString() + } + + private fun generateProxyKey(): String = "rainy-" + UUID.randomUUID().toString().replace("-", "").take(16) From 8da1f2b377b9f9e99a689e446150759726b33a74 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:13:39 +0800 Subject: [PATCH 12/69] =?UTF-8?q?chore:=20=E7=89=88=E6=9C=AC=E5=8F=B7?= =?UTF-8?q?=E5=8D=87=E8=87=B3=201.6.6=20(versionCode=2016)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1726179..88b9c41 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 = 16 + versionName = "1.6.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { From 15408a6640b0ea80814c5a4fd46ca52c2fb73eb8 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:18:12 +0800 Subject: [PATCH 13/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.6?= =?UTF-8?q?.6=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index ca204f8..2540766 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 14, - "versionName": "1.6.4", - "apkUrl": "http://124.222.97.212:8443/attachments/47c454a6-72ea-4449-9e4a-2875d207c149", - "notes": "包含签到记录重构(日期/状态/id/数量),失败可当天重试" + "versionCode": 16, + "versionName": "1.6.6", + "apkUrl": "http://124.222.97.212:8443/attachments/39eef1fe-2ec6-46d0-974c-255adb24c015", + "notes": "代理网关 dev-proxy:新增本地 OpenAI 兼容反代、WorkBuddy/Trae 适配、账号池路由、流式透传、随机 API Key" } From d0b79cf1bd026726803556ce39e100fe8f8e52e0 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:29:40 +0800 Subject: [PATCH 14/69] =?UTF-8?q?fix(proxy):=20Trae=20CN=20=E4=B8=8A?= =?UTF-8?q?=E6=B8=B8=E6=8D=A2=E7=94=A8=20trae-api-cn.mchost.guru=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E6=AD=A3=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat 才是模型网关 - api.trae.cn 只是账号/OAuth 主机,不暴露 IDE 模型路由 - 请求头对齐参考实现:真实 x-app-id / x-ide-version / 设备指纹 - llm_utils_chat body 收敛为 SOLO 结构(config_name/model/function/stream) --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 52 +++++++++++-------- 2 files changed, 31 insertions(+), 25 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 88b9c41..b952496 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 = 16 - versionName = "1.6.6" + versionCode = 17 + versionName = "1.6.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 8f3f8af..c5a718d 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -51,9 +51,9 @@ class TraeChatProxy @Inject constructor( ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://grow-normal.trae.ai" + "https://a0ai-api-sg.byteintlapi.com" } else { - "https://api.trae.cn" + "https://trae-api-cn.mchost.guru" } val upstreamBody = buildUpstreamBody(requestBody, credential) val requestId = UUID.randomUUID().toString() @@ -63,19 +63,24 @@ class TraeChatProxy @Inject constructor( .url(base + "/api/agent/v3/llm_utils_chat") .addHeader("Content-Type", "application/json") .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Cloud-IDE-JWT $jwt") + .addHeader("Authorization", "Cloud-IDE-JWT jwt") .addHeader("X-Cloudide-Token", jwt) .addHeader("x-uid", credential.userId ?: "") + .addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8") .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") .addHeader("x-request-id", requestId) - .addHeader("x-app-id", "trae") - .addHeader("x-ide-version", "1.0.0") - .addHeader("x-ide-version-code", "1000000") + .addHeader("x-ide-version", "3.3.67") + .addHeader("x-ide-version-code", "20260401") .addHeader("x-ide-version-type", "stable") - .addHeader("x-os-version", "Android") - .addHeader("x-system-type", "Android") - .addHeader("User-Agent", "RainyToken/1.0") + .addHeader("x-device-cpu", "AMD") + .addHeader("x-device-brand", "83DG") + .addHeader("x-device-type", "windows") + .addHeader("x-ide-token", jwt) + .addHeader("x-os-version", "Windows 10") + .addHeader("x-system-type", "Windows") + .addHeader("Connection", "keep-alive") + .addHeader("User-Agent", "") .post(upstreamBody.toRequestBody("application/json".toMediaType())) val response = try { @@ -117,19 +122,24 @@ class TraeChatProxy @Inject constructor( .url(base + "/api/agent/v3/llm_utils_chat") .addHeader("Content-Type", "application/json") .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Cloud-IDE-JWT $jwt") + .addHeader("Authorization", "Cloud-IDE-JWT jwt") .addHeader("X-Cloudide-Token", jwt) .addHeader("x-uid", credential.userId ?: "") + .addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8") .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") .addHeader("x-request-id", requestId) - .addHeader("x-app-id", "trae") - .addHeader("x-ide-version", "1.0.0") - .addHeader("x-ide-version-code", "1000000") + .addHeader("x-ide-version", "3.3.67") + .addHeader("x-ide-version-code", "20260401") .addHeader("x-ide-version-type", "stable") - .addHeader("x-os-version", "Android") - .addHeader("x-system-type", "Android") - .addHeader("User-Agent", "RainyToken/1.0") + .addHeader("x-device-cpu", "AMD") + .addHeader("x-device-brand", "83DG") + .addHeader("x-device-type", "windows") + .addHeader("x-ide-token", jwt) + .addHeader("x-os-version", "Windows 10") + .addHeader("x-system-type", "Windows") + .addHeader("Connection", "keep-alive") + .addHeader("User-Agent", "") .post(upstreamBody.toRequestBody("application/json".toMediaType())) val response = try { @@ -162,17 +172,13 @@ class TraeChatProxy @Inject constructor( val sessionId = UUID.randomUUID().toString().replace("-", "") return buildJsonObject { - put("conversation_id", "") - put("session_id", sessionId) - put("user_id", credential.userId ?: "") - put("device_id", credential.deviceId ?: credential.checkinDeviceId ?: "") - put("agent_type", "solo_work_lite") - put("model_name", model + "__dev") + put("messages", messages) put("config_name", model) + put("model", model) put("function", "solo_work_lite") put("stream", true) put("request_id", sessionId) - put("messages", messages) + 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 { From e2bf924246ee3526df47a265ed5aa4507b04d294 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:30:50 +0800 Subject: [PATCH 15/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.6?= =?UTF-8?q?.7=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 2540766..b96b3b3 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 16, - "versionName": "1.6.6", - "apkUrl": "http://124.222.97.212:8443/attachments/39eef1fe-2ec6-46d0-974c-255adb24c015", - "notes": "代理网关 dev-proxy:新增本地 OpenAI 兼容反代、WorkBuddy/Trae 适配、账号池路由、流式透传、随机 API Key" + "versionCode": 17, + "versionName": "1.6.7", + "apkUrl": "http://124.222.97.212:8443/attachments/6783e345-65a7-4b9d-91dd-8a7d09d568bd", + "notes": "修复 Trae CN 404:模型网关切换为 trae-api-cn.mchost.guru,请求头/body 对齐上游 SOLO 协议" } From 5adf053b6f506dbeda71e6bbde1e69787ca7873b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:38:52 +0800 Subject: [PATCH 16/69] =?UTF-8?q?fix(update):=20=E5=A3=B0=E6=98=8E=20REQUE?= =?UTF-8?q?ST=5FINSTALL=5FPACKAGES=20=E6=9D=83=E9=99=90=E5=B9=B6=E5=8A=A0?= =?UTF-8?q?=E5=9B=BA=E5=AE=89=E8=A3=85=E6=B5=81=E7=A8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 缺少该权限时部分 ROM 启动系统安装器会被拒绝/闪退 - install() 整体容错:FileProvider URI 失败、ACTION_VIEW 失败均捕获并回退 ACTION_INSTALL_PACKAGE - 版本升至 1.6.8 --- app/build.gradle.kts | 4 +-- app/src/main/AndroidManifest.xml | 1 + .../token/ui/settings/UpdateViewModel.kt | 30 +++++++++++++------ 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b952496..35cef3b 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 = 17 - versionName = "1.6.7" + versionCode = 18 + versionName = "1.6.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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/ui/settings/UpdateViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt index 6cd9d95..41183ff 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 @@ -80,19 +80,31 @@ 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 { + if (!context.packageManager.canRequestPackageInstalls()) { + _uiState.update { it.copy(phase = Phase.NeedInstallPermission) } + 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.Error("安装文件无效:" + (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.Error(e.message ?: "无法启动安装器")) } } - } else { - _uiState.update { it.copy(phase = Phase.NeedInstallPermission) } } } } From 40af5db889805861c6c62dfb1016fec87ed77fe5 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:39:57 +0800 Subject: [PATCH 17/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.6?= =?UTF-8?q?.8=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index b96b3b3..75c4ec1 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 17, - "versionName": "1.6.7", - "apkUrl": "http://124.222.97.212:8443/attachments/6783e345-65a7-4b9d-91dd-8a7d09d568bd", - "notes": "修复 Trae CN 404:模型网关切换为 trae-api-cn.mchost.guru,请求头/body 对齐上游 SOLO 协议" + "versionCode": 18, + "versionName": "1.6.8", + "apkUrl": "http://124.222.97.212:8443/attachments/469bcef1-307c-47f2-a104-4dd9273219f7", + "notes": "修复应用内更新安装闪退:补充 REQUEST_INSTALL_PACKAGES 权限,安装流程全链路容错" } From 4597fb0f8e569321bf629798d122d6e8f9c31d0e Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:46:06 +0800 Subject: [PATCH 18/69] =?UTF-8?q?fix(update):=20canRequestPackageInstalls?= =?UTF-8?q?=20=E5=8C=85=20try/catch=EF=BC=8C=E6=97=A7=E5=8C=85=E4=B9=9F?= =?UTF-8?q?=E4=B8=8D=E9=97=AA=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 旧版本未声明 REQUEST_INSTALL_PACKAGES 时调该 API 本身会抛 SecurityException - 现在降级为 capture 后按 false 处理,引导去系统设置开启未知来源 - 版本升至 1.6.9 --- app/build.gradle.kts | 4 ++-- .../main/java/com/rainy/token/ui/settings/UpdateViewModel.kt | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 35cef3b..aaea2d1 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 = 18 - versionName = "1.6.8" + versionCode = 19 + versionName = "1.6.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 41183ff..3fc9456 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 @@ -80,7 +80,9 @@ class UpdateViewModel @Inject constructor( } fun install(file: File) { - if (!context.packageManager.canRequestPackageInstalls()) { + val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() } + .getOrDefault(false) + if (!canInstall) { _uiState.update { it.copy(phase = Phase.NeedInstallPermission) } return } From 8e692a0c6e13c17b86f2dfbec747a27aa5fa5a3f Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 09:47:09 +0800 Subject: [PATCH 19/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.6?= =?UTF-8?q?.9=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 75c4ec1..348970a 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 18, - "versionName": "1.6.8", - "apkUrl": "http://124.222.97.212:8443/attachments/469bcef1-307c-47f2-a104-4dd9273219f7", - "notes": "修复应用内更新安装闪退:补充 REQUEST_INSTALL_PACKAGES 权限,安装流程全链路容错" + "versionCode": 19, + "versionName": "1.6.9", + "apkUrl": "http://124.222.97.212:8443/attachments/57ab917c-7776-403c-be16-a69b64bbdbd1", + "notes": "修复更新安装闪退:声明权限 + canRequestPackageInstalls 容错" } From fa796d57b5232ea8d37d7d92495f5df058a476db Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:02:07 +0800 Subject: [PATCH 20/69] =?UTF-8?q?fix(proxy):=20=E4=BF=AE=E5=A4=8D=20Trae?= =?UTF-8?q?=20Authorization=20=E5=A4=B4=E5=8F=91=E6=88=90=E5=AD=97?= =?UTF-8?q?=E9=9D=A2=E9=87=8F=20jwt=20+=20=E8=A1=A5=E5=85=A8=20SOLO=20?= =?UTF-8?q?=E5=A4=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 上一版把 \ 写成了字符串 jwt,Trae 收到 Cloud-IDE-JWT jwt 必然被网关拒绝 - 完整 SOLO 头集合对齐 trae2api-cn:x-app-version/x-lscbd-*/x-ttnet-* 等 - 版本升至 1.7.0 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 99 ++++++++++--------- 2 files changed, 54 insertions(+), 49 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index aaea2d1..bbe625d 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 = 19 - versionName = "1.6.9" + versionCode = 20 + versionName = "1.7.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index c5a718d..ccc5035 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -15,6 +15,7 @@ import kotlinx.serialization.json.JsonPrimitive 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 @@ -23,9 +24,9 @@ import okhttp3.RequestBody.Companion.toRequestBody /** * Trae CN 上游 Chat 代理。 * - * 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat, - * 请求头需要 Cloud-IDE-JWT / x-device-id / x-uid 等设备与身份指纹。 - * M1b 实现最小 body 转换:保留 messages/tools,注入 config_name/function/stream/session。 + * 上游为私有协议 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( @@ -57,30 +58,10 @@ class TraeChatProxy @Inject constructor( } val upstreamBody = buildUpstreamBody(requestBody, credential) val requestId = UUID.randomUUID().toString() - val sessionId = UUID.randomUUID().toString().replace("-", "") val builder = Request.Builder() .url(base + "/api/agent/v3/llm_utils_chat") - .addHeader("Content-Type", "application/json") - .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Cloud-IDE-JWT jwt") - .addHeader("X-Cloudide-Token", jwt) - .addHeader("x-uid", credential.userId ?: "") - .addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8") - .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") - .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") - .addHeader("x-request-id", requestId) - .addHeader("x-ide-version", "3.3.67") - .addHeader("x-ide-version-code", "20260401") - .addHeader("x-ide-version-type", "stable") - .addHeader("x-device-cpu", "AMD") - .addHeader("x-device-brand", "83DG") - .addHeader("x-device-type", "windows") - .addHeader("x-ide-token", jwt) - .addHeader("x-os-version", "Windows 10") - .addHeader("x-system-type", "Windows") - .addHeader("Connection", "keep-alive") - .addHeader("User-Agent", "") + .headers(soloHeaders(jwt, credential, requestId).toHeaders()) .post(upstreamBody.toRequestBody("application/json".toMediaType())) val response = try { @@ -110,36 +91,16 @@ class TraeChatProxy @Inject constructor( ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://grow-normal.trae.ai" + "https://a0ai-api-sg.byteintlapi.com" } else { - "https://api.trae.cn" + "https://trae-api-cn.mchost.guru" } val upstreamBody = buildUpstreamBody(requestBody, credential) val requestId = UUID.randomUUID().toString() - val sessionId = UUID.randomUUID().toString().replace("-", "") val builder = Request.Builder() .url(base + "/api/agent/v3/llm_utils_chat") - .addHeader("Content-Type", "application/json") - .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Cloud-IDE-JWT jwt") - .addHeader("X-Cloudide-Token", jwt) - .addHeader("x-uid", credential.userId ?: "") - .addHeader("x-app-id", "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8") - .addHeader("x-device-id", credential.deviceId ?: credential.checkinDeviceId ?: "") - .addHeader("x-machine-id", credential.deviceId ?: credential.checkinDeviceId ?: "") - .addHeader("x-request-id", requestId) - .addHeader("x-ide-version", "3.3.67") - .addHeader("x-ide-version-code", "20260401") - .addHeader("x-ide-version-type", "stable") - .addHeader("x-device-cpu", "AMD") - .addHeader("x-device-brand", "83DG") - .addHeader("x-device-type", "windows") - .addHeader("x-ide-token", jwt) - .addHeader("x-os-version", "Windows 10") - .addHeader("x-system-type", "Windows") - .addHeader("Connection", "keep-alive") - .addHeader("User-Agent", "") + .headers(soloHeaders(jwt, credential, requestId).toHeaders()) .post(upstreamBody.toRequestBody("application/json".toMediaType())) val response = try { @@ -159,6 +120,50 @@ class TraeChatProxy @Inject constructor( ) } + 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 buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { val src = try { json.parseToJsonElement(raw) as? JsonObject From 571b270cb661edce50679c9ffbdc34026205d40c Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:03:27 +0800 Subject: [PATCH 21/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.0=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 348970a..6383ed5 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 19, - "versionName": "1.6.9", - "apkUrl": "http://124.222.97.212:8443/attachments/57ab917c-7776-403c-be16-a69b64bbdbd1", - "notes": "修复更新安装闪退:声明权限 + canRequestPackageInstalls 容错" + "versionCode": 20, + "versionName": "1.7.0", + "apkUrl": "http://124.222.97.212:8443/attachments/17698b23-12a7-49c1-a22f-da0d479be0ef", + "notes": "修复 Trae 鉴权头字面量 bug + SOLO 协议头补全,请测试 Trae CN" } From 481c57c5350217ca78f47442645d89019fda63f5 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:11:26 +0800 Subject: [PATCH 22/69] =?UTF-8?q?feat(update):=20=E5=B7=B2=E4=B8=8B?= =?UTF-8?q?=E8=BD=BD=20APK=20=E7=9B=B4=E6=8E=A5=E5=A4=8D=E7=94=A8=20+=20?= =?UTF-8?q?=E5=AE=89=E8=A3=85=E5=A4=B1=E8=B4=A5=E5=8F=AF=E9=87=8D=E8=AF=95?= =?UTF-8?q?=E4=B8=8D=E9=87=8D=E4=B8=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - downloadApk 检测同版本已完成文件,存在则跳过下载 - 下载先写 .part 再改名,避免半截文件被误用 - install 失败保留 Ready(file, error),UI 显示重试安装 - NeedInstallPermission 携带文件,开启权限后可继续安装 --- app/build.gradle.kts | 4 ++-- .../token/data/repository/UpdateRepository.kt | 11 +++++++++-- .../rainy/token/ui/settings/SettingsScreen.kt | 19 ++++++++++++++++++- .../token/ui/settings/UpdateViewModel.kt | 10 +++++----- 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index bbe625d..e402f8b 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 = 20 - versionName = "1.7.0" + versionCode = 21 + versionName = "1.7.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 6344a8a..6b25d18 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 @@ -508,9 +508,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 -> { @@ -529,6 +543,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/UpdateViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/UpdateViewModel.kt index 3fc9456..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 } @@ -83,7 +83,7 @@ class UpdateViewModel @Inject constructor( val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() } .getOrDefault(false) if (!canInstall) { - _uiState.update { it.copy(phase = Phase.NeedInstallPermission) } + _uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) } return } _uiState.update { it.copy(phase = Phase.Ready(file)) } @@ -91,7 +91,7 @@ class UpdateViewModel @Inject constructor( val uri = try { FileProvider.getUriForFile(context, authority, file) } catch (e: Exception) { - _uiState.update { it.copy(phase = Phase.Error("安装文件无效:" + (e.message ?: "未知错误"))) } + _uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) } return } val actionView = Intent(Intent.ACTION_VIEW).apply { @@ -105,7 +105,7 @@ class UpdateViewModel @Inject constructor( addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) } runCatching { context.startActivity(actionInstall) }.onFailure { e -> - _uiState.update { it.copy(phase = Phase.Error(e.message ?: "无法启动安装器")) } + _uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) } } } } From b5ef9249d82921808ca6dd96d78b4e03de54f21a Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:12:59 +0800 Subject: [PATCH 23/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.1=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 6383ed5..99839c3 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 20, - "versionName": "1.7.0", - "apkUrl": "http://124.222.97.212:8443/attachments/17698b23-12a7-49c1-a22f-da0d479be0ef", - "notes": "修复 Trae 鉴权头字面量 bug + SOLO 协议头补全,请测试 Trae CN" + "versionCode": 21, + "versionName": "1.7.1", + "apkUrl": "http://124.222.97.212:8443/attachments/d5800fcc-ccc0-4033-8357-59f4f03ce104", + "notes": "更新下载复用:同版本已下载不再重复下载;安装失败可一键重试安装" } From ada750df7ed498b8d97bbad84de15409e6476056 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:21:48 +0800 Subject: [PATCH 24/69] =?UTF-8?q?fix(proxy):=20Trae=20messages.content=20?= =?UTF-8?q?=E8=BD=AC=E5=8E=9F=E7=94=9F=E5=9D=97=E6=95=B0=E7=BB=84=EF=BC=8C?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=204001?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 上游 LLMRawMessage.messages.content 要求 []*LLMRawMessageContent - 字符串转 [{"type":"text","text":...}],列表块按 text/input_text 归一 - 版本升至 1.7.2 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 68 ++++++++++++++++++- 2 files changed, 68 insertions(+), 4 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e402f8b..57148a0 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 = 21 - versionName = "1.7.1" + versionCode = 22 + versionName = "1.7.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index ccc5035..beb034b 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -10,8 +10,11 @@ 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 @@ -164,6 +167,67 @@ class TraeChatProxy @Inject constructor( "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 + } + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { val src = try { json.parseToJsonElement(raw) as? JsonObject @@ -173,11 +237,11 @@ class TraeChatProxy @Inject constructor( val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: "glm-5.2" - val messages = src["messages"] as? JsonArray ?: JsonArray(emptyList()) + val messages = convertNativeMessages(src["messages"]) val sessionId = UUID.randomUUID().toString().replace("-", "") return buildJsonObject { - put("messages", messages) + put("messages", JsonArray(messages)) put("config_name", model) put("model", model) put("function", "solo_work_lite") From a804d46acdf0732bceb84df54520e0a46a543edb Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:22:52 +0800 Subject: [PATCH 25/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.2=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 99839c3..d10eafb 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 21, - "versionName": "1.7.1", - "apkUrl": "http://124.222.97.212:8443/attachments/d5800fcc-ccc0-4033-8357-59f4f03ce104", - "notes": "更新下载复用:同版本已下载不再重复下载;安装失败可一键重试安装" + "versionCode": 22, + "versionName": "1.7.2", + "apkUrl": "http://124.222.97.212:8443/attachments/e3c3ec41-72c8-4589-b87e-2db5cedc8dd5", + "notes": "修复 Trae 4001:messages.content 转原生内容块数组" } From dcd5cab2b297bd21cf43db928cbc04a0591ca6bf Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:28:19 +0800 Subject: [PATCH 26/69] =?UTF-8?q?feat(proxy):=20=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E7=BE=8E=E5=8C=96=20+=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E5=A4=8D=E5=88=B6=E5=8E=9F=E5=A7=8B=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 调试输出改为滚动等宽代码块,状态一眼可读 - 新增“复制全部”按钮,一键把原始输出写入剪贴板 - 版本升至 1.7.3 --- app/build.gradle.kts | 4 +- .../rainy/token/ui/settings/SettingsScreen.kt | 48 ++++++++++++++++--- 2 files changed, 44 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 57148a0..62e5020 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 = 22 - versionName = "1.7.2" + versionCode = 23 + versionName = "1.7.3" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 6b25d18..1ddd92e 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 @@ -207,6 +217,7 @@ private fun ProxyGatewayCard( onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit ) { + val context = LocalContext.current Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -257,12 +268,37 @@ private fun ProxyGatewayCard( } } if (debug != null) { - Text( - text = debug, - style = MaterialTheme.typography.bodySmall, - color = InkMuted, - modifier = Modifier.padding(top = 4.dp) - ) + 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("代理网关探针输出", 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( From 7de7111b18b31f45a2acf4a5b40953026112280d Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:29:43 +0800 Subject: [PATCH 27/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.3=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index d10eafb..793da74 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 22, - "versionName": "1.7.2", - "apkUrl": "http://124.222.97.212:8443/attachments/e3c3ec41-72c8-4589-b87e-2db5cedc8dd5", - "notes": "修复 Trae 4001:messages.content 转原生内容块数组" + "versionCode": 23, + "versionName": "1.7.3", + "apkUrl": "http://124.222.97.212:8443/attachments/dfe6e9bf-1fb1-43c5-84a6-ae4b193b7330", + "notes": "探针输出美化 + 一键复制原始输出" } From fbbca4a03be55cbb112e4ac18778491b749d9214 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:46:30 +0800 Subject: [PATCH 28/69] =?UTF-8?q?feat(proxy):=20=E6=8E=A2=E9=92=88?= =?UTF-8?q?=E7=BB=93=E6=9E=9C=E7=BB=93=E6=9E=84=E5=8C=96=E6=91=98=E8=A6=81?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E5=88=B7=E5=B1=8F=E5=8E=9F=E5=A7=8B?= =?UTF-8?q?=E5=8F=82=E6=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 显示:请求行/模型/messages数/stream、HTTP、事件帧数、模型、文本摘要、用量、错误 - 原始请求+响应仍完整保留,点“复制原始”一键复制 - 版本升至 1.7.4 --- app/build.gradle.kts | 4 +- .../rainy/token/ui/settings/SettingsScreen.kt | 9 +- .../token/ui/settings/SettingsViewModel.kt | 173 ++++++++++++++++-- 3 files changed, 166 insertions(+), 20 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 62e5020..72370b8 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 = 23 - versionName = "1.7.3" + versionCode = 24 + versionName = "1.7.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 1ddd92e..6440d04 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 @@ -104,6 +104,7 @@ fun SettingsScreen( val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle() val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() + val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -180,6 +181,7 @@ fun SettingsScreen( apiKey = proxyApiKey, error = proxyError, debug = proxyDebug, + debugRaw = proxyDebugRaw, onToggle = viewModel::setProxyEnabled, onTestTrae = viewModel::testTraeStream, onTestWorkBuddy = viewModel::testWorkBuddyStream @@ -213,6 +215,7 @@ private fun ProxyGatewayCard( apiKey: String, error: String?, debug: String?, + debugRaw: String?, onToggle: (Boolean) -> Unit, onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit @@ -271,17 +274,17 @@ private fun ProxyGatewayCard( Spacer(modifier = Modifier.padding(top = 8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Text( - 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("代理网关探针输出", debug)) + clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug)) Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show() }) { - Text("复制全部") + Text("复制原始") } } Box( 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 335a600..167f30f 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 @@ -20,6 +20,13 @@ 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 /** @@ -59,46 +66,68 @@ class SettingsViewModel @Inject constructor( } } - /** 代理调试输出(原始 SSE 帧)。 */ + /** 代理调试输出(结构化摘要)。 */ private val _proxyDebug = MutableStateFlow(null) val proxyDebug: StateFlow = _proxyDebug.asStateFlow() - /** 用 Trae CN 默认账号发一条 stream=true 测试,抓上游原始 SSE。 */ + /** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */ + private val _proxyDebugRaw = MutableStateFlow(null) + val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() + + /** 用 Trae CN 默认账号发一条 stream=true 测试。 */ fun testTraeStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + _proxyDebugRaw.value = null + val body = """{\"model\":\"glm-5.2\",\"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 } - _proxyDebug.value = readStreamSample(stream, "Trae CN") + 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 测试,抓上游原始 SSE。 */ + /** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */ fun testWorkBuddyStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 WorkBuddy CN 流..." - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + _proxyDebugRaw.value = null + val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { _proxyDebug.value = "WorkBuddy CN 未配置或未登录" return@launch } - _proxyDebug.value = readStreamSample(stream, "WorkBuddy CN") + 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 } } } @@ -106,24 +135,133 @@ class SettingsViewModel @Inject constructor( private suspend fun readStreamSample( stream: ProxyUpstreamStream, label: String, - ): String = withContext(Dispatchers.IO) { - val sb = StringBuilder() - sb.append(label).append(" HTTP ").append(stream.status) - sb.append(" Content-Type=").append(stream.contentType).append("\n") + 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(80) { + repeat(120) { val line = reader.readLine() ?: return@repeat - sb.append(line).append("\n") + lines.add(line) + raw.append(line).append("\n") if (line.trim() == "data: [DONE]") return@repeat - if (sb.length > 6000) return@repeat + if (raw.length > 12000) return@repeat } } finally { stream.close() } - sb.toString() + 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) @@ -151,6 +289,11 @@ class SettingsViewModel @Inject constructor( } } +data class StreamProbeResult( + val summary: String, + val raw: String, +) + data class SettingsUiState( val loading: Boolean = false, val credentialStatuses: List = emptyList() From 8a9d9432fe6384425b5d5aea752244c94081e562 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 10:47:24 +0800 Subject: [PATCH 29/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.4=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 793da74..8f1ff72 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 23, - "versionName": "1.7.3", - "apkUrl": "http://124.222.97.212:8443/attachments/dfe6e9bf-1fb1-43c5-84a6-ae4b193b7330", - "notes": "探针输出美化 + 一键复制原始输出" + "versionCode": 24, + "versionName": "1.7.4", + "apkUrl": "http://124.222.97.212:8443/attachments/65c61faa-9b04-4ae2-b705-0c6d0ed303f7", + "notes": "探针结果改为结构化摘要,原始输出可一键复制" } From 0241840f9cdbb7fa757e07908abed6d09d84e98e Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:03:27 +0800 Subject: [PATCH 30/69] =?UTF-8?q?feat(proxy):=20Trae=20=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E5=88=87=E6=8D=A2=20+=20=E5=9C=A8=E7=BA=BF=E6=8B=89=E5=8F=96?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 设置页新增模型下拉选择,内置参考映射兜底 - TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models) - 选中模型持久化,作为反代缺省模型 - 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型 - 修复探针测试请求体混入反斜杠导致 JSON 无效的问题 - 版本升至 1.7.5 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 58 ++++++- .../token/data/proxy/TraeModelProvider.kt | 161 ++++++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 6 +- .../rainy/token/ui/settings/SettingsScreen.kt | 47 +++++ .../token/ui/settings/SettingsViewModel.kt | 30 +++- 6 files changed, 297 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 72370b8..5f53740 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 24 - versionName = "1.7.4" + versionCode = 25 + versionName = "1.7.5" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index beb034b..54907fd 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -35,6 +35,7 @@ import okhttp3.RequestBody.Companion.toRequestBody class TraeChatProxy @Inject constructor( private val okHttpClient: OkHttpClient, private val credentialRepository: CredentialRepository, + private val modelProvider: TraeModelProvider, ) { private val json = Json { ignoreUnknownKeys = true } @@ -228,6 +229,13 @@ class TraeChatProxy @Inject constructor( return null } + /** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */ + private fun normalizeModel(raw: String?): String { + val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel + if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel + return MODEL_ALIASES[id.lowercase()] ?: id + } + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { val src = try { json.parseToJsonElement(raw) as? JsonObject @@ -235,8 +243,8 @@ class TraeChatProxy @Inject constructor( null } ?: return raw - val model = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - ?: "glm-5.2" + val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } + val model = normalizeModel(rawModel) val messages = convertNativeMessages(src["messages"]) val sessionId = UUID.randomUUID().toString().replace("-", "") @@ -255,4 +263,50 @@ class TraeChatProxy @Inject constructor( } }.toString() } + + companion object { + /** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */ + private val MODEL_ALIASES = mapOf( + "auto" to "glm-5.2", + "gpt-4o" to "DeepSeek-V4-Pro", + "gpt-4o-latest" to "DeepSeek-V4-Pro", + "gpt-4.1" to "DeepSeek-V4-Pro", + "deepseek-v3" to "DeepSeek-V4-Pro", + "deepseek-r1" to "DeepSeek-V4-Pro", + "gpt-4o-mini" to "DeepSeek-V4-Flash", + "claude-opus-4-7" to "glm-5.2", + "claude-opus-4-6" to "glm-5.2", + "claude-opus-4-5" to "glm-5.2", + "claude-sonnet-4-6" to "glm-5.2", + "claude-sonnet-4-5" to "glm-5.2", + "claude-sonnet-4" to "glm-5.2", + "claude-3.7-sonnet" to "glm-5.2", + "claude-3-7-sonnet" to "glm-5.2", + "claude-3.5-sonnet" to "glm-5.2", + "claude-haiku-4-5" to "glm-5.1", + "deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official", + "deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official", + "doubao-seed-evolving" to "Doubao-Seed-Evolving", + "deepseek-v4-pro" to "DeepSeek-V4-Pro", + "deepseek-v4-flash" to "DeepSeek-V4-Flash", + "seed-2.1-pro" to "Doubao-Seed-2.1-Pro", + "seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo", + "seed-code" to "Doubao-Seed-Code", + "seed-evolving" to "Doubao-Seed-Evolving", + "qwen3.7-plus" to "qwen-3.7-plus", + "kimi-k2.6" to "kimi-k2.6", + "kimi-k3" to "kimi-k3", + "kimi-k2.7-code" to "kimi-k2.7-code", + "kimi-k2" to "kimi-k2", + "kimi-k2.5" to "kimi-k2.5", + "minimax-m3" to "minimax-m3", + "minimax-m2.7" to "minimax-m2.7", + "minimax-m2.1" to "minimax-m2.1", + "mimo-v2.5" to "mimo-v2.5", + "mimo-v2.5-pro" to "mimo-v2.5-pro", + "minimax-m25" to "minimax-m25", + "qwen36-35b" to "qwen36-35b", + ) + } + } diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt new file mode 100644 index 0000000..c84bae3 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -0,0 +1,161 @@ +package com.rainy.token.data.proxy + +import android.content.Context +import android.content.SharedPreferences +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。 + * 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。 + */ +@Singleton +class TraeModelProvider @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + @ApplicationContext private val context: Context, +) { + + data class TraeModel(val id: String, val label: String? = null) + + private val prefs: SharedPreferences = + context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) + + private val _models = MutableStateFlow(defaultModels()) + val models: StateFlow> = _models.asStateFlow() + + /** 当前默认模型(用户在设置页选择的 Trae 模型名)。 */ + var selectedModel: String = prefs.getString(KEY_MODEL, "glm-5.2") ?: "glm-5.2" + private set + + private val json = Json { ignoreUnknownKeys = true } + + fun setSelectedModel(id: String) { + if (id.isBlank()) return + selectedModel = id + prefs.edit().putString(KEY_MODEL, id).apply() + } + + /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ + suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.TRAE, null) + ?: return@withContext false + if (credential !is Credential.TraeCredential) return@withContext false + val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false + + val effectiveRegion = forcedRegion ?: runCatching { + ProviderRegion.valueOf(credential.region.uppercase()) + }.getOrNull() + val intl = effectiveRegion == ProviderRegion.INTL + val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" + val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn" + val url = base + "/api/remote/v1/models" + + "?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" + + "&show_custom_model=true" + + val headers = linkedMapOf( + "Authorization" to "Cloud-IDE-JWT $jwt", + "Content-Type" to "application/json", + "X-Trae-Client-Type" to "web", + "X-Preferenced-Language" to "zh-CN", + "x-user-region" to if (intl) "SG" else "CN", + "Origin" to origin, + "Referer" to origin + "/", + "User-Agent" to ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36" + ), + ) + val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build() + val parsed = try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val body = resp.body?.string() ?: return@use null + parseModelList(body) + } + } catch (_: Exception) { + null + } + if (parsed.isNullOrEmpty()) return@withContext false + _models.value = parsed + true + } + + private fun parseModelList(body: String): List? { + val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() + ?: return null + val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null + val seen = LinkedHashMap() + for (groupEl in list) { + val group = groupEl as? JsonObject ?: continue + val models = group["models"] as? JsonArray ?: continue + for (rawEl in models) { + val raw = rawEl as? JsonObject ?: continue + val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim() + ?.takeIf { it.isNotEmpty() } ?: continue + val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull + ?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull + ?: id + seen[id] = TraeModel(id, label) + } + } + return seen.values.toList().ifEmpty { null } + } + + companion object { + private const val KEY_MODEL = "trae_default_model" + + /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */ + fun defaultModels(): List = listOf( + TraeModel("glm-5.2", "GLM-5.2"), + TraeModel("glm-5.3", "GLM-5.3"), + TraeModel("glm-5.1", "GLM-5.1"), + TraeModel("glm-5", "GLM-5"), + TraeModel("glm-4.7", "GLM-4.7"), + TraeModel("glm-4.6", "GLM-4.6"), + TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"), + TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"), + TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"), + TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"), + TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"), + TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"), + TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"), + TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"), + TraeModel("qwen-3.7-plus", "qwen-3.7-plus"), + TraeModel("qwen-3.6-plus", "qwen-3.6-plus"), + TraeModel("qwen3.8-max", "qwen3.8-max"), + TraeModel("qwen-3.5", "qwen-3.5"), + TraeModel("qwen3-coder", "qwen3-coder"), + TraeModel("kimi-k2.6", "kimi-k2.6"), + TraeModel("kimi-k3", "kimi-k3"), + TraeModel("kimi-k2.7-code", "kimi-k2.7-code"), + TraeModel("kimi-k2", "kimi-k2"), + TraeModel("kimi-k2.5", "kimi-k2.5"), + TraeModel("minimax-m3", "minimax-m3"), + TraeModel("minimax-m2.7", "minimax-m2.7"), + TraeModel("minimax-m2.1", "minimax-m2.1"), + TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"), + TraeModel("mimo-v2.5", "mimo-v2.5"), + TraeModel("minimax-m25", "minimax-m25"), + TraeModel("qwen36-35b", "qwen36-35b"), + TraeModel("work", "Work(200K 档)"), + ) + } +} diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 3c3124a..7c8bb96 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -26,6 +26,7 @@ import com.rainy.token.data.proxy.KtorLocalProxyServer import com.rainy.token.data.proxy.LocalProxyServer import com.rainy.token.data.proxy.Sub2ApiChatProxy import com.rainy.token.data.proxy.TraeChatProxy +import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.WorkBuddyRepository import dagger.Module @@ -250,8 +251,9 @@ object NetworkModule { @Singleton fun provideTraeChatProxy( okHttpClient: OkHttpClient, - credentialRepository: CredentialRepository - ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository) + credentialRepository: CredentialRepository, + traeModelProvider: TraeModelProvider, + ): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider) @Provides @Singleton diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index 6440d04..a579963 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -33,6 +33,8 @@ import androidx.compose.material3.AlertDialog import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -60,6 +62,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.rainy.token.domain.model.CredentialStatus +import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.domain.service.ServiceType import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.StatusChip @@ -105,6 +108,8 @@ fun SettingsScreen( val proxyError by viewModel.proxyError.collectAsStateWithLifecycle() val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() + val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle() + val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } Scaffold( @@ -182,6 +187,10 @@ fun SettingsScreen( error = proxyError, debug = proxyDebug, debugRaw = proxyDebugRaw, + models = proxyModels, + selectedModel = proxyModel, + onSelectModel = viewModel::selectProxyModel, + onRefreshModels = viewModel::refreshProxyModels, onToggle = viewModel::setProxyEnabled, onTestTrae = viewModel::testTraeStream, onTestWorkBuddy = viewModel::testWorkBuddyStream @@ -216,11 +225,16 @@ private fun ProxyGatewayCard( error: String?, debug: String?, debugRaw: String?, + models: List, + selectedModel: String, + onSelectModel: (String) -> Unit, + onRefreshModels: () -> Unit, onToggle: (Boolean) -> Unit, onTestTrae: () -> Unit, onTestWorkBuddy: () -> Unit ) { val context = LocalContext.current + var modelMenuExpanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -270,6 +284,39 @@ private fun ProxyGatewayCard( Text("测试 WorkBuddy 流") } } + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "模型", + style = MaterialTheme.typography.labelSmall, + color = InkMuted + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + TextButton(onClick = { modelMenuExpanded = true }) { + Text(selectedModel + " ▾") + } + DropdownMenu( + expanded = modelMenuExpanded, + onDismissRequest = { modelMenuExpanded = false } + ) { + models.forEach { m -> + DropdownMenuItem( + text = { Text(m.label ?: m.id) }, + onClick = { + onSelectModel(m.id) + modelMenuExpanded = false + } + ) + } + } + } + TextButton(onClick = onRefreshModels) { + Text("刷新") + } + } if (debug != null) { Spacer(modifier = Modifier.padding(top = 8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 167f30f..8170b38 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -7,6 +7,7 @@ import com.rainy.token.data.proxy.ProviderRegion import com.rainy.token.data.proxy.ProxyUpstreamStream import com.rainy.token.data.proxy.ProxyServerConfig import com.rainy.token.data.proxy.TraeChatProxy +import com.rainy.token.data.proxy.TraeModelProvider import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus @@ -38,7 +39,8 @@ class SettingsViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val localProxyServer: LocalProxyServer, private val traeChatProxy: TraeChatProxy, - private val workBuddyChatProxy: WorkBuddyChatProxy + private val workBuddyChatProxy: WorkBuddyChatProxy, + private val traeModelProvider: TraeModelProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -74,12 +76,34 @@ class SettingsViewModel @Inject constructor( private val _proxyDebugRaw = MutableStateFlow(null) val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() + /** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */ + val proxyModels: StateFlow> = traeModelProvider.models + + /** 当前选择的 Trae 默认模型。 */ + private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel) + val proxyModel: StateFlow = _proxyModel.asStateFlow() + + /** 切换 Trae 默认模型并持久化。 */ + fun selectProxyModel(id: String) { + traeModelProvider.setSelectedModel(id) + _proxyModel.value = id + } + + /** 拉取当前账号可用的 Trae 模型列表。 */ + fun refreshProxyModels() { + viewModelScope.launch { + val ok = traeModelProvider.refreshFor(ProviderRegion.CN) + if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" + } + } + /** 用 Trae CN 默认账号发一条 stream=true 测试。 */ fun testTraeStream() { viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." _proxyDebugRaw.value = null - val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" + val model = traeModelProvider.selectedModel + val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { @@ -108,7 +132,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _proxyDebug.value = "正在请求 WorkBuddy CN 流..." _proxyDebugRaw.value = null - val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\""" + val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { From 0283ce6bdcd48b5bdf0020f0d9208a5283c09441 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:04:31 +0800 Subject: [PATCH 31/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.5=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 8f1ff72..d8cd2ac 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 24, - "versionName": "1.7.4", - "apkUrl": "http://124.222.97.212:8443/attachments/65c61faa-9b04-4ae2-b705-0c6d0ed303f7", - "notes": "探针结果改为结构化摘要,原始输出可一键复制" + "versionCode": 25, + "versionName": "1.7.5", + "apkUrl": "http://124.222.97.212:8443/attachments/ebc58052-fadc-47c4-b60d-07353a31d946", + "notes": "新增 Trae 模型切换:下拉选择、在线拉取模型列表、记住选择" } From f13b38c02bdceec5b84612fb4ca475e2011cc9fb Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:21:53 +0800 Subject: [PATCH 32/69] =?UTF-8?q?feat(proxy):=20=E6=8C=89=E4=BB=A3?= =?UTF-8?q?=E7=90=86/=E5=8C=BA=E5=9F=9F=E5=8C=BA=E5=88=86=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=88=97=E8=A1=A8=E4=B8=8E=E9=80=89=E6=8B=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增“代理”下拉:Trae CN/INTL、WorkBuddy CN/INTL - WorkBuddyModelProvider 在线拉取 /v2/enterprises/personal/models - Trae/WorkBuddy 各区域独立记住所选测试模型 - 真实代理转发仍按前缀自动路由,不受测试选择影响 - 版本升至 1.7.6 --- app/build.gradle.kts | 4 +- .../rainy/token/data/proxy/TraeChatProxy.kt | 15 +-- .../token/data/proxy/TraeModelProvider.kt | 22 ++-- .../data/proxy/WorkBuddyModelProvider.kt | 119 ++++++++++++++++++ .../rainy/token/ui/settings/SettingsScreen.kt | 38 ++++++ .../token/ui/settings/SettingsViewModel.kt | 68 ++++++++-- 6 files changed, 239 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5f53740..5ea6e18 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -16,8 +16,8 @@ android { applicationId = "com.rainy.token" minSdk = 31 targetSdk = 35 - versionCode = 25 - versionName = "1.7.5" + versionCode = 26 + versionName = "1.7.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 54907fd..9d27c84 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -60,7 +60,7 @@ class TraeChatProxy @Inject constructor( } else { "https://trae-api-cn.mchost.guru" } - val upstreamBody = buildUpstreamBody(requestBody, credential) + val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() val builder = Request.Builder() @@ -99,7 +99,7 @@ class TraeChatProxy @Inject constructor( } else { "https://trae-api-cn.mchost.guru" } - val upstreamBody = buildUpstreamBody(requestBody, credential) + val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() val builder = Request.Builder() @@ -230,13 +230,14 @@ class TraeChatProxy @Inject constructor( } /** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */ - private fun normalizeModel(raw: String?): String { - val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return modelProvider.selectedModel - if (id.equals("auto", ignoreCase = true)) return modelProvider.selectedModel + private fun normalizeModel(raw: String?, region: ProviderRegion?): String { + val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN) + val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected + if (id.equals("auto", ignoreCase = true)) return selected return MODEL_ALIASES[id.lowercase()] ?: id } - private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String { + private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String { val src = try { json.parseToJsonElement(raw) as? JsonObject } catch (_: Throwable) { @@ -244,7 +245,7 @@ class TraeChatProxy @Inject constructor( } ?: return raw val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } - val model = normalizeModel(rawModel) + val model = normalizeModel(rawModel, region) val messages = convertNativeMessages(src["messages"]) val sessionId = UUID.randomUUID().toString().replace("-", "") diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index c84bae3..7f6fec9 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -41,16 +41,20 @@ class TraeModelProvider @Inject constructor( private val _models = MutableStateFlow(defaultModels()) val models: StateFlow> = _models.asStateFlow() - /** 当前默认模型(用户在设置页选择的 Trae 模型名)。 */ - var selectedModel: String = prefs.getString(KEY_MODEL, "glm-5.2") ?: "glm-5.2" - private set - private val json = Json { ignoreUnknownKeys = true } - fun setSelectedModel(id: String) { + /** 当前区域选择的默认模型(未设置时 CN 用 GLM-5.2)。 */ + fun selectedFor(region: ProviderRegion): String { + if (region == ProviderRegion.INTL) { + return prefs.getString(KEY_MODEL_INTL, DEFAULT_MODEL_INTL) ?: DEFAULT_MODEL_INTL + } + return prefs.getString(KEY_MODEL_CN, "glm-5.2") ?: "glm-5.2" + } + + fun setSelectedFor(region: ProviderRegion, id: String) { if (id.isBlank()) return - selectedModel = id - prefs.edit().putString(KEY_MODEL, id).apply() + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + prefs.edit().putString(key, id).apply() } /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ @@ -120,7 +124,9 @@ class TraeModelProvider @Inject constructor( } companion object { - private const val KEY_MODEL = "trae_default_model" + private const val KEY_MODEL_CN = "trae_default_model_cn" + private const val KEY_MODEL_INTL = "trae_default_model_intl" + private const val DEFAULT_MODEL_INTL = "glm-5.2" /** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */ fun defaultModels(): List = listOf( diff --git a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt new file mode 100644 index 0000000..46298bb --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -0,0 +1,119 @@ +package com.rainy.token.data.proxy + +import android.content.Context +import android.content.SharedPreferences +import com.rainy.token.data.repository.CredentialRepository +import com.rainy.token.domain.model.Credential +import com.rainy.token.domain.service.ServiceType +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject +import javax.inject.Singleton +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull +import okhttp3.Headers.Companion.toHeaders +import okhttp3.OkHttpClient +import okhttp3.Request + +/** + * WorkBuddy / CodeBuddy 模型列表: + * 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。 + */ +@Singleton +class WorkBuddyModelProvider @Inject constructor( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + @ApplicationContext private val context: Context, +) { + + private val prefs: SharedPreferences = + context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) + + private val _models = MutableStateFlow(defaultModels()) + val models: StateFlow> = _models.asStateFlow() + + private val json = Json { ignoreUnknownKeys = true } + + fun selectedFor(region: ProviderRegion): String { + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL + } + + fun setSelectedFor(region: ProviderRegion, id: String) { + if (id.isBlank()) return + val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN + prefs.edit().putString(key, id).apply() + } + + suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.WORKBUDDY, null) + ?: return@withContext false + if (credential !is Credential.WorkBuddyCredential) return@withContext false + val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } + ?: return@withContext false + val intl = region == ProviderRegion.INTL + val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com" + val url = base + "/v2/enterprises/personal/models" + + val headers = linkedMapOf( + "Authorization" to "Bearer $accessToken", + "Content-Type" to "application/json", + "Accept" to "application/json", + "X-Client-ID" to "codebuddy-cli", + "X-Client-Version" to "2.143.1", + "X-Product" to "SaaS", + "User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1", + "Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"), + ) + val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build() + val parsed = try { + okHttpClient.newCall(request).execute().use { resp -> + if (!resp.isSuccessful) return@use null + val body = resp.body?.string() ?: return@use null + parseModelList(body) + } + } catch (_: Exception) { + null + } + if (parsed.isNullOrEmpty()) return@withContext false + _models.value = parsed + true + } + + private fun parseModelList(body: String): List? { + val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() + ?: return null + val data = root["data"] as? JsonObject ?: return null + val models = data["models"] as? JsonArray ?: return null + val seen = LinkedHashMap() + for (rawEl in models) { + val raw = rawEl as? JsonObject ?: continue + val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim() + ?.takeIf { it.isNotEmpty() } ?: continue + val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id + seen[id] = TraeModelProvider.TraeModel(id, label) + } + return seen.values.toList().ifEmpty { null } + } + + companion object { + private const val KEY_MODEL_CN = "wb_default_model_cn" + private const val KEY_MODEL_INTL = "wb_default_model_intl" + private const val DEFAULT_MODEL = "hy4-preview" + + /** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */ + fun defaultModels(): List = listOf( + TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"), + TraeModelProvider.TraeModel("hy3", "hy3"), + TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"), + TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"), + ) + } +} diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt index a579963..6aaeb32 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsScreen.kt @@ -109,6 +109,7 @@ fun SettingsScreen( val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle() val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle() val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle() + val selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle() val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle() var showLanguageDialog by remember { mutableStateOf(false) } @@ -187,6 +188,9 @@ fun SettingsScreen( error = proxyError, debug = proxyDebug, debugRaw = proxyDebugRaw, + proxyTargets = viewModel.proxyTargets, + selectedProxy = selectedProxy, + onSelectProxy = viewModel::selectProxy, models = proxyModels, selectedModel = proxyModel, onSelectModel = viewModel::selectProxyModel, @@ -225,6 +229,9 @@ private fun ProxyGatewayCard( error: String?, debug: String?, debugRaw: String?, + proxyTargets: List, + selectedProxy: ProxyTarget, + onSelectProxy: (ProxyTarget) -> Unit, models: List, selectedModel: String, onSelectModel: (String) -> Unit, @@ -234,6 +241,7 @@ private fun ProxyGatewayCard( onTestWorkBuddy: () -> Unit ) { val context = LocalContext.current + var proxyMenuExpanded by remember { mutableStateOf(false) } var modelMenuExpanded by remember { mutableStateOf(false) } Card( modifier = Modifier.fillMaxWidth(), @@ -284,6 +292,36 @@ private fun ProxyGatewayCard( Text("测试 WorkBuddy 流") } } + Row( + modifier = Modifier.padding(top = 4.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = "代理", + style = MaterialTheme.typography.labelSmall, + color = InkMuted + ) + Spacer(modifier = Modifier.width(8.dp)) + Box { + TextButton(onClick = { proxyMenuExpanded = true }) { + Text(selectedProxy.label + " ▾") + } + DropdownMenu( + expanded = proxyMenuExpanded, + onDismissRequest = { proxyMenuExpanded = false } + ) { + proxyTargets.forEach { t -> + DropdownMenuItem( + text = { Text(t.label) }, + onClick = { + onSelectProxy(t) + proxyMenuExpanded = false + } + ) + } + } + } + } Row( modifier = Modifier.padding(top = 4.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 8170b38..9f512f7 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt @@ -8,6 +8,7 @@ import com.rainy.token.data.proxy.ProxyUpstreamStream import com.rainy.token.data.proxy.ProxyServerConfig import com.rainy.token.data.proxy.TraeChatProxy import com.rainy.token.data.proxy.TraeModelProvider +import com.rainy.token.data.proxy.WorkBuddyModelProvider import com.rainy.token.data.proxy.WorkBuddyChatProxy import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.domain.model.CredentialStatus @@ -41,6 +42,7 @@ class SettingsViewModel @Inject constructor( private val traeChatProxy: TraeChatProxy, private val workBuddyChatProxy: WorkBuddyChatProxy, private val traeModelProvider: TraeModelProvider, + private val workBuddyModelProvider: WorkBuddyModelProvider, ) : ViewModel() { private val _uiState = MutableStateFlow(SettingsUiState()) @@ -76,23 +78,56 @@ class SettingsViewModel @Inject constructor( private val _proxyDebugRaw = MutableStateFlow(null) val proxyDebugRaw: StateFlow = _proxyDebugRaw.asStateFlow() - /** Trae 可用模型列表(内置兜底 + 在线拉取账号模型)。 */ - val proxyModels: StateFlow> = traeModelProvider.models + /** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */ + val proxyTargets: List = ProxyTarget.entries - /** 当前选择的 Trae 默认模型。 */ - private val _proxyModel = MutableStateFlow(traeModelProvider.selectedModel) + private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN) + val selectedProxy: StateFlow = _selectedProxy.asStateFlow() + + private val _proxyModels = MutableStateFlow>(traeModelProvider.models.value) + val proxyModels: StateFlow> = _proxyModels.asStateFlow() + + private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN)) val proxyModel: StateFlow = _proxyModel.asStateFlow() - /** 切换 Trae 默认模型并持久化。 */ + /** 切换当前测试目标代理。 */ + fun selectProxy(target: ProxyTarget) { + _selectedProxy.value = target + val region = target.region() + _proxyModels.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value + else -> workBuddyModelProvider.models.value + } + _proxyModel.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region) + else -> workBuddyModelProvider.selectedFor(region) + } + } + + /** 切换当前代理的测试模型并持久化。 */ fun selectProxyModel(id: String) { - traeModelProvider.setSelectedModel(id) + val target = _selectedProxy.value + val region = target.region() + when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id) + else -> workBuddyModelProvider.setSelectedFor(region, id) + } _proxyModel.value = id } - /** 拉取当前账号可用的 Trae 模型列表。 */ + /** 拉取当前代理可用模型;失败保留内置列表。 */ fun refreshProxyModels() { + val target = _selectedProxy.value + val region = target.region() viewModelScope.launch { - val ok = traeModelProvider.refreshFor(ProviderRegion.CN) + val ok = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.refreshFor(region) + else -> workBuddyModelProvider.refreshFor(region) + } + _proxyModels.value = when (target) { + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value + else -> workBuddyModelProvider.models.value + } if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" } } @@ -102,7 +137,7 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _proxyDebug.value = "正在请求 Trae CN 流..." _proxyDebugRaw.value = null - val model = traeModelProvider.selectedModel + val model = traeModelProvider.selectedFor(ProviderRegion.CN) val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN) @@ -132,7 +167,8 @@ class SettingsViewModel @Inject constructor( viewModelScope.launch { _proxyDebug.value = "正在请求 WorkBuddy CN 流..." _proxyDebugRaw.value = null - val body = """{"model":"glm-5.2","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" + val model = workBuddyModelProvider.selectedFor(ProviderRegion.CN) + val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}""" try { val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN) if (stream == null) { @@ -313,6 +349,18 @@ class SettingsViewModel @Inject constructor( } } +enum class ProxyTarget(val key: String, val label: String) { + TRAE_CN("trae_cn", "Trae CN"), + TRAE_INTL("trae_intl", "Trae INTL"), + WORKBUDDY_CN("wb_cn", "WorkBuddy CN"), + WORKBUDDY_INTL("wb_intl", "WorkBuddy INTL"); + + fun region(): ProviderRegion = when (this) { + TRAE_CN, WORKBUDDY_CN -> ProviderRegion.CN + else -> ProviderRegion.INTL + } +} + data class StreamProbeResult( val summary: String, val raw: String, From 73ff6b2ee74deb126401314693e95b1f7911c339 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:22:57 +0800 Subject: [PATCH 33/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.6=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index d8cd2ac..20ce54b 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 25, - "versionName": "1.7.5", - "apkUrl": "http://124.222.97.212:8443/attachments/ebc58052-fadc-47c4-b60d-07353a31d946", - "notes": "新增 Trae 模型切换:下拉选择、在线拉取模型列表、记住选择" + "versionCode": 26, + "versionName": "1.7.6", + "apkUrl": "http://124.222.97.212:8443/attachments/200cd0dc-5dc6-4f76-817e-c120d6dd42bd", + "notes": "按代理/区域区分模型列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取和选择" } From 597632dad3d8c7cddf60cd5070f93d8a87c9cc96 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:49:38 +0800 Subject: [PATCH 34/69] =?UTF-8?q?feat(proxy):=20=E8=81=9A=E5=90=88?= =?UTF-8?q?=E7=BD=91=E5=85=B3=E2=80=94=E2=80=94=E6=8C=89=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E9=80=89=E4=BE=9B=E5=BA=94=E5=95=86=E5=B9=B6?= =?UTF-8?q?=E5=A4=B1=E8=B4=A5=E5=88=87=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ProviderRouter 重构为聚合路由:请求只写 model,网关自动匹配供应商 - 候选按历史速度排序,上游不可用/HTTP>=400/抛错自动切换下一个 - Sub2API 作为通用兜底;显式前缀 traecn-/wbintl- 仍可强制指定 - 显式前缀转发前自动剥离,避免上游收到 traecn-glm-5.2 这类模型名 - 内存速度/冷却统计:失败供应商冷却 30s 起步、指数退避 - 版本升至 1.7.7 --- app/build.gradle.kts | 4 +- .../token/data/proxy/KtorLocalProxyServer.kt | 32 +- .../rainy/token/data/proxy/ProviderRouter.kt | 283 +++++++++++++++++- .../token/data/proxy/Sub2ApiChatProxy.kt | 6 + .../rainy/token/data/proxy/TraeChatProxy.kt | 3 + .../token/data/proxy/TraeModelProvider.kt | 8 + .../java/com/rainy/token/di/NetworkModule.kt | 17 +- 7 files changed, 309 insertions(+), 44 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5ea6e18..acdca91 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 = 26 - versionName = "1.7.6" + versionCode = 27 + versionName = "1.7.7" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 index 48be89a..0f26125 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/KtorLocalProxyServer.kt @@ -122,28 +122,14 @@ class KtorLocalProxyServer @Inject constructor( call.respond(HttpStatusCode(413, ""), errorBody("请求体过大")) return@post } - val model = extractModel(rawBody) - val route = router.route(model) - val upstreamModel = stripModelPrefix(model) - val body = if (upstreamModel != model) rewriteModelBody(rawBody, upstreamModel) else rawBody val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody) - val pooled = pool.next(route.kind, route.region, conversationId) - val accountId = pooled?.accountId try { - if (extractStream(body)) { - val stream: ProxyUpstreamStream? = when (route.kind) { - ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> - workBuddy.openStreamingChat(body, accountId, route.region) - - ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> - trae.openStreamingChat(body, accountId, route.region) - - else -> sub2Api.openStreamingChat(body, accountId) - } + if (extractStream(rawBody)) { + val stream = router.openStreamingChat(rawBody, conversationId) if (stream == null) { call.respond( HttpStatusCode.BadRequest, - errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") + errorBody("所有上游均不可用或未配置,请检查设置") ) } else { call.respondOutputStream( @@ -165,19 +151,11 @@ class KtorLocalProxyServer @Inject constructor( } } } else { - val result: ProxyUpstreamResponse? = when (route.kind) { - ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> - workBuddy.forwardChat(body, accountId, route.region) - - ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> - trae.forwardChat(body, accountId, route.region) - - else -> sub2Api.forwardChat(body, accountId) - } + val result = router.forwardChat(rawBody, conversationId) if (result == null) { call.respond( HttpStatusCode.BadRequest, - errorBody("${route.kind.displayName} 未配置或未登录,请先在设置中配置") + errorBody("所有上游均不可用或未配置,请检查设置") ) } else { call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, "")) 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 index 126de77..bc99dd0 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/ProviderRouter.kt @@ -1,38 +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 + /** - * 模型名 → Provider + Region 路由。 + * 聚合路由:一个本地 API Key 通吃所有供应商。 * - * 约定前缀: - * - wbcn- / workbuddy-cn / codebuddy- → WorkBuddy 国内版 - * - wbintl- / workbuddy-intl → WorkBuddy 国际版 - * - traecn- / trae-cn / trae- → Trae CN - * - traeintl- / trae-intl → Trae INTL - * - 其余默认 OpenAI 兼容透传(Sub2API) + * 规则: + * 1. 模型名带显式前缀(traecn- / wbintl- 等)→ 强制指定供应商; + * 2. 否则按【模型名】找所有支持的供应商,按历史速度排序逐个尝试; + * 3. 上游不可用/HTTP>=400/抛错 → 自动切换到下一个供应商; + * 4. Sub2API 作为通用兜底(配置了账号时)。 */ -class ProviderRouter { +@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?, ) - fun route(model: String): Route { + /** 网关内供应商目标。 */ + 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-") -> - Route(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN) + ProviderTarget.WORKBUDDY_CN m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") -> - Route(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL) + ProviderTarget.WORKBUDDY_INTL m.startsWith("traeintl-") || m.startsWith("trae-intl") -> - Route(ProviderKind.TRAE_INTL, ProviderRegion.INTL) + ProviderTarget.TRAE_INTL m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") -> - Route(ProviderKind.TRAE_CN, ProviderRegion.CN) + ProviderTarget.TRAE_CN - else -> Route(ProviderKind.OPENAI_COMPATIBLE, null) + 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/Sub2ApiChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt index 34f4480..9087cec 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/Sub2ApiChatProxy.kt @@ -117,6 +117,12 @@ class Sub2ApiChatProxy @Inject constructor( 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 + } } /** diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 9d27c84..7a84c27 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -266,6 +266,9 @@ class TraeChatProxy @Inject constructor( } 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", diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index 7f6fec9..b54b258 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -57,6 +57,14 @@ class TraeModelProvider @Inject constructor( 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 (models.value.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 credential = credentialRepository.get(ServiceType.TRAE, null) 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 7c8bb96..4f9219d 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -28,6 +28,7 @@ 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 @@ -257,7 +258,21 @@ object NetworkModule { @Provides @Singleton - fun provideProviderRouter(): ProviderRouter = ProviderRouter() + fun provideProviderRouter( + sub2ApiChatProxy: Sub2ApiChatProxy, + workBuddyChatProxy: WorkBuddyChatProxy, + traeChatProxy: TraeChatProxy, + traeModelProvider: TraeModelProvider, + workBuddyModelProvider: WorkBuddyModelProvider, + accountPool: AccountPool, + ): ProviderRouter = ProviderRouter( + traeChatProxy, + workBuddyChatProxy, + sub2ApiChatProxy, + traeModelProvider, + workBuddyModelProvider, + accountPool, + ) @Provides @Singleton From fee433d2fce4b79160fe4a1a2d0be3ec0073cbae Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:50:44 +0800 Subject: [PATCH 35/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.7=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 20ce54b..f6efd50 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 26, - "versionName": "1.7.6", - "apkUrl": "http://124.222.97.212:8443/attachments/200cd0dc-5dc6-4f76-817e-c120d6dd42bd", - "notes": "按代理/区域区分模型列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取和选择" + "versionCode": 27, + "versionName": "1.7.7", + "apkUrl": "http://124.222.97.212:8443/attachments/ed827649-ed17-4c30-a64e-6b7175e36442", + "notes": "聚合网关:一个 API Key 通吃,按模型自动选供应商、失败自动切换" } From 1a96bcb1059a8eec108ce5d6f9ce484a4615ebbc Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 11:51:24 +0800 Subject: [PATCH 36/69] =?UTF-8?q?feat(proxy):=20WorkBuddyModelProvider=20?= =?UTF-8?q?=E5=A2=9E=E5=8A=A0=20supports=20=E5=88=A4=E6=96=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 供聚合路由按模型匹配 WorkBuddy 供应商。 --- .../com/rainy/token/data/proxy/WorkBuddyModelProvider.kt | 7 +++++++ 1 file changed, 7 insertions(+) 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 index 46298bb..517b331 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -52,6 +52,13 @@ class WorkBuddyModelProvider @Inject constructor( 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 models.value.any { it.id.equals(modelId, ignoreCase = true) } + } + suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) { val credential = credentialRepository.get(ServiceType.WORKBUDDY, null) ?: return@withContext false From eb4451c4de6d4d45e1d6b7e35f56fa2c040af9cd Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 12:26:12 +0800 Subject: [PATCH 37/69] =?UTF-8?q?feat(proxy):=20CN/INTL=20=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E4=B8=8E=E6=A8=A1=E5=9E=8B=E5=88=97=E8=A1=A8=E5=AE=8C?= =?UTF-8?q?=E5=85=A8=E5=88=86=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 模型 Provider 改为按区域独立列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取,不再互相覆盖 - 拉模型按区域取账号:未配置该区域账号时返回失败并保留内置兜底 - WorkBuddy 凭据表单补充区域(CN/INTL)选择,解决国际版无处配置的问题 - 账号管理列表显示 CN/INTL 区域徽标,一眼区分 - 版本升至 1.7.8 --- app/build.gradle.kts | 4 +-- .../token/data/proxy/TraeModelProvider.kt | 35 +++++++++++++------ .../data/proxy/WorkBuddyModelProvider.kt | 29 +++++++++++---- .../ui/settings/AccountManagementScreen.kt | 27 ++++++++++++-- .../ui/settings/AccountManagementViewModel.kt | 21 +++++++++-- .../token/ui/settings/CredentialEditScreen.kt | 21 +++++++++++ .../ui/settings/CredentialEditViewModel.kt | 12 +++++-- .../token/ui/settings/SettingsViewModel.kt | 10 +++--- 8 files changed, 127 insertions(+), 32 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index acdca91..fcac958 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 = 27 - versionName = "1.7.7" + versionCode = 28 + versionName = "1.7.8" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index b54b258..86f0f48 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -38,8 +38,13 @@ class TraeModelProvider @Inject constructor( private val prefs: SharedPreferences = context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) - private val _models = MutableStateFlow(defaultModels()) - val models: StateFlow> = _models.asStateFlow() + private val _modelsByRegion = MutableStateFlow( + ProviderRegion.entries.associateWith { defaultModels() } + ) + + /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ + fun modelsFor(region: ProviderRegion): List = + _modelsByRegion.value[region] ?: defaultModels() private val json = Json { ignoreUnknownKeys = true } @@ -61,20 +66,16 @@ class TraeModelProvider @Inject constructor( fun supports(modelId: String, region: ProviderRegion): Boolean { val want = modelId.trim().lowercase() if (want.isBlank() || want == "auto") return true - if (models.value.any { it.id.equals(modelId, ignoreCase = true) }) return true + if (modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }) return true return TraeChatProxy.resolveAlias(modelId) != null } - /** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */ + /** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */ suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.TRAE, null) - ?: return@withContext false - if (credential !is Credential.TraeCredential) return@withContext false + val effectiveRegion = forcedRegion ?: ProviderRegion.CN + val credential = pickCredential(effectiveRegion) ?: return@withContext false val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false - val effectiveRegion = forcedRegion ?: runCatching { - ProviderRegion.valueOf(credential.region.uppercase()) - }.getOrNull() val intl = effectiveRegion == ProviderRegion.INTL val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn" @@ -106,10 +107,22 @@ class TraeModelProvider @Inject constructor( null } if (parsed.isNullOrEmpty()) return@withContext false - _models.value = parsed + _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 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 index 517b331..f89e1f1 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -36,8 +36,13 @@ class WorkBuddyModelProvider @Inject constructor( private val prefs: SharedPreferences = context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) - private val _models = MutableStateFlow(defaultModels()) - val models: StateFlow> = _models.asStateFlow() + private val _modelsByRegion = MutableStateFlow( + ProviderRegion.entries.associateWith { defaultModels() } + ) + + /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ + fun modelsFor(region: ProviderRegion): List = + _modelsByRegion.value[region] ?: defaultModels() private val json = Json { ignoreUnknownKeys = true } @@ -56,13 +61,11 @@ class WorkBuddyModelProvider @Inject constructor( fun supports(modelId: String, region: ProviderRegion): Boolean { val want = modelId.trim().lowercase() if (want.isBlank() || want == "auto") return true - return models.value.any { it.id.equals(modelId, ignoreCase = true) } + return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) } } suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) { - val credential = credentialRepository.get(ServiceType.WORKBUDDY, null) - ?: return@withContext false - if (credential !is Credential.WorkBuddyCredential) return@withContext false + val credential = pickCredential(region) ?: return@withContext false val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() } ?: return@withContext false val intl = region == ProviderRegion.INTL @@ -90,10 +93,22 @@ class WorkBuddyModelProvider @Inject constructor( null } if (parsed.isNullOrEmpty()) return@withContext false - _models.value = parsed + _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 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..f968da7 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 @@ -169,6 +169,8 @@ fun CredentialEditScreen( hasExisting = uiState.hasExisting, onAccessChange = viewModel::updateWorkBuddyAccess, onRefreshChange = viewModel::updateWorkBuddyRefresh, + region = uiState.workBuddyRegion, + onRegionChange = viewModel::updateWorkBuddyRegion, onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, onSave = viewModel::saveWorkBuddyCredential ) @@ -916,9 +918,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 ) { @@ -934,6 +938,23 @@ private fun WorkBuddyCredentialForm( ) { Text(stringResource(R.string.action_login_in_app)) } + Text( + text = stringResource(R.string.field_trae_region), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.outline + ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + RegionButton( + label = stringResource(R.string.option_region_cn), + selected = region.uppercase() != "INTL", + onSelect = { onRegionChange("CN") } + ) + RegionButton( + label = stringResource(R.string.option_region_intl), + selected = region.uppercase() == "INTL", + onSelect = { onRegionChange("INTL") } + ) + } OutlinedTextField( value = access, onValueChange = onAccessChange, 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..5c727d3 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 @@ -126,6 +126,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(), @@ -353,6 +354,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 +376,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 +858,7 @@ class CredentialEditViewModel @Inject constructor( traeCheckinDeviceId = "", workBuddyAccess = "", workBuddyRefresh = "", + workBuddyRegion = "CN", sub2BaseUrl = "", sub2Email = "", sub2Password = "", @@ -974,9 +981,10 @@ data class CredentialEditUiState( val traeRegion: String = "CN", /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ val traeCheckinDeviceId: String = "", - /** WorkBuddy:OAuth tokens */ + /** 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/SettingsViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/SettingsViewModel.kt index 9f512f7..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 @@ -84,7 +84,7 @@ class SettingsViewModel @Inject constructor( private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN) val selectedProxy: StateFlow = _selectedProxy.asStateFlow() - private val _proxyModels = MutableStateFlow>(traeModelProvider.models.value) + private val _proxyModels = MutableStateFlow>(traeModelProvider.modelsFor(ProviderRegion.CN)) val proxyModels: StateFlow> = _proxyModels.asStateFlow() private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN)) @@ -95,8 +95,8 @@ class SettingsViewModel @Inject constructor( _selectedProxy.value = target val region = target.region() _proxyModels.value = when (target) { - ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value - else -> workBuddyModelProvider.models.value + 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) @@ -125,8 +125,8 @@ class SettingsViewModel @Inject constructor( else -> workBuddyModelProvider.refreshFor(region) } _proxyModels.value = when (target) { - ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value - else -> workBuddyModelProvider.models.value + ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region) + else -> workBuddyModelProvider.modelsFor(region) } if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)" } From e914633797e4551ac684ec6e81d98c8d207065e0 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 12:27:15 +0800 Subject: [PATCH 38/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.8=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index f6efd50..d7bd48c 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 27, - "versionName": "1.7.7", - "apkUrl": "http://124.222.97.212:8443/attachments/ed827649-ed17-4c30-a64e-6b7175e36442", - "notes": "聚合网关:一个 API Key 通吃,按模型自动选供应商、失败自动切换" + "versionCode": 28, + "versionName": "1.7.8", + "apkUrl": "http://124.222.97.212:8443/attachments/e8082080-2f42-4ba9-9220-395f82ffac8e", + "notes": "CN/INTL 配置和模型列表分离;WorkBuddy 支持国际版账号" } From d6e4d34a423d27e950fdfb83dc6b65e2842eb3bb Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 15:28:00 +0800 Subject: [PATCH 39/69] =?UTF-8?q?fix(login):=20INT=20L=20=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=B5=B0=E5=AF=B9=E5=BA=94=E5=9F=9F=E5=90=8D=20+=20?= =?UTF-8?q?=E5=8F=AF=E6=B8=85=E9=99=A4=20WebView=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trae INTL 登录改打开 trae.ai 授权页,不再误开 trae.cn - WorkBuddy auth state/token 按区域切换 copilot.tencent.com / workbuddy.ai - 登录页新增「清除登录」按钮:清 Cookie/WebStorage 后重开,避免沿用上一个号码的登录态 - 新建账号表单保持留空(确认逻辑已存在),登录结果回填新账号 - 版本升至 1.7.9 --- app/build.gradle.kts | 4 +- .../com/rainy/token/ui/RainyTokenNavHost.kt | 50 ++++++++++++++----- .../ui/servicedetail/ServiceDetailScreen.kt | 4 +- .../servicedetail/ServiceDetailViewModel.kt | 11 +++- .../token/ui/settings/CredentialEditScreen.kt | 8 +-- .../token/ui/webview/WebViewLoginScreen.kt | 17 ++++++- .../token/ui/webview/WebViewLoginViewModel.kt | 28 +++++++++-- app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 10 files changed, 97 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fcac958..36c5e49 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 = 28 - versionName = "1.7.8" + versionCode = 29 + versionName = "1.7.9" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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..56a4de6 100644 --- a/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt +++ b/app/src/main/java/com/rainy/token/ui/RainyTokenNavHost.kt @@ -90,13 +90,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)}" @@ -356,7 +359,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 +372,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 回传给上一层(凭据编辑页),新建账号模式用于回填 @@ -417,7 +427,7 @@ private fun CompactNavHost() { } }, onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) }, - onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) }, + onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) }, onOpenRecharge = { svc -> ServiceConfigProvider.get(svc).externalUrl?.let { url -> navController.navigate(Routes.webviewPage(url, rechargeLabel)) @@ -543,7 +553,7 @@ private fun ExpandedDetailPane( onBack = onClose, onSwitchAccount = onSwitchAccount, onConfigureCredential = { onClose() }, - onStartWebViewLogin = { _, _ -> }, + onStartWebViewLogin = { _, _, _ -> }, onOpenRecharge = { svc -> ServiceConfigProvider.get(svc).externalUrl?.let { url -> navController.navigate(Routes.webviewPage(url, rechargeLabel)) @@ -692,8 +702,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 +717,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 +834,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 +849,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/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 1a1d1d5..473674a 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 @@ -114,7 +114,7 @@ fun ServiceDetailScreen( onBack: () -> Unit, onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onConfigureCredential: (ServiceType) -> Unit, - onStartWebViewLogin: (ServiceType, String?) -> Unit, + onStartWebViewLogin: (ServiceType, String?, String?) -> Unit, onOpenRecharge: (ServiceType) -> Unit = { }, viewModel: ServiceDetailViewModel = hiltViewModel() ) { @@ -315,7 +315,7 @@ fun ServiceDetailScreen( modelsLoading = modelsLoading, onRefresh = { viewModel.refresh() }, onConfigureCredential = { onConfigureCredential(service) }, - onStartWebViewLogin = { onStartWebViewLogin(service, accountId) }, + onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) }, onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } }, onTriggerUsage = { viewModel.triggerUsage() }, onSelectModel = { viewModel.selectModel(it) }, 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..a7aa4ae 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 @@ -454,6 +454,13 @@ 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) } when (change) { CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> { @@ -1008,7 +1015,9 @@ sealed class State { 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/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index f968da7..11f03be 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() @@ -159,7 +159,7 @@ fun CredentialEditScreen( onJwtChange = viewModel::updateTraeJwt, onRegionChange = viewModel::updateTraeRegion, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, - onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) }, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, onSave = viewModel::saveTraeCredential ) } else if (service == ServiceType.WORKBUDDY) { @@ -171,7 +171,7 @@ fun CredentialEditScreen( onRefreshChange = viewModel::updateWorkBuddyRefresh, region = uiState.workBuddyRegion, onRegionChange = viewModel::updateWorkBuddyRegion, - onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, + onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) }, onSave = viewModel::saveWorkBuddyCredential ) } else if (service == ServiceType.SUB2API) { @@ -269,7 +269,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() } 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..36aac71 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 @@ -29,7 +29,10 @@ 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.Modifier import androidx.compose.ui.res.stringResource @@ -54,6 +57,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 +66,11 @@ 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() } + var webViewGeneration by remember { mutableStateOf(0) } // 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填) LaunchedEffect(uiState.loginSucceeded) { @@ -97,6 +102,14 @@ fun WebViewLoginScreen( IconButton(onClick = onBack) { Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) } + }, + actions = { + TextButton(onClick = { + viewModel.clearWebViewSession() + webViewGeneration++ + }) { + Text(stringResource(R.string.webview_clear_login)) + } } ) } @@ -105,6 +118,7 @@ fun WebViewLoginScreen( .fillMaxSize() .padding(innerPadding)) { if (uiState.loginUrl.isNotEmpty()) { + key(webViewGeneration) { AndroidView( factory = { context -> WebView(context).apply { @@ -180,6 +194,7 @@ fun WebViewLoginScreen( }, modifier = Modifier.fillMaxSize() ) + } } 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..b679aa4 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 @@ -57,14 +57,19 @@ class WebViewLoginViewModel @Inject constructor( /** WorkBuddy 轮询协程,bind 新服务时取消。 */ private var workBuddyPollingJob: Job? = null + /** WorkBuddy 当前基址(决定 auth state/token 用 CN 还是 INTL)。 */ + private var workBuddyBase = "https://copilot.tencent.com" /** 目标账号(多账号服务);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 { @@ -134,8 +139,9 @@ class WebViewLoginViewModel @Inject constructor( // 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。 val deviceId = traeDeviceId() val callbackUrl = "http://127.0.0.1:18080/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&") @@ -221,7 +227,7 @@ class WebViewLoginViewModel @Inject constructor( val credential = Credential.TraeCredential( service = ServiceType.TRAE, jwt = token, - region = "CN", + region = if (region == "INTL") "INTL" else "CN", deviceId = deviceId, userId = userId?.takeIf { it.isNotBlank() }, lastVerifiedAt = System.currentTimeMillis() @@ -239,8 +245,9 @@ class WebViewLoginViewModel @Inject constructor( private fun bindWorkBuddy() { viewModelScope.launch { try { + workBuddyBase = if (region == "INTL") "https://www.workbuddy.ai" else "https://copilot.tencent.com" val request = Request.Builder() - .url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI") + .url(workBuddyBase + "/v2/plugin/auth/state?platform=CLI") .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") .addHeader("Content-Type", "application/json") .post("{}".toRequestBody("application/json".toMediaTypeOrNull())) @@ -285,7 +292,7 @@ class WebViewLoginViewModel @Inject constructor( if (_uiState.value.loginSucceeded) return try { val request = Request.Builder() - .url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state") + .url(workBuddyBase + "/v2/plugin/auth/token?state=$state") .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") .get() .build() @@ -312,6 +319,7 @@ class WebViewLoginViewModel @Inject constructor( service = ServiceType.WORKBUDDY, accessToken = accessToken, refreshToken = refreshToken ?: "", + region = if (region == "INTL") "INTL" else "CN", expiresAt = expiresAt, lastVerifiedAt = System.currentTimeMillis() ) @@ -361,6 +369,16 @@ class WebViewLoginViewModel @Inject constructor( } } + /** 清除 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) { 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..d2d2811 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -447,6 +447,7 @@ token 刷新失败,请重新登录或重新导入 auth.json + 清除登录 登录 %1$s 完成登录了吗? 如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。 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..cd8f83e 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -447,6 +447,7 @@ token 重新整理失敗,請重新登入或重新匯入 auth.json + 清除登入 登入 %1$s 完成登入了嗎? 如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 15d0bee..bfed392 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -450,6 +450,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. From 3c2b2ec16e26d9b39dc0f950efface0dcaf2eb71 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 15:29:04 +0800 Subject: [PATCH 40/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.9=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index d7bd48c..6cc49b5 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 28, - "versionName": "1.7.8", - "apkUrl": "http://124.222.97.212:8443/attachments/e8082080-2f42-4ba9-9220-395f82ffac8e", - "notes": "CN/INTL 配置和模型列表分离;WorkBuddy 支持国际版账号" + "versionCode": 29, + "versionName": "1.7.9", + "apkUrl": "http://124.222.97.212:8443/attachments/1607a88e-1664-4832-a584-3889a20fa308", + "notes": "INTL登录域名修复 + 登录页可清除WebView登录态" } From 46b4d457b87b560a8c568a7b42c9569906cd42d2 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 15:35:19 +0800 Subject: [PATCH 41/69] =?UTF-8?q?fix(login):=20=E6=AF=8F=E6=AC=A1=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E8=87=AA=E5=8A=A8=E6=B8=85=E9=99=A4=20WebView=20?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 登录前自动清 Cookie/WebStorage,避免上一个号码的登录态串到新账号。账号凭据本身已按账号 ID 独立保存。 --- app/build.gradle.kts | 4 ++-- .../java/com/rainy/token/ui/webview/WebViewLoginViewModel.kt | 5 +++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 36c5e49..5bf2a5b 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 = 29 - versionName = "1.7.9" + versionCode = 30 + versionName = "1.7.10" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 b679aa4..b5a1aae 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 @@ -82,6 +82,11 @@ class WebViewLoginViewModel @Inject constructor( ) } + // 每次进入登录页强制清掉 WebView 全局登录态,避免沿用上一个账号的 Cookie + if (service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) { + clearWebViewSession() + } + when (service) { ServiceType.TRAE -> bindTrae() ServiceType.WORKBUDDY -> bindWorkBuddy() From b94e2f6994ed0bb5f1ad424dd673093fc9488dca Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 15:36:21 +0800 Subject: [PATCH 42/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.10=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 6cc49b5..6c29bba 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 29, - "versionName": "1.7.9", - "apkUrl": "http://124.222.97.212:8443/attachments/1607a88e-1664-4832-a584-3889a20fa308", - "notes": "INTL登录域名修复 + 登录页可清除WebView登录态" + "versionCode": 30, + "versionName": "1.7.10", + "apkUrl": "http://124.222.97.212:8443/attachments/e8c90c65-518a-46c6-ad1c-cc9e44150ada", + "notes": "登录自动清WebView登录态,各账号凭证独立保存" } From 7574c3c7fbfc8f2b0ed14e9b8c16698a5219a48e Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 16:32:36 +0800 Subject: [PATCH 43/69] =?UTF-8?q?fix(login):=20=E9=A1=B5=E9=9D=A2=E5=8A=A0?= =?UTF-8?q?=E8=BD=BD=E5=8F=8D=E9=A6=88=20+=20WB=20=E5=87=AD=E8=AF=81?= =?UTF-8?q?=E8=A7=A3=E6=9E=90=E5=AE=B9=E9=94=99=20+=20=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E7=99=BB=E5=BD=95=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录页新增加载进度条、加载失败提示和重试 - 顶部新增「在浏览器打开」:Trae 谷歌登录被 WebView 拦时可走系统浏览器(沿用本机 Chrome 的 Google 登录态) - WorkBuddy 轮询兼容 accessToken/access_token/token/authToken 等返回结构,失败时打印调试日志 --- app/build.gradle.kts | 4 +- .../token/ui/webview/WebViewLoginScreen.kt | 73 ++++++++++++++++++- .../token/ui/webview/WebViewLoginViewModel.kt | 43 ++++++++--- 3 files changed, 106 insertions(+), 14 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5bf2a5b..f9df029 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 = 30 - versionName = "1.7.10" + versionCode = 31 + versionName = "1.7.11" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 36aac71..c0ca551 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 @@ -34,6 +39,7 @@ 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 @@ -70,7 +76,10 @@ fun WebViewLoginScreen( } 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) { @@ -104,6 +113,18 @@ fun WebViewLoginScreen( } }, actions = { + TextButton(onClick = { + val url = uiState.loginUrl + if (url.isNotBlank()) { + runCatching { + context.startActivity( + Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + } + }) { + Text(stringResource(R.string.action_open_login_entry)) + } TextButton(onClick = { viewModel.clearWebViewSession() webViewGeneration++ @@ -118,6 +139,9 @@ fun WebViewLoginScreen( .fillMaxSize() .padding(innerPadding)) { if (uiState.loginUrl.isNotEmpty()) { + if (pageLoading) { + LinearProgressIndicator(modifier = Modifier.fillMaxWidth()) + } key(webViewGeneration) { AndroidView( factory = { context -> @@ -176,6 +200,8 @@ fun WebViewLoginScreen( } override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) { + pageLoading = true + pageError = null DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}") super.onPageStarted(view, url, favicon) } @@ -187,14 +213,59 @@ fun WebViewLoginScreen( viewModel.onPageFinished(url) DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}") } + + override fun onReceivedError( + view: WebView?, + request: android.webkit.WebResourceRequest?, + error: android.webkit.WebResourceError? + ) { + pageLoading = false + pageError = error?.description?.toString() ?: request?.url?.toString().orEmpty() + DebugLog.w("TraeLogin", "pageError: ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}") + super.onReceivedError(view, request, error) + } + } + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + pageLoading = newProgress < 100 + 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 b5a1aae..c946738 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 @@ -273,6 +273,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)) @@ -307,16 +308,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 同款注释) @@ -337,11 +356,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) } From 62304ecc9b4d84001c2fe37208397ff45ec41611 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 16:33:46 +0800 Subject: [PATCH 44/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.11=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 6c29bba..250aae0 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 30, - "versionName": "1.7.10", - "apkUrl": "http://124.222.97.212:8443/attachments/e8c90c65-518a-46c6-ad1c-cc9e44150ada", - "notes": "登录自动清WebView登录态,各账号凭证独立保存" + "versionCode": 31, + "versionName": "1.7.11", + "apkUrl": "http://124.222.97.212:8443/attachments/5297d86d-6f73-4847-88c2-1763e572296b", + "notes": "登录页加载反馈 + WB 凭证解析容错 + 系统浏览器登录入口" } From d5a152ef1eba72603b293e871493358e16f4e728 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 16:41:35 +0800 Subject: [PATCH 45/69] =?UTF-8?q?fix(wb):=20=E6=8C=89=E5=AE=98=E6=96=B9?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E7=AB=AF=E8=A1=A5=E9=BD=90=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E5=A4=B4=E5=B9=B6=E5=BB=B6=E9=95=BF=20INTL?= =?UTF-8?q?=20=E6=8E=88=E6=9D=83=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - WorkBuddy auth/state 改用 VSCode(国内)/workbuddy-ai(INTL) platform,与官方客户端一致 - 补齐 Origin/Referer/X-Client-ID/X-Client-Version/X-Product/X-No-Authorization - INTL 浏览器登录等待时间从 120s 延长到 15 分钟,普通用户邮箱/验证码流程不会再中途超时 --- app/build.gradle.kts | 4 +- .../token/ui/webview/WebViewLoginViewModel.kt | 40 ++++++++++++++++--- 2 files changed, 36 insertions(+), 8 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f9df029..fbbd5d3 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 = 31 - versionName = "1.7.11" + versionCode = 32 + versionName = "1.7.12" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 c946738..c7d13bf 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 @@ -59,6 +59,12 @@ class WebViewLoginViewModel @Inject constructor( 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 @@ -250,10 +256,26 @@ class WebViewLoginViewModel @Inject constructor( private fun bindWorkBuddy() { viewModelScope.launch { try { - workBuddyBase = if (region == "INTL") "https://www.workbuddy.ai" else "https://copilot.tencent.com" + if (region == "INTL") { + workBuddyBase = "https://www.workbuddy.ai" + workBuddyOrigin = "https://www.workbuddy.ai" + workBuddyPlatform = "workbuddy-ai" + workBuddyPollTimeoutMs = 900_000L + } else { + workBuddyBase = "https://copilot.tencent.com" + workBuddyOrigin = "https://www.codebuddy.cn" + workBuddyPlatform = "VSCode" + workBuddyPollTimeoutMs = 300_000L + } + workBuddyUserAgent = "CLI/2.143.1 CodeBuddy/2.143.1" val request = Request.Builder() - .url(workBuddyBase + "/v2/plugin/auth/state?platform=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("Content-Type", "application/json") .post("{}".toRequestBody("application/json".toMediaTypeOrNull())) .build() @@ -290,16 +312,22 @@ 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(workBuddyBase + "/v2/plugin/auth/token?state=$state") - .addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") + .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") .get() .build() val body = withContext(Dispatchers.IO) { From b62dc90c406974e2a91294bc1bfa27272166d3aa Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 16:42:45 +0800 Subject: [PATCH 46/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.12=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 250aae0..7ff5d36 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 31, - "versionName": "1.7.11", - "apkUrl": "http://124.222.97.212:8443/attachments/5297d86d-6f73-4847-88c2-1763e572296b", - "notes": "登录页加载反馈 + WB 凭证解析容错 + 系统浏览器登录入口" + "versionCode": 32, + "versionName": "1.7.12", + "apkUrl": "http://124.222.97.212:8443/attachments/7c798ad2-0b30-4e5a-8a48-908670e3438e", + "notes": "WB 登录按官方客户端补齐请求头,浏览器登录后自动取凭据" } From 56969cb879905da5a26bfec9738fc1a92405cb2f Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 17:11:35 +0800 Subject: [PATCH 47/69] =?UTF-8?q?fix(webview):=20=E5=AD=90=E8=B5=84?= =?UTF-8?q?=E6=BA=90=E8=BF=9E=E6=8E=A5=E5=85=B3=E9=97=AD=E4=B8=8D=E5=86=8D?= =?UTF-8?q?=E8=AF=AF=E6=8A=A5=E6=95=B4=E9=A1=B5=E5=A4=B1=E8=B4=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ERR_CONNECTION_CLOSED 若来自图片/脚本/统计等子资源,忽略并继续等待主页面 - 页面进度继续推进时自动清除瞬时错误提示 --- app/build.gradle.kts | 4 ++-- .../rainy/token/ui/webview/WebViewLoginScreen.kt | 13 ++++++++++++- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fbbd5d3..1156d6f 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 = 32 - versionName = "1.7.12" + versionCode = 33 + versionName = "1.7.13" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 c0ca551..d21c061 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 @@ -219,15 +219,26 @@ fun WebViewLoginScreen( 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: ${request?.url?.toString()?.toSafeLogUrl()} ${error?.description}") + 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) } } From 57bd49f470c6247f370bb49ee88b459111dfeb0c Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 17:12:39 +0800 Subject: [PATCH 48/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.13=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 7ff5d36..3fb1e5a 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 32, - "versionName": "1.7.12", - "apkUrl": "http://124.222.97.212:8443/attachments/7c798ad2-0b30-4e5a-8a48-908670e3438e", - "notes": "WB 登录按官方客户端补齐请求头,浏览器登录后自动取凭据" + "versionCode": 33, + "versionName": "1.7.13", + "apkUrl": "http://124.222.97.212:8443/attachments/d480f25a-f2da-4f34-ab73-addb047ea153", + "notes": "修复子资源误报网络错误" } From 9db3e01d498d63f5222d8846ba7b091404f73d57 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 17:21:48 +0800 Subject: [PATCH 49/69] =?UTF-8?q?fix(trae):=20=E6=94=AF=E6=8C=81=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E4=BB=85=E5=B8=A6=20refreshToken=20=E6=97=B6=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=85=91=E6=8D=A2=20JWT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 登录回调不再只认 userJwt;老流程只回传 refreshToken 时调用 ExchangeToken 兑换 Cloud-IDE-JWT - 兑换失败给出明确提示,不再永远停在 Authenticating --- app/build.gradle.kts | 4 +- .../token/ui/webview/WebViewLoginViewModel.kt | 141 +++++++++++++----- app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 109 insertions(+), 39 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 1156d6f..3596af6 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 = 33 - versionName = "1.7.13" + versionCode = 34 + versionName = "1.7.14" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 c7d13bf..eb6ce4a 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 @@ -195,52 +195,76 @@ class WebViewLoginViewModel @Inject constructor( DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") return } - // 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志) + // 只记录参数名(不含 token 值,避免把 token 明文落调试日志) DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") - // Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串 + // Trae 回调可能走两套: + // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程) + // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT 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"}") - + ?: "" 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 + ?: parsed.queryParameter("refreshToken") + ?: parsed.queryParameter("refresh_token") + ?: parsed.queryParameter("data") + val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull + ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull + ?: parsed.queryParameter("clientId") + ?: parsed.queryParameter("client_id") + ?: "en1oxy7wnw8j9n" + + // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT + if (token.isNullOrBlank() && !refresh.isNullOrBlank()) { + val exchangeHost = parsed.queryParameter("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 = 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 (parsedUserId.isNullOrBlank()) "null" else "ok"}") + val credential = Credential.TraeCredential( service = ServiceType.TRAE, jwt = token, region = if (region == "INTL") "INTL" else "CN", deviceId = deviceId, - userId = userId?.takeIf { it.isNotBlank() }, + userId = parsedUserId?.takeIf { it.isNotBlank() }, lastVerifiedAt = System.currentTimeMillis() ) val effectiveId = credentialRepository.save(credential, accountId) @@ -251,6 +275,49 @@ 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() { 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 d2d2811..ee87539 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -423,6 +423,7 @@ 请先选择模型 未抓到 Cookie,请确认已登录 未配置登录 URL + 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴 登录超时,请重试 URL 应以 %1$s 开头 授权失败: %1$s 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 cd8f83e..2c447a4 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -423,6 +423,7 @@ 請先選擇模型 未抓到 Cookie,請確認已登入 未配置登入 URL + 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上 登入逾時,請重試 URL 應以 %1$s 開頭 授權失敗: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bfed392..7b14865 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -426,6 +426,7 @@ 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. Login timed out, please retry URL should start with %1$s Authorization failed: %1$s From 101e2bc24055afe18add08ad0047e4279af853df Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 17:22:50 +0800 Subject: [PATCH 50/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.14=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 3fb1e5a..9eaa774 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 33, - "versionName": "1.7.13", - "apkUrl": "http://124.222.97.212:8443/attachments/d480f25a-f2da-4f34-ab73-addb047ea153", - "notes": "修复子资源误报网络错误" + "versionCode": 34, + "versionName": "1.7.14", + "apkUrl": "http://124.222.97.212:8443/attachments/35655c77-0193-4957-b3ec-04ab8a314a96", + "notes": "Trae 登录回调支持 refreshToken 自动兑换 JWT" } From 68cdbcf0b0f34cbb7067da4147e24f4d2a65fcfd Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 18:01:06 +0800 Subject: [PATCH 51/69] =?UTF-8?q?fix(trae):=20=E5=9B=9E=E8=B0=83=E5=85=BC?= =?UTF-8?q?=E5=AE=B9=20localhost/fragment=EF=BC=8CpageStarted=20=E5=85=9C?= =?UTF-8?q?=E5=BA=95=E6=8B=A6=E6=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 识别任意 127.0.0.1/localhost:18080/authorize 回调(http/https、query/fragment) - shouldOverrideUrlLoading 未触发时在 onPageStarted 拦截并停止加载,避免卡 Authenticating --- app/build.gradle.kts | 4 +-- .../token/ui/webview/WebViewLoginScreen.kt | 19 ++++++++-- .../token/ui/webview/WebViewLoginViewModel.kt | 36 +++++++++++++------ 3 files changed, 44 insertions(+), 15 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3596af6..104391d 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 = 34 - versionName = "1.7.14" + versionCode = 35 + versionName = "1.7.15" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 d21c061..73055e7 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 @@ -190,8 +190,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 @@ -199,10 +200,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) } 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 eb6ce4a..f4e1679 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 @@ -191,17 +191,31 @@ class WebViewLoginViewModel @Inject constructor( if (service != ServiceType.TRAE) return if (_uiState.value.loginSucceeded) return + val callbackUri = android.net.Uri.parse(url) + // 参数同时支持 query 和 fragment(部分 OAuth 回调用 # 而非 ?) + fun param(name: String): String? { + callbackUri.getQueryParameter(name)?.let { return it } + val frag = callbackUri.fragment + if (!frag.isNullOrBlank()) { + frag.split("&").forEach { pair -> + val kv = pair.split("=", limit = 2) + if (kv.size == 2 && kv[0] == name) return android.net.Uri.decode(kv[1]) + if (kv.size == 1 && kv[0] == name) return "" + } + } + return null + } val parsed = url.toHttpUrlOrNull() ?: run { DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") return } - // 只记录参数名(不含 token 值,避免把 token 明文落调试日志) - DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") + // 只记录参数名(不含 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 = parsed.queryParameter("userJwt") - ?: parsed.queryParameter("user_jwt") + val userJwtRaw = param("userJwt") + ?: param("user_jwt") ?: "" viewModelScope.launch { val userJwt = runCatching { @@ -214,18 +228,18 @@ class WebViewLoginViewModel @Inject constructor( ?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull ?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull - ?: parsed.queryParameter("refreshToken") - ?: parsed.queryParameter("refresh_token") - ?: parsed.queryParameter("data") + ?: param("refreshToken") + ?: param("refresh_token") + ?: param("data") val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull ?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull - ?: parsed.queryParameter("clientId") - ?: parsed.queryParameter("client_id") + ?: param("clientId") + ?: param("client_id") ?: "en1oxy7wnw8j9n" // 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT if (token.isNullOrBlank() && !refresh.isNullOrBlank()) { - val exchangeHost = parsed.queryParameter("host")?.takeIf { it.startsWith("http") } + 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) { @@ -248,7 +262,7 @@ class WebViewLoginViewModel @Inject constructor( // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。 val deviceId = traeDeviceId() // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 - val parsedUserId = parsed.queryParameter("userInfo")?.let { raw -> + val parsedUserId = param("userInfo")?.let { raw -> runCatching { val u = Json.parseToJsonElement(raw).jsonObject u["userId"]?.jsonPrimitive?.contentOrNull From df84aa94abe28b9ad14adb0c9ea69afd56853e24 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 18:02:09 +0800 Subject: [PATCH 52/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.15=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 9eaa774..0c3263c 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 34, - "versionName": "1.7.14", - "apkUrl": "http://124.222.97.212:8443/attachments/35655c77-0193-4957-b3ec-04ab8a314a96", - "notes": "Trae 登录回调支持 refreshToken 自动兑换 JWT" + "versionCode": 35, + "versionName": "1.7.15", + "apkUrl": "http://124.222.97.212:8443/attachments/e89f9122-fb03-4c60-bb41-f8c4d0f1e782", + "notes": "Trae 回调兼容 localhost/fragment + pageStarted 兜底" } From deea17bf6e4ce2f4a894fcb90fcb6eb1eea93117 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 18:38:01 +0800 Subject: [PATCH 53/69] =?UTF-8?q?feat(trae):=20=E7=B2=98=E8=B4=B4=E5=9B=9E?= =?UTF-8?q?=E8=B0=83=E5=AF=BC=E5=85=A5=20JWT=20+=20=E6=B5=8F=E8=A7=88?= =?UTF-8?q?=E5=99=A8=E5=9B=9E=E8=B0=83=E6=9C=8D=E5=8A=A1=E5=99=A8=E8=87=AA?= =?UTF-8?q?=E5=8A=A8=E5=9B=9E=E4=BC=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trae 表单新增「登录回调链接」粘贴框,支持整条 http://127.0.0.1:18080/authorize?... 解析并填入 JWT - 「在浏览器中打开登录入口」改为启动本地 18081 回调服务器 + 浏览器登录,登录完自动保存(Sub2API 同款) --- app/build.gradle.kts | 4 +- .../token/data/proxy/LoginCallbackServer.kt | 70 +++++++++++++++ .../token/ui/settings/CredentialEditScreen.kt | 22 +++++ .../ui/settings/CredentialEditViewModel.kt | 73 +++++++++++++++- .../token/ui/webview/WebViewLoginScreen.kt | 23 ++--- .../token/ui/webview/WebViewLoginViewModel.kt | 86 ++++++++++++++++++- app/src/main/res/values-b+zh+Hans/strings.xml | 6 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 6 ++ app/src/main/res/values/strings.xml | 6 ++ 9 files changed, 279 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/proxy/LoginCallbackServer.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 104391d..7e97029 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 = 35 - versionName = "1.7.15" + versionCode = 36 + versionName = "1.7.16" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 11f03be..da8c0cf 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 @@ -153,10 +153,13 @@ fun CredentialEditScreen( if (service == ServiceType.TRAE) { TraeCredentialForm( jwt = uiState.traeJwt, + callbackUrl = uiState.traeCallbackUrl, region = uiState.traeRegion, checkinDeviceId = uiState.traeCheckinDeviceId, hasExisting = uiState.hasExisting, onJwtChange = viewModel::updateTraeJwt, + onCallbackUrlChange = viewModel::updateTraeCallbackUrl, + onImportCallback = { viewModel.importTraeCallbackFromUrl(it) }, onRegionChange = viewModel::updateTraeRegion, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, @@ -779,10 +782,13 @@ private fun ManualCookieForm( @Composable private fun TraeCredentialForm( jwt: String, + callbackUrl: String, region: String, checkinDeviceId: String, hasExisting: Boolean, onJwtChange: (String) -> Unit, + onCallbackUrlChange: (String) -> Unit, + onImportCallback: (String) -> Unit, onRegionChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit, onStartInAppLogin: () -> Unit, @@ -800,6 +806,22 @@ private fun TraeCredentialForm( ) { Text(stringResource(R.string.action_login_in_app)) } + OutlinedTextField( + value = callbackUrl, + onValueChange = onCallbackUrlChange, + label = { Text(stringResource(R.string.field_trae_callback_url)) }, + supportingText = { Text(stringResource(R.string.field_trae_callback_hint)) }, + minLines = 1, + maxLines = 3, + modifier = Modifier.fillMaxWidth() + ) + OutlinedButton( + onClick = { onImportCallback(callbackUrl) }, + enabled = callbackUrl.isNotBlank(), + modifier = Modifier.fillMaxWidth() + ) { + Text(stringResource(R.string.action_parse_callback)) + } OutlinedTextField( value = jwt, onValueChange = onJwtChange, 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 5c727d3..17e5645 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 @@ -299,6 +300,70 @@ 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.first, + traeRegion = parsed.second, + traeCallbackUrl = input + ) + } + } + + private fun parseTraeCallback(input: String): Pair? { + 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 + return token to (if (intl) "INTL" else "CN") + } fun updateTraeRegion(value: String) { _uiState.update { it.copy(traeRegion = value) } @@ -311,7 +376,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() + if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { + val parsed = parseTraeCallback(current.traeCallbackUrl) + if (parsed != null) jwt = parsed.first + } if (jwt.isBlank()) { _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } return @@ -978,6 +1047,8 @@ 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 = "", 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 73055e7..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 @@ -113,16 +113,7 @@ fun WebViewLoginScreen( } }, actions = { - TextButton(onClick = { - val url = uiState.loginUrl - if (url.isNotBlank()) { - runCatching { - context.startActivity( - Intent(Intent.ACTION_VIEW, Uri.parse(url)).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - ) - } - } - }) { + TextButton(onClick = { viewModel.openExternalBrowser() }) { Text(stringResource(R.string.action_open_login_entry)) } TextButton(onClick = { @@ -142,6 +133,18 @@ fun WebViewLoginScreen( 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 -> 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 f4e1679..3570806 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 @@ -149,7 +150,17 @@ 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) } + 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(authRoot).append("?") @@ -165,8 +176,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 位数字并保存,此后跨登录复用。 */ @@ -504,6 +515,67 @@ 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 { @@ -668,6 +740,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 { @@ -698,5 +774,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 ee87539..0c9ce51 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -424,6 +424,12 @@ 未抓到 Cookie,请确认已登录 未配置登录 URL 登录成功但未获取到令牌,请复制回调地址里的 userJwt 后手动粘贴 + Trae 登录回调链接 + 在浏览器登录完成后,把地址栏整条链接粘到这里(http://127.0.0.1:18080/authorize?...) + 解析并填入 JWT + 无法解析这条回调链接,请完整复制地址栏内容 + 本地回调服务启动失败,请改用应用内登录 + 已在浏览器打开,登录完成后会自动回到雨晴Token 登录超时,请重试 URL 应以 %1$s 开头 授权失败: %1$s 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 2c447a4..67b3637 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -424,6 +424,12 @@ 未抓到 Cookie,請確認已登入 未配置登入 URL 登入成功但未取得令牌,請複製回呼位址中的 userJwt 後手動貼上 + Trae 登入回呼連結 + 在瀏覽器登入完成後,把網址列整條連結貼到這裡(http://127.0.0.1:18080/authorize?...) + 解析並填入 JWT + 無法解析這條回呼連結,請完整複製網址列內容 + 本機回呼服務啟動失敗,請改用應用內登入 + 已在瀏覽器開啟,登入完成後會自動回到雨晴Token 登入逾時,請重試 URL 應以 %1$s 開頭 授權失敗: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 7b14865..f5b151f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -427,6 +427,12 @@ 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 From 4ae2aa01b62141645f0347ab27899416f56b6322 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 18:39:12 +0800 Subject: [PATCH 54/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.16=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 0c3263c..1203a09 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 35, - "versionName": "1.7.15", - "apkUrl": "http://124.222.97.212:8443/attachments/e89f9122-fb03-4c60-bb41-f8c4d0f1e782", - "notes": "Trae 回调兼容 localhost/fragment + pageStarted 兜底" + "versionCode": 36, + "versionName": "1.7.16", + "apkUrl": "http://124.222.97.212:8443/attachments/6f516320-c344-4195-b774-ae86c066d4c7", + "notes": "Trae 粘贴回调导入 + 浏览器登录自动回传凭证" } From cf6f426a36dde5b8910c7d0aa4a1a33ff220716b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:11:48 +0800 Subject: [PATCH 55/69] =?UTF-8?q?feat(trae):=20INTL=20=E7=BE=8E=E5=85=83?= =?UTF-8?q?=E8=AE=A1=E8=B4=B9=E5=B1=95=E7=A4=BA=20+=20apiHost=20+=20?= =?UTF-8?q?=E5=85=85=E5=80=BC=E5=85=A5=E5=8F=A3=20+=20INTL=20=E7=BD=91?= =?UTF-8?q?=E5=85=B3=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Trae 凭据新增 apiHost(回调 host 自动保存,如 api-us-east.trae.ai) - INTL 余额改走官方 v1 计费接口(grow-normal/api-us-east),解析 Free plan 请求额度(Premium fast/slow、高级、自动补全) - 首页 INTL 不再显示空 0 Credits,改为按月配额展示 - Trae 凭据页新增「充值 / 升级」入口(CN→trae.cn/subscription,INTL→trae.ai/pricing) - INTL 模型/对话网关修正为 core-normal.trae.ai(实测接口有效) --- app/build.gradle.kts | 4 +-- .../rainy/token/data/proxy/TraeChatProxy.kt | 23 +++++++------ .../token/data/proxy/TraeModelProvider.kt | 7 ++-- .../token/data/repository/TraeRepository.kt | 31 +++++++++++++++-- .../rainy/token/domain/model/Credential.kt | 4 ++- .../token/ui/dashboard/ServiceBalanceCards.kt | 23 +++++++++++-- .../token/ui/settings/CredentialEditScreen.kt | 22 +++++++++++++ .../ui/settings/CredentialEditViewModel.kt | 33 ++++++++++++++----- .../token/ui/webview/WebViewLoginViewModel.kt | 1 + app/src/main/res/values-b+zh+Hans/strings.xml | 6 ++++ app/src/main/res/values-b+zh+Hant/strings.xml | 6 ++++ app/src/main/res/values/strings.xml | 6 ++++ 12 files changed, 138 insertions(+), 28 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7e97029..66751c3 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 = 36 - versionName = "1.7.16" + versionCode = 37 + versionName = "1.7.17" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt index 7a84c27..3f791a4 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeChatProxy.kt @@ -55,11 +55,7 @@ class TraeChatProxy @Inject constructor( val effectiveRegion = forcedRegion ?: runCatching { ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() - val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://a0ai-api-sg.byteintlapi.com" - } else { - "https://trae-api-cn.mchost.guru" - } + val base = chatBase(effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() @@ -94,11 +90,7 @@ class TraeChatProxy @Inject constructor( val effectiveRegion = forcedRegion ?: runCatching { ProviderRegion.valueOf(credential.region.uppercase()) }.getOrNull() - val base = if (effectiveRegion == ProviderRegion.INTL) { - "https://a0ai-api-sg.byteintlapi.com" - } else { - "https://trae-api-cn.mchost.guru" - } + val base = chatBase(effectiveRegion) val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion) val requestId = UUID.randomUUID().toString() @@ -237,6 +229,17 @@ class TraeChatProxy @Inject constructor( 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 diff --git a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt index 86f0f48..51a931c 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/TraeModelProvider.kt @@ -77,8 +77,11 @@ class TraeModelProvider @Inject constructor( val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false val intl = effectiveRegion == ProviderRegion.INTL - val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru" + 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" @@ -88,7 +91,7 @@ class TraeModelProvider @Inject constructor( "Content-Type" to "application/json", "X-Trae-Client-Type" to "web", "X-Preferenced-Language" to "zh-CN", - "x-user-region" to if (intl) "SG" else "CN", + "x-user-region" to userRegion, "Origin" to origin, "Referer" to origin + "/", "User-Agent" to ( 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..2f43437 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 @@ -66,9 +66,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 +127,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 +161,26 @@ 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("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", "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() 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 a2a9b1d..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=旧凭据未保存 */ 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..4ab00e1 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,21 @@ 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)) + 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/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index da8c0cf..7448125 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 @@ -163,6 +163,21 @@ fun CredentialEditScreen( onRegionChange = viewModel::updateTraeRegion, onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId, uiState.traeRegion) }, + onRecharge = { + val url = if (uiState.traeRegion.uppercase() == "INTL") { + "https://www.trae.ai/pricing" + } else { + "https://www.trae.cn/subscription" + } + runCatching { + context.startActivity( + android.content.Intent( + android.content.Intent.ACTION_VIEW, + android.net.Uri.parse(url) + ).addFlags(android.content.Intent.FLAG_ACTIVITY_NEW_TASK) + ) + } + }, onSave = viewModel::saveTraeCredential ) } else if (service == ServiceType.WORKBUDDY) { @@ -792,6 +807,7 @@ private fun TraeCredentialForm( onRegionChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit, onStartInAppLogin: () -> Unit, + onRecharge: () -> Unit, onSave: () -> Unit ) { Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) @@ -847,6 +863,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, 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 17e5645..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 @@ -318,14 +318,21 @@ class CredentialEditViewModel @Inject constructor( } _uiState.update { it.copy( - traeJwt = parsed.first, - traeRegion = parsed.second, + traeJwt = parsed.jwt, + traeRegion = parsed.region, + traeApiHost = parsed.apiHost, traeCallbackUrl = input ) } } - private fun parseTraeCallback(input: String): Pair? { + 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? { @@ -362,7 +369,12 @@ class CredentialEditViewModel @Inject constructor( val intl = userRegion?.let { it != "cn" } == true || userInfoRegion?.let { !it.equals("cn", true) && !it.equals("china", true) } == true || query("userRegion")?.equals("us", true) == true - return token to (if (intl) "INTL" else "CN") + 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) { @@ -377,10 +389,10 @@ class CredentialEditViewModel @Inject constructor( val type = serviceType ?: return val current = _uiState.value var jwt = current.traeJwt.trim() - if (jwt.isBlank() && current.traeCallbackUrl.isNotBlank()) { - val parsed = parseTraeCallback(current.traeCallbackUrl) - if (parsed != null) jwt = parsed.first - } + 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 @@ -396,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, @@ -1052,6 +1067,8 @@ data class CredentialEditUiState( val traeRegion: String = "CN", /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ val traeCheckinDeviceId: String = "", + /** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */ + val traeApiHost: String = "", /** WorkBuddy:OAuth tokens + 区域 */ val workBuddyAccess: String = "", val workBuddyRefresh: String = "", 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 3570806..9010707 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 @@ -288,6 +288,7 @@ class WebViewLoginViewModel @Inject constructor( service = ServiceType.TRAE, jwt = token, region = if (region == "INTL") "INTL" else "CN", + apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'), deviceId = deviceId, userId = parsedUserId?.takeIf { it.isNotBlank() }, lastVerifiedAt = System.currentTimeMillis() 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 0c9ce51..ef3fe77 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -792,4 +792,10 @@ 需要开启「安装未知应用」权限 去开启 更新失败:%1$s + Premium 快速 + Premium 慢速 + 高级额度 + 自动补全 + 次/月 + 充值 / 升级 \ 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 67b3637..2fe0e33 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -792,4 +792,10 @@ 需要開啟「安裝未知應用」權限 前往開啟 更新失敗:%1$s + Premium 快速 + Premium 慢速 + 進階額度 + 自動補全 + 次/月 + 儲值 / 升級 \ 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 f5b151f..749e51a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -797,4 +797,10 @@ 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 \ No newline at end of file From 0ca6ccd384a31bed430bd260b6724dbfb323a3bc Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:14:25 +0800 Subject: [PATCH 56/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.17=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 1203a09..e3ad92f 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 36, - "versionName": "1.7.16", - "apkUrl": "http://124.222.97.212:8443/attachments/6f516320-c344-4195-b774-ae86c066d4c7", - "notes": "Trae 粘贴回调导入 + 浏览器登录自动回传凭证" + "versionCode": 37, + "versionName": "1.7.17", + "apkUrl": "http://124.222.97.212:8443/attachments/0f217999-3cc0-4139-8f7f-f314873f77b1", + "notes": "Trae INTL 美元计费额度展示 + 充值入口 + INTL 网关修正" } From 8062a5425273971ee169d76b003c263633b44232 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:31:20 +0800 Subject: [PATCH 57/69] =?UTF-8?q?fix(trae):=20=E5=9B=BD=E9=99=85=E7=89=88?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=AD=BE=E5=88=B0=E5=8D=A1=EF=BC=8C=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5=E6=8C=89=E7=BE=8E=E5=85=83/=E6=9C=88?= =?UTF-8?q?=E9=85=8D=E9=A2=9D=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - INTL 详细页不再显示每日签到卡(国际版无签到体系),改为说明卡 - Trae 账户信息卡在美元计费时显示套餐名 + Premium 快速/慢速、高级、自动补全月配额 - INTL 不再生成 0 积分权益包计数,避免残留“积分”字样 --- app/build.gradle.kts | 4 +- .../token/data/repository/TraeRepository.kt | 2 +- .../ui/servicedetail/ServiceDetailScreen.kt | 50 ++++++++++++++++++- app/src/main/res/values-b+zh+Hans/strings.xml | 4 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 4 ++ app/src/main/res/values/strings.xml | 4 ++ 6 files changed, 63 insertions(+), 5 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 66751c3..5281277 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 = 37 - versionName = "1.7.17" + versionCode = 38 + versionName = "1.7.18" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 2f43437..a3d2f74 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 @@ -211,7 +211,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) 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 473674a..72e490b 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 @@ -235,9 +235,12 @@ fun ServiceDetailScreen( item { OllamaUsageCard(uiState.state) } } ServiceType.TRAE -> { - // 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮 + // 每日签到卡;未配置凭据时展示引导卡,国际版无签到则展示说明卡 item { - if (uiState.hasCredential) { + val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true) + if (intl) { + TraeIntlNoCheckinCard() + } else if (uiState.hasCredential) { CheckinCard( state = checkinState, todayCheckedIn = checkinInfo?.todayCheckedIn == true, @@ -1504,6 +1507,26 @@ 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.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 +1554,29 @@ internal fun TraeAccountInfoCard(state: State) { * * 旧缓存无 wb_detail_raw 时回退为「资源 #007」简化行。无任何数据时整卡不渲染。 */ +@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 internal fun WorkBuddyResourcesCard(state: State) { val balance = when (state) { 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 ef3fe77..abe73b2 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -798,4 +798,8 @@ 自动补全 次/月 充值 / 升级 + 套餐 + 基础用量已用 + 国际版没有每日签到 + Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。 \ 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 2fe0e33..af27a3d 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -798,4 +798,8 @@ 自動補全 次/月 儲值 / 升級 + 套餐 + 基礎用量已用 + 國際版沒有每日簽到 + Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。 \ 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 749e51a..2ebfed2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -803,4 +803,8 @@ Auto completion /mo Upgrade / Top up + Plan + Basic usage used + International edition has no daily check-in + TRAE International uses USD billing with monthly request quotas instead of credit check-ins. \ No newline at end of file From 8e22d1ab9f005f3d495e1d58545b98868f316405 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:32:21 +0800 Subject: [PATCH 58/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.18=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index e3ad92f..c8939d9 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 37, - "versionName": "1.7.17", - "apkUrl": "http://124.222.97.212:8443/attachments/0f217999-3cc0-4139-8f7f-f314873f77b1", - "notes": "Trae INTL 美元计费额度展示 + 充值入口 + INTL 网关修正" + "versionCode": 38, + "versionName": "1.7.18", + "apkUrl": "http://124.222.97.212:8443/attachments/7e059224-eb8e-4531-a2c7-76c2c9f812e7", + "notes": "国际版 Trae 移除签到卡,详情按美元/月配额显示" } From 6dfb9ecb71cefdef57bf384a14b1111676ede4e6 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:42:46 +0800 Subject: [PATCH 59/69] =?UTF-8?q?fix(trae):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E4=B8=BB=E4=BD=99=E9=A2=9D=E6=94=B9=E4=B8=BA=20Free=20plan=20?= =?UTF-8?q?=E6=9C=88=E9=85=8D=E9=A2=9D=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情页顶部「当前余额」在 INTL 美元计费时显示套餐名 + 月配额明细,不再显示 0 Credits - 设备已实测:Free plan / 10次/月 / 50次/月 / 1000次/月 / 5000次/月 --- app/build.gradle.kts | 4 +- .../ui/servicedetail/ServiceDetailScreen.kt | 68 +++++++++++++++---- app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 59 insertions(+), 16 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5281277..fdba975 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 = 38 - versionName = "1.7.18" + versionCode = 39 + versionName = "1.7.19" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 72e490b..77dc0a6 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 @@ -1094,6 +1094,42 @@ private fun MainBalanceCard(state: State, service: ServiceType) { } } +@Composable +private fun TraeIntlBigNumber(extras: Map) { + val planName = extras["intl.plan_name"] ?: "Free plan" + Text( + text = planName, + style = MaterialTheme.typography.displayLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(6.dp)) + Text( + text = stringResource(R.string.trae_intl_monthly_quota), + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + modifier = Modifier.padding(bottom = 8.dp) + ) + Spacer(modifier = Modifier.height(6.dp)) + val quotaUnit = stringResource(R.string.trae_intl_quota_unit) + InfoRow( + label = stringResource(R.string.trae_intl_premium_fast_title), + value = (extras["intl.premium_fast_limit"] ?: "—") + " $quotaUnit" + ) + InfoRow( + label = stringResource(R.string.trae_intl_premium_slow_title), + value = (extras["intl.premium_slow_limit"] ?: "—") + " $quotaUnit" + ) + InfoRow( + label = stringResource(R.string.trae_intl_advanced_title), + value = (extras["intl.advanced_limit"] ?: "—") + " $quotaUnit" + ) + InfoRow( + label = stringResource(R.string.trae_intl_auto_completion_title), + value = (extras["intl.auto_completion_limit"] ?: "—") + " $quotaUnit" + ) +} + @Composable private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) { if (balance == null) { @@ -1104,20 +1140,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)) 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 abe73b2..d02a85e 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -802,4 +802,5 @@ 基础用量已用 国际版没有每日签到 Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。 + 月配额 \ 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 af27a3d..21327ef 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -802,4 +802,5 @@ 基礎用量已用 國際版沒有每日簽到 Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。 + 月額度 \ 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 2ebfed2..e1e3938 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -807,4 +807,5 @@ Basic usage used International edition has no daily check-in TRAE International uses USD billing with monthly request quotas instead of credit check-ins. + Monthly quota \ No newline at end of file From 94bb4de0b438401ea4a6261c7e556dcd52f23670 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 22:43:50 +0800 Subject: [PATCH 60/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.19=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index c8939d9..4d18366 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 38, - "versionName": "1.7.18", - "apkUrl": "http://124.222.97.212:8443/attachments/7e059224-eb8e-4531-a2c7-76c2c9f812e7", - "notes": "国际版 Trae 移除签到卡,详情按美元/月配额显示" + "versionCode": 39, + "versionName": "1.7.19", + "apkUrl": "http://124.222.97.212:8443/attachments/4431d774-f4ff-4dcf-b188-00cc7ffaf593", + "notes": "详情页主余额改为 Free plan 月配额展示,不再显示 0 Credits" } From 5a12f10d4e7c7bbc33698bfb8cda55bff6893f53 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 23:14:52 +0800 Subject: [PATCH 61/69] =?UTF-8?q?feat(trae):=20INTL=20=E6=98=BE=E7=A4=BA?= =?UTF-8?q?=E7=BE=8E=E5=85=83=E7=94=A8=E9=87=8F=E9=A2=9D=E5=BA=A6=20$0.00?= =?UTF-8?q?=20/=20$1.00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 详情页主余额与首页卡片新增「美元用量额度」行(basic_usage_limit/used) - 账户信息卡同步展示已用/总额美元额度 - 设备已实测:Free plan / $0.00 / $1.00 + 月请求配额 --- app/build.gradle.kts | 4 ++-- .../token/data/repository/TraeRepository.kt | 2 ++ .../token/ui/dashboard/ServiceBalanceCards.kt | 21 +++++++++++++++++++ .../ui/servicedetail/ServiceDetailScreen.kt | 19 +++++++++++++++++ app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 47 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index fdba975..f1d1e65 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 = 39 - versionName = "1.7.19" + versionCode = 40 + versionName = "1.7.20" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 a3d2f74..c97ddf2 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 @@ -174,9 +174,11 @@ class TraeRepository constructor( 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 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 4ab00e1..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 @@ -218,6 +218,27 @@ private fun TraeDollarPlanSummary(extras: Map) { 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)) 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 77dc0a6..881c93c 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 @@ -1111,6 +1111,14 @@ private fun TraeIntlBigNumber(extras: Map) { modifier = Modifier.padding(bottom = 8.dp) ) Spacer(modifier = Modifier.height(6.dp)) + val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull() + val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull() + if (dollarLimit != null) { + InfoRow( + label = stringResource(R.string.trae_intl_dollar_usage_title), + value = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit) + ) + } val quotaUnit = stringResource(R.string.trae_intl_quota_unit) InfoRow( label = stringResource(R.string.trae_intl_premium_fast_title), @@ -1548,6 +1556,17 @@ internal fun TraeAccountInfoCard(state: State) { 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) } 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 d02a85e..6295489 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -803,4 +803,5 @@ 国际版没有每日签到 Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。 月配额 + 美元用量额度 \ 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 21327ef..b553c5b 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -803,4 +803,5 @@ 國際版沒有每日簽到 Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。 月額度 + 美元用量額度 \ 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 e1e3938..ba80a64 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -808,4 +808,5 @@ 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 \ No newline at end of file From 2a7e06acf36d8d131837add463f43c9630514660 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Fri, 9 Oct 2026 23:16:06 +0800 Subject: [PATCH 62/69] =?UTF-8?q?chore(update):=20=E5=8F=91=E5=B8=83=201.7?= =?UTF-8?q?.20=20=E6=9B=B4=E6=96=B0=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- update.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/update.json b/update.json index 4d18366..d2be546 100644 --- a/update.json +++ b/update.json @@ -1,6 +1,6 @@ { - "versionCode": 39, - "versionName": "1.7.19", - "apkUrl": "http://124.222.97.212:8443/attachments/4431d774-f4ff-4dcf-b188-00cc7ffaf593", - "notes": "详情页主余额改为 Free plan 月配额展示,不再显示 0 Credits" + "versionCode": 40, + "versionName": "1.7.20", + "apkUrl": "http://124.222.97.212:8443/attachments/28bb378e-c34a-4f55-b17a-c74f76b596a7", + "notes": "国际版 Trae 显示美元用量额度 $0.00/$1.00 + 月请求配额" } From dab9b7b272a114408d88af2489defab360f47166 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 00:17:03 +0800 Subject: [PATCH 63/69] =?UTF-8?q?feat(trae):=20=E8=AF=A6=E6=83=85=E9=A1=B5?= =?UTF-8?q?=E7=9B=B4=E6=8E=A5=E5=B1=95=E7=A4=BA=20INTL=20=E7=94=A8?= =?UTF-8?q?=E9=87=8F=E4=BA=8B=E4=BB=B6=EF=BC=8CINTL=20=E5=85=85=E5=80=BC?= =?UTF-8?q?=E6=94=B9=E5=A4=96=E9=83=A8=E6=B5=8F=E8=A7=88=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../token/data/repository/TraeRepository.kt | 86 ++++++ .../domain/service/ServiceConfigProvider.kt | 15 + .../com/rainy/token/ui/RainyTokenNavHost.kt | 65 +++-- .../token/ui/account/ApiManagementScreen.kt | 10 +- .../ui/account/ApiManagementViewModel.kt | 13 +- .../token/ui/dashboard/DashboardScreen.kt | 8 +- .../token/ui/dashboard/DashboardViewModel.kt | 7 +- .../ui/servicedetail/ServiceDetailScreen.kt | 258 ++++++++++++++---- .../servicedetail/ServiceDetailViewModel.kt | 51 +++- app/src/main/res/values-b+zh+Hans/strings.xml | 6 + app/src/main/res/values-b+zh+Hant/strings.xml | 6 + app/src/main/res/values/strings.xml | 6 + 13 files changed, 454 insertions(+), 81 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f1d1e65..45d7e94 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 = 40 - versionName = "1.7.20" + versionCode = 41 + versionName = "1.7.21" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 c97ddf2..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()) @@ -763,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/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 56a4de6..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 /** * 应用导航图。 @@ -140,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() @@ -229,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) }, @@ -239,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)) } } ) @@ -415,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, @@ -428,9 +444,11 @@ private fun CompactNavHost() { }, onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) }, onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) }, - onOpenRecharge = { svc -> - ServiceConfigProvider.get(svc).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + 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)) } } ) @@ -471,6 +489,7 @@ private fun ExpandedLayout() { prevPane = detailPane } + val context = LocalContext.current val rechargeLabel = stringResource(R.string.action_recharge) Row(modifier = Modifier.fillMaxSize()) { @@ -488,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 @@ -546,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, @@ -554,9 +576,11 @@ private fun ExpandedDetailPane( onSwitchAccount = onSwitchAccount, onConfigureCredential = { onClose() }, onStartWebViewLogin = { _, _, _ -> }, - onOpenRecharge = { svc -> - ServiceConfigProvider.get(svc).externalUrl?.let { url -> - navController.navigate(Routes.webviewPage(url, rechargeLabel)) + 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)) } } ) @@ -664,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)) } } ) 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/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 881c93c..c48e1a5 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 @@ -115,7 +117,7 @@ fun ServiceDetailScreen( onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onConfigureCredential: (ServiceType) -> Unit, onStartWebViewLogin: (ServiceType, String?, String?) -> Unit, - onOpenRecharge: (ServiceType) -> 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,27 +238,31 @@ fun ServiceDetailScreen( item { OllamaUsageCard(uiState.state) } } ServiceType.TRAE -> { - // 每日签到卡;未配置凭据时展示引导卡,国际版无签到则展示说明卡 - item { - val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true) - if (intl) { - TraeIntlNoCheckinCard() - } else 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 { @@ -319,7 +326,9 @@ fun ServiceDetailScreen( onRefresh = { viewModel.refresh() }, onConfigureCredential = { onConfigureCredential(service) }, onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) }, - onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } }, + 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) } @@ -1096,46 +1105,37 @@ private fun MainBalanceCard(state: State, service: ServiceType) { @Composable private fun TraeIntlBigNumber(extras: Map) { - val planName = extras["intl.plan_name"] ?: "Free plan" + val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull() + val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull() Text( - text = planName, + 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 = stringResource(R.string.trae_intl_monthly_quota), + 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) ) - Spacer(modifier = Modifier.height(6.dp)) - val dollarUsed = extras["intl.dollar_used"]?.toDoubleOrNull() - val dollarLimit = extras["intl.dollar_limit"]?.toDoubleOrNull() if (dollarLimit != null) { - InfoRow( - label = stringResource(R.string.trae_intl_dollar_usage_title), - value = String.format(Locale.US, "\$%.2f / \$%.2f", dollarUsed ?: 0.0, dollarLimit) + 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() ) } - val quotaUnit = stringResource(R.string.trae_intl_quota_unit) - InfoRow( - label = stringResource(R.string.trae_intl_premium_fast_title), - value = (extras["intl.premium_fast_limit"] ?: "—") + " $quotaUnit" - ) - InfoRow( - label = stringResource(R.string.trae_intl_premium_slow_title), - value = (extras["intl.premium_slow_limit"] ?: "—") + " $quotaUnit" - ) - InfoRow( - label = stringResource(R.string.trae_intl_advanced_title), - value = (extras["intl.advanced_limit"] ?: "—") + " $quotaUnit" - ) - InfoRow( - label = stringResource(R.string.trae_intl_auto_completion_title), - value = (extras["intl.auto_completion_limit"] ?: "—") + " $quotaUnit" - ) } @Composable @@ -1613,6 +1613,170 @@ 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( 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 a7aa4ae..bfdef13 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 @@ -415,6 +449,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 } @@ -461,6 +496,9 @@ class ServiceDetailViewModel @Inject constructor( else -> null } _uiState.update { it.copy(region = currentRegion) } + if (type == ServiceType.TRAE && newHasCredential) { + preloadUsageEvents(type, accountId) + } when (change) { CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> { @@ -590,6 +628,9 @@ class ServiceDetailViewModel @Inject constructor( ) } } + if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) { + preloadUsageEvents(type, accountId) + } } } @@ -1012,6 +1053,14 @@ 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, 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 6295489..1fa83c9 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -804,4 +804,10 @@ 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 b553c5b..8f6dedd 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -804,4 +804,10 @@ 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 ba80a64..fd8f264 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -809,4 +809,10 @@ 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 From 525b91f370854ea9321e2990a425109b41521cb4 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 01:17:56 +0800 Subject: [PATCH 64/69] =?UTF-8?q?feat(login):=20Trae/WorkBuddy=20INTL=20?= =?UTF-8?q?=E6=8F=90=E4=BE=9B=E6=89=8B=E6=9C=BA=E6=B5=8F=E8=A7=88=E5=99=A8?= =?UTF-8?q?=E7=99=BB=E5=BD=95=E5=B9=B6=E8=87=AA=E5=8A=A8=E5=9B=9E=E5=A1=AB?= =?UTF-8?q?=EF=BC=9BTrae=20CN=20=E4=BF=9D=E7=95=99=E8=BD=AF=E4=BB=B6?= =?UTF-8?q?=E5=86=85=E7=99=BB=E5=BD=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 +- .../ui/servicedetail/ServiceDetailScreen.kt | 14 +++++ .../token/ui/settings/CredentialEditScreen.kt | 62 +++++++++++-------- .../token/ui/webview/WebViewLoginScreen.kt | 13 ++++ app/src/main/res/values-b+zh+Hans/strings.xml | 1 + app/src/main/res/values-b+zh+Hant/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 7 files changed, 69 insertions(+), 27 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 45d7e94..ae369d6 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 = 41 - versionName = "1.7.21" + versionCode = 42 + versionName = "1.7.22" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { 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 c48e1a5..b68dadc 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 @@ -319,6 +319,7 @@ fun ServiceDetailScreen( hasCredential = uiState.hasCredential, isManualMode = isManualMode, service = service, + region = uiState.region, triggerState = triggerState, models = models, selectedModel = selectedModel, @@ -2105,6 +2106,7 @@ private fun ActionButtons( hasCredential: Boolean, isManualMode: Boolean, service: ServiceType, + region: String?, triggerState: TriggerState, models: List, selectedModel: String?, @@ -2225,6 +2227,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/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 7448125..eb5ffba 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 @@ -816,27 +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)) + } } - 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)) + 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, @@ -976,11 +986,13 @@ 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)) + } } Text( text = stringResource(R.string.field_trae_region), 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 19102cc..cf039a6 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 @@ -37,6 +37,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.key import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext @@ -77,6 +78,18 @@ fun WebViewLoginScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current + // Trae/WorkBuddy 国际版:进入即用手机浏览器登录,回调/轮询结果自动回填到应用 + val browserLoginService = service == ServiceType.TRAE || service == ServiceType.WORKBUDDY + val browserLoginRegion = region != null && region.equals("INTL", ignoreCase = true) + var autoBrowserStarted by rememberSaveable { mutableStateOf(false) } + LaunchedEffect(uiState.loginUrl, uiState.externalBrowserWaiting, uiState.loginSucceeded, autoBrowserStarted) { + if (browserLoginService && browserLoginRegion && !autoBrowserStarted && + uiState.loginUrl.isNotBlank() && !uiState.externalBrowserWaiting && !uiState.loginSucceeded + ) { + autoBrowserStarted = true + viewModel.openExternalBrowser() + } + } var webViewGeneration by remember { mutableStateOf(0) } var pageLoading by remember { mutableStateOf(true) } var pageError by remember { mutableStateOf(null) } 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 1fa83c9..d996177 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 @@ 清除 保存 软件内登录 + 手机浏览器登录 跳转 ◀ 上一页 下一页 ▶ 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 8f6dedd..40e61b3 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 @@ 清除 儲存 軟體內登入 + 手機瀏覽器登入 跳轉 ◀ 上一頁 下一頁 ▶ diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd8f264..59f6e38 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 ▶ From 1dd72986f52b99a6c84728b64cb987bef917faa2 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 01:45:05 +0800 Subject: [PATCH 65/69] =?UTF-8?q?fix(wb):=20CN=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E8=BD=AF=E4=BB=B6=E5=86=85=E7=99=BB=E5=BD=95=EF=BC=9BINTL=20?= =?UTF-8?q?=E4=BB=8E=E7=BB=91=E5=AE=9A=E5=90=8E=E7=9B=B4=E6=8E=A5=E6=89=93?= =?UTF-8?q?=E5=BC=80=E7=B3=BB=E7=BB=9F=E6=B5=8F=E8=A7=88=E5=99=A8=E5=B9=B6?= =?UTF-8?q?=E6=8C=89=E5=AE=98=E6=96=B9=E6=8E=A5=E5=8F=A3=E8=BD=AE=E8=AF=A2?= =?UTF-8?q?=E5=9B=9E=E5=A1=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../rainy/token/ui/settings/CredentialEditScreen.kt | 7 +++++++ .../rainy/token/ui/webview/WebViewLoginScreen.kt | 13 ------------- .../rainy/token/ui/webview/WebViewLoginViewModel.kt | 12 ++++++++++++ 3 files changed, 19 insertions(+), 13 deletions(-) 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 eb5ffba..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 @@ -993,6 +993,13 @@ private fun WorkBuddyCredentialForm( ) { 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), 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 cf039a6..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 @@ -37,7 +37,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.key import androidx.compose.runtime.remember -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext @@ -78,18 +77,6 @@ fun WebViewLoginScreen( val uiState by viewModel.uiState.collectAsStateWithLifecycle() val snackbarHostState = remember { SnackbarHostState() } val context = LocalContext.current - // Trae/WorkBuddy 国际版:进入即用手机浏览器登录,回调/轮询结果自动回填到应用 - val browserLoginService = service == ServiceType.TRAE || service == ServiceType.WORKBUDDY - val browserLoginRegion = region != null && region.equals("INTL", ignoreCase = true) - var autoBrowserStarted by rememberSaveable { mutableStateOf(false) } - LaunchedEffect(uiState.loginUrl, uiState.externalBrowserWaiting, uiState.loginSucceeded, autoBrowserStarted) { - if (browserLoginService && browserLoginRegion && !autoBrowserStarted && - uiState.loginUrl.isNotBlank() && !uiState.externalBrowserWaiting && !uiState.loginSucceeded - ) { - autoBrowserStarted = true - viewModel.openExternalBrowser() - } - } var webViewGeneration by remember { mutableStateOf(0) } var pageLoading by remember { mutableStateOf(true) } var pageError by remember { mutableStateOf(null) } 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 9010707..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 @@ -152,6 +152,10 @@ class WebViewLoginViewModel @Inject constructor( val deviceId = traeDeviceId() val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT) _uiState.update { it.copy(loginUrl = callbackUrl) } + if (region == "INTL") { + // 国际版登录页在系统浏览器完成,回调由本地服务器自动接收并回填 + openExternalBrowser() + } return } @@ -369,6 +373,8 @@ class WebViewLoginViewModel @Inject constructor( .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() @@ -397,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)) @@ -421,6 +431,8 @@ class WebViewLoginViewModel @Inject constructor( .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) { From 809b8db2d3fed980d7b3a401b6ae3a78a2b84e55 Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 01:59:06 +0800 Subject: [PATCH 66/69] =?UTF-8?q?fix(wb):=20=E4=BD=99=E9=A2=9D=E4=B8=8E=20?= =?UTF-8?q?token=20=E5=88=B7=E6=96=B0=E6=8C=89=E5=8C=BA=E5=9F=9F=E5=88=86?= =?UTF-8?q?=E6=B5=81=EF=BC=88INTL=20=E8=B5=B0=20workbuddy.ai=EF=BC=8CCN=20?= =?UTF-8?q?=E4=BF=9D=E6=8C=81=20workbuddy.cn/copilot.tencent.com=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/WorkBuddyRepository.kt | 46 +++++++++++++------ 1 file changed, 33 insertions(+), 13 deletions(-) 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..5da36eb 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() @@ -595,7 +615,7 @@ 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("Content-Type", "application/json") @@ -615,7 +635,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 { From f917e5bfd67bdb864dfd5f6ee70ef267684e861b Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 02:34:27 +0800 Subject: [PATCH 67/69] =?UTF-8?q?fix(wb):=20=E5=9B=BD=E9=99=85=E7=89=88?= =?UTF-8?q?=E6=97=A0=E7=8B=AC=E7=AB=8B=E7=AD=BE=E5=88=B0=E6=B4=BB=E5=8A=A8?= =?UTF-8?q?=EF=BC=8C=E4=B8=8D=E5=86=8D=E8=B0=83=20daily-checkin=EF=BC=8C?= =?UTF-8?q?=E8=AF=A6=E6=83=85=E9=A1=B5=E5=B1=95=E7=A4=BA=E8=AF=B4=E6=98=8E?= =?UTF-8?q?=E5=8D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/repository/WorkBuddyRepository.kt | 46 +++++++++++++++-- .../ui/servicedetail/ServiceDetailScreen.kt | 51 +++++++++++++++---- app/src/main/res/values-b+zh+Hans/strings.xml | 3 ++ app/src/main/res/values-b+zh+Hant/strings.xml | 3 ++ app/src/main/res/values/strings.xml | 3 ++ 5 files changed, 92 insertions(+), 14 deletions(-) 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 5da36eb..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 @@ -459,7 +459,9 @@ private fun parseResourceDetail(body: String): Map? { /** 今日已签到(幂等) */ ALREADY, /** 签到失败(服务端给了原因,见 message) */ - FAILED + FAILED, + /** 国际站无签到活动(活动未开启/未上线,非错误) */ + NO_CAMPAIGN } /** @@ -486,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( @@ -517,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) @@ -550,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") @@ -566,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 @@ -617,8 +644,21 @@ private fun parseResourceDetail(body: String): Map? { val request = Request.Builder() .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 { 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 b68dadc..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 @@ -265,17 +265,23 @@ fun ServiceDetailScreen( } } 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」+ 余额;套餐/冻结/付费信息归账户信息) @@ -1801,6 +1807,29 @@ private fun TraeIntlNoCheckinCard() { } } +@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) { 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 d996177..51663ff 100644 --- a/app/src/main/res/values-b+zh+Hans/strings.xml +++ b/app/src/main/res/values-b+zh+Hans/strings.xml @@ -228,6 +228,7 @@ 签到成功 今日已签到 今日已签到(连签 %1$d 天) + 暂无可领取的签到活动 签到失败,请检查凭据 该服务不支持签到 今日已签到 @@ -801,6 +802,8 @@ 充值 / 升级 套餐 基础用量已用 +国际版无独立签到 + WorkBuddy 国际版积分来自网页/客户端活跃使用,没有独立签到活动。 国际版没有每日签到 Trae 国际版按美元计费,使用月度请求额度,没有签到积分体系。 月配额 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 40e61b3..237ae7b 100644 --- a/app/src/main/res/values-b+zh+Hant/strings.xml +++ b/app/src/main/res/values-b+zh+Hant/strings.xml @@ -228,6 +228,7 @@ 簽到成功 今日已簽到 今日已簽到(連簽 %1$d 天) + 暫無可領取的簽到活動 簽到失敗,請檢查憑證 該服務不支援簽到 今日已簽到 @@ -801,6 +802,8 @@ 儲值 / 升級 套餐 基礎用量已用 +國際版無獨立簽到 + WorkBuddy 國際版積分來自網頁/客戶端活躍使用,沒有獨立簽到活動。 國際版沒有每日簽到 Trae 國際版按美元計費,使用月度請求額度,沒有簽到積分體系。 月額度 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 59f6e38..8e5150b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -231,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 @@ -806,6 +807,8 @@ 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 From 027eb6185d7531d8d45847019ea0e14c0ce3b37f Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 08:29:50 +0800 Subject: [PATCH 68/69] =?UTF-8?q?fix(wb):=20=E5=9B=BD=E9=99=85=E7=89=88?= =?UTF-8?q?=E5=8F=8D=E4=BB=A3=E8=B5=B0=20console=20=E8=B7=AF=E7=94=B1?= =?UTF-8?q?=E5=B9=B6=E8=A1=A5=E9=BD=90=E8=AF=B7=E6=B1=82=E5=A4=B4=EF=BC=9B?= =?UTF-8?q?=E6=A8=A1=E5=9E=8B=E7=9B=AE=E5=BD=95=E6=94=B9=20/v3/config=20?= =?UTF-8?q?=E4=B8=8E=20CLI=20=E5=90=8D=E5=8D=95=E8=BF=87=E6=BB=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../token/data/proxy/WorkBuddyChatProxy.kt | 221 ++++++++++++++---- .../data/proxy/WorkBuddyModelProvider.kt | 62 +++-- .../autocheckin/AutoCheckinManager.kt | 3 + .../servicedetail/ServiceDetailViewModel.kt | 2 + 4 files changed, 227 insertions(+), 61 deletions(-) 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 index c22a7cc..e72c758 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyChatProxy.kt @@ -12,12 +12,17 @@ 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(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。 + * 这里补齐桌面端请求头与上游 body 归一化,国际版先走 console 路由再退回 v2。 */ @Singleton class WorkBuddyChatProxy @Inject constructor( @@ -45,32 +50,28 @@ class WorkBuddyChatProxy @Inject constructor( } else { "https://copilot.tencent.com" } - val requestId = UUID.randomUUID().toString() - val builder = Request.Builder() - .url(base + "/v2/chat/completions") - .addHeader("Content-Type", "application/json") - .addHeader("Accept", "text/event-stream") - .addHeader("Authorization", "Bearer $accessToken") - .addHeader("User-Agent", "RainyToken/1.0") - .addHeader("X-IDE-Type", "VSCode") - .addHeader("X-IDE-Name", "CodeBuddy") - .addHeader("X-IDE-Version", "3.0.0") - .addHeader("X-Product", "CodeBuddy") - .addHeader("X-Agent-Intent", "craft") - .addHeader("X-Request-ID", requestId) - .addHeader("X-Conv-Request-ID", requestId) - .post(requestBody.toRequestBody("application/json".toMediaType())) - - val response = try { - okHttpClient.newCall(builder.build()).execute() - } catch (e: java.io.IOException) { - throw e + val 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) } - 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) + return@withContext null } suspend fun openStreamingChat( requestBody: String, @@ -91,37 +92,161 @@ class WorkBuddyChatProxy @Inject constructor( } 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 + "/v2/chat/completions") + .url(base + path) .addHeader("Content-Type", "application/json") - .addHeader("Accept", "text/event-stream") + .addHeader("Accept", "application/json, text/event-stream") .addHeader("Authorization", "Bearer $accessToken") - .addHeader("User-Agent", "RainyToken/1.0") - .addHeader("X-IDE-Type", "VSCode") - .addHeader("X-IDE-Name", "CodeBuddy") - .addHeader("X-IDE-Version", "3.0.0") - .addHeader("X-Product", "CodeBuddy") - .addHeader("X-Agent-Intent", "craft") + .addHeader("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) - .post(requestBody.toRequestBody("application/json".toMediaType())) + 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() + } - val response = try { - okHttpClient.newCall(builder.build()).execute() - } catch (e: java.io.IOException) { - throw e + /** + * 对齐 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 input = response.body?.byteStream() ?: run { - response.close() - return@withContext null + val maxCompletion = (root["max_completion_tokens"] as? JsonPrimitive)?.contentOrNull + ?.toLongOrNull() + if (maxCompletion != null && !out.containsKey("max_tokens")) { + out["max_tokens"] = JsonPrimitive(maxCompletion) } - ProxyUpstreamStream( - status = response.code, - contentType = response.header("Content-Type") ?: "text/event-stream", - input = input, - close = { response.close() }, - ) + 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 index f89e1f1..d3f4948 100644 --- a/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt +++ b/app/src/main/java/com/rainy/token/data/proxy/WorkBuddyModelProvider.kt @@ -37,12 +37,12 @@ class WorkBuddyModelProvider @Inject constructor( context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE) private val _modelsByRegion = MutableStateFlow( - ProviderRegion.entries.associateWith { defaultModels() } + ProviderRegion.entries.associateWith { defaultModels(it) } ) /** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */ fun modelsFor(region: ProviderRegion): List = - _modelsByRegion.value[region] ?: defaultModels() + _modelsByRegion.value[region] ?: defaultModels(region) private val json = Json { ignoreUnknownKeys = true } @@ -70,17 +70,23 @@ class WorkBuddyModelProvider @Inject constructor( ?: return@withContext false val intl = region == ProviderRegion.INTL val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com" - val url = base + "/v2/enterprises/personal/models" - + // 国内站官方 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.143.1", - "X-Product" to "SaaS", - "User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1", - "Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"), + "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 { @@ -112,13 +118,24 @@ class WorkBuddyModelProvider @Inject constructor( private fun parseModelList(body: String): List? { val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return null - val data = root["data"] as? JsonObject ?: return null - val models = data["models"] as? JsonArray ?: return null + // 国内站永远是 {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 models) { + 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) } @@ -130,12 +147,31 @@ class WorkBuddyModelProvider @Inject constructor( private const val KEY_MODEL_INTL = "wb_default_model_intl" private const val DEFAULT_MODEL = "hy4-preview" - /** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */ - fun defaultModels(): List = listOf( + /** 内置兜底(在线列表成功后会被替换)。 */ + 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/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/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index bfdef13..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 @@ -184,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)) From 75ccd2fe8e578441cb49dcd66735d9f235548bcd Mon Sep 17 00:00:00 2001 From: Liuxinyu176 <1041316040@qq.com> Date: Sat, 10 Oct 2026 10:12:09 +0800 Subject: [PATCH 69/69] =?UTF-8?q?chore(release):=201.7.23=EF=BC=8843?= =?UTF-8?q?=EF=BC=89=E5=8F=8D=E4=BB=A3=E4=BF=AE=E5=A4=8D=E7=89=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ae369d6..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 = 42 - versionName = "1.7.22" + versionCode = 43 + versionName = "1.7.23" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables {