Compare commits

...

89 Commits
v1.7.6 ... dev

Author SHA1 Message Date
218278f14e chore(update): 1.7.28(48)指向局域网共享修复包 2026-10-10 13:50:38 +08:00
a858c4bce0 feat(proxy): 允许局域网/热点设备连接选项(绑定 0.0.0.0) 2026-10-10 13:48:52 +08:00
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
aab50bbe78 chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:35 +08:00
faf7f40524 chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:27 +08:00
342788056b chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:48 +08:00
b479cb2a7a chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:57 +08:00
6e0d67afb7 chore(update): 发布 1.7.1 更新清单 2026-10-09 10:13:03 +08:00
8d68c3b280 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:32 +08:00
71f3f8d554 chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:13 +08:00
dfd91a5355 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:40:01 +08:00
149fc5f125 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:31:01 +08:00
95cde916ca chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:15 +08:00
37 changed files with 2501 additions and 409 deletions

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token"
minSdk = 31
targetSdk = 35
versionCode = 26
versionName = "1.7.6"
versionCode = 48
versionName = "1.7.28"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {

View File

@ -38,7 +38,7 @@ import kotlinx.coroutines.flow.asStateFlow
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 安全:
* - 只绑定 127.0.0.1
* - 默认只绑定 127.0.0.1;config.allowLan=true 时绑定 0.0.0.0(热点/局域网共享)
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
*/
@Singleton
@ -62,7 +62,8 @@ class KtorLocalProxyServer @Inject constructor(
synchronized(lock) {
if (_isRunning.value) return Result.success(Unit)
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) {
val bindHost = if (config.allowLan) "0.0.0.0" else "127.0.0.1"
val engine = embeddedServer(CIO, host = bindHost, port = config.port) {
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
}
engine.start(wait = false)
@ -122,28 +123,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 +152,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

@ -10,6 +10,8 @@ data class ProxyServerConfig(
val port: Int = 8787,
/** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */
val apiKey: String? = null,
/** true = 绑定 0.0.0.0,允许热点/同一 WiFi 下其他设备连接(需配合 API Key 使用)。 */
val allowLan: Boolean = false,
)
/**

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

@ -46,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() }
@ -55,11 +55,7 @@ class TraeChatProxy @Inject constructor(
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
@ -85,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() }
@ -94,11 +90,7 @@ class TraeChatProxy @Inject constructor(
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://a0ai-api-sg.byteintlapi.com"
} else {
"https://trae-api-cn.mchost.guru"
}
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
@ -237,6 +229,41 @@ class TraeChatProxy @Inject constructor(
return MODEL_ALIASES[id.lowercase()] ?: id
}
/**
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
*/
/** 按区域选凭据:显式 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
@ -266,6 +293,9 @@ class TraeChatProxy @Inject constructor(
}
companion object {
/** 供路由判断模型是否可能由 Trae 消化。 */
internal fun resolveAlias(modelName: String): String? =
MODEL_ALIASES[modelName.trim().lowercase()]
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
private val MODEL_ALIASES = mapOf(
"auto" to "glm-5.2",

View File

@ -38,8 +38,13 @@ class TraeModelProvider @Inject constructor(
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModel>> = _models.asStateFlow()
private val _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true }
@ -57,19 +62,26 @@ class TraeModelProvider @Inject constructor(
prefs.edit().putString(key, id).apply()
}
/** 拉取账号在线模型列表;失败时保留内置列表并返回 false。 */
/** 该区域是否可能支持此模型(内置列表/在线列表/别名)。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 credential = credentialRepository.get(ServiceType.TRAE, null)
?: return@withContext false
if (credential !is Credential.TraeCredential) return@withContext false
val effectiveRegion = forcedRegion ?: ProviderRegion.CN
val credential = pickCredential(effectiveRegion) ?: return@withContext false
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() } ?: return@withContext false
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val intl = effectiveRegion == ProviderRegion.INTL
val base = if (intl) "https://a0ai-api-sg.byteintlapi.com" else "https://trae-api-cn.mchost.guru"
val 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"
@ -79,7 +91,7 @@ class TraeModelProvider @Inject constructor(
"Content-Type" to "application/json",
"X-Trae-Client-Type" to "web",
"X-Preferenced-Language" to "zh-CN",
"x-user-region" to if (intl) "SG" else "CN",
"x-user-region" to userRegion,
"Origin" to origin,
"Referer" to origin + "/",
"User-Agent" to (
@ -98,10 +110,22 @@ class TraeModelProvider @Inject constructor(
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed
_modelsByRegion.value = _modelsByRegion.value + (effectiveRegion to parsed)
true
}
private suspend fun pickCredential(region: ProviderRegion): Credential.TraeCredential? {
val accounts = credentialRepository.accountsFor(ServiceType.TRAE)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.TRAE, null)
return (legacy as? Credential.TraeCredential)
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
}
return accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.TRAE, account.id) as? Credential.TraeCredential
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
}
private fun parseModelList(body: String): List<TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null

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

@ -36,8 +36,13 @@ class WorkBuddyModelProvider @Inject constructor(
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _models = MutableStateFlow(defaultModels())
val models: StateFlow<List<TraeModelProvider.TraeModel>> = _models.asStateFlow()
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 }
@ -52,25 +57,36 @@ class WorkBuddyModelProvider @Inject constructor(
prefs.edit().putString(key, id).apply()
}
/** 该区域是否可能支持此模型(在线/内置列表)。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }
}
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, null)
?: return@withContext false
if (credential !is Credential.WorkBuddyCredential) return@withContext false
val credential = pickCredential(region) ?: return@withContext false
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext false
val intl = region == ProviderRegion.INTL
val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
val url = base + "/v2/enterprises/personal/models"
// 国内站官方 CLI 吃 /console/enterprises/personal/models;国际站产品目录在 /v3/config,
// 且必须用 App 形态 UA,否则网关拒发(实测 code 12403)。
val url = base + if (intl) "/v3/config" else "/console/enterprises/personal/models"
val origin = if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"
val headers = linkedMapOf(
"Authorization" to "Bearer $accessToken",
"Content-Type" to "application/json",
"Accept" to "application/json",
"X-Requested-With" to "XMLHttpRequest",
"Origin" to origin,
"Referer" to "$origin/",
"X-CodeBuddy-Request" to "1",
"Accept-Language" to (if (intl) "en-US" else "zh-CN"),
"X-Client-ID" to "codebuddy-cli",
"X-Client-Version" to "2.143.1",
"X-Product" to "SaaS",
"User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1",
"Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"),
"X-Client-Version" to "2.63.2",
"X-Product" to (if (intl) "SaaS" else "WorkBuddy"),
"User-Agent" to (if (intl) "WorkBuddyAI/5.5.2" else "CLI/2.63.2 CodeBuddy/2.63.2"),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
@ -83,20 +99,43 @@ class WorkBuddyModelProvider @Inject constructor(
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_models.value = parsed
_modelsByRegion.value = _modelsByRegion.value + (region to parsed)
true
}
private suspend fun pickCredential(region: ProviderRegion): Credential.WorkBuddyCredential? {
val accounts = credentialRepository.accountsFor(ServiceType.WORKBUDDY)
if (accounts.isEmpty()) {
val legacy = credentialRepository.get(ServiceType.WORKBUDDY, null)
return (legacy as? Credential.WorkBuddyCredential)
?.takeIf { it.region.equals(region.name, ignoreCase = true) }
}
return accounts.mapNotNull { account ->
credentialRepository.get(ServiceType.WORKBUDDY, account.id) as? Credential.WorkBuddyCredential
}.firstOrNull { it.region.equals(region.name, ignoreCase = true) }
}
private fun parseModelList(body: String): List<TraeModelProvider.TraeModel>? {
val root = runCatching { json.parseToJsonElement(body) as? JsonObject }.getOrNull()
?: return null
val data = root["data"] as? JsonObject ?: return null
val models = data["models"] as? JsonArray ?: return null
// 国内站永远是 {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 models) {
for (rawEl in rawModels) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
if (allowed != null && id !in allowed) continue
if (raw["disabled"] as? JsonPrimitive == JsonPrimitive(true)) continue
val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id
seen[id] = TraeModelProvider.TraeModel(id, label)
}
@ -108,12 +147,31 @@ class WorkBuddyModelProvider @Inject constructor(
private const val KEY_MODEL_INTL = "wb_default_model_intl"
private const val DEFAULT_MODEL = "hy4-preview"
/** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */
fun defaultModels(): List<TraeModelProvider.TraeModel> = listOf(
/** 内置兜底(在线列表成功后会被替换)。 */
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

@ -28,6 +28,7 @@ import com.rainy.token.data.proxy.Sub2ApiChatProxy
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module
import dagger.Provides
@ -244,8 +245,9 @@ object NetworkModule {
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
credentialRepository: CredentialRepository,
workBuddyRepository: WorkBuddyRepository,
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository, workBuddyRepository)
@Provides
@Singleton
@ -257,7 +259,21 @@ object NetworkModule {
@Provides
@Singleton
fun provideProviderRouter(): ProviderRouter = ProviderRouter()
fun provideProviderRouter(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
traeModelProvider: TraeModelProvider,
workBuddyModelProvider: WorkBuddyModelProvider,
accountPool: AccountPool,
): ProviderRouter = ProviderRouter(
traeChatProxy,
workBuddyChatProxy,
sub2ApiChatProxy,
traeModelProvider,
workBuddyModelProvider,
accountPool,
)
@Provides
@Singleton

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

@ -104,6 +104,7 @@ fun SettingsScreen(
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
val proxyAllowLan by viewModel.proxyAllowLan.collectAsStateWithLifecycle()
val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle()
val proxyError by viewModel.proxyError.collectAsStateWithLifecycle()
val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle()
@ -184,6 +185,7 @@ fun SettingsScreen(
item {
ProxyGatewayCard(
running = proxyRunning,
allowLan = proxyAllowLan,
apiKey = proxyApiKey,
error = proxyError,
debug = proxyDebug,
@ -196,6 +198,7 @@ fun SettingsScreen(
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onAllowLanChange = viewModel::setProxyAllowLan,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
)
@ -225,6 +228,7 @@ fun SettingsScreen(
@Composable
private fun ProxyGatewayCard(
running: Boolean,
allowLan: Boolean,
apiKey: String,
error: String?,
debug: String?,
@ -237,6 +241,7 @@ private fun ProxyGatewayCard(
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onAllowLanChange: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
@ -265,7 +270,11 @@ private fun ProxyGatewayCard(
fontWeight = FontWeight.SemiBold
)
Text(
text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止",
text = when {
!running -> "已停止"
allowLan -> "运行中 · 局域网模式(其他设备填 http://手机热点IP:8787/v1)"
else -> "运行中 · http://127.0.0.1:8787/v1"
},
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
@ -276,6 +285,21 @@ private fun ProxyGatewayCard(
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "允许局域网/热点设备连接",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.weight(1f)
)
Switch(
checked = allowLan,
onCheckedChange = onAllowLanChange
)
}
if (error != null) {
Text(
text = error,

View File

@ -1,5 +1,6 @@
package com.rainy.token.ui.settings
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.proxy.LocalProxyServer
@ -14,6 +15,7 @@ import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -37,6 +39,7 @@ import javax.inject.Inject
*/
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext private val context: Context,
private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer,
private val traeChatProxy: TraeChatProxy,
@ -51,6 +54,13 @@ class SettingsViewModel @Inject constructor(
/** 本地反代服务运行状态。 */
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
/** 是否允许局域网/热点设备连接(绑定 0.0.0.0)。 */
private val _proxyAllowLan = MutableStateFlow(
context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE)
.getBoolean("allow_lan", false)
)
val proxyAllowLan: StateFlow<Boolean> = _proxyAllowLan.asStateFlow()
/** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
val proxyApiKey: StateFlow<String> = MutableStateFlow(generateProxyKey())
@ -61,7 +71,11 @@ class SettingsViewModel @Inject constructor(
/** 启动/停止本地反代服务。 */
fun setProxyEnabled(enabled: Boolean) {
if (enabled) {
localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value))
val config = ProxyServerConfig(
apiKey = proxyApiKey.value,
allowLan = _proxyAllowLan.value
)
localProxyServer.start(config)
.onSuccess { _proxyError.value = null }
.onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" }
} else {
@ -70,6 +84,17 @@ class SettingsViewModel @Inject constructor(
}
}
/** 切换「允许局域网/热点设备连接」;运行中会按新配置重启代理。 */
fun setProxyAllowLan(enabled: Boolean) {
context.getSharedPreferences("proxy_settings", Context.MODE_PRIVATE)
.edit().putBoolean("allow_lan", enabled).apply()
_proxyAllowLan.value = enabled
if (proxyRunning.value) {
localProxyServer.stop()
setProxyEnabled(true)
}
}
/** 代理调试输出(结构化摘要)。 */
private val _proxyDebug = MutableStateFlow<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
@ -84,7 +109,7 @@ class SettingsViewModel @Inject constructor(
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.models.value)
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))
@ -95,8 +120,8 @@ class SettingsViewModel @Inject constructor(
_selectedProxy.value = target
val region = target.region()
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value
else -> workBuddyModelProvider.models.value
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
_proxyModel.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
@ -125,68 +150,82 @@ class SettingsViewModel @Inject constructor(
else -> workBuddyModelProvider.refreshFor(region)
}
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.models.value
else -> workBuddyModelProvider.models.value
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
/** 用当前选中的 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 = "正在请求 Trae CN 流..."
_proxyDebug.value = if (intl) "正在请求 Trae INTL 流..." else "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
val model = traeModelProvider.selectedFor(ProviderRegion.CN)
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 model = workBuddyModelProvider.selectedFor(ProviderRegion.CN)
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
}
}

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": 25,
"versionName": "1.7.5",
"apkUrl": "http://124.222.97.212:8443/attachments/ebc58052-fadc-47c4-b60d-07353a31d946",
"notes": "新增 Trae 模型切换:下拉选择、在线拉取模型列表、记住选择"
"versionCode": 48,
"versionName": "1.7.28",
"apkUrl": "https://gitea.xxcsn.site/maya/Rainytoken/releases/download/v1.7.28/RainyToken-v1.7.28.apk",
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到;国际版反代修复与模型目录修正;版本 1.7.28;新增局域网/热点共享代理开关"
}