Compare commits

...

91 Commits
v1.7.4 ... dev

Author SHA1 Message Date
f35bc6d3fc chore(update): 1.7.27(47)指向签到修复包 2026-10-10 12:36:39 +08:00
6cede91ec6 fix(checkin): 设备已签到文案按 ALREADY 标记;INTL 无活动当日不再重试 2026-10-10 12:34:45 +08:00
44057f3dc8 chore(update): 1.7.26(46)指向最新修复包 2026-10-10 12:19:58 +08:00
83dbf9e6ad fix(proxy): INTL 401 自动 refresh token 重试;Trae 重复签到文案判定为已签 2026-10-10 12:18:02 +08:00
18404d63e7 chore(update): 1.7.25(45)指向最新修复包 2026-10-10 11:52:16 +08:00
f10d333cbf chore(release): 1.7.25(45)修复版 2026-10-10 11:50:56 +08:00
1ad89763ad fix(proxy): 测试按区域选凭据不再误用默认账号;签到成功/已签即时落本地记录 2026-10-10 11:39:28 +08:00
cfbbc371c3 chore(update): 1.7.24 改用 release download 直链 2026-10-10 11:23:58 +08:00
2f9ce6ec82 chore(update): 1.7.24(44)指向最新修复包
Some checks are pending
Release / build (push) Waiting to run
2026-10-10 11:06:27 +08:00
b0a85265c2 chore(release): 1.7.24(44)修复版 2026-10-10 11:04:24 +08:00
f85b319083 chore(update): 1.7.23 指向 https 域名最新包 2026-10-10 10:39:33 +08:00
df357c1eeb fix(update): 更新清单改用 https 域名,避开 Clash 代理不可达的裸 IP 端口 2026-10-10 10:36:00 +08:00
38298ab944 chore(update): 1.7.23 指向测试区域修复包 2026-10-10 10:28:28 +08:00
5b7a4df868 fix(settings): 测试按钮跟随当前选中代理区域,不再固定国内 2026-10-10 10:26:13 +08:00
029114b72d Merge branch 'dev-proxy' into dev: 国际版反代/模型修复 2026-10-10 10:14:20 +08:00
f27eb7a8db chore(update): 1.7.23(43)反代修复版 2026-10-10 10:14:20 +08:00
75ccd2fe8e chore(release): 1.7.23(43)反代修复版 2026-10-10 10:12:09 +08:00
a31dad6e9f chore(update): 1.7.22 安装包换为反代修复版本 2026-10-10 08:35:59 +08:00
027eb6185d fix(wb): 国际版反代走 console 路由并补齐请求头;模型目录改 /v3/config 与 CLI 名单过滤 2026-10-10 08:29:50 +08:00
618eecf676 Merge pull request '_tmp_update' (#11) from _tmp_update into dev 2026-10-10 02:39:06 +08:00
8207285b23 update 1.7.22 final2
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:39:01 +08:00
f917e5bfd6 fix(wb): 国际版无独立签到活动,不再调 daily-checkin,详情页展示说明卡 2026-10-10 02:34:27 +08:00
82bd04597d Merge pull request '_tmp_update' (#3) from _tmp_update into dev 2026-10-10 02:00:16 +08:00
5850fc452c update 1.7.22 final
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 02:00:15 +08:00
809b8db2d3 fix(wb): 余额与 token 刷新按区域分流(INTL 走 workbuddy.ai,CN 保持 workbuddy.cn/copilot.tencent.com) 2026-10-10 01:59:06 +08:00
c6a75f8fce Merge pull request '_tmp_update' (#2) from _tmp_update into dev 2026-10-10 01:47:32 +08:00
a384309689 update 1.7.22
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 01:47:31 +08:00
1dd72986f5 fix(wb): CN 恢复软件内登录;INTL 从绑定后直接打开系统浏览器并按官方接口轮询回填 2026-10-10 01:45:05 +08:00
525b91f370 feat(login): Trae/WorkBuddy INTL 提供手机浏览器登录并自动回填;Trae CN 保留软件内登录 2026-10-10 01:17:56 +08:00
8692014340 Merge pull request 'chore(update): publish 1.7.21 manifest' (#1) from _tmp_update into dev
Some checks are pending
Release / build (push) Waiting to run
2026-10-10 00:25:54 +08:00
15c6bc24c4 chore(update): 发布 1.7.21 更新清单
Some checks are pending
CI / Unit Tests & Lint (pull_request) Waiting to run
CI / Debug APK (pull_request) Blocked by required conditions
CI / Release APK (debug-signed) (pull_request) Blocked by required conditions
2026-10-10 00:25:12 +08:00
dab9b7b272 feat(trae): 详情页直接展示 INTL 用量事件,INTL 充值改外部浏览器 2026-10-10 00:17:03 +08:00
460bed7279 chore(update): 发布 1.7.20 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 23:16:10 +08:00
2a7e06acf3 chore(update): 发布 1.7.20 更新清单 2026-10-09 23:16:06 +08:00
5a12f10d4e feat(trae): INTL 显示美元用量额度 $0.00 / $1.00
- 详情页主余额与首页卡片新增「美元用量额度」行(basic_usage_limit/used)
- 账户信息卡同步展示已用/总额美元额度
- 设备已实测:Free plan / $0.00 / $1.00 + 月请求配额
2026-10-09 23:14:52 +08:00
ecbc86fdf2 chore(update): 发布 1.7.19 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:43:53 +08:00
94bb4de0b4 chore(update): 发布 1.7.19 更新清单 2026-10-09 22:43:50 +08:00
6dfb9ecb71 fix(trae): 详情页主余额改为 Free plan 月配额展示
- 详情页顶部「当前余额」在 INTL 美元计费时显示套餐名 + 月配额明细,不再显示 0 Credits
- 设备已实测:Free plan / 10次/月 / 50次/月 / 1000次/月 / 5000次/月
2026-10-09 22:42:46 +08:00
05b5107f46 chore(update): 发布 1.7.18 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:32:24 +08:00
8e22d1ab9f chore(update): 发布 1.7.18 更新清单 2026-10-09 22:32:21 +08:00
8062a54252 fix(trae): 国际版移除签到卡,详情页按美元/月配额展示
- INTL 详细页不再显示每日签到卡(国际版无签到体系),改为说明卡
- Trae 账户信息卡在美元计费时显示套餐名 + Premium 快速/慢速、高级、自动补全月配额
- INTL 不再生成 0 积分权益包计数,避免残留“积分”字样
2026-10-09 22:31:20 +08:00
bccd7bad83 chore(update): 发布 1.7.17 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 22:14:31 +08:00
0ca6ccd384 chore(update): 发布 1.7.17 更新清单 2026-10-09 22:14:25 +08:00
cf6f426a36 feat(trae): INTL 美元计费展示 + apiHost + 充值入口 + INTL 网关修正
- 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(实测接口有效)
2026-10-09 22:11:48 +08:00
c5120c5952 chore(update): 发布 1.7.16 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:39:17 +08:00
4ae2aa01b6 chore(update): 发布 1.7.16 更新清单 2026-10-09 18:39:12 +08:00
deea17bf6e feat(trae): 粘贴回调导入 JWT + 浏览器回调服务器自动回传
- Trae 表单新增「登录回调链接」粘贴框,支持整条 http://127.0.0.1:18080/authorize?... 解析并填入 JWT
- 「在浏览器中打开登录入口」改为启动本地 18081 回调服务器 + 浏览器登录,登录完自动保存(Sub2API 同款)
2026-10-09 18:38:01 +08:00
2c5a3b2055 chore(update): 发布 1.7.15 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 18:02:12 +08:00
df84aa94ab chore(update): 发布 1.7.15 更新清单 2026-10-09 18:02:09 +08:00
68cdbcf0b0 fix(trae): 回调兼容 localhost/fragment,pageStarted 兜底拦截
- 识别任意 127.0.0.1/localhost:18080/authorize 回调(http/https、query/fragment)
- shouldOverrideUrlLoading 未触发时在 onPageStarted 拦截并停止加载,避免卡 Authenticating
2026-10-09 18:01:06 +08:00
c76193e892 chore(update): 发布 1.7.14 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:22:54 +08:00
101e2bc240 chore(update): 发布 1.7.14 更新清单 2026-10-09 17:22:50 +08:00
9db3e01d49 fix(trae): 支持回调仅带 refreshToken 时自动兑换 JWT
- 登录回调不再只认 userJwt;老流程只回传 refreshToken 时调用 ExchangeToken 兑换 Cloud-IDE-JWT
- 兑换失败给出明确提示,不再永远停在 Authenticating
2026-10-09 17:21:48 +08:00
6edd68f80e chore(update): 发布 1.7.13 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 17:12:43 +08:00
57bd49f470 chore(update): 发布 1.7.13 更新清单 2026-10-09 17:12:39 +08:00
56969cb879 fix(webview): 子资源连接关闭不再误报整页失败
- ERR_CONNECTION_CLOSED 若来自图片/脚本/统计等子资源,忽略并继续等待主页面
- 页面进度继续推进时自动清除瞬时错误提示
2026-10-09 17:11:35 +08:00
750b9d9f90 chore(update): 发布 1.7.12 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:42:53 +08:00
b62dc90c40 chore(update): 发布 1.7.12 更新清单 2026-10-09 16:42:45 +08:00
d5a152ef1e fix(wb): 按官方客户端补齐登录请求头并延长 INTL 授权超时
- WorkBuddy auth/state 改用 VSCode(国内)/workbuddy-ai(INTL) platform,与官方客户端一致
- 补齐 Origin/Referer/X-Client-ID/X-Client-Version/X-Product/X-No-Authorization
- INTL 浏览器登录等待时间从 120s 延长到 15 分钟,普通用户邮箱/验证码流程不会再中途超时
2026-10-09 16:41:35 +08:00
0f4d68033e chore(update): 发布 1.7.11 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 16:33:50 +08:00
62304ecc9b chore(update): 发布 1.7.11 更新清单 2026-10-09 16:33:46 +08:00
7574c3c7fb fix(login): 页面加载反馈 + WB 凭证解析容错 + 系统浏览器登录入口
- 登录页新增加载进度条、加载失败提示和重试
- 顶部新增「在浏览器打开」:Trae 谷歌登录被 WebView 拦时可走系统浏览器(沿用本机 Chrome 的 Google 登录态)
- WorkBuddy 轮询兼容 accessToken/access_token/token/authToken 等返回结构,失败时打印调试日志
2026-10-09 16:32:36 +08:00
7c7a23f097 chore(update): 发布 1.7.10 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:36:25 +08:00
b94e2f6994 chore(update): 发布 1.7.10 更新清单 2026-10-09 15:36:21 +08:00
46b4d457b8 fix(login): 每次登录自动清除 WebView 登录态
登录前自动清 Cookie/WebStorage,避免上一个号码的登录态串到新账号。账号凭据本身已按账号 ID 独立保存。
2026-10-09 15:35:19 +08:00
7c3809e879 chore(update): 发布 1.7.9 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 15:29:08 +08:00
3c2b2ec16e chore(update): 发布 1.7.9 更新清单 2026-10-09 15:29:04 +08:00
d6e4d34a42 fix(login): INT L 登录走对应域名 + 可清除 WebView 登录态
- Trae INTL 登录改打开 trae.ai 授权页,不再误开 trae.cn
- WorkBuddy auth state/token 按区域切换 copilot.tencent.com / workbuddy.ai
- 登录页新增「清除登录」按钮:清 Cookie/WebStorage 后重开,避免沿用上一个号码的登录态
- 新建账号表单保持留空(确认逻辑已存在),登录结果回填新账号
- 版本升至 1.7.9
2026-10-09 15:28:00 +08:00
7ad49e994d chore(update): 发布 1.7.8 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 12:27:18 +08:00
e914633797 chore(update): 发布 1.7.8 更新清单 2026-10-09 12:27:15 +08:00
eb4451c4de feat(proxy): CN/INTL 配置与模型列表完全分离
- 模型 Provider 改为按区域独立列表:Trae CN/INTL、WorkBuddy CN/INTL 各自拉取,不再互相覆盖
- 拉模型按区域取账号:未配置该区域账号时返回失败并保留内置兜底
- WorkBuddy 凭据表单补充区域(CN/INTL)选择,解决国际版无处配置的问题
- 账号管理列表显示 CN/INTL 区域徽标,一眼区分
- 版本升至 1.7.8
2026-10-09 12:26:12 +08:00
8f40360b9f chore(update): 发布 1.7.7 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 11:51:52 +08:00
1a96bcb105 feat(proxy): WorkBuddyModelProvider 增加 supports 判断
供聚合路由按模型匹配 WorkBuddy 供应商。
2026-10-09 11:51:24 +08:00
fee433d2fc chore(update): 发布 1.7.7 更新清单 2026-10-09 11:50:44 +08:00
597632dad3 feat(proxy): 聚合网关——按模型自动选供应商并失败切换
Some checks failed
Release / build (push) Has been cancelled
- ProviderRouter 重构为聚合路由:请求只写 model,网关自动匹配供应商
- 候选按历史速度排序,上游不可用/HTTP>=400/抛错自动切换下一个
- Sub2API 作为通用兜底;显式前缀 traecn-/wbintl- 仍可强制指定
- 显式前缀转发前自动剥离,避免上游收到 traecn-glm-5.2 这类模型名
- 内存速度/冷却统计:失败供应商冷却 30s 起步、指数退避
- 版本升至 1.7.7
2026-10-09 11:49:38 +08:00
08a3b42a82 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:23:01 +08:00
73ff6b2ee7 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:22:57 +08:00
f13b38c02b feat(proxy): 按代理/区域区分模型列表与选择
Some checks failed
Release / build (push) Has been cancelled
- 新增“代理”下拉:Trae CN/INTL、WorkBuddy CN/INTL
- WorkBuddyModelProvider 在线拉取 /v2/enterprises/personal/models
- Trae/WorkBuddy 各区域独立记住所选测试模型
- 真实代理转发仍按前缀自动路由,不受测试选择影响
- 版本升至 1.7.6
2026-10-09 11:21:53 +08:00
aab50bbe78 chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:35 +08:00
0283ce6bdc chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:31 +08:00
0241840f9c feat(proxy): Trae 模型切换 + 在线拉取模型列表
Some checks failed
Release / build (push) Has been cancelled
- 设置页新增模型下拉选择,内置参考映射兜底
- TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models)
- 选中模型持久化,作为反代缺省模型
- 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型
- 修复探针测试请求体混入反斜杠导致 JSON 无效的问题
- 版本升至 1.7.5
2026-10-09 11:03:27 +08:00
faf7f40524 chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:27 +08:00
8a9d9432fe chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:24 +08:00
342788056b chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:48 +08:00
b479cb2a7a chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:57 +08:00
6e0d67afb7 chore(update): 发布 1.7.1 更新清单 2026-10-09 10:13:03 +08:00
8d68c3b280 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:32 +08:00
71f3f8d554 chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:13 +08:00
dfd91a5355 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:40:01 +08:00
149fc5f125 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:31:01 +08:00
95cde916ca chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:15 +08:00
36 changed files with 2920 additions and 380 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 24
versionName = "1.7.4"
versionCode = 47
versionName = "1.7.27"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -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, ""))

View File

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

View File

@ -1,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<ProviderTarget, Long>()
private val cooldownUntil = ConcurrentHashMap<ProviderTarget, Long>()
private val failCount = ConcurrentHashMap<ProviderTarget, Int>()
private fun recordSuccess(target: ProviderTarget, startedMs: Long, status: Int) {
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
val old = avgLatency[target]
avgLatency[target] = if (old == null) ms else (old * 3 + ms) / 4
cooldownUntil.remove(target)
failCount.remove(target)
}
private fun recordFail(target: ProviderTarget, startedMs: Long) {
val ms = (System.currentTimeMillis() - startedMs).coerceAtLeast(1L)
val old = avgLatency[target]
avgLatency[target] = if (old == null) ms + 5000L else old + 5000L
val fails = (failCount[target] ?: 0) + 1
failCount[target] = fails
cooldownUntil[target] = System.currentTimeMillis() + 30_000L * fails
}
private fun cooling(target: ProviderTarget): Boolean {
val until = cooldownUntil[target] ?: return false
if (System.currentTimeMillis() >= until) {
cooldownUntil.remove(target)
return false
}
return true
}
private fun orderBySpeed(input: List<Candidate>): List<Candidate> = input.sortedWith(
compareBy<Candidate>(
{ cooling(it.target) },
{ avgLatency[it.target] ?: Long.MAX_VALUE },
{ if (it.target == ProviderTarget.SUB2API) 1 else 0 },
)
)
// ---- 流式转发(带失败切换) ----
suspend fun openStreamingChat(
requestBody: String,
conversationId: String? = null,
): ProxyUpstreamStream? {
val model = extractModel(requestBody)
val forced = explicitTarget(model)
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
val candidates = candidatesFor(model, conversationId, forced)
for (candidate in candidates) {
val started = System.currentTimeMillis()
val stream = try {
when (candidate.target.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddyChatProxy.openStreamingChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
traeChatProxy.openStreamingChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
else ->
sub2ApiChatProxy.openStreamingChat(upstreamBody, candidate.accountId)
}
} catch (e: java.io.IOException) {
recordFail(candidate.target, started)
null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
recordFail(candidate.target, started)
null
}
if (stream == null) {
recordFail(candidate.target, started)
continue
}
if (stream.status >= 400) {
stream.close()
recordFail(candidate.target, started)
continue
}
recordSuccess(candidate.target, started, stream.status)
return stream
}
return null
}
// ---- 非流式转发(带失败切换) ----
suspend fun forwardChat(
requestBody: String,
conversationId: String? = null,
): ProxyUpstreamResponse? {
val model = extractModel(requestBody)
val forced = explicitTarget(model)
val upstreamBody = if (forced != null) stripModelInBody(requestBody, model) else requestBody
val candidates = candidatesFor(model, conversationId, forced)
for (candidate in candidates) {
val started = System.currentTimeMillis()
val result = try {
when (candidate.target.kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL ->
workBuddyChatProxy.forwardChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL ->
traeChatProxy.forwardChat(
upstreamBody, candidate.accountId, candidate.target.region,
)
else ->
sub2ApiChatProxy.forwardChat(upstreamBody, candidate.accountId)
}
} catch (e: java.io.IOException) {
recordFail(candidate.target, started)
null
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
recordFail(candidate.target, started)
null
}
if (result == null) {
recordFail(candidate.target, started)
continue
}
if (result.status >= 400) {
recordFail(candidate.target, started)
continue
}
recordSuccess(candidate.target, started, result.status)
return result
}
return null
}
// ---- 候选构建 ----
private suspend fun candidatesFor(
model: String,
conversationId: String?,
forced: ProviderTarget?,
): List<Candidate> {
val targets = if (forced != null) {
listOf(forced)
} else {
capableTargets(model)
}
val built = targets.mapNotNull { target ->
val pooled = accountPool.next(target.kind, target.region, conversationId)
pooled?.accountId?.let { Candidate(target, it) }
}
return orderBySpeed(built)
}
private fun capableTargets(model: String): List<ProviderTarget> {
val m = model.trim().lowercase()
if (m.isBlank() || m == "auto") {
return listOf(
ProviderTarget.TRAE_CN,
ProviderTarget.WORKBUDDY_CN,
ProviderTarget.TRAE_INTL,
ProviderTarget.WORKBUDDY_INTL,
ProviderTarget.SUB2API,
)
}
val out = mutableListOf<ProviderTarget>()
if (traeModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.TRAE_CN
if (traeModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.TRAE_INTL
if (workBuddyModelProvider.supports(model, ProviderRegion.CN)) out += ProviderTarget.WORKBUDDY_CN
if (workBuddyModelProvider.supports(model, ProviderRegion.INTL)) out += ProviderTarget.WORKBUDDY_INTL
// 通用兜底:Sub2API 是 OpenAI 兼容实例,什么模型都可能支持
out += ProviderTarget.SUB2API
return out.distinct().ifEmpty { listOf(ProviderTarget.TRAE_CN) }
}
private fun explicitTarget(model: String): ProviderTarget? {
val m = model.trim().lowercase()
return when {
m.startsWith("wbcn-") || m.startsWith("workbuddy-cn") || m.startsWith("codebuddy-") ->
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() ?: ""
}

View File

@ -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
}
}
/**

View File

@ -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 }
@ -45,7 +46,7 @@ class TraeChatProxy @Inject constructor(
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
val credential = pickCredential(accountId, forcedRegion)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
@ -54,12 +55,8 @@ 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 upstreamBody = buildUpstreamBody(requestBody, credential)
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
@ -84,7 +81,7 @@ class TraeChatProxy @Inject constructor(
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
val credential = pickCredential(accountId, forcedRegion)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
@ -93,12 +90,8 @@ 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 upstreamBody = buildUpstreamBody(requestBody, credential)
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
@ -228,15 +221,58 @@ class TraeChatProxy @Inject constructor(
return null
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential): String {
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
private fun normalizeModel(raw: String?, region: ProviderRegion?): String {
val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN)
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected
if (id.equals("auto", ignoreCase = true)) return selected
return MODEL_ALIASES[id.lowercase()] ?: id
}
/**
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
*/
/** 按区域选凭据:显式 accountId 优先;未指定时优先同区域账号,再兼容默认账号。 */
private suspend fun pickCredential(
accountId: String?,
region: ProviderRegion?,
): Credential.TraeCredential? {
if (!accountId.isNullOrBlank()) {
return credentialRepository.get(ServiceType.TRAE, accountId) as? Credential.TraeCredential
}
val accounts = credentialRepository.accountsFor(ServiceType.TRAE)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.TRAE, null) as? Credential.TraeCredential
?: return null
return if (region == null || legacy.region.equals(region.name, ignoreCase = true)) legacy else null
}
val all = accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential
}
if (all.isEmpty()) return null
if (region != null) {
all.firstOrNull { it.region.equals(region.name, ignoreCase = true) }?.let { return it }
}
return all.first()
}
private fun chatBase(region: ProviderRegion?): String =
if (region == ProviderRegion.INTL) {
"https://core-normal.trae.ai"
} else {
"https://trae-api-cn.mchost.guru"
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val 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, region)
val messages = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "")
@ -255,4 +291,53 @@ class TraeChatProxy @Inject constructor(
}
}.toString()
}
companion object {
/** 供路由判断模型是否可能由 Trae 消化。 */
internal fun resolveAlias(modelName: String): String? =
MODEL_ALIASES[modelName.trim().lowercase()]
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
private val MODEL_ALIASES = mapOf(
"auto" to "glm-5.2",
"gpt-4o" to "DeepSeek-V4-Pro",
"gpt-4o-latest" to "DeepSeek-V4-Pro",
"gpt-4.1" to "DeepSeek-V4-Pro",
"deepseek-v3" to "DeepSeek-V4-Pro",
"deepseek-r1" to "DeepSeek-V4-Pro",
"gpt-4o-mini" to "DeepSeek-V4-Flash",
"claude-opus-4-7" to "glm-5.2",
"claude-opus-4-6" to "glm-5.2",
"claude-opus-4-5" to "glm-5.2",
"claude-sonnet-4-6" to "glm-5.2",
"claude-sonnet-4-5" to "glm-5.2",
"claude-sonnet-4" to "glm-5.2",
"claude-3.7-sonnet" to "glm-5.2",
"claude-3-7-sonnet" to "glm-5.2",
"claude-3.5-sonnet" to "glm-5.2",
"claude-haiku-4-5" to "glm-5.1",
"deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official",
"deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official",
"doubao-seed-evolving" to "Doubao-Seed-Evolving",
"deepseek-v4-pro" to "DeepSeek-V4-Pro",
"deepseek-v4-flash" to "DeepSeek-V4-Flash",
"seed-2.1-pro" to "Doubao-Seed-2.1-Pro",
"seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo",
"seed-code" to "Doubao-Seed-Code",
"seed-evolving" to "Doubao-Seed-Evolving",
"qwen3.7-plus" to "qwen-3.7-plus",
"kimi-k2.6" to "kimi-k2.6",
"kimi-k3" to "kimi-k3",
"kimi-k2.7-code" to "kimi-k2.7-code",
"kimi-k2" to "kimi-k2",
"kimi-k2.5" to "kimi-k2.5",
"minimax-m3" to "minimax-m3",
"minimax-m2.7" to "minimax-m2.7",
"minimax-m2.1" to "minimax-m2.1",
"mimo-v2.5" to "mimo-v2.5",
"mimo-v2.5-pro" to "mimo-v2.5-pro",
"minimax-m25" to "minimax-m25",
"qwen36-35b" to "qwen36-35b",
)
}
}

View File

@ -0,0 +1,191 @@
package com.rainy.token.data.proxy
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* Trae 模型列表:内置参考映射 + 在线拉取账号可用模型 + 记住用户选择。
* 选择结果同时供设置页测试和 [TraeChatProxy] 作为缺省模型使用。
*/
@Singleton
class TraeModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
data class TraeModel(val id: String, val label: String? = null)
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true }
/** 当前区域选择的默认模型(未设置时 CN 用 GLM-5.2)。 */
fun selectedFor(region: ProviderRegion): String {
if (region == ProviderRegion.INTL) {
return prefs.getString(KEY_MODEL_INTL, DEFAULT_MODEL_INTL) ?: DEFAULT_MODEL_INTL
}
return prefs.getString(KEY_MODEL_CN, "glm-5.2") ?: "glm-5.2"
}
fun setSelectedFor(region: ProviderRegion, id: String) {
if (id.isBlank()) return
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(key, id).apply()
}
/** 该区域是否可能支持此模型(内置列表/在线列表/别名)。auto 视为支持。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
if (modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }) return true
return TraeChatProxy.resolveAlias(modelId) != null
}
/** 拉取指定区域账号的在线模型列表;该区域未配置或失败时保留内置列表并返回 false。 */
suspend fun refreshFor(forcedRegion: ProviderRegion?): Boolean = withContext(Dispatchers.IO) {
val effectiveRegion = forcedRegion ?: ProviderRegion.CN
val credential = pickCredential(effectiveRegion) ?: return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://core-normal.trae.ai" else "https://trae-api-cn.mchost.guru"
val origin = if (intl) "https://solo.trae.ai" else "https://solo.trae.cn"
val userRegion = if (intl) {
if (credential.apiHost?.contains("us", ignoreCase = true) == true) "US" else "SG"
} else "CN"
val url = base + "/api/remote/v1/models" +
"?functions=solo_agent_remote%2Csolo_work_remote%2Csolo_design_remote" +
"&show_custom_model=true"
val headers = linkedMapOf(
"Authorization" to "Cloud-IDE-JWT $jwt",
"Content-Type" to "application/json",
"X-Trae-Client-Type" to "web",
"X-Preferenced-Language" to "zh-CN",
"x-user-region" to userRegion,
"Origin" to origin,
"Referer" to origin + "/",
"User-Agent" to (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
"(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36"
),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
parseModelList(body)
}
} catch (_: Exception) {
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_modelsByRegion.value = _modelsByRegion.value + (effectiveRegion to parsed)
true
}
private suspend fun pickCredential(region: ProviderRegion): Credential.TraeCredential? {
val accounts = credentialRepository.accountsFor(ServiceType.TRAE)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.TRAE, null)
return (legacy as? Credential.TraeCredential)
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
}
return accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
}
private fun parseModelList(body: String): List<TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val list = (root["data"] as? JsonObject)?.get("list") as? JsonArray ?: return null
val seen = LinkedHashMap<String, TraeModel>()
for (groupEl in list) {
val group = groupEl as? JsonObject ?: continue
val models = group["models"] as? JsonArray ?: continue
for (rawEl in models) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["name"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
val label = (raw["display_name"] as? JsonPrimitive)?.contentOrNull
?: (raw["display_model_name"] as? JsonPrimitive)?.contentOrNull
?: id
seen[id] = TraeModel(id, label)
}
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL_CN = "trae_default_model_cn"
private const val KEY_MODEL_INTL = "trae_default_model_intl"
private const val DEFAULT_MODEL_INTL = "glm-5.2"
/** 内置兜底模型(参考 trae2api-cn 的模型映射 /v1/models 列表)。 */
fun defaultModels(): List<TraeModel> = listOf(
TraeModel("glm-5.2", "GLM-5.2"),
TraeModel("glm-5.3", "GLM-5.3"),
TraeModel("glm-5.1", "GLM-5.1"),
TraeModel("glm-5", "GLM-5"),
TraeModel("glm-4.7", "GLM-4.7"),
TraeModel("glm-4.6", "GLM-4.6"),
TraeModel("DeepSeek-V4-Pro", "DeepSeek-V4-Pro"),
TraeModel("DeepSeek-V4-Pro-Official", "DeepSeek-V4-Pro 正式版"),
TraeModel("DeepSeek-V4-Flash", "DeepSeek-V4-Flash"),
TraeModel("DeepSeek-V4-Flash-Official", "DeepSeek-V4-Flash 正式版"),
TraeModel("Doubao-Seed-2.1-Pro", "Doubao-Seed-2.1-Pro"),
TraeModel("Doubao-Seed-2.1-Turbo", "Doubao-Seed-2.1-Turbo"),
TraeModel("Doubao-Seed-Code", "Doubao-Seed-Code"),
TraeModel("Doubao-Seed-Evolving", "Doubao-Seed-Evolving"),
TraeModel("qwen-3.7-plus", "qwen-3.7-plus"),
TraeModel("qwen-3.6-plus", "qwen-3.6-plus"),
TraeModel("qwen3.8-max", "qwen3.8-max"),
TraeModel("qwen-3.5", "qwen-3.5"),
TraeModel("qwen3-coder", "qwen3-coder"),
TraeModel("kimi-k2.6", "kimi-k2.6"),
TraeModel("kimi-k3", "kimi-k3"),
TraeModel("kimi-k2.7-code", "kimi-k2.7-code"),
TraeModel("kimi-k2", "kimi-k2"),
TraeModel("kimi-k2.5", "kimi-k2.5"),
TraeModel("minimax-m3", "minimax-m3"),
TraeModel("minimax-m2.7", "minimax-m2.7"),
TraeModel("minimax-m2.1", "minimax-m2.1"),
TraeModel("mimo-v2.5-pro", "mimo-v2.5-pro"),
TraeModel("mimo-v2.5", "mimo-v2.5"),
TraeModel("minimax-m25", "minimax-m25"),
TraeModel("qwen36-35b", "qwen36-35b"),
TraeModel("work", "Work(200K 档)"),
)
}
}

View File

@ -1,6 +1,7 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.WorkBuddyRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
@ -12,17 +13,23 @@ 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(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val workBuddyRepository: WorkBuddyRepository,
) {
suspend fun forwardChat(
@ -31,7 +38,7 @@ class WorkBuddyChatProxy @Inject constructor(
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
val credential = pickCredential(accountId, forcedRegion)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
@ -45,39 +52,51 @@ 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 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")
var current = credential
var token = accessToken
for (path in paths) {
var attempt = 0
while (true) {
val request = buildChatRequest(base, path, upstreamBody, token, intl)
val response = try {
okHttpClient.newCall(builder.build()).execute()
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()
break
}
if ((status == 401 || status == 403) && attempt == 0) {
response.close()
val refreshed = workBuddyRepository.refreshCredential(current)
if (refreshed != null) {
credentialRepository.save(refreshed, accountId)
current = refreshed
token = refreshed.accessToken
attempt++
continue
}
}
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 ProxyUpstreamResponse(status, contentType, bytes)
}
}
return@withContext null
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
val credential = pickCredential(accountId, forcedRegion)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
@ -91,37 +110,201 @@ 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 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")
var current = credential
var token = accessToken
for (path in paths) {
var attempt = 0
while (true) {
val request = buildChatRequest(base, path, upstreamBody, token, intl)
val response = try {
okHttpClient.newCall(builder.build()).execute()
okHttpClient.newCall(request).execute()
} catch (e: java.io.IOException) {
throw e
}
if (response.code == 404 || response.code == 405) {
response.close()
break
}
if ((response.code == 401 || response.code == 403) && attempt == 0) {
response.close()
val refreshed = workBuddyRepository.refreshCredential(current)
if (refreshed != null) {
credentialRepository.save(refreshed, accountId)
current = refreshed
token = refreshed.accessToken
attempt++
continue
}
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
return@withContext ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
}
return@withContext null
}
/** 按区域选凭据:显式 accountId 优先;未指定时优先同区域账号,再兼容单账号旧档。 */
private suspend fun pickCredential(
accountId: String?,
region: ProviderRegion?,
): Credential.WorkBuddyCredential? {
if (!accountId.isNullOrBlank()) {
return credentialRepository.get(ServiceType.WORKBUDDY, accountId) as? Credential.WorkBuddyCredential
}
val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null) as? Credential.WorkBuddyCredential
?: return null
return if (region == null || legacy.region.equals(region.name, ignoreCase = true)) legacy else null
}
val all = accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential
}
if (all.isEmpty()) return null
if (region != null) {
all.firstOrNull { it.region.equals(region.name, ignoreCase = true) }?.let { return it }
}
return all.first()
}
private fun buildChatRequest(
base: String,
path: String,
upstreamBody: String,
accessToken: String,
intl: Boolean,
): Request {
val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + path)
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "application/json, text/event-stream")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Origin", origin)
.addHeader("Referer", origin + "/")
.addHeader("X-CodeBuddy-Request", "1")
.addHeader("Accept-Language", if (intl) "en-US" else "zh-CN")
.addHeader("X-No-User-Id", "1")
.addHeader("X-Agent-Purpose", "conversation")
.addHeader("X-IDE-Name", "WorkBuddy")
.addHeader("X-IDE-Type", "WorkBuddy")
.addHeader("X-IDE-Version", "2.63.2")
.addHeader("X-Product", "WorkBuddy")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
if (intl) {
// 国际站强制声明个人客户端:不携带 enterprise,域固定为 www.workbuddy.ai
builder.addHeader("X-No-Enterprise-Id", "1")
builder.addHeader("X-Domain", "www.workbuddy.ai")
} else {
builder.addHeader("X-No-Enterprise-Id", "1")
builder.addHeader("X-No-Department-Info", "1")
}
return builder.post(upstreamBody.toRequestBody("application/json".toMediaType())).build()
}
/**
* 对齐 dsh-workbuddy-connect 的上游归一化:
* - 上游拒绝非流式,强制 stream=true;
* - developer -> system(国内站直接拒绝 developer role);
* - 国际站首条必须 system,缺省则前置空 system(避免 11128);
* - max_completion_tokens 翻译成 max_tokens;
* - tool_choice 只接受字符串,对象形式拍平成 auto/工具名/none。
*/
private fun prepareChatBody(body: String, intl: Boolean): String {
val root = runCatching { Json.parseToJsonElement(body) as? JsonObject }.getOrNull() ?: return body
if (root.isEmpty()) return body
val out = LinkedHashMap(root)
out["stream"] = JsonPrimitive(true)
if (!out.containsKey("stream_options")) {
out["stream_options"] = JsonObject(
mapOf("include_usage" to JsonPrimitive(true))
)
}
val maxCompletion = (root["max_completion_tokens"] as? JsonPrimitive)?.contentOrNull
?.toLongOrNull()
if (maxCompletion != null && !out.containsKey("max_tokens")) {
out["max_tokens"] = JsonPrimitive(maxCompletion)
}
val messages = root["messages"] as? JsonArray
if (messages != null) {
var list = messages.map { msg ->
val obj = msg as? JsonObject ?: return@map msg
if ((obj["role"] as? JsonPrimitive)?.contentOrNull == "developer") {
JsonObject(obj.entries.associate { (key, value) ->
key to (if (key == "role") JsonPrimitive("system") else value)
})
} else {
obj
}
}.toMutableList()
if (intl) {
val firstRole = (list.firstOrNull() as? JsonObject)?.get("role") as? JsonPrimitive
if (firstRole?.contentOrNull != "system") {
val fallback = JsonObject(
mapOf(
"role" to JsonPrimitive("system"),
"content" to JsonPrimitive("You are a helpful assistant."),
)
)
list.add(0, fallback)
}
}
out["messages"] = JsonArray(list)
}
normalizeToolChoice(out)
return JsonObject(out).toString()
}
private fun normalizeToolChoice(out: MutableMap<String, kotlinx.serialization.json.JsonElement>) {
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")
}
}

View File

@ -0,0 +1,177 @@
package com.rainy.token.data.proxy
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* WorkBuddy / CodeBuddy 模型列表:
* 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。
*/
@Singleton
class WorkBuddyModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels(it) }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
_modelsByRegion.value[region] ?: defaultModels(region)
private val json = Json { ignoreUnknownKeys = true }
fun selectedFor(region: ProviderRegion): String {
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL
}
fun setSelectedFor(region: ProviderRegion, id: String) {
if (id.isBlank()) return
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(key, id).apply()
}
/** 该区域是否可能支持此模型(在线/内置列表)。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }
}
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = pickCredential(region) ?: return@withContext false
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext false
val intl = region == ProviderRegion.INTL
val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
// 国内站官方 CLI 吃 /console/enterprises/personal/models;国际站产品目录在 /v3/config,
// 且必须用 App 形态 UA,否则网关拒发(实测 code 12403)。
val url = base + if (intl) "/v3/config" else "/console/enterprises/personal/models"
val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
val headers = linkedMapOf(
"Authorization" to "Bearer $accessToken",
"Content-Type" to "application/json",
"Accept" to "application/json",
"X-Requested-With" to "XMLHttpRequest",
"Origin" to origin,
"Referer" to "$origin/",
"X-CodeBuddy-Request" to "1",
"Accept-Language" to (if (intl) "en-US" else "zh-CN"),
"X-Client-ID" to "codebuddy-cli",
"X-Client-Version" to "2.63.2",
"X-Product" to (if (intl) "SaaS" else "WorkBuddy"),
"User-Agent" to (if (intl) "WorkBuddyAI/5.5.2" else "CLI/2.63.2 CodeBuddy/2.63.2"),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
parseModelList(body)
}
} catch (_: Exception) {
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_modelsByRegion.value = _modelsByRegion.value + (region to parsed)
true
}
private suspend fun pickCredential(region: ProviderRegion): Credential.WorkBuddyCredential? {
val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null)
return (legacy as? Credential.WorkBuddyCredential)
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
}
return accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
}
private fun parseModelList(body: String): List<TraeModelProvider.TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
// 国内站永远是 {code,msg,data} 包络;国际站 /v3/config 有时裸返回产品文档。
val data = (root["data"] as? JsonObject) ?: root
val rawModels = data["models"] as? JsonArray ?: return null
val agents = data["agents"] as? JsonArray
// 只暴露 cli agent 可用的模型(国际站 App 目录里 agents[].name=="cli" 限定模型名单)
val cliIds = agents?.mapNotNull { it as? JsonObject }?.firstOrNull { agent ->
(agent["name"] as? JsonPrimitive)?.contentOrNull == "cli"
}?.get("models") as? JsonArray
// 有 agents 文档但找不到 cli 列表时,不猜模型,避免把 App 只读模型塞给 CLI 反代
if (agents != null && cliIds == null) return null
val allowed = cliIds?.mapNotNull { (it as? JsonPrimitive)?.contentOrNull }?.toSet()
val seen = LinkedHashMap<String, TraeModelProvider.TraeModel>()
for (rawEl in rawModels) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
if (allowed != null && id !in allowed) continue
if (raw["disabled"] as? JsonPrimitive == JsonPrimitive(true)) continue
val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id
seen[id] = TraeModelProvider.TraeModel(id, label)
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL_CN = "wb_default_model_cn"
private const val KEY_MODEL_INTL = "wb_default_model_intl"
private const val DEFAULT_MODEL = "hy4-preview"
/** 内置兜底(在线列表成功后会被替换)。 */
fun defaultModels(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
if (region == ProviderRegion.INTL) intlDefaultModels() else cnDefaultModels()
/** 国内站 CLI 模型兜底(参考 workbuddy-gateway 模型目录)。 */
private fun cnDefaultModels(): List<TraeModelProvider.TraeModel> = listOf(
TraeModelProvider.TraeModel("hy4-preview", "hy4-preview"),
TraeModelProvider.TraeModel("hy3", "hy3"),
TraeModelProvider.TraeModel("hy3-preview", "hy3-preview"),
TraeModelProvider.TraeModel("deepseek-v4.1-flash", "deepseek-v4.1-flash"),
)
/** 国际站 App 模型兜底(参考 cpa-multi-plugins 实测目录,避免刷新前误用国内模型名)。 */
private fun intlDefaultModels(): List<TraeModelProvider.TraeModel> = 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"),
)
}
}

View File

@ -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<List<TraeUsageEvent>> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
if (credential !is Credential.TraeCredential || credential.jwt.isBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = if (regionKey == "INTL") {
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
?: "https://grow-normal.trae.ai"
host + "/trae/api/v1/pay/query_user_usage_group_by_session"
} else {
"https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session"
}
val body =
"{\"start_time\":$startSec,\"end_time\":$endSec,\"page_size\":$pageSize,\"page_num\":$pageNum,\"usage_type\":[7]}"
val request = Request.Builder()
.url(url)
.addHeader("Authorization", "Cloud-IDE-JWT " + credential.jwt.trim())
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
.build()
val text = try {
okHttpClient.newCall(request).execute().use { resp ->
val t = resp.body?.string().orEmpty()
DebugLog.i(TAG, "fetchUsageEvents: http=${resp.code} len=${t.length}")
if (!resp.isSuccessful) {
return@withContext Result.failure(
if (resp.code == 401 || resp.code == 403) authErrorFor(credential.jwt.trim())
else mapHttpError(resp.code)
)
}
t
}
} catch (e: IOException) {
return@withContext Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchUsageEvents exception: " + e)
return@withContext Result.failure(RepositoryError.Network(e))
}
val events = try {
val root = json.parseToJsonElement(text).jsonObject
root["user_usage_group_by_sessions"]?.jsonArray?.mapNotNull { el ->
val obj = el.jsonObject
val extra = obj["extra_info"]?.jsonObject
TraeUsageEvent(
usageTime = obj.scalarOr("usage_time")?.toLongOrNull() ?: 0L,
modelName = obj.scalarOr("model_name").orEmpty(),
mode = obj.scalarOr("mode").orEmpty(),
userInputPreview = obj.scalarOr("user_input_preview").orEmpty(),
creditsFloat = obj.numberOr("credits_float", "amount_float") ?: 0.0,
costMoneyFloat = obj.numberOr("cost_money_float") ?: 0.0,
inputToken = extra?.numberOr("input_token")?.toLong() ?: 0L,
outputToken = extra?.numberOr("output_token")?.toLong() ?: 0L,
cacheReadToken = extra?.numberOr("cache_read_token")?.toLong() ?: 0L,
cacheWriteToken = extra?.numberOr("cache_write_token")?.toLong() ?: 0L
)
} ?: emptyList()
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchUsageEvents parse error: " + e)
emptyList()
}
Result.success(events.sortedByDescending { it.usageTime }.take(50))
}
suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -66,9 +139,12 @@ class TraeRepository constructor(
}
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = when (regionKey) {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
val url = if (regionKey == "INTL") {
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
?: "https://grow-normal.trae.ai"
host + "/trae/api/v1/pay/ide_user_ent_usage"
} else {
"https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
}
val request = Request.Builder()
.url(url)
@ -124,6 +200,8 @@ class TraeRepository constructor(
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it }
root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_dollar_usage_billing"] = it }
val isDollarBilling = root["is_dollar_usage_billing"]?.jsonPrimitive?.contentOrNull?.equals("true", true) == true
if (isDollarBilling) extras["billing_mode"] = "dollar"
root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总
@ -156,6 +234,28 @@ class TraeRepository constructor(
?: base?.get("product_extra")?.jsonObject
?.get("package_extra")?.jsonObject?.getString("package_name")
?: "pack[$index]"
if (isDollarBilling) {
packCount++
if (packShown == 0) {
extras["intl.plan_name"] = name
extras["intl.billing_version"] = root["billing_version"]?.jsonPrimitive?.contentOrNull ?: ""
packShown++
}
quota?.let { q ->
q.getNumber("premium_model_fast_request_limit")?.let { extras["intl.premium_fast_limit"] = trimNum(it) }
q.getNumber("premium_model_slow_request_limit")?.let { extras["intl.premium_slow_limit"] = trimNum(it) }
q.getNumber("advanced_model_request_limit")?.let { extras["intl.advanced_limit"] = trimNum(it) }
q.getNumber("auto_completion_limit")?.let { extras["intl.auto_completion_limit"] = trimNum(it) }
q.getNumber("basic_usage_limit")?.let { extras["intl.basic_usage_limit"] = trimNum(it) }
q.getNumber("basic_usage_limit")?.let { extras["intl.dollar_limit"] = it.toString() }
q.getNumber("bonus_usage_limit")?.let { extras["intl.bonus_usage_limit"] = trimNum(it) }
}
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.basic_used"] = trimNum(it) }
pack.getNumber("usage", "basic_usage_amount")?.let { extras["intl.dollar_used"] = it.toString() }
pack.getNumber("usage", "bonus_usage_amount")?.let { extras["intl.bonus_used"] = trimNum(it) }
pack.getNumber("usage", "credits_amount")?.let { extras["intl.credits_used"] = trimNum(it) }
return@forEachIndexed
}
val limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble()
@ -186,7 +286,7 @@ class TraeRepository constructor(
}
}
}
if (packCount > 0) {
if (packCount > 0 && !isDollarBilling) {
extras["pack_count"] = packCount.toString()
extras["general_credits"] = trimNum(generalSum)
extras["work_credits"] = trimNum(workSum)
@ -615,14 +715,19 @@ class TraeRepository constructor(
val succeeded = code == 0L || code == 200L
val lowerMsg = msg.lowercase()
val deviceBlock = msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
// 已签语义优先于「设备」判定:像「当前设备今日已经签到」这种文案虽然含“设备”,
// 但语义是今日已签,必须按 ALREADY 落本地记录,避免误判 DEVICE_BLOCKED 反复重试。
val alreadyMsg = msg.contains("已签到") || msg.contains("已经签到") || msg.contains("重复签到") ||
msg.contains("重复") || msg.contains("不能重复") ||
msg.contains("明日再来") || msg.contains("今日已完成") || msg.contains("已领取") ||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed") ||
lowerMsg.contains("duplicate") || lowerMsg.contains("repeat")
val deviceBlock = !alreadyMsg && (
msg.contains("设备") || lowerMsg.contains("device") || lowerMsg.contains("machine")
)
// 权威口径幂等兜底码是 10001(trae-checkin-helper 逆向);历史实现用 1001,
// 两者都认,避免把已签误判为 FAILED 硬错误。
val already = (code == 1001L || code == 10001L) || (!deviceBlock && (
msg.contains("已签到") || msg.contains("已经签到") || msg.contains("明日再来") ||
msg.contains("今日已完成") || msg.contains("已领取") ||
lowerMsg.contains("already") || lowerMsg.contains("checked") || lowerMsg.contains("claimed")
))
val already = (code == 1001L || code == 10001L) || alreadyMsg
// 服务端临时限流/排队(账号、设备或活动人数限制):提示可稍后重试,不算硬错误。
val retryable = !already && !deviceBlock && (
msg.contains("参与") || msg.contains("稍后重试") || msg.contains("火爆") ||
@ -736,3 +841,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
)

View File

@ -114,6 +114,6 @@ class UpdateRepository @Inject constructor(
companion object {
const val UPDATE_MANIFEST_URL =
"http://124.222.97.212:8443/maya/Rainytoken/raw/branch/dev/update.json"
"https://gitea.xxcsn.site/maya/Rainytoken/raw/branch/dev/update.json"
}
}

View File

@ -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<String, String>? {
private fun fetchResourceDetailExtras(credential: Credential.WorkBuddyCredential, accessToken: String): Map<String, String>? {
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()
@ -233,6 +253,21 @@ class WorkBuddyRepository constructor(
}
}
/** 供 Chat 代理等非余额路径使用的 token 刷新入口:成功返回新凭据(由调用方落库)。 */
fun refreshCredential(credential: Credential.WorkBuddyCredential): Credential.WorkBuddyCredential? {
val refreshed = tryRefesh(credential, credential.refreshToken) ?: return null
return credential.copy(
accessToken = refreshed.accessToken,
refreshToken = refreshed.refreshToken,
expiresAt = if (refreshed.expiresIn > 0L) {
System.currentTimeMillis() + refreshed.expiresIn * 1000L
} else {
credential.expiresAt
},
lastVerifiedAt = System.currentTimeMillis()
)
}
private fun JsonObject.readToken(vararg keys: String): String? {
for (key in keys) {
this[key]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() }?.let { return it }
@ -439,7 +474,9 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
/** 今日已签到(幂等) */
ALREADY,
/** 签到失败(服务端给了原因,见 message) */
FAILED
FAILED,
/** 国际站无签到活动(活动未开启/未上线,非错误) */
NO_CAMPAIGN
}
/**
@ -466,6 +503,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 国际版没有独立签到档案:积分来自网页/客户端活跃使用,直接返回“无活动”。
if (credential.isIntlRegion()) {
return@withContext Result.success(
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
)
}
// 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
val body = authedPostFirstSuccessful(
@ -497,6 +540,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential())
}
// 国际版不调用 daily-checkin(无签到营业),直接按无活动处理,避免每次刷新都报错。
if (credential.isIntlRegion()) {
return@withContext Result.success(
CheckinStatus(false, false, 0, 0, 0, "", "", CheckinOutcome.NO_CAMPAIGN)
)
}
val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, accountId, r.rotatedCredential)
@ -530,8 +579,15 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
}.getOrNull().orEmpty()
val status = fetchCheckinStatus(accountId).getOrNull()
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
val lowerMsg = msg.lowercase()
// 国际站无签到活动:接口可能返回 data.active=false 或「活动未开启/已过期」。
val inactive = runCatching {
val r = json.parseToJsonElement(body).jsonObject
((r["data"] as? JsonObject)?.get("active") as? JsonPrimitive)?.contentOrNull?.equals("false", true) == true
}.getOrDefault(false)
val noCampaign = inactive || msg.contains("活动未开启") || msg.contains("已过期") ||
lowerMsg.contains("not active")
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
lowerMsg.contains("claimed")
@ -546,6 +602,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
outcome = CheckinOutcome.SUCCESS
)
)
noCampaign -> Result.success(
(status ?: CheckinStatus(false, false, 0, 0, 0, "")).copy(
message = msg,
outcome = CheckinOutcome.NO_CAMPAIGN
)
)
already -> Result.success(
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
outcome = CheckinOutcome.ALREADY
@ -595,10 +657,23 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt ->
val request = Request.Builder()
.url("https://www.codebuddy.cn" + path)
.url(credential.originBase() + path)
.addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json")
.addHeader("Accept", "application/json, text/plain, */*")
.addHeader("Content-Type", "application/json")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
.addHeader("Origin", credential.originBase())
.addHeader("Referer", credential.originBase() + "/profile/growth-center")
.addHeader("X-CodeBuddy-Request", "1")
.addHeader("Accept-Language", if (credential.isIntlRegion()) "en-US" else "zh-CN")
.apply {
if (credential.isIntlRegion()) {
addHeader("X-Domain", "www.workbuddy.ai")
addHeader("X-No-Enterprise-Id", "1")
} else {
addHeader("X-No-Enterprise-Id", "1")
}
}
.post("{}".toRequestBody(jsonMedia))
.build()
val resp = try {
@ -615,7 +690,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
when {
code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> {
val refreshed = tryRefesh(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 {

View File

@ -26,7 +26,9 @@ import com.rainy.token.data.proxy.KtorLocalProxyServer
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.Sub2ApiChatProxy
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -243,19 +245,35 @@ object NetworkModule {
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
credentialRepository: CredentialRepository,
workBuddyRepository: WorkBuddyRepository,
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository, workBuddyRepository)
@Provides
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository)
credentialRepository: CredentialRepository,
traeModelProvider: TraeModelProvider,
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
@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

View File

@ -79,8 +79,10 @@ sealed class Credential {
override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String,
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.trae.ai) */
/** 区域:"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=旧凭据未保存 */

View File

@ -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<ServiceConfig> = ServiceType.entries.map { get(it) }
}

View File

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

View File

@ -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

View File

@ -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

View File

@ -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<DashboardHomeItem> {
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
},

View File

@ -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

View File

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

View File

@ -166,7 +166,15 @@ class AutoCheckinManager @Inject constructor(
ServiceType.TRAE -> refreshBalanceUseCase.checkInTrae(account.id).fold(
onSuccess = { r ->
when (r.outcome) {
TraeRepository.CheckinOutcome.SUCCESS -> ServiceResult(
TraeRepository.CheckinOutcome.SUCCESS -> {
checkinStatusStore.record(
ServiceType.TRAE,
account.id,
CheckinStatusStore.STATUS_SUCCESS,
checkinId = java.util.UUID.randomUUID().toString(),
credits = r.points
)
ServiceResult(
service = service,
accountLabel = label,
newlyCheckedIn = true,
@ -175,10 +183,24 @@ class AutoCheckinManager @Inject constructor(
message = "",
failed = false
)
TraeRepository.CheckinOutcome.ALREADY ->
}
TraeRepository.CheckinOutcome.ALREADY -> {
checkinStatusStore.record(
ServiceType.TRAE,
account.id,
CheckinStatusStore.STATUS_ALREADY
)
ServiceResult(service, label, false, true, 0, "", false)
// INTL 不支持签到:静默跳过,不进弹窗(避免误报「已签到」)
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> null
}
// INTL 不支持自动签到:当天落一条已签记录防反复重试,静默跳过不进弹窗
TraeRepository.CheckinOutcome.INTL_UNSUPPORTED -> {
checkinStatusStore.record(
ServiceType.TRAE,
account.id,
CheckinStatusStore.STATUS_ALREADY
)
null
}
TraeRepository.CheckinOutcome.DEVICE_BLOCKED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_DEVICE_BLOCKED)
ServiceResult(service, label, false, false, 0, r.message, failed = true)
@ -201,7 +223,15 @@ class AutoCheckinManager @Inject constructor(
ServiceType.WORKBUDDY -> refreshBalanceUseCase.checkInWorkBuddy(account.id).fold(
onSuccess = { s ->
when (s.outcome) {
WorkBuddyRepository.CheckinOutcome.SUCCESS -> ServiceResult(
WorkBuddyRepository.CheckinOutcome.SUCCESS -> {
checkinStatusStore.record(
ServiceType.WORKBUDDY,
account.id,
CheckinStatusStore.STATUS_SUCCESS,
checkinId = java.util.UUID.randomUUID().toString(),
credits = (s.todayCredit.takeIf { it > 0 } ?: s.dailyCredit)
)
ServiceResult(
service = service,
accountLabel = label,
newlyCheckedIn = true,
@ -210,10 +240,32 @@ class AutoCheckinManager @Inject constructor(
message = "",
failed = false
)
WorkBuddyRepository.CheckinOutcome.ALREADY ->
}
WorkBuddyRepository.CheckinOutcome.ALREADY -> {
checkinStatusStore.record(
ServiceType.WORKBUDDY,
account.id,
CheckinStatusStore.STATUS_ALREADY
)
ServiceResult(service, label, false, true, 0, "", false)
WorkBuddyRepository.CheckinOutcome.QUERY ->
}
WorkBuddyRepository.CheckinOutcome.QUERY -> {
checkinStatusStore.record(
ServiceType.WORKBUDDY,
account.id,
CheckinStatusStore.STATUS_ALREADY
)
ServiceResult(service, label, false, true, 0, "", false)
}
// 国际站无签到活动不是账号失败:当天落已签记录防反复重试,静默跳过不进弹窗
WorkBuddyRepository.CheckinOutcome.NO_CAMPAIGN -> {
checkinStatusStore.record(
ServiceType.WORKBUDDY,
account.id,
CheckinStatusStore.STATUS_ALREADY
)
null
}
WorkBuddyRepository.CheckinOutcome.FAILED -> {
recordFailure(service, account.id, CheckinStatusStore.STATUS_FAILED)
ServiceResult(service, label, false, false, 0, s.message, failed = true)

View File

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

View File

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

View File

@ -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 {

View File

@ -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<ServiceAccountInfo> = emptyList(),
val accounts: List<AccountItem> = emptyList(),
val message: UiText? = null
)

View File

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

View File

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

View File

@ -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,9 @@ 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 selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold(
@ -182,6 +188,13 @@ fun SettingsScreen(
error = proxyError,
debug = proxyDebug,
debugRaw = proxyDebugRaw,
proxyTargets = viewModel.proxyTargets,
selectedProxy = selectedProxy,
onSelectProxy = viewModel::selectProxy,
models = proxyModels,
selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
@ -216,11 +229,20 @@ private fun ProxyGatewayCard(
error: String?,
debug: String?,
debugRaw: String?,
proxyTargets: List<ProxyTarget>,
selectedProxy: ProxyTarget,
onSelectProxy: (ProxyTarget) -> Unit,
models: List<TraeModelProvider.TraeModel>,
selectedModel: String,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
val context = LocalContext.current
var proxyMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
@ -270,6 +292,69 @@ 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
) {
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) {

View File

@ -7,6 +7,8 @@ import com.rainy.token.data.proxy.ProviderRegion
import com.rainy.token.data.proxy.ProxyUpstreamStream
import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
@ -38,7 +40,9 @@ 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,
private val workBuddyModelProvider: WorkBuddyModelProvider,
) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState())
@ -74,59 +78,129 @@ class SettingsViewModel @Inject constructor(
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
fun testTraeStream() {
/** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
val proxyTargets: List<ProxyTarget> = ProxyTarget.entries
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.modelsFor(ProviderRegion.CN))
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow()
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换当前测试目标代理。 */
fun selectProxy(target: ProxyTarget) {
_selectedProxy.value = target
val region = target.region()
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
_proxyModel.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
else -> workBuddyModelProvider.selectedFor(region)
}
}
/** 切换当前代理的测试模型并持久化。 */
fun selectProxyModel(id: String) {
val target = _selectedProxy.value
val region = target.region()
when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id)
else -> workBuddyModelProvider.setSelectedFor(region, id)
}
_proxyModel.value = id
}
/** 拉取当前代理可用模型;失败保留内置列表。 */
fun refreshProxyModels() {
val target = _selectedProxy.value
val region = target.region()
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae 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.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用当前选中的 Trae 目标发一条 stream=true 测试(未选 Trae 时兜底 CN)。 */
fun testTraeStream() {
val target = _selectedProxy.value
val region = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> target.region()
else -> ProviderRegion.CN
}
val intl = region == ProviderRegion.INTL
viewModelScope.launch {
_proxyDebug.value = if (intl) "正在请求 Trae INTL 流..." else "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
val body = """{\"model\":\"glm-5.2\",\"stream\":true,\"messages\":[{\"role\":\"user\",\"content\":\"只回复OK两个字\"}],\"max_tokens\":100}\"""
val model = traeModelProvider.selectedFor(region)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
val requestLine = if (intl) {
"POST https://core-normal.trae.ai/api/agent/v3/llm_utils_chat"
} else {
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
}
try {
val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
val stream = traeChatProxy.openStreamingChat(body, null, region)
if (stream == null) {
_proxyDebug.value = "Trae CN 未配置或未登录"
_proxyDebug.value = if (intl) "Trae INTL 未配置或未登录" else "Trae CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"Trae CN",
body,
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
)
val label = if (intl) "Trae INTL" else "Trae CN"
val result = readStreamSample(stream, label, body, requestLine)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误")
_proxyDebug.value = (if (intl) "Trae INTL" else "Trae CN") + " 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
/** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */
/** 用当前选中的 WorkBuddy 目标发一条 stream=true 测试(未选 WorkBuddy 时兜底 CN)。 */
fun testWorkBuddyStream() {
val target = _selectedProxy.value
val region = when (target) {
ProxyTarget.WORKBUDDY_CN, ProxyTarget.WORKBUDDY_INTL -> target.region()
else -> ProviderRegion.CN
}
val intl = region == ProviderRegion.INTL
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebug.value = if (intl) "正在请求 WorkBuddy INTL 流..." else "正在请求 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(region)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
val requestLine = if (intl) {
"POST https://www.workbuddy.ai/console/chat/completions"
} else {
"POST https://copilot.tencent.com/v2/chat/completions"
}
try {
val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
val stream = workBuddyChatProxy.openStreamingChat(body, null, region)
if (stream == null) {
_proxyDebug.value = "WorkBuddy CN 未配置或未登录"
_proxyDebug.value = if (intl) "WorkBuddy INTL 未配置或未登录" else "WorkBuddy CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"WorkBuddy CN",
body,
"POST https://copilot.tencent.com/v2/chat/completions"
)
val label = if (intl) "WorkBuddy INTL" else "WorkBuddy CN"
val result = readStreamSample(stream, label, body, requestLine)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误")
_proxyDebug.value = (if (intl) "WorkBuddy INTL" else "WorkBuddy CN") + " 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
@ -289,6 +363,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,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
{
"versionCode": 23,
"versionName": "1.7.3",
"apkUrl": "http://124.222.97.212:8443/attachments/dfe6e9bf-1fb1-43c5-84a6-ae4b193b7330",
"notes": "探针输出美化 + 一键复制原始输出"
"versionCode": 47,
"versionName": "1.7.27",
"apkUrl": "https://gitea.xxcsn.site/maya/Rainytoken/releases/download/v1.7.27/RainyToken-v1.7.27.apk",
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到;国际版反代修复与模型目录修正;版本 1.7.27;设备已签到停止重试;INTL 无活动当日跳过"
}