Compare commits

..

67 Commits

Author SHA1 Message Date
f917e5bfd6 fix(wb): 国际版无独立签到活动,不再调 daily-checkin,详情页展示说明卡 2026-10-10 02:34:27 +08:00
809b8db2d3 fix(wb): 余额与 token 刷新按区域分流(INTL 走 workbuddy.ai,CN 保持 workbuddy.cn/copilot.tencent.com) 2026-10-10 01:59:06 +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
dab9b7b272 feat(trae): 详情页直接展示 INTL 用量事件,INTL 充值改外部浏览器 2026-10-10 00:17:03 +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
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
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
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
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
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
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
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
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
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
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
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
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
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 are pending
Release / build (push) Waiting to run
- ProviderRouter 重构为聚合路由:请求只写 model,网关自动匹配供应商
- 候选按历史速度排序,上游不可用/HTTP>=400/抛错自动切换下一个
- Sub2API 作为通用兜底;显式前缀 traecn-/wbintl- 仍可强制指定
- 显式前缀转发前自动剥离,避免上游收到 traecn-glm-5.2 这类模型名
- 内存速度/冷却统计:失败供应商冷却 30s 起步、指数退避
- 版本升至 1.7.7
2026-10-09 11:49:38 +08:00
73ff6b2ee7 chore(update): 发布 1.7.6 更新清单 2026-10-09 11:22:57 +08:00
f13b38c02b feat(proxy): 按代理/区域区分模型列表与选择
Some checks are pending
Release / build (push) Waiting to run
- 新增“代理”下拉:Trae CN/INTL、WorkBuddy CN/INTL
- WorkBuddyModelProvider 在线拉取 /v2/enterprises/personal/models
- Trae/WorkBuddy 各区域独立记住所选测试模型
- 真实代理转发仍按前缀自动路由,不受测试选择影响
- 版本升至 1.7.6
2026-10-09 11:21:53 +08:00
0283ce6bdc chore(update): 发布 1.7.5 更新清单 2026-10-09 11:04:31 +08:00
0241840f9c feat(proxy): Trae 模型切换 + 在线拉取模型列表
Some checks are pending
Release / build (push) Waiting to run
- 设置页新增模型下拉选择,内置参考映射兜底
- TraeModelProvider 在线拉取账号可用模型(/api/remote/v1/models)
- 选中模型持久化,作为反代缺省模型
- 常用 OpenAI/Claude 模型名自动映射到 Trae 内部模型
- 修复探针测试请求体混入反斜杠导致 JSON 无效的问题
- 版本升至 1.7.5
2026-10-09 11:03:27 +08:00
8a9d9432fe chore(update): 发布 1.7.4 更新清单 2026-10-09 10:47:24 +08:00
fbbca4a03b feat(proxy): 探针结果结构化摘要,不再刷屏原始参数
Some checks are pending
Release / build (push) Waiting to run
- 显示:请求行/模型/messages数/stream、HTTP、事件帧数、模型、文本摘要、用量、错误
- 原始请求+响应仍完整保留,点“复制原始”一键复制
- 版本升至 1.7.4
2026-10-09 10:46:30 +08:00
7de7111b18 chore(update): 发布 1.7.3 更新清单 2026-10-09 10:29:43 +08:00
dcd5cab2b2 feat(proxy): 探针输出美化 + 一键复制原始输出
Some checks are pending
Release / build (push) Waiting to run
- 调试输出改为滚动等宽代码块,状态一眼可读
- 新增“复制全部”按钮,一键把原始输出写入剪贴板
- 版本升至 1.7.3
2026-10-09 10:28:19 +08:00
a804d46acd chore(update): 发布 1.7.2 更新清单 2026-10-09 10:22:52 +08:00
ada750df7e fix(proxy): Trae messages.content 转原生块数组,修复 4001
Some checks are pending
Release / build (push) Waiting to run
- 上游 LLMRawMessage.messages.content 要求 []*LLMRawMessageContent
- 字符串转 [{"type":"text","text":...}],列表块按 text/input_text 归一
- 版本升至 1.7.2
2026-10-09 10:21:48 +08:00
b5ef9249d8 chore(update): 发布 1.7.1 更新清单
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 10:12:59 +08:00
481c57c535 feat(update): 已下载 APK 直接复用 + 安装失败可重试不重下
Some checks are pending
Release / build (push) Waiting to run
- downloadApk 检测同版本已完成文件,存在则跳过下载
- 下载先写 .part 再改名,避免半截文件被误用
- install 失败保留 Ready(file, error),UI 显示重试安装
- NeedInstallPermission 携带文件,开启权限后可继续安装
2026-10-09 10:11:26 +08:00
571b270cb6 chore(update): 发布 1.7.0 更新清单 2026-10-09 10:03:27 +08:00
fa796d57b5 fix(proxy): 修复 Trae Authorization 头发成字面量 jwt + 补全 SOLO 头
Some checks are pending
Release / build (push) Waiting to run
- 上一版把 \ 写成了字符串 jwt,Trae 收到 Cloud-IDE-JWT jwt 必然被网关拒绝
- 完整 SOLO 头集合对齐 trae2api-cn:x-app-version/x-lscbd-*/x-ttnet-* 等
- 版本升至 1.7.0
2026-10-09 10:02:07 +08:00
8e692a0c6e chore(update): 发布 1.6.9 更新清单 2026-10-09 09:47:09 +08:00
4597fb0f8e fix(update): canRequestPackageInstalls 包 try/catch,旧包也不闪退
Some checks are pending
Release / build (push) Waiting to run
- 旧版本未声明 REQUEST_INSTALL_PACKAGES 时调该 API 本身会抛 SecurityException
- 现在降级为 capture 后按 false 处理,引导去系统设置开启未知来源
- 版本升至 1.6.9
2026-10-09 09:46:06 +08:00
40af5db889 chore(update): 发布 1.6.8 更新清单 2026-10-09 09:39:57 +08:00
5adf053b6f fix(update): 声明 REQUEST_INSTALL_PACKAGES 权限并加固安装流程
Some checks are pending
Release / build (push) Waiting to run
- 缺少该权限时部分 ROM 启动系统安装器会被拒绝/闪退
- install() 整体容错:FileProvider URI 失败、ACTION_VIEW 失败均捕获并回退 ACTION_INSTALL_PACKAGE
- 版本升至 1.6.8
2026-10-09 09:38:52 +08:00
e2bf924246 chore(update): 发布 1.6.7 更新清单 2026-10-09 09:30:50 +08:00
d0b79cf1bd fix(proxy): Trae CN 上游换用 trae-api-cn.mchost.guru,修正 404
Some checks are pending
Release / build (push) Waiting to run
- trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat 才是模型网关
- api.trae.cn 只是账号/OAuth 主机,不暴露 IDE 模型路由
- 请求头对齐参考实现:真实 x-app-id / x-ide-version / 设备指纹
- llm_utils_chat body 收敛为 SOLO 结构(config_name/model/function/stream)
2026-10-09 09:29:40 +08:00
15408a6640 chore(update): 发布 1.6.6 更新清单 2026-10-09 09:18:12 +08:00
8da1f2b377 chore: 版本号升至 1.6.6 (versionCode 16)
Some checks are pending
Release / build (push) Waiting to run
2026-10-09 09:13:39 +08:00
a4d854271f feat(proxy): 设置页新增 Trae/WorkBuddy 流式探针
- 用默认账号直接请求 llm_utils_chat/v2 chat,抓取上游原始 SSE 帧
- 调试输出显示 HTTP 状态、Content-Type 与最多 80 行原始帧
- 为 StreamNormalizer 提供真实协议样本
2026-10-09 08:08:47 +08:00
67e1f060c5 feat(proxy): 网关随机 API Key + 启动失败提示
- SettingsViewModel 每次进程生成随机本机 Key,start 失败时暴露错误信息
- Settings 页显示 API Key 与错误提示
2026-10-09 00:04:55 +08:00
f5162bbf74 docs: 更新流式转发与 Qoder 攻坚状态 2026-10-08 23:53:30 +08:00
de95013164 feat(proxy): stream=true 实时透传上游 SSE 字节流
- Ktor respondOutputStream 逐块转发上游 body,不再整包缓冲
- 流式/非流式按客户端 body 的 stream 字段分流
- openStreamingChat 入口正式接线
2026-10-08 23:52:12 +08:00
072f7efa55 docs: 更新反代网关开发进度 2026-10-08 23:46:18 +08:00
1609e210bd fix(proxy): 修复 WorkBuddy/Trae 鉴权头、401、模型前缀 + 流式入口骨架
- WorkBuddyChatProxy/TraeChatProxy:Authorization 插值去掉误转义
- KtorLocalProxyServer:authorized() 失败返回 401
- 路由剥离 wbcn-/traecn- 等前缀后再转发,Trae config_name/model_name 不再带前缀
- Sub2ApiChatProxy 去掉 IOException 吞掉逻辑
- 新增 ProxyUpstreamStream 与三个代理的 openStreamingChat 入口(暂未接入实时转发)
2026-10-08 23:44:44 +08:00
a1605e6993 feat(proxy): M2 账号池轮询 + 会话粘性 + 双区域路由
- ProviderRouter:前缀路由到 WorkBuddy CN/INTL、Trae CN/INTL、OpenAI 兼容
- AccountPool:从 CredentialRepository 实时取号,round-robin + 会话粘性(X-Conversation-Id / user)
- 账号按 credential.region 与路由区域匹配,找不到时回退全部账号
- WorkBuddy/Trae 代理支持 forcedRegion 覆盖
- Ktor 路由接入账号池
2026-10-08 23:34:35 +08:00
5aa2d8f116 chore: 忽略 build_compile.log 2026-10-08 23:24:06 +08:00
11b9065f82 feat(proxy): M1b WorkBuddy CN/Trae CN 适配 + 设置页网关开关
- KtorLocalProxyServer:修复审计阻断项(start/stop 加锁、apiKey Bearer 鉴权、IOException 502、请求体上限、错误 JSON 规范化)
- WorkBuddyChatProxy:转发到 copilot.tencent.com/v2/chat/completions,Bearer accessToken + IDE 请求头
- TraeChatProxy:转发到 api.trae.cn/api/agent/v3/llm_utils_chat,Cloud-IDE-JWT 身份/设备头,最小 body 转换
- Credential.WorkBuddyCredential 增加 region 字段(默认 CN,兼容旧数据)
- Settings 页新增代理网关开关(默认 127.0.0.1:8787,本机 Key: rainytoken)
2026-10-08 23:23:16 +08:00
85c6cf4283 feat(proxy): M1a 本地 HTTP 服务 + Sub2API 透传
- 引入 Ktor 3.0.3(server-core / cio / content-negotiation / kotlinx-json)
- Sub2ApiChatProxy:把 /v1/chat/completions 与 /v1/models 透传到用户配置的 Sub2API 实例
- KtorLocalProxyServer:绑定 127.0.0.1:8787,提供 /health、/v1/models、/v1/chat/completions
- NetworkModule 提供 Hilt 注入
2026-10-08 22:58:33 +08:00
9d3c004617 feat(proxy): 新增反代网关骨架与开发路线图
- docs/PROXY-ROADMAP.md:M0-M4 里程碑、Provider 矩阵、风险与参考仓库
- data/proxy/:ProviderKind/ProviderRegion/ProxyModels/ProviderAdapter/AccountPool/RegionRouter/LocalProxyServer 接口骨架
- .gitignore:忽略 _tmp_* 调试文件,防止真实接口响应/账号数据入库
2026-10-08 22:22:45 +08:00
48 changed files with 4160 additions and 226 deletions

4
.gitignore vendored
View File

@ -23,3 +23,7 @@ release.jks
tmp_cc/ tmp_cc/
goal.md goal.md
/reference/ /reference/
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
_tmp_*
build_compile.log

View File

@ -16,8 +16,8 @@ android {
applicationId = "com.rainy.token" applicationId = "com.rainy.token"
minSdk = 31 minSdk = 31
targetSdk = 35 targetSdk = 35
versionCode = 14 versionCode = 42
versionName = "1.6.4" versionName = "1.7.22"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables { vectorDrawables {
@ -214,6 +214,12 @@ dependencies {
implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.serialization.json)
implementation(libs.retrofit.kotlinx.serialization.converter) implementation(libs.retrofit.kotlinx.serialization.converter)
// Ktor 本地反代 HTTP 服务
implementation(libs.ktor.server.core)
implementation(libs.ktor.server.cio)
implementation(libs.ktor.server.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
// DataStore // DataStore
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)

View File

@ -3,6 +3,7 @@
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" /> <uses-permission android:name="com.android.launcher.permission.INSTALL_SHORTCUT" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" /> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />

View File

@ -0,0 +1,87 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
/**
* 账号池:从 CredentialRepository 实时取号,负责轮询与会话粘性。
*
* - 多账号服务(Trae / WorkBuddy / Sub2API)按 ProviderKind 分组;
* - 会话粘性:同一 conversation 头持续命中同一账号,避免上下文错乱;
* - 区域匹配:优先选 credential.region 与路由 region 一致的账号,找不到时退回全部账号。
*/
@Singleton
class AccountPool @Inject constructor(
private val credentialRepository: CredentialRepository,
) {
data class PooledAccount(
val kind: ProviderKind,
val region: ProviderRegion? = null,
val accountId: String? = null,
val label: String? = null,
)
private val mutex = Mutex()
private val sessionSticky = mutableMapOf<String, String>()
private val cursor = mutableMapOf<ProviderKind, Int>()
suspend fun next(
kind: ProviderKind,
region: ProviderRegion? = null,
sessionKey: String? = null,
): PooledAccount? = mutex.withLock {
val service = serviceFor(kind) ?: return null
val accounts = credentialRepository.accountsFor(service)
val allIds = accounts.map { it.id }
val matchedIds = if (region == null) {
allIds
} else {
allIds.filter { accountId -> regionMatches(service, accountId, region) }
.ifEmpty { allIds }
}
if (matchedIds.isEmpty()) return null
val accountId = if (sessionKey != null) {
sessionSticky[sessionKey]
?.takeIf { it in matchedIds }
?: pickRoundRobin(kind, matchedIds).also { sessionSticky[sessionKey] = it }
} else {
pickRoundRobin(kind, matchedIds)
}
PooledAccount(
kind = kind,
region = region,
accountId = accountId,
label = accounts.firstOrNull { it.id == accountId }?.label,
)
}
private suspend fun regionMatches(service: ServiceType, accountId: String, region: ProviderRegion): Boolean {
val credential = credentialRepository.get(service, accountId) ?: return false
return when (credential) {
is Credential.TraeCredential -> credential.region.equals(region.name, ignoreCase = true)
is Credential.WorkBuddyCredential -> credential.region.equals(region.name, ignoreCase = true)
else -> true
}
}
private fun pickRoundRobin(kind: ProviderKind, ids: List<String>): String {
val idx = cursor[kind] ?: 0
cursor[kind] = (idx + 1) % ids.size
return ids[idx]
}
private fun serviceFor(kind: ProviderKind): ServiceType? = when (kind) {
ProviderKind.WORKBUDDY_CN, ProviderKind.WORKBUDDY_INTL -> ServiceType.WORKBUDDY
ProviderKind.TRAE_CN, ProviderKind.TRAE_INTL -> ServiceType.TRAE
ProviderKind.OPENAI_COMPATIBLE -> ServiceType.SUB2API
else -> null
}
}

View File

@ -0,0 +1,243 @@
package com.rainy.token.data.proxy
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.serialization.kotlinx.json.json
import io.ktor.server.application.Application
import io.ktor.server.application.ApplicationCall
import io.ktor.server.application.install
import io.ktor.server.cio.CIO
import io.ktor.server.engine.EmbeddedServer
import io.ktor.server.engine.embeddedServer
import io.ktor.server.plugins.contentnegotiation.ContentNegotiation
import io.ktor.server.request.receiveText
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.response.respondOutputStream
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.routing
import java.io.IOException
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import kotlinx.serialization.json.buildJsonObject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* 基于 Ktor CIO 的本地 HTTP 反代服务。
*
* M1b 能力:
* - GET /health
* - GET /v1/models
* - POST /v1/chat/completions
* 按模型前缀路由:wbcn- → WorkBuddy 国内版;traecn- → Trae CN;其余 → Sub2API 透传
*
* 安全:
* - 只绑定 127.0.0.1
* - config.apiKey 非空时,所有 v1 业务路由要求 Bearer Key 一致,否则 401
*/
@Singleton
class KtorLocalProxyServer @Inject constructor(
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeChatProxy: TraeChatProxy,
private val providerRouter: ProviderRouter,
private val accountPool: AccountPool,
) : LocalProxyServer {
private val lock = Any()
@Volatile
private var server: EmbeddedServer<*, *>? = null
private val _isRunning = MutableStateFlow(false)
override val isRunning: StateFlow<Boolean> = _isRunning.asStateFlow()
override fun start(config: ProxyServerConfig): Result<Unit> {
synchronized(lock) {
if (_isRunning.value) return Result.success(Unit)
return try {
val engine = embeddedServer(CIO, host = "127.0.0.1", port = config.port) {
proxyModule(config.apiKey, sub2ApiChatProxy, workBuddyChatProxy, traeChatProxy, providerRouter, accountPool)
}
engine.start(wait = false)
server = engine
_isRunning.value = true
Result.success(Unit)
} catch (e: Throwable) {
server = null
_isRunning.value = false
Result.failure(e)
}
}
}
override fun stop() {
synchronized(lock) {
runCatching { server?.stop(gracePeriodMillis = 500, timeoutMillis = 2000) }
server = null
_isRunning.value = false
}
}
private fun Application.proxyModule(
apiKey: String?,
sub2Api: Sub2ApiChatProxy,
workBuddy: WorkBuddyChatProxy,
trae: TraeChatProxy,
router: ProviderRouter,
pool: AccountPool,
) {
install(ContentNegotiation) {
json()
}
routing {
get("/health") {
call.respond(mapOf("status" to "ok"))
}
get("/v1/models") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@get
}
val result = sub2Api.forwardModels()
if (result == null) {
call.respond(HttpStatusCode.BadRequest, errorBody("Sub2API 未配置或未登录,请在设置中填写 API Key"))
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
post("/v1/chat/completions") {
if (!authorized(call, apiKey)) {
call.respond(HttpStatusCode.Unauthorized, errorBody("未授权"))
return@post
}
val rawBody = call.receiveText()
if (rawBody.length > MAX_REQUEST_BYTES) {
call.respond(HttpStatusCode(413, ""), errorBody("请求体过大"))
return@post
}
val conversationId = call.request.headers["X-Conversation-Id"] ?: extractUser(rawBody)
try {
if (extractStream(rawBody)) {
val stream = router.openStreamingChat(rawBody, conversationId)
if (stream == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("所有上游均不可用或未配置,请检查设置")
)
} else {
call.respondOutputStream(
contentType = contentTypeOf(stream.contentType),
status = HttpStatusCode(stream.status, "")
) {
try {
val buffer = ByteArray(8192)
val input = stream.input
while (true) {
val read = input.read(buffer)
if (read < 0) break
write(buffer, 0, read)
flush()
}
} finally {
stream.close()
}
}
}
} else {
val result = router.forwardChat(rawBody, conversationId)
if (result == null) {
call.respond(
HttpStatusCode.BadRequest,
errorBody("所有上游均不可用或未配置,请检查设置")
)
} else {
call.respondBytes(result.body, contentTypeOf(result.contentType), HttpStatusCode(result.status, ""))
}
}
} catch (e: IOException) {
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.BadGateway,
errorBody("上游网络错误:$detail")
)
} catch (e: Exception) {
if (e is kotlinx.coroutines.CancellationException) throw e
val detail = e.message ?: "未知错误"
call.respond(
HttpStatusCode.InternalServerError,
errorBody("网关内部错误:$detail")
)
}
}
}
}
private fun authorized(call: ApplicationCall, apiKey: String?): Boolean {
if (apiKey.isNullOrBlank()) return true
val header = call.request.headers[HttpHeaders.Authorization] ?: return false
val expect = "Bearer $apiKey"
return header.trim() == expect
}
private fun errorBody(message: String): Map<String, Any> =
mapOf("error" to mapOf("message" to message, "type" to "invalid_request_error"))
private fun contentTypeOf(raw: String): ContentType =
runCatching { ContentType.parse(raw) }.getOrDefault(ContentType.Application.Json)
private fun extractStream(body: String): Boolean = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("stream")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.contentOrNull?.toBooleanStrictOrNull() else null }
}.getOrNull() ?: false
private fun stripModelPrefix(model: String): String {
val m = model.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) }
return if (prefix != null) m.substring(prefix.length).ifBlank { m } else m
}
private fun rewriteModelBody(body: String, newModel: String): String = try {
val obj = kotlinx.serialization.json.Json.parseToJsonElement(body)
as? kotlinx.serialization.json.JsonObject ?: return body
kotlinx.serialization.json.buildJsonObject {
obj.forEach { (key, value) ->
put(key, if (key == "model") kotlinx.serialization.json.JsonPrimitive(newModel) else value)
}
}.toString()
} catch (_: Throwable) {
body
}
private fun extractModel(body: String): String = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("model")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
private fun extractUser(body: String): String? = runCatching {
val el = kotlinx.serialization.json.Json.parseToJsonElement(body)
(el as? kotlinx.serialization.json.JsonObject)
?.get("user")
?.let { if (it is kotlinx.serialization.json.JsonPrimitive) it.content else null }
}.getOrNull()
companion object {
private const val MAX_REQUEST_BYTES = 10 * 1024 * 1024
}
}

View File

@ -0,0 +1,27 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
/**
* 本地 HTTP 反代服务配置。
*/
data class ProxyServerConfig(
val port: Int = 8787,
/** 客户端必须携带的 Bearer Key;null = 关闭鉴权(仅本机建议)。 */
val apiKey: String? = null,
)
/**
* 本地 HTTP 反代服务。
*
* 具体实现建议用 Ktor CIO(协程友好,方便 SSE 流式转发);
* 若 APK 体积敏感,可换 NanoHTTPD,但流式转发需要额外桥接。
*/
interface LocalProxyServer {
val isRunning: StateFlow<Boolean>
fun start(config: ProxyServerConfig): Result<Unit>
fun stop()
}

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

@ -0,0 +1,21 @@
package com.rainy.token.data.proxy
import kotlinx.coroutines.flow.Flow
/**
* 统一的上游 Provider 适配器。
*
* 实现类负责把 [ProxyChatRequest] 翻译成各家私有协议
* (llm_utils_chat / v2/plugin / ACP 等),并把上游响应/SSE 流翻译回 [Flow]。
*
* 账号池、熔断、会话粘性由网关上层统一处理,不在适配器内重复实现。
*/
interface ProviderAdapter {
val kind: ProviderKind
/** 该适配器支持的模型列表;空列表表示暂未加载/不支持。 */
suspend fun models(): List<ProxyModel>
/** 发起对话,内部处理流式/非流式。 */
fun chat(request: ProxyChatRequest, accountId: String?): Flow<ProxyChatChunk>
}

View File

@ -0,0 +1,33 @@
package com.rainy.token.data.proxy
/**
* 网关支持的 Provider 清单。
*
* 命名规则:按「产品 + 区域」区分(同一个产品在国内版/国际版使用不同域名与鉴权体系)。
* supportsCheckin 表示该平台官方是否有每日签到类活动;是否已实测由各自 Repository 决定。
*/
enum class ProviderKind(
val displayName: String,
val regions: Set<ProviderRegion> = ProviderRegion.entries.toSet(),
val supportsChat: Boolean = true,
val supportsCheckin: Boolean = false,
) {
WORKBUDDY_CN("WorkBuddy 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
WORKBUDDY_INTL("WorkBuddy 国际版", setOf(ProviderRegion.INTL)),
TRAE_CN("Trae CN", setOf(ProviderRegion.CN), supportsCheckin = true),
TRAE_INTL("Trae INTL", setOf(ProviderRegion.INTL)),
TRAE_WORK("TraeWork 桌面版", setOf(ProviderRegion.CN)),
QODER_CN("Qoder CN", setOf(ProviderRegion.CN), supportsCheckin = true),
RACCOON_CN("小浣熊(商汤)", setOf(ProviderRegion.CN), supportsCheckin = true),
CATPAW_CN("CatPaw(美团)", setOf(ProviderRegion.CN)),
AUTOCLAW_CN("AutoClaw 国内版", setOf(ProviderRegion.CN), supportsCheckin = true),
AUTOCLAW_INTL("AutoClaw 国际版", setOf(ProviderRegion.INTL)),
CLINE("Cline", setOf(ProviderRegion.INTL)),
ACCIO_CN("Accio 国内版", setOf(ProviderRegion.CN)),
ACCIO_INTL("Accio 国际版", setOf(ProviderRegion.INTL)),
ZCODE_CN("ZCode 国内版", setOf(ProviderRegion.CN)),
ZCODE_INTL("ZCode 国际版", setOf(ProviderRegion.INTL)),
CODEARTS_CN("CodeArts(华为云)", setOf(ProviderRegion.CN)),
LOOMY_CN("Loomy(讯飞)", setOf(ProviderRegion.CN), supportsCheckin = true),
OPENAI_COMPATIBLE("OpenAI 兼容自定义", ProviderRegion.entries.toSet()),
}

View File

@ -0,0 +1,6 @@
package com.rainy.token.data.proxy
/**
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
*/
enum class ProviderRegion { CN, INTL }

View File

@ -0,0 +1,293 @@
package com.rainy.token.data.proxy
import java.io.IOException
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
/**
* 聚合路由:一个本地 API Key 通吃所有供应商。
*
* 规则:
* 1. 模型名带显式前缀(traecn- / wbintl- 等)→ 强制指定供应商;
* 2. 否则按【模型名】找所有支持的供应商,按历史速度排序逐个尝试;
* 3. 上游不可用/HTTP>=400/抛错 → 自动切换到下一个供应商;
* 4. Sub2API 作为通用兜底(配置了账号时)。
*/
@Singleton
class ProviderRouter @Inject constructor(
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val sub2ApiChatProxy: Sub2ApiChatProxy,
private val traeModelProvider: TraeModelProvider,
private val workBuddyModelProvider: WorkBuddyModelProvider,
private val accountPool: AccountPool,
) {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion?,
)
/** 网关内供应商目标。 */
enum class ProviderTarget(
val kind: ProviderKind,
val region: ProviderRegion?,
val displayName: String,
) {
TRAE_CN(ProviderKind.TRAE_CN, ProviderRegion.CN, "Trae CN"),
TRAE_INTL(ProviderKind.TRAE_INTL, ProviderRegion.INTL, "Trae INTL"),
WORKBUDDY_CN(ProviderKind.WORKBUDDY_CN, ProviderRegion.CN, "WorkBuddy CN"),
WORKBUDDY_INTL(ProviderKind.WORKBUDDY_INTL, ProviderRegion.INTL, "WorkBuddy INTL"),
SUB2API(ProviderKind.OPENAI_COMPATIBLE, null, "Sub2API"),
}
private data class Candidate(
val target: ProviderTarget,
val accountId: String?,
)
// ---- 速度 / 健康度统计(内存态) ----
private val avgLatency = ConcurrentHashMap<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-") ->
ProviderTarget.WORKBUDDY_CN
m.startsWith("wbintl-") || m.startsWith("workbuddy-intl") || m.startsWith("workbuddy-") ->
ProviderTarget.WORKBUDDY_INTL
m.startsWith("traeintl-") || m.startsWith("trae-intl") ->
ProviderTarget.TRAE_INTL
m.startsWith("traecn-") || m.startsWith("trae-cn") || m.startsWith("trae-") ->
ProviderTarget.TRAE_CN
m.startsWith("sub2api-") || m.startsWith("openai-") ->
ProviderTarget.SUB2API
else -> null
}
}
/** 兼容旧调用:只返回模型名前缀对应的路由。 */
fun route(model: String): Route {
val target = explicitTarget(model)
return if (target == null) {
Route(ProviderKind.OPENAI_COMPATIBLE, null)
} else {
Route(target.kind, target.region)
}
}
private fun stripModelInBody(body: String, originalModel: String): String {
val m = originalModel.trim()
val lower = m.lowercase()
val prefix = listOf(
"wbcn-", "workbuddy-cn", "codebuddy-",
"wbintl-", "workbuddy-intl", "workbuddy-",
"traeintl-", "trae-intl", "traecn-", "trae-cn", "trae-",
"sub2api-", "openai-",
).firstOrNull { lower.startsWith(it) } ?: return body
val newModel = m.substring(prefix.length).ifBlank { m }
if (newModel == m) return body
return runCatching {
val obj = Json.parseToJsonElement(body) as? JsonObject ?: return@runCatching body
JsonObject(
obj.entries.associate { (key, value) ->
key to (if (key == "model") JsonPrimitive(newModel) else value)
}
).toString()
}.getOrDefault(body)
}
private fun extractModel(body: String): String = runCatching {
val el = Json.parseToJsonElement(body)
(el as? JsonObject)
?.get("model")
?.let { if (it is JsonPrimitive) it.content else null }
}.getOrNull() ?: ""
}

View File

@ -0,0 +1,56 @@
package com.rainy.token.data.proxy
import kotlinx.serialization.json.JsonElement
/**
* 网关对外的最小 OpenAI 兼容模型。
* 后续按协议(Chat Completions / Responses / Anthropic)扩展。
*/
data class ProxyChatRequest(
val model: String,
val messages: List<ProxyChatMessage> = emptyList(),
val stream: Boolean = false,
val tools: List<JsonElement>? = null,
val toolChoice: JsonElement? = null,
val reasoningEffort: String? = null,
val maxTokens: Int? = null,
)
data class ProxyChatMessage(
val role: String,
val content: String? = null,
val toolCalls: List<JsonElement>? = null,
val toolCallId: String? = null,
val name: String? = null,
)
data class ProxyModel(
val id: String,
val provider: ProviderKind,
val region: ProviderRegion? = null,
)
/** 流式/非流式统一输出块,由 StreamNormalizer 归一。 */
sealed interface ProxyChatChunk {
data class Done(
val text: String? = null,
val usage: ProxyUsage? = null,
) : ProxyChatChunk
data class Delta(
val text: String? = null,
val reasoning: String? = null,
val toolCalls: List<JsonElement>? = null,
) : ProxyChatChunk
data class Error(
val message: String,
val code: Int? = null,
) : ProxyChatChunk
}
data class ProxyUsage(
val promptTokens: Long? = null,
val completionTokens: Long? = null,
val totalTokens: Long? = null,
)

View File

@ -0,0 +1,15 @@
package com.rainy.token.data.proxy
import java.io.InputStream
/**
* 上游流式响应句柄:由本地网关在收到客户端 stream=true 请求时创建,
* 把上游 SSE/原始字节流实时转发给本地客户端。
* 使用方必须在 finally 中调用 [close]。
*/
class ProxyUpstreamStream(
val status: Int,
val contentType: String,
val input: InputStream,
val close: () -> Unit,
)

View File

@ -0,0 +1,30 @@
package com.rainy.token.data.proxy
/**
* 模型名 → Provider + Region 路由。
*
* 骨架策略:按模型家族前缀映射;后续可用 agent2api / proxy-hub 的远端模型目录替换。
*/
class RegionRouter {
data class Route(
val kind: ProviderKind,
val region: ProviderRegion,
)
fun route(model: String): Route? {
val normalized = model.lowercase()
return when {
// 国内/字节/智谱系模型默认 CN
CN_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.CN)
// 国际模型默认 INTL
INTL_MODEL_FAMILIES.any { normalized.contains(it) } -> Route(ProviderKind.OPENAI_COMPATIBLE, ProviderRegion.INTL)
else -> null
}
}
companion object {
private val CN_MODEL_FAMILIES = listOf("glm", "deepseek", "kimi", "qwen", "doubao", "hunyuan", "minimax")
private val INTL_MODEL_FAMILIES = listOf("gpt", "o1", "o3", "o4", "claude", "gemini", "grok")
}
}

View File

@ -0,0 +1,135 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Sub2API Chat 透传代理。
*
* Sub2API 实例本身暴露 OpenAI 兼容接口,因此这里不做协议转换:
* 取出用户保存的 Sub2ApiCredential(优先 sk- API Key,其次面板 authToken),
* 把客户端请求原样转发到 {base}/v1/chat/completions,
* 并把上游响应(含 SSE 流式内容)原样返回给本地客户端。
*
* M1a 阶段:先做整包透传(流式也先缓冲),后续由 StreamNormalizer 升级为逐块转发。
*/
@Singleton
class Sub2ApiChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
/** 转发 POST /v1/chat/completions。 */
suspend fun forwardChat(requestBody: String, accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/chat/completions", requestBody = requestBody, accountId = accountId)
}
/** 转发流式 POST /v1/chat/completions(上游 SSE 原样转发)。 */
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val base = normalizeBase(credential.baseUrl) ?: return@withContext null
val builder = Request.Builder()
.url(base + "/v1/chat/completions")
.addHeader("Authorization", auth)
.addHeader("Content-Type", "application/json")
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = okHttpClient.newCall(builder.build()).execute()
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
/** 转发 GET /v1/models。 */
suspend fun forwardModels(accountId: String? = null): ProxyUpstreamResponse? {
val base = resolveBase(accountId) ?: return null
return forward(base = base, path = "/v1/models", requestBody = null, accountId = accountId)
}
private suspend fun resolveBase(accountId: String?): String? {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId) ?: return null
if (credential !is Credential.Sub2ApiCredential) return null
if (resolveAuth(credential) == null) return null
return normalizeBase(credential.baseUrl)
}
private suspend fun forward(
base: String,
path: String,
requestBody: String?,
accountId: String?,
): ProxyUpstreamResponse? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.SUB2API, accountId)
?: return@withContext null
if (credential !is Credential.Sub2ApiCredential) return@withContext null
val auth = resolveAuth(credential) ?: return@withContext null
val builder = Request.Builder()
.url(base + path)
.addHeader("Authorization", auth)
val body = requestBody?.takeIf { it.isNotBlank() }
if (body != null) {
builder
.addHeader("Content-Type", "application/json")
.post(body.toRequestBody("application/json".toMediaType()))
}
val response = okHttpClient.newCall(builder.build()).execute()
val bytes = response.body?.bytes() ?: ByteArray(0)
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
private fun resolveAuth(credential: Credential.Sub2ApiCredential): String? {
credential.apiKey?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
credential.authToken?.trim()?.takeIf { it.isNotBlank() }?.let { return "Bearer $it" }
return null
}
private fun normalizeBase(raw: String): String? {
var s = raw.trim()
while (s.endsWith("/")) s = s.dropLast(1)
return s.takeIf { it.isNotBlank() }
}
/** 是否已配置 Sub2API 凭据(用于聚合网关兜底)。 */
suspend fun hasCredential(): Boolean {
val credential = credentialRepository.get(ServiceType.SUB2API, null)
return credential != null
}
}
/**
* 上游 HTTP 响应(透传用)。
*/
data class ProxyUpstreamResponse(
val status: Int,
val contentType: String,
val body: ByteArray,
)

View File

@ -0,0 +1,319 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.put
import okhttp3.Headers.Companion.toHeaders
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* Trae CN 上游 Chat 代理。
*
* 上游为私有协议 POST {base}/api/agent/v3/llm_utils_chat。
* CN 模型网关固定走 trae-api-cn.mchost.guru(api.trae.cn 只是账号/OAuth 主机,返回 404)。
* 请求头/body 对齐 trae2api-cn 参考实现的 SOLO 协议。
*/
@Singleton
class TraeChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
private val modelProvider: TraeModelProvider,
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext null
if (credential !is Credential.TraeCredential) return@withContext null
val jwt = credential.jwt.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = chatBase(effectiveRegion)
val upstreamBody = buildUpstreamBody(requestBody, credential, effectiveRegion)
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/api/agent/v3/llm_utils_chat")
.headers(soloHeaders(jwt, credential, requestId).toHeaders())
.post(upstreamBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
private fun soloHeaders(
jwt: String,
credential: Credential.TraeCredential,
requestId: String,
): Map<String, String> = linkedMapOf(
"Content-Type" to "application/json",
"Accept" to "text/event-stream",
"Connection" to "keep-alive",
"Authorization" to "Cloud-IDE-JWT $jwt",
"X-Cloudide-Token" to jwt,
"x-ide-token" to jwt,
"x-uid" to (credential.userId ?: ""),
"x-app-id" to "6eefa01c-1036-4c7e-9ca5-d891f63bfcd8",
"x-device-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-machine-id" to (credential.deviceId ?: credential.checkinDeviceId ?: ""),
"x-request-id" to requestId,
"x-ide-version" to "0.1.52",
"x-ide-version-code" to "20260811",
"x-ide-version-type" to "stable",
"x-app-version" to "default",
"x-app-version-code" to "20260811",
"x-version-code" to "20260811",
"x-device-cpu" to "AMD",
"x-device-brand" to "83DG",
"x-device-type" to "windows",
"x-device-platform" to "windows",
"x-os-version" to "Windows 11 Pro",
"x-system-type" to "Windows",
"package-type" to "stable_cn",
"x-lscbd-aid" to "787976",
"x-lscbd-platform" to "windows",
"x-ss-dp" to "787976",
"x-plugin-channel" to "icube-ai",
"app-version" to "0.1.52",
"x-bridge-transport" to "aha",
"x-ahanet-timeout" to "86400",
"x-lgw-req-sdk-type" to "3",
"x-net-sdk-domain-dispatch" to "1",
"x-ttnet-bypass-decompression" to "1",
"x-ttnet-bypass-cookie" to "0",
"request-traffic-type" to "prod",
"User-Agent" to "Trae/0.1.52",
)
private fun convertNativeMessages(src: JsonElement?): List<JsonElement> {
val arr = src as? JsonArray ?: return emptyList()
return arr.mapNotNull { el ->
val m = el as? JsonObject ?: return@mapNotNull null
val rawRole = (m["role"] as? JsonPrimitive)?.contentOrNull?.lowercase() ?: "user"
val role = when (rawRole) {
"developer" -> "system"
"system", "user", "assistant", "tool", "function" -> rawRole
else -> "user"
}
buildJsonObject {
put("role", role)
val content = nativeContent(m["content"])
if (content != null) put("content", content)
m["name"]?.let { put("name", it) }
m["tool_call_id"]?.let { put("tool_call_id", it) }
m["tool_calls"]?.let { put("tool_calls", it) }
}
}
}
private fun nativeContent(content: JsonElement?): JsonElement? {
if (content == null || content is JsonNull) return null
if (content is JsonPrimitive) {
return buildJsonArray {
add(buildJsonObject {
put("type", "text")
put("text", content.content)
})
}
}
if (content is JsonArray) {
return buildJsonArray {
content.forEach { block ->
when (block) {
is JsonPrimitive -> add(buildJsonObject {
put("type", "text")
put("text", block.content)
})
is JsonObject -> {
val type = (block["type"] as? JsonPrimitive)?.contentOrNull?.lowercase()
if (type == "text" || type == "input_text") {
val text = (block["text"] as? JsonPrimitive)?.contentOrNull
?: (block["content"] as? JsonPrimitive)?.contentOrNull
?: ""
add(buildJsonObject {
put("type", "text")
put("text", text)
})
} else {
add(block)
}
}
else -> add(block)
}
}
}
}
return null
}
/** 外部模型名 -> Trae 内部模型名;空/auto 用设置页选择的默认模型。 */
private fun normalizeModel(raw: String?, region: ProviderRegion?): String {
val selected = modelProvider.selectedFor(region ?: ProviderRegion.CN)
val id = raw?.trim()?.takeIf { it.isNotBlank() } ?: return selected
if (id.equals("auto", ignoreCase = true)) return selected
return MODEL_ALIASES[id.lowercase()] ?: id
}
/**
* INTL 统一走 Web 网关 core-normal.trae.ai(实测 api-us-east/a0ai 均不提供
* llm_utils_chat 路由);CN 走 IDE 网关 trae-api-cn.mchost.guru。
*/
private fun chatBase(region: ProviderRegion?): String =
if (region == ProviderRegion.INTL) {
"https://core-normal.trae.ai"
} else {
"https://trae-api-cn.mchost.guru"
}
private fun buildUpstreamBody(raw: String, credential: Credential.TraeCredential, region: ProviderRegion?): String {
val src = try {
json.parseToJsonElement(raw) as? JsonObject
} catch (_: Throwable) {
null
} ?: return raw
val rawModel = (src["model"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }
val model = normalizeModel(rawModel, region)
val messages = convertNativeMessages(src["messages"])
val sessionId = UUID.randomUUID().toString().replace("-", "")
return buildJsonObject {
put("messages", JsonArray(messages))
put("config_name", model)
put("model", model)
put("function", "solo_work_lite")
put("stream", true)
put("request_id", sessionId)
put("session_id", sessionId)
src["tools"]?.let { put("tools", it) }
src["tool_choice"]?.let { put("tool_choice", it) }
(src["max_tokens"] as? JsonPrimitive)?.contentOrNull?.toIntOrNull()?.let {
put("max_tokens", it)
}
}.toString()
}
companion object {
/** 供路由判断模型是否可能由 Trae 消化。 */
internal fun resolveAlias(modelName: String): String? =
MODEL_ALIASES[modelName.trim().lowercase()]
/** OpenAI/Claude 常用名 -> Trae CN 内部模型名(参考 trae2api-cn)。 */
private val MODEL_ALIASES = mapOf(
"auto" to "glm-5.2",
"gpt-4o" to "DeepSeek-V4-Pro",
"gpt-4o-latest" to "DeepSeek-V4-Pro",
"gpt-4.1" to "DeepSeek-V4-Pro",
"deepseek-v3" to "DeepSeek-V4-Pro",
"deepseek-r1" to "DeepSeek-V4-Pro",
"gpt-4o-mini" to "DeepSeek-V4-Flash",
"claude-opus-4-7" to "glm-5.2",
"claude-opus-4-6" to "glm-5.2",
"claude-opus-4-5" to "glm-5.2",
"claude-sonnet-4-6" to "glm-5.2",
"claude-sonnet-4-5" to "glm-5.2",
"claude-sonnet-4" to "glm-5.2",
"claude-3.7-sonnet" to "glm-5.2",
"claude-3-7-sonnet" to "glm-5.2",
"claude-3.5-sonnet" to "glm-5.2",
"claude-haiku-4-5" to "glm-5.1",
"deepseek-v4-flash-official" to "DeepSeek-V4-Flash-Official",
"deepseek-v4-pro-official" to "DeepSeek-V4-Pro-Official",
"doubao-seed-evolving" to "Doubao-Seed-Evolving",
"deepseek-v4-pro" to "DeepSeek-V4-Pro",
"deepseek-v4-flash" to "DeepSeek-V4-Flash",
"seed-2.1-pro" to "Doubao-Seed-2.1-Pro",
"seed-2.1-turbo" to "Doubao-Seed-2.1-Turbo",
"seed-code" to "Doubao-Seed-Code",
"seed-evolving" to "Doubao-Seed-Evolving",
"qwen3.7-plus" to "qwen-3.7-plus",
"kimi-k2.6" to "kimi-k2.6",
"kimi-k3" to "kimi-k3",
"kimi-k2.7-code" to "kimi-k2.7-code",
"kimi-k2" to "kimi-k2",
"kimi-k2.5" to "kimi-k2.5",
"minimax-m3" to "minimax-m3",
"minimax-m2.7" to "minimax-m2.7",
"minimax-m2.1" to "minimax-m2.1",
"mimo-v2.5" to "mimo-v2.5",
"mimo-v2.5-pro" to "mimo-v2.5-pro",
"minimax-m25" to "minimax-m25",
"qwen36-35b" to "qwen36-35b",
)
}
}

View File

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

View File

@ -0,0 +1,127 @@
package com.rainy.token.data.proxy
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
/**
* WorkBuddy / CodeBuddy 上游 Chat 代理(国内版)。
*
* 上游协议为 OpenAI 兼容的 POST {base}/v2/chat/completions,Bearer accessToken。
* 这里保留客户端原始 body(M1b 最小实现),并补齐桌面端请求头;完整归一化在后续迭代。
*/
@Singleton
class WorkBuddyChatProxy @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
) {
suspend fun forwardChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamResponse? =
withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://www.workbuddy.ai"
} else {
"https://copilot.tencent.com"
}
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/v2/chat/completions")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("User-Agent", "RainyToken/1.0")
.addHeader("X-IDE-Type", "VSCode")
.addHeader("X-IDE-Name", "CodeBuddy")
.addHeader("X-IDE-Version", "3.0.0")
.addHeader("X-Product", "CodeBuddy")
.addHeader("X-Agent-Intent", "craft")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val bytes = try { response.body?.bytes() ?: ByteArray(0) } catch (_: Throwable) { ByteArray(0) }
val contentType = response.header("Content-Type") ?: "application/json"
val status = response.code
response.close()
ProxyUpstreamResponse(status, contentType, bytes)
}
suspend fun openStreamingChat(
requestBody: String,
accountId: String? = null,
forcedRegion: ProviderRegion? = null,
): ProxyUpstreamStream? = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.WORKBUDDY, accountId)
?: return@withContext null
if (credential !is Credential.WorkBuddyCredential) return@withContext null
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext null
val effectiveRegion = forcedRegion ?: runCatching {
ProviderRegion.valueOf(credential.region.uppercase())
}.getOrNull()
val base = if (effectiveRegion == ProviderRegion.INTL) {
"https://www.workbuddy.ai"
} else {
"https://copilot.tencent.com"
}
val requestId = UUID.randomUUID().toString()
val builder = Request.Builder()
.url(base + "/v2/chat/completions")
.addHeader("Content-Type", "application/json")
.addHeader("Accept", "text/event-stream")
.addHeader("Authorization", "Bearer $accessToken")
.addHeader("User-Agent", "RainyToken/1.0")
.addHeader("X-IDE-Type", "VSCode")
.addHeader("X-IDE-Name", "CodeBuddy")
.addHeader("X-IDE-Version", "3.0.0")
.addHeader("X-Product", "CodeBuddy")
.addHeader("X-Agent-Intent", "craft")
.addHeader("X-Request-ID", requestId)
.addHeader("X-Conv-Request-ID", requestId)
.post(requestBody.toRequestBody("application/json".toMediaType()))
val response = try {
okHttpClient.newCall(builder.build()).execute()
} catch (e: java.io.IOException) {
throw e
}
val input = response.body?.byteStream() ?: run {
response.close()
return@withContext null
}
ProxyUpstreamStream(
status = response.code,
contentType = response.header("Content-Type") ?: "text/event-stream",
input = input,
close = { response.close() },
)
}
}

View File

@ -0,0 +1,141 @@
package com.rainy.token.data.proxy
import android.content.Context
import android.content.SharedPreferences
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.Headers.Companion.toHeaders
import okhttp3.OkHttpClient
import okhttp3.Request
/**
* WorkBuddy / CodeBuddy 模型列表:
* 在线接口 GET {base}/v2/enterprises/personal/models + 内置兜底 + 按区域记住选择。
*/
@Singleton
class WorkBuddyModelProvider @Inject constructor(
private val okHttpClient: OkHttpClient,
private val credentialRepository: CredentialRepository,
@ApplicationContext private val context: Context,
) {
private val prefs: SharedPreferences =
context.getSharedPreferences("rainytoken_proxy", Context.MODE_PRIVATE)
private val _modelsByRegion = MutableStateFlow(
ProviderRegion.entries.associateWith { defaultModels() }
)
/** 获取指定区域模型列表(在线拉取成功后该区域独立更新)。 */
fun modelsFor(region: ProviderRegion): List<TraeModelProvider.TraeModel> =
_modelsByRegion.value[region] ?: defaultModels()
private val json = Json { ignoreUnknownKeys = true }
fun selectedFor(region: ProviderRegion): String {
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
return prefs.getString(key, DEFAULT_MODEL) ?: DEFAULT_MODEL
}
fun setSelectedFor(region: ProviderRegion, id: String) {
if (id.isBlank()) return
val key = if (region == ProviderRegion.INTL) KEY_MODEL_INTL else KEY_MODEL_CN
prefs.edit().putString(key, id).apply()
}
/** 该区域是否可能支持此模型(在线/内置列表)。 */
fun supports(modelId: String, region: ProviderRegion): Boolean {
val want = modelId.trim().lowercase()
if (want.isBlank() || want == "auto") return true
return modelsFor(region).any { it.id.equals(modelId, ignoreCase = true) }
}
suspend fun refreshFor(region: ProviderRegion): Boolean = withContext(Dispatchers.IO) {
val credential = pickCredential(region) ?: return@withContext false
val accessToken = credential.accessToken.trim().takeIf { it.isNotBlank() }
?: return@withContext false
val intl = region == ProviderRegion.INTL
val base = if (intl) "https://www.workbuddy.ai" else "https://copilot.tencent.com"
val url = base + "/v2/enterprises/personal/models"
val headers = linkedMapOf(
"Authorization" to "Bearer $accessToken",
"Content-Type" to "application/json",
"Accept" to "application/json",
"X-Client-ID" to "codebuddy-cli",
"X-Client-Version" to "2.143.1",
"X-Product" to "SaaS",
"User-Agent" to "CLI/2.143.1 CodeBuddy/2.143.1",
"Origin" to (if (intl) "https://www.workbuddy.ai" else "https://www.codebuddy.cn"),
)
val request = Request.Builder().url(url).headers(headers.toHeaders()).get().build()
val parsed = try {
okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) return@use null
val body = resp.body?.string() ?: return@use null
parseModelList(body)
}
} catch (_: Exception) {
null
}
if (parsed.isNullOrEmpty()) return@withContext false
_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
val seen = LinkedHashMap<String, TraeModelProvider.TraeModel>()
for (rawEl in models) {
val raw = rawEl as? JsonObject ?: continue
val id = (raw["id"] as? JsonPrimitive)?.contentOrNull?.trim()
?.takeIf { it.isNotEmpty() } ?: continue
val label = (raw["name"] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() } ?: id
seen[id] = TraeModelProvider.TraeModel(id, label)
}
return seen.values.toList().ifEmpty { null }
}
companion object {
private const val KEY_MODEL_CN = "wb_default_model_cn"
private const val KEY_MODEL_INTL = "wb_default_model_intl"
private const val DEFAULT_MODEL = "hy4-preview"
/** 内置兜底(参考 workbuddy-gateway 模型目录,在线列表成功后会被替换)。 */
fun defaultModels(): List<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"),
)
}
}

View File

@ -58,6 +58,79 @@ class TraeRepository constructor(
private val json = Json { ignoreUnknownKeys = true } 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) { suspend fun fetchBalance(accountId: String? = null): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.TRAE, accountId) val credential = credentialRepository.get(ServiceType.TRAE, accountId)
?: return@withContext Result.failure(RepositoryError.InvalidCredential()) ?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -66,9 +139,12 @@ class TraeRepository constructor(
} }
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN" val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
val url = when (regionKey) { val url = if (regionKey == "INTL") {
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage" val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage" ?: "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() val request = Request.Builder()
.url(url) .url(url)
@ -124,6 +200,8 @@ class TraeRepository constructor(
root["is_credits_billing"]?.jsonPrimitive?.contentOrNull?.let { extras["is_credits_billing"] = it } 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 } 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 } root["is_pay_freshman"]?.jsonPrimitive?.contentOrNull?.let { extras["is_new_user"] = it }
// 1) 权威汇总 // 1) 权威汇总
@ -156,6 +234,28 @@ class TraeRepository constructor(
?: base?.get("product_extra")?.jsonObject ?: base?.get("product_extra")?.jsonObject
?.get("package_extra")?.jsonObject?.getString("package_name") ?.get("package_extra")?.jsonObject?.getString("package_name")
?: "pack[$index]" ?: "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 limit = quota?.getNumber("credits_limit")?.toLong()
val used = pack.getNumber("usage", "credits_amount") val used = pack.getNumber("usage", "credits_amount")
?: base?.get("usage")?.jsonObject?.get("credits_amount")?.safeDouble() ?: 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["pack_count"] = packCount.toString()
extras["general_credits"] = trimNum(generalSum) extras["general_credits"] = trimNum(generalSum)
extras["work_credits"] = trimNum(workSum) extras["work_credits"] = trimNum(workSum)
@ -736,3 +836,16 @@ class TraeRepository constructor(
} }
} }
} }
/** 官方 Usage Events 按会话聚合的一条用量记录。 */
data class TraeUsageEvent(
val usageTime: Long,
val modelName: String,
val mode: String,
val userInputPreview: String,
val creditsFloat: Double,
val costMoneyFloat: Double,
val inputToken: Long,
val outputToken: Long,
val cacheReadToken: Long,
val cacheWriteToken: Long
)

View File

@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
} }
} }
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */ /** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
suspend fun downloadApk( suspend fun downloadApk(
info: UpdateInfo, info: UpdateInfo,
onProgress: (downloaded: Long, total: Long) -> Unit onProgress: (downloaded: Long, total: Long) -> Unit
@ -64,6 +64,11 @@ class UpdateRepository @Inject constructor(
try { try {
val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() } val dir = File(appContext.cacheDir, "apk_downloads").apply { mkdirs() }
val file = File(dir, "rainytoken-" + info.versionName + ".apk") val file = File(dir, "rainytoken-" + info.versionName + ".apk")
if (file.exists() && file.length() > 0L) {
return@withContext Result.success(file)
}
// 先写 .part,下载完整后再替换正式文件名,避免半截文件被当成完整包
val part = File(dir, file.name + ".part")
val request = Request.Builder().url(info.apkUrl).get().build() val request = Request.Builder().url(info.apkUrl).get().build()
okHttpClient.newCall(request).execute().use { resp -> okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) { if (!resp.isSuccessful) {
@ -71,7 +76,7 @@ class UpdateRepository @Inject constructor(
} }
val body = resp.body ?: return@withContext Result.failure(IOException("empty body")) val body = resp.body ?: return@withContext Result.failure(IOException("empty body"))
val total = body.contentLength() val total = body.contentLength()
file.outputStream().buffered().use { output -> part.outputStream().buffered().use { output ->
body.byteStream().use { input -> body.byteStream().use { input ->
val buffer = ByteArray(DEFAULT_BUFFER_SIZE) val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var downloaded = 0L var downloaded = 0L
@ -84,6 +89,8 @@ class UpdateRepository @Inject constructor(
} }
} }
} }
if (file.exists()) file.delete()
part.renameTo(file)
Result.success(file) Result.success(file)
} catch (e: Exception) { } catch (e: Exception) {
Result.failure(e) Result.failure(e)

View File

@ -53,6 +53,26 @@ class WorkBuddyRepository constructor(
private val jsonMedia = "application/json".toMediaType() 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 { private val codebuddyBody = buildJsonObject {
put("PageNumber", 1) put("PageNumber", 1)
put("PageSize", 100) put("PageSize", 100)
@ -79,7 +99,7 @@ class WorkBuddyRepository constructor(
var lastBody = "" var lastBody = ""
while (attempts < 3) { while (attempts < 3) {
attempts++ 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 { val resp = try {
okHttpClient.newCall(request).execute() okHttpClient.newCall(request).execute()
} catch (e: IOException) { } catch (e: IOException) {
@ -101,7 +121,7 @@ class WorkBuddyRepository constructor(
return@withContext Result.failure(mapHttpError(code, body)) return@withContext Result.failure(mapHttpError(code, body))
} }
401, 403 -> { 401, 403 -> {
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure( val refreshed = tryRefesh(credential, refresh) ?: return@withContext Result.failure(
RepositoryError.InvalidCredential() RepositoryError.InvalidCredential()
) )
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制) // 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
@ -141,7 +161,7 @@ class WorkBuddyRepository constructor(
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。 // 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。 // 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull() val detailExtras = runCatching { fetchResourceDetailExtras(credential, access) }.getOrNull()
val mergedExtras = if (detailExtras != null) { val mergedExtras = if (detailExtras != null) {
parsed.extras + detailExtras parsed.extras + detailExtras
} else { } else {
@ -166,9 +186,9 @@ class WorkBuddyRepository constructor(
* 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。 * 拉取细粒度账户明细并转成 extras 增量([KEY_DETAIL_RAW] / [KEY_PLAN_CODE])。
* 请求失败或解析失败返回 null(调用方降级到 summary 的简化 extras)。 * 请求失败或解析失败返回 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() val request = Request.Builder()
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource") .url(credential.resourceUrl())
.addHeader("Authorization", "Bearer $accessToken") .addHeader("Authorization", "Bearer $accessToken")
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json")
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
@ -187,18 +207,18 @@ class WorkBuddyRepository constructor(
} }
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */ /** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
private fun summaryRequest(accessToken: String): Request = private fun summaryRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
Request.Builder() Request.Builder()
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary") .url(credential.summaryUrl())
.addHeader("Authorization", "Bearer $accessToken") .addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post("{}".toRequestBody(jsonMedia)) .post("{}".toRequestBody(jsonMedia))
.build() .build()
/** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */ /** codebuddy.cn 官方 CLI 插件模型(桌面/CLI accessToken 兜底)。 */
private fun codebuddyRequest(accessToken: String): Request = private fun codebuddyRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
Request.Builder() Request.Builder()
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource") .url(credential.resourceUrl())
.addHeader("Authorization", "Bearer $accessToken") .addHeader("Authorization", "Bearer $accessToken")
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post(codebuddyBody.toRequestBody(jsonMedia)) .post(codebuddyBody.toRequestBody(jsonMedia))
@ -208,9 +228,9 @@ class WorkBuddyRepository constructor(
* refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。 * refresh_token 单次轮换:POST token/refresh(X-Refresh-Token 头)。
* 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。 * 只读取并返回新凭据;轮换后的提交交给主流程的 save(同一成功才落库)。
*/ */
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? { private fun tryRefesh(credential: Credential.WorkBuddyCredential, refreshToken: String): RefreshedWorkBuddy? {
val request = Request.Builder() val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh") .url(credential.refreshUrl())
.addHeader("X-Refresh-Token", refreshToken) .addHeader("X-Refresh-Token", refreshToken)
.post("{}".toRequestBody(jsonMedia)) .post("{}".toRequestBody(jsonMedia))
.build() .build()
@ -439,7 +459,9 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
/** 今日已签到(幂等) */ /** 今日已签到(幂等) */
ALREADY, ALREADY,
/** 签到失败(服务端给了原因,见 message) */ /** 签到失败(服务端给了原因,见 message) */
FAILED FAILED,
/** 国际站无签到活动(活动未开启/未上线,非错误) */
NO_CAMPAIGN
} }
/** /**
@ -466,6 +488,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) { if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential()) 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 兜底) // 两条候选路径 fallback(cpa-plugin 实测:activity-status 优先,旧路径 checkin-status 兜底)
// 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。 // 共享轮换状态:前一条路径轮换出的新 token 会用于下一条,且失败也先落库。
val body = authedPostFirstSuccessful( val body = authedPostFirstSuccessful(
@ -497,6 +525,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
if (credential !is Credential.WorkBuddyCredential) { if (credential !is Credential.WorkBuddyCredential) {
return@withContext Result.failure(RepositoryError.InvalidCredential()) 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")) { val body = when (val r = authedPost(credential, "/v2/billing/meter/daily-checkin")) {
is AuthedPostResult.Ok -> { is AuthedPostResult.Ok -> {
persistCheckinOutcome(credential, accountId, r.rotatedCredential) persistCheckinOutcome(credential, accountId, r.rotatedCredential)
@ -530,8 +564,15 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message") ?: (r["data"] as? JsonObject)?.scalar("msg", "message", "Message")
}.getOrNull().orEmpty() }.getOrNull().orEmpty()
val status = fetchCheckinStatus(accountId).getOrNull() val status = fetchCheckinStatus(accountId).getOrNull()
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
val lowerMsg = msg.lowercase() 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("已经签到") || val already = code == 10001L || msg.contains("已签") || msg.contains("已经签到") ||
msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") || msg.contains("今日") || lowerMsg.contains("already") || lowerMsg.contains("checked") ||
lowerMsg.contains("claimed") lowerMsg.contains("claimed")
@ -546,6 +587,12 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
outcome = CheckinOutcome.SUCCESS outcome = CheckinOutcome.SUCCESS
) )
) )
noCampaign -> Result.success(
(status ?: CheckinStatus(false, false, 0, 0, 0, "")).copy(
message = msg,
outcome = CheckinOutcome.NO_CAMPAIGN
)
)
already -> Result.success( already -> Result.success(
(status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy( (status ?: CheckinStatus(true, true, 0, 0, 0, "")).copy(
outcome = CheckinOutcome.ALREADY outcome = CheckinOutcome.ALREADY
@ -595,10 +642,23 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
var rotated: Credential.WorkBuddyCredential? = null var rotated: Credential.WorkBuddyCredential? = null
repeat(2) { attempt -> repeat(2) { attempt ->
val request = Request.Builder() val request = Request.Builder()
.url("https://www.codebuddy.cn" + path) .url(credential.originBase() + path)
.addHeader("Authorization", "Bearer " + access) .addHeader("Authorization", "Bearer " + access)
.addHeader("Accept", "application/json") .addHeader("Accept", "application/json, text/plain, */*")
.addHeader("Content-Type", "application/json") .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)) .post("{}".toRequestBody(jsonMedia))
.build() .build()
val resp = try { val resp = try {
@ -615,7 +675,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
when { when {
code in 200..299 -> return AuthedPostResult.Ok(body, rotated) code in 200..299 -> return AuthedPostResult.Ok(body, rotated)
(code == 401 || code == 403) && attempt == 0 -> { (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) { val expiresAt = if (refreshed.expiresIn > 0) {
System.currentTimeMillis() + refreshed.expiresIn * 1000 System.currentTimeMillis() + refreshed.expiresIn * 1000
} else { } else {

View File

@ -20,6 +20,15 @@ import com.rainy.token.data.repository.OllamaRepository
import com.rainy.token.data.repository.Sub2ApiRepository import com.rainy.token.data.repository.Sub2ApiRepository
import com.rainy.token.data.repository.TraeRepository import com.rainy.token.data.repository.TraeRepository
import com.rainy.token.data.repository.UpdateRepository import com.rainy.token.data.repository.UpdateRepository
import com.rainy.token.data.proxy.AccountPool
import com.rainy.token.data.proxy.ProviderRouter
import com.rainy.token.data.proxy.KtorLocalProxyServer
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.Sub2ApiChatProxy
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.repository.WorkBuddyRepository import com.rainy.token.data.repository.WorkBuddyRepository
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
@ -223,6 +232,70 @@ object NetworkModule {
balanceCache: BalanceCache balanceCache: BalanceCache
): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache) ): Sub2ApiRepository = Sub2ApiRepository(okHttpClient, credentialRepository, balanceCache)
// ---- 本地反代网关 ----
@Provides
@Singleton
fun provideSub2ApiChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): Sub2ApiChatProxy = Sub2ApiChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideWorkBuddyChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository
): WorkBuddyChatProxy = WorkBuddyChatProxy(okHttpClient, credentialRepository)
@Provides
@Singleton
fun provideTraeChatProxy(
okHttpClient: OkHttpClient,
credentialRepository: CredentialRepository,
traeModelProvider: TraeModelProvider,
): TraeChatProxy = TraeChatProxy(okHttpClient, credentialRepository, traeModelProvider)
@Provides
@Singleton
fun provideProviderRouter(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
traeModelProvider: TraeModelProvider,
workBuddyModelProvider: WorkBuddyModelProvider,
accountPool: AccountPool,
): ProviderRouter = ProviderRouter(
traeChatProxy,
workBuddyChatProxy,
sub2ApiChatProxy,
traeModelProvider,
workBuddyModelProvider,
accountPool,
)
@Provides
@Singleton
fun provideAccountPool(
credentialRepository: CredentialRepository
): AccountPool = AccountPool(credentialRepository)
@Provides
@Singleton
fun provideLocalProxyServer(
sub2ApiChatProxy: Sub2ApiChatProxy,
workBuddyChatProxy: WorkBuddyChatProxy,
traeChatProxy: TraeChatProxy,
providerRouter: ProviderRouter,
accountPool: AccountPool
): LocalProxyServer = KtorLocalProxyServer(
sub2ApiChatProxy,
workBuddyChatProxy,
traeChatProxy,
providerRouter,
accountPool
)
/** 余额缓存 DataStore(计划 7.1) */ /** 余额缓存 DataStore(计划 7.1) */
@Provides @Provides
@Singleton @Singleton

View File

@ -79,8 +79,10 @@ sealed class Credential {
override val service: ServiceType, override val service: ServiceType,
/** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */ /** `Authorization: Cloud-IDE-JWT <jwt>` 的 JWT 值 */
val jwt: String, 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", val region: String = "CN",
/** 登录回调携带的账号 API 主机(如 https://api-us-east.trae.ai);null=旧凭据用区域默认 */
val apiHost: String? = null,
/** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */ /** 登录会话绑定的设备 ID(App 持久化每机唯一),null=旧凭据未保存 */
val deviceId: String? = null, val deviceId: String? = null,
/** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */ /** OAuth 用户 ID(回调 userInfo.userId),null=旧凭据未保存 */
@ -106,6 +108,8 @@ sealed class Credential {
override val service: ServiceType, override val service: ServiceType,
val accessToken: String, val accessToken: String,
val refreshToken: String, val refreshToken: String,
/** 区域:"CN"(copilot.tencent.com / codebuddy.cn)/ "INTL"(workbuddy.ai) */
val region: String = "CN",
/** 过期时间(epoch millis),0=未知 */ /** 过期时间(epoch millis),0=未知 */
val expiresAt: Long = 0L, val expiresAt: Long = 0L,
override val lastVerifiedAt: Long = 0L override val lastVerifiedAt: Long = 0L

View File

@ -102,5 +102,20 @@ object ServiceConfigProvider {
fun get(type: ServiceType): ServiceConfig = fun get(type: ServiceType): ServiceConfig =
configs.getValue(type) 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) } fun all(): List<ServiceConfig> = ServiceType.entries.map { get(it) }
} }

View File

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

View File

@ -85,7 +85,7 @@ import java.util.Locale
fun ApiManagementScreen( fun ApiManagementScreen(
onBack: () -> Unit, onBack: () -> Unit,
onConfigure: (ServiceType) -> Unit, onConfigure: (ServiceType) -> Unit,
onRecharge: (ServiceType) -> Unit, onRecharge: (ServiceType, String?) -> Unit,
viewModel: ApiManagementViewModel = hiltViewModel() viewModel: ApiManagementViewModel = hiltViewModel()
) { ) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
@ -150,7 +150,7 @@ fun ApiManagementScreen(
onEdit = { onConfigure(row.service) }, onEdit = { onConfigure(row.service) },
onTest = { viewModel.test(row.service) }, onTest = { viewModel.test(row.service) },
onDelete = { pendingDelete = 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, onDelete: () -> Unit,
onRecharge: () -> Unit onRecharge: () -> Unit
) { ) {
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口 // 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
val rechargeUrl = remember(row.service) { val rechargeUrl = remember(row.service, row.region) {
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull() runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull()
} }
Card( Card(
modifier = Modifier modifier = Modifier

View File

@ -63,6 +63,15 @@ class ApiManagementViewModel @Inject constructor(
credentialRepository.accountsFor(type).size credentialRepository.accountsFor(type).size
} else { } else {
0 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 cachedBalance: CachedBalance?,
val maskedCredential: String?, val maskedCredential: String?,
/** 多账号服务的账号个数;单账号服务为 0。 */ /** 多账号服务的账号个数;单账号服务为 0。 */
val accountCount: Int = 0 val accountCount: Int = 0,
/** Trae 账号区域(用于选择官方充值/用量页)。 */
val region: String? = null
) { ) {
val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED val isConfigured: Boolean get() = state != CredentialStatus.State.NOT_CONFIGURED

View File

@ -138,7 +138,7 @@ fun DashboardScreen(
onOpenUsageDetail: () -> Unit, onOpenUsageDetail: () -> Unit,
onOpenCcgoUsageDetail: () -> Unit = {}, onOpenCcgoUsageDetail: () -> Unit = {},
onOpenHeatmap: () -> Unit = {}, onOpenHeatmap: () -> Unit = {},
onOpenRecharge: (ServiceType) -> Unit = {}, onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
viewModel: DashboardViewModel = hiltViewModel() viewModel: DashboardViewModel = hiltViewModel()
) { ) {
// 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的 // 从设置页返回时重新读取本地凭据状态 + 缓存;若有已配置但无余额缓存的
@ -549,7 +549,7 @@ private fun rememberDashboardItems(
onOpenService: (ServiceType, String?) -> Unit, onOpenService: (ServiceType, String?) -> Unit,
onOpenHeatmap: () -> Unit, onOpenHeatmap: () -> Unit,
onOpenSub2PanelManagement: (String?) -> Unit, onOpenSub2PanelManagement: (String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit, onOpenRecharge: (ServiceType, String?) -> Unit,
refreshTrigger: Int refreshTrigger: Int
): List<DashboardHomeItem> { ): List<DashboardHomeItem> {
val defaultItems = buildList { val defaultItems = buildList {
@ -607,8 +607,8 @@ private fun rememberDashboardItems(
null null
}, },
// 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开 // 充值/订阅入口:有官方 externalUrl 的服务(DeepSeek/Trae/WorkBuddy)在卡片上直开
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) { onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null || card.service == ServiceType.TRAE) {
{ onOpenRecharge(card.service) } { onOpenRecharge(card.service, card.region) }
} else { } else {
null null
}, },

View File

@ -287,7 +287,8 @@ class DashboardViewModel @Inject constructor(
siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon, siteIcon = (local.credential as? Credential.Sub2ApiCredential)?.siteIcon,
// 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示 // 可签到服务(Trae/WorkBuddy)各账号的今日签到角标;其它服务恒 false 不显示
checkedInToday = local.service in CHECKIN_SERVICES && 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( private fun newerOf(
@ -463,7 +464,9 @@ data class DashboardCardUi(
/** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */ /** 站点 Logo URL(Sub2API 面板登录后抓取;非空时主页卡片用它替换内置图标)。 */
val siteIcon: String? = null, val siteIcon: String? = null,
/** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */ /** 今日已签到角标(仅可签到服务有值,见 [CHECKIN_SERVICES])。 */
val checkedInToday: Boolean = false val checkedInToday: Boolean = false,
/** 账号区域(Trae CN/INTL 等,用于选择官方充值/用量页)。 */
val region: String? = null
) { ) {
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */ /** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
val displayBalance: ServiceBalance? get() = cachedBalance?.balance val displayBalance: ServiceBalance? get() = cachedBalance?.balance

View File

@ -146,6 +146,10 @@ internal fun BalanceMainArea(card: DashboardCardUi) {
@Composable @Composable
private fun TraeSplitMainBalance(balance: ServiceBalance) { private fun TraeSplitMainBalance(balance: ServiceBalance) {
val extras = balance.extras val extras = balance.extras
if (extras?.get("billing_mode") == "dollar") {
TraeDollarPlanSummary(extras)
return
}
val general = extras?.get("general_credits")?.toDoubleOrNull() val general = extras?.get("general_credits")?.toDoubleOrNull()
val work = extras?.get("work_credits")?.toDoubleOrNull() val work = extras?.get("work_credits")?.toDoubleOrNull()
if (general == null && work == null) { if (general == null && work == null) {
@ -176,7 +180,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) {
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */ /** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
@Composable @Composable
private fun TraeSplitLine(label: String, value: Double?) { private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.Bottom, verticalAlignment = Alignment.Bottom,
@ -196,7 +200,7 @@ private fun TraeSplitLine(label: String, value: Double?) {
) )
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text( Text(
text = "Credits", text = unit,
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
color = inkMuted(), color = inkMuted(),
modifier = Modifier.padding(bottom = 2.dp) 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 @Composable
internal fun OpenCodeGoMainBalance(balance: ServiceBalance) { internal fun OpenCodeGoMainBalance(balance: ServiceBalance) {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {

View File

@ -57,6 +57,7 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight 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.text.style.TextAlign
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
@ -66,6 +67,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.repository.ModelUsageRow import com.rainy.token.data.repository.ModelUsageRow
import com.rainy.token.data.repository.OpenCodeGoRepository 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.WorkBuddyRepository
import com.rainy.token.data.repository.WindowModelUsage import com.rainy.token.data.repository.WindowModelUsage
import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.model.ServiceBalance
@ -114,8 +116,8 @@ fun ServiceDetailScreen(
onBack: () -> Unit, onBack: () -> Unit,
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> }, onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
onConfigureCredential: (ServiceType) -> Unit, onConfigureCredential: (ServiceType) -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit, onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onOpenRecharge: (ServiceType) -> Unit = { }, onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
viewModel: ServiceDetailViewModel = hiltViewModel() viewModel: ServiceDetailViewModel = hiltViewModel()
) { ) {
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) } LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
@ -129,6 +131,7 @@ fun ServiceDetailScreen(
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle() val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle() val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val checkinState by viewModel.checkinState.collectAsStateWithLifecycle() val checkinState by viewModel.checkinState.collectAsStateWithLifecycle()
val traeUsageEvents by viewModel.traeUsageEvents.collectAsStateWithLifecycle()
val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle() val checkinInfo by viewModel.checkinInfo.collectAsStateWithLifecycle()
val accounts by viewModel.accounts.collectAsStateWithLifecycle() val accounts by viewModel.accounts.collectAsStateWithLifecycle()
@ -235,6 +238,12 @@ fun ServiceDetailScreen(
item { OllamaUsageCard(uiState.state) } item { OllamaUsageCard(uiState.state) }
} }
ServiceType.TRAE -> { ServiceType.TRAE -> {
val intl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
if (intl) {
// 国际版:单张用量卡(套餐/美元额度/月请求配额),不再重复展示签到卡和积分账户卡
item { TraeIntlUsageCard(uiState.state) }
item { TraeUsageEventsCard(traeUsageEvents) }
} else {
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮 // 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
item { item {
if (uiState.hasCredential) { if (uiState.hasCredential) {
@ -254,7 +263,13 @@ fun ServiceDetailScreen(
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键) // 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
item { TraeAccountInfoCard(uiState.state) } item { TraeAccountInfoCard(uiState.state) }
} }
}
ServiceType.WORKBUDDY -> { ServiceType.WORKBUDDY -> {
val wbIntl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
if (wbIntl) {
// 国际版没有独立签到体系:积分来自网页/客户端活跃使用,直接展示说明卡
item { WorkBuddyIntlNoCheckinCard() }
} else {
item { item {
if (uiState.hasCredential) { if (uiState.hasCredential) {
CheckinCard( CheckinCard(
@ -268,6 +283,7 @@ fun ServiceDetailScreen(
CheckinNeedCredentialCard() CheckinNeedCredentialCard()
} }
} }
}
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息) // 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
item { WorkBuddyResourcesCard(uiState.state) } item { WorkBuddyResourcesCard(uiState.state) }
} }
@ -309,14 +325,17 @@ fun ServiceDetailScreen(
hasCredential = uiState.hasCredential, hasCredential = uiState.hasCredential,
isManualMode = isManualMode, isManualMode = isManualMode,
service = service, service = service,
region = uiState.region,
triggerState = triggerState, triggerState = triggerState,
models = models, models = models,
selectedModel = selectedModel, selectedModel = selectedModel,
modelsLoading = modelsLoading, modelsLoading = modelsLoading,
onRefresh = { viewModel.refresh() }, onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) }, onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) }, onStartWebViewLogin = { onStartWebViewLogin(service, accountId, uiState.region) },
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } }, onOpenRecharge = if (config.externalUrl != null || service == ServiceType.TRAE) {
{ onOpenRecharge(service, uiState.region) }
} else null,
onTriggerUsage = { viewModel.triggerUsage() }, onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) }, onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) } 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 @Composable
private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) { private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
if (balance == null) { if (balance == null) {
@ -1101,6 +1155,9 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
) )
return return
} }
if (service == ServiceType.TRAE && balance.extras?.get("billing_mode") == "dollar") {
TraeIntlBigNumber(balance.extras)
} else {
Row(verticalAlignment = Alignment.Bottom) { Row(verticalAlignment = Alignment.Bottom) {
Text( Text(
text = formatAmount(balance.amount), text = formatAmount(balance.amount),
@ -1116,6 +1173,7 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
} }
}
if (!balance.isAvailable) { if (!balance.isAvailable) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
@ -1504,6 +1562,37 @@ internal fun TraeAccountInfoCard(state: State) {
extras["pack_count"]?.toIntOrNull()?.takeIf { it > 0 }?.let { extras["pack_count"]?.toIntOrNull()?.takeIf { it > 0 }?.let {
add(stringResource(R.string.extra_label_pack_count) to it.toString()) 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 { val billingLabel = when {
extras["is_credits_billing"] == "true" -> stringResource(R.string.extra_value_billing_credits) 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」简化行。无任何数据时整卡不渲染。 * 旧缓存无 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 @Composable
internal fun WorkBuddyResourcesCard(state: State) { internal fun WorkBuddyResourcesCard(state: State) {
val balance = when (state) { val balance = when (state) {
@ -1836,6 +2135,7 @@ private fun ActionButtons(
hasCredential: Boolean, hasCredential: Boolean,
isManualMode: Boolean, isManualMode: Boolean,
service: ServiceType, service: ServiceType,
region: String?,
triggerState: TriggerState, triggerState: TriggerState,
models: List<String>, models: List<String>,
selectedModel: String?, selectedModel: String?,
@ -1956,6 +2256,18 @@ private fun ActionButtons(
Spacer(modifier = Modifier.height(4.dp)) 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) { if (hasCredential && !isManualMode) {
Button( Button(
onClick = onRefresh, 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.CredentialRepository
import com.rainy.token.data.repository.RepositoryError import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TraeRepository 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.WorkBuddyRepository
import com.rainy.token.data.repository.TriggerError import com.rainy.token.data.repository.TriggerError
import com.rainy.token.data.repository.TriggerErrorReason import com.rainy.token.data.repository.TriggerErrorReason
@ -36,7 +37,8 @@ class ServiceDetailViewModel @Inject constructor(
private val balanceCache: BalanceCache, private val balanceCache: BalanceCache,
private val refreshBalanceUseCase: RefreshBalanceUseCase, private val refreshBalanceUseCase: RefreshBalanceUseCase,
private val checkinStatusStore: com.rainy.token.data.repository.CheckinStatusStore, 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() { ) : ViewModel() {
private val _serviceType = MutableStateFlow<ServiceType?>(null) private val _serviceType = MutableStateFlow<ServiceType?>(null)
@ -75,6 +77,10 @@ class ServiceDetailViewModel @Inject constructor(
private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null) private val _checkinInfo = MutableStateFlow<CheckinInfo?>(null)
val checkinInfo: StateFlow<CheckinInfo?> = _checkinInfo.asStateFlow() 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) 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 中文串)。 */ /** 触发每日签到:仅 WORKBUDDY / TRAE 有效;结果按语义本地化(不直接显示 Repository 中文串)。 */
fun checkin() { fun checkin() {
val service = _serviceType.value ?: return val service = _serviceType.value ?: return
@ -415,6 +449,7 @@ class ServiceDetailViewModel @Inject constructor(
_triggerState.value = TriggerState.Idle _triggerState.value = TriggerState.Idle
// 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台) // 切换服务/账号时重置签到状态,避免上一服务/账号的 Done/Error 残留(按钮态/文案串台)
_checkinState.value = CheckinState.Idle _checkinState.value = CheckinState.Idle
_traeUsageEvents.value = TraeUsageEventsState.Idle
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) { if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
loadSelectedModel(service)?.let { _selectedModel.value = it } loadSelectedModel(service)?.let { _selectedModel.value = it }
@ -454,6 +489,16 @@ class ServiceDetailViewModel @Inject constructor(
if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch if (serviceGen != serviceGeneration || _serviceType.value != type) return@launch
currentCredentialFingerprint = newFingerprint 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) { when (change) {
CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> { CredentialChange.NONE_TO_NONE, CredentialChange.UNCHANGED -> {
@ -583,6 +628,9 @@ class ServiceDetailViewModel @Inject constructor(
) )
} }
} }
if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
preloadUsageEvents(type, accountId)
}
} }
} }
@ -1005,10 +1053,20 @@ sealed class State {
data object ManualModeHint : 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( data class ServiceDetailUiState(
val state: State = State.Loading, val state: State = State.Loading,
val hasCredential: Boolean = false, val hasCredential: Boolean = false,
val cached: CachedBalance? = null val cached: CachedBalance? = null,
/** 当前账号区域(Trae/WorkBuddy):CN / INTL;其他服务为 null。 */
val region: String? = null
) )
sealed class TriggerState { sealed class TriggerState {

View File

@ -156,10 +156,12 @@ fun AccountManagementScreen(
} }
} }
} }
items(uiState.accounts, key = { it.id }) { account -> items(uiState.accounts, key = { it.account.id }) { item ->
val ordinal = accountOrdinal(uiState.accounts, account) val account = item.account
val ordinal = accountOrdinal(uiState.accounts.map { it.account }, account)
AccountRow( AccountRow(
account = account, account = account,
region = item.region,
displayName = account.label?.takeIf { it.isNotBlank() } displayName = account.label?.takeIf { it.isNotBlank() }
?: stringResource(R.string.account_fallback_name, ordinal), ?: stringResource(R.string.account_fallback_name, ordinal),
menuExpanded = menuAccountId == account.id, menuExpanded = menuAccountId == account.id,
@ -262,6 +264,7 @@ fun AccountManagementScreen(
@Composable @Composable
private fun AccountRow( private fun AccountRow(
account: ServiceAccountInfo, account: ServiceAccountInfo,
region: String?,
displayName: String, displayName: String,
menuExpanded: Boolean, menuExpanded: Boolean,
onMenuToggle: () -> Unit, 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 { Box {

View File

@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.repository.CredentialRepository 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.model.ServiceAccountInfo
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.UiText import com.rainy.token.ui.components.UiText
@ -46,13 +47,22 @@ class AccountManagementViewModel @Inject constructor(
viewModelScope.launch { viewModelScope.launch {
_uiState.update { it.copy(loading = true) } _uiState.update { it.copy(loading = true) }
val accounts = credentialRepository.accountsFor(type) 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 if (generation != loadGeneration) return@launch
_uiState.update { _uiState.update {
it.copy( it.copy(
loading = false, 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( data class AccountManagementUiState(
val loading: Boolean = false, val loading: Boolean = false,
val accounts: List<ServiceAccountInfo> = emptyList(), val accounts: List<AccountItem> = emptyList(),
val message: UiText? = null val message: UiText? = null
) )

View File

@ -82,7 +82,7 @@ fun CredentialEditScreen(
service: ServiceType, service: ServiceType,
accountId: String? = null, accountId: String? = null,
onBack: () -> Unit, onBack: () -> Unit,
onStartWebViewLogin: (ServiceType, String?) -> Unit, onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
onWebViewLoginSuccess: (ServiceType) -> Unit, onWebViewLoginSuccess: (ServiceType) -> Unit,
onStartCodexOAuth: () -> Unit = {}, onStartCodexOAuth: () -> Unit = {},
viewModel: CredentialEditViewModel = hiltViewModel() viewModel: CredentialEditViewModel = hiltViewModel()
@ -153,13 +153,31 @@ fun CredentialEditScreen(
if (service == ServiceType.TRAE) { if (service == ServiceType.TRAE) {
TraeCredentialForm( TraeCredentialForm(
jwt = uiState.traeJwt, jwt = uiState.traeJwt,
callbackUrl = uiState.traeCallbackUrl,
region = uiState.traeRegion, region = uiState.traeRegion,
checkinDeviceId = uiState.traeCheckinDeviceId, checkinDeviceId = uiState.traeCheckinDeviceId,
hasExisting = uiState.hasExisting, hasExisting = uiState.hasExisting,
onJwtChange = viewModel::updateTraeJwt, onJwtChange = viewModel::updateTraeJwt,
onCallbackUrlChange = viewModel::updateTraeCallbackUrl,
onImportCallback = { viewModel.importTraeCallbackFromUrl(it) },
onRegionChange = viewModel::updateTraeRegion, onRegionChange = viewModel::updateTraeRegion,
onCheckinDeviceIdChange = viewModel::updateTraeCheckinDeviceId, 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 onSave = viewModel::saveTraeCredential
) )
} else if (service == ServiceType.WORKBUDDY) { } else if (service == ServiceType.WORKBUDDY) {
@ -169,7 +187,9 @@ fun CredentialEditScreen(
hasExisting = uiState.hasExisting, hasExisting = uiState.hasExisting,
onAccessChange = viewModel::updateWorkBuddyAccess, onAccessChange = viewModel::updateWorkBuddyAccess,
onRefreshChange = viewModel::updateWorkBuddyRefresh, onRefreshChange = viewModel::updateWorkBuddyRefresh,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) }, region = uiState.workBuddyRegion,
onRegionChange = viewModel::updateWorkBuddyRegion,
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId, uiState.workBuddyRegion) },
onSave = viewModel::saveWorkBuddyCredential onSave = viewModel::saveWorkBuddyCredential
) )
} else if (service == ServiceType.SUB2API) { } else if (service == ServiceType.SUB2API) {
@ -267,7 +287,7 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) }, onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) }, onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) }, onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) }, onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId, null) },
onShowHelp = { showGoHelp = true }, onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey, onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() } onSaveApiKey = { viewModel.saveTriggerApiKey() }
@ -777,13 +797,17 @@ private fun ManualCookieForm(
@Composable @Composable
private fun TraeCredentialForm( private fun TraeCredentialForm(
jwt: String, jwt: String,
callbackUrl: String,
region: String, region: String,
checkinDeviceId: String, checkinDeviceId: String,
hasExisting: Boolean, hasExisting: Boolean,
onJwtChange: (String) -> Unit, onJwtChange: (String) -> Unit,
onCallbackUrlChange: (String) -> Unit,
onImportCallback: (String) -> Unit,
onRegionChange: (String) -> Unit, onRegionChange: (String) -> Unit,
onCheckinDeviceIdChange: (String) -> Unit, onCheckinDeviceIdChange: (String) -> Unit,
onStartInAppLogin: () -> Unit, onStartInAppLogin: () -> Unit,
onRecharge: () -> Unit,
onSave: () -> Unit onSave: () -> Unit
) { ) {
Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium) Text(text = stringResource(R.string.credential_title_trae), style = MaterialTheme.typography.titleMedium)
@ -792,12 +816,38 @@ private fun TraeCredentialForm(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.outline
) )
if (region.uppercase() != "INTL") {
OutlinedButton( OutlinedButton(
onClick = onStartInAppLogin, onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.action_login_in_app)) 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( OutlinedTextField(
value = jwt, value = jwt,
onValueChange = onJwtChange, onValueChange = onJwtChange,
@ -823,6 +873,12 @@ private fun TraeCredentialForm(
onSelect = { onRegionChange("INTL") } onSelect = { onRegionChange("INTL") }
) )
} }
OutlinedButton(
onClick = onRecharge,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_trae_recharge))
}
OutlinedTextField( OutlinedTextField(
value = checkinDeviceId, value = checkinDeviceId,
onValueChange = onCheckinDeviceIdChange, onValueChange = onCheckinDeviceIdChange,
@ -916,9 +972,11 @@ private fun RegionButton(label: String, selected: Boolean, onSelect: () -> Unit)
private fun WorkBuddyCredentialForm( private fun WorkBuddyCredentialForm(
access: String, access: String,
refresh: String, refresh: String,
region: String,
hasExisting: Boolean, hasExisting: Boolean,
onAccessChange: (String) -> Unit, onAccessChange: (String) -> Unit,
onRefreshChange: (String) -> Unit, onRefreshChange: (String) -> Unit,
onRegionChange: (String) -> Unit,
onStartInAppLogin: () -> Unit, onStartInAppLogin: () -> Unit,
onSave: () -> Unit onSave: () -> Unit
) { ) {
@ -928,12 +986,38 @@ private fun WorkBuddyCredentialForm(
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline color = MaterialTheme.colorScheme.outline
) )
if (region.uppercase() == "INTL") {
OutlinedButton(
onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth()
) {
Text(stringResource(R.string.action_mobile_browser_login))
}
} else {
OutlinedButton( OutlinedButton(
onClick = onStartInAppLogin, onClick = onStartInAppLogin,
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
Text(stringResource(R.string.action_login_in_app)) 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( OutlinedTextField(
value = access, value = access,
onValueChange = onAccessChange, onValueChange = onAccessChange,

View File

@ -26,6 +26,7 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
@ -126,6 +127,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(), traeCheckinDeviceId = (existing as? Credential.TraeCredential)?.checkinDeviceId.orEmpty(),
workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(), workBuddyAccess = (existing as? Credential.WorkBuddyCredential)?.accessToken.orEmpty(),
workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(), workBuddyRefresh = (existing as? Credential.WorkBuddyCredential)?.refreshToken.orEmpty(),
workBuddyRegion = (existing as? Credential.WorkBuddyCredential)?.region ?: "CN",
sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(), sub2BaseUrl = (existing as? Credential.Sub2ApiCredential)?.baseUrl.orEmpty(),
sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon, sub2SiteIcon = (existing as? Credential.Sub2ApiCredential)?.siteIcon,
sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(), sub2Email = (existing as? Credential.Sub2ApiCredential)?.email.orEmpty(),
@ -298,6 +300,82 @@ class CredentialEditViewModel @Inject constructor(
fun updateTraeJwt(value: String) { fun updateTraeJwt(value: String) {
_uiState.update { it.copy(traeJwt = value) } _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) { fun updateTraeRegion(value: String) {
_uiState.update { it.copy(traeRegion = value) } _uiState.update { it.copy(traeRegion = value) }
@ -310,7 +388,11 @@ class CredentialEditViewModel @Inject constructor(
fun saveTraeCredential() { fun saveTraeCredential() {
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value 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()) { if (jwt.isBlank()) {
_uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) } _uiState.update { it.copy(message = UiText.Resource(R.string.error_api_key_empty)) }
return return
@ -326,6 +408,9 @@ class CredentialEditViewModel @Inject constructor(
val effectiveId = credentialRepository.save( val effectiveId = credentialRepository.save(
(existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy( (existing ?: Credential.TraeCredential(service = type, jwt = jwt, region = "CN")).copy(
jwt = jwt, 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", region = if (current.traeRegion.uppercase() == "INTL") "INTL" else "CN",
// 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」 // 换账号(JWT 变化)时清空本地签到短路标记,避免新账号被误判「今日已签到」
lastCheckinDate = if (existing != null && existing.jwt == jwt) existing.lastCheckinDate else null, 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) } _uiState.update { it.copy(workBuddyRefresh = value) }
} }
fun updateWorkBuddyRegion(value: String) {
_uiState.update { it.copy(workBuddyRegion = value) }
}
fun saveWorkBuddyCredential() { fun saveWorkBuddyCredential() {
val type = serviceType ?: return val type = serviceType ?: return
val current = _uiState.value val current = _uiState.value
@ -371,10 +460,11 @@ class CredentialEditViewModel @Inject constructor(
credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential credentialRepository.get(type, accountId) as? Credential.WorkBuddyCredential
} }
val effectiveId = credentialRepository.save( 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( .copy(
accessToken = access, accessToken = access,
refreshToken = refresh, refreshToken = refresh,
region = if (current.workBuddyRegion.uppercase() == "INTL") "INTL" else "CN",
expiresAt = existing?.expiresAt ?: 0L, expiresAt = existing?.expiresAt ?: 0L,
lastVerifiedAt = existing?.lastVerifiedAt ?: 0L lastVerifiedAt = existing?.lastVerifiedAt ?: 0L
), ),
@ -852,6 +942,7 @@ class CredentialEditViewModel @Inject constructor(
traeCheckinDeviceId = "", traeCheckinDeviceId = "",
workBuddyAccess = "", workBuddyAccess = "",
workBuddyRefresh = "", workBuddyRefresh = "",
workBuddyRegion = "CN",
sub2BaseUrl = "", sub2BaseUrl = "",
sub2Email = "", sub2Email = "",
sub2Password = "", sub2Password = "",
@ -971,12 +1062,17 @@ data class CredentialEditUiState(
val triggerApiKey: String = "", val triggerApiKey: String = "",
/** Trae:Cloud-IDE-JWT + 区域 */ /** Trae:Cloud-IDE-JWT + 区域 */
val traeJwt: String = "", val traeJwt: String = "",
/** Trae:浏览器回调地址粘贴导入(http://127.0.0.1:18080/authorize?...) */
val traeCallbackUrl: String = "",
val traeRegion: String = "CN", val traeRegion: String = "CN",
/** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */ /** Trae 签到设备 ID(官方客户端绑定 did;留空用内置默认) */
val traeCheckinDeviceId: String = "", val traeCheckinDeviceId: String = "",
/** WorkBuddy:OAuth tokens */ /** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
val traeApiHost: String = "",
/** WorkBuddy:OAuth tokens + 区域 */
val workBuddyAccess: String = "", val workBuddyAccess: String = "",
val workBuddyRefresh: String = "", val workBuddyRefresh: String = "",
val workBuddyRegion: String = "CN",
/** Sub2API:自托管实例连接 */ /** Sub2API:自托管实例连接 */
val sub2BaseUrl: String = "", val sub2BaseUrl: String = "",
val sub2Email: String = "", val sub2Email: String = "",

View File

@ -2,7 +2,17 @@ package com.rainy.token.ui.settings
import android.app.Activity import android.app.Activity
import android.os.Build import android.os.Build
import android.widget.Toast
import android.content.ClipboardManager
import android.content.ClipData
import android.content.Context
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.draw.clip
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -23,11 +33,14 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
@ -49,6 +62,7 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import com.rainy.token.ui.components.ServiceIcon import com.rainy.token.ui.components.ServiceIcon
import com.rainy.token.ui.components.StatusChip import com.rainy.token.ui.components.StatusChip
@ -89,6 +103,14 @@ fun SettingsScreen(
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val proxyRunning by viewModel.proxyRunning.collectAsStateWithLifecycle()
val proxyApiKey by viewModel.proxyApiKey.collectAsStateWithLifecycle()
val proxyError by viewModel.proxyError.collectAsStateWithLifecycle()
val proxyDebug by viewModel.proxyDebug.collectAsStateWithLifecycle()
val proxyDebugRaw by viewModel.proxyDebugRaw.collectAsStateWithLifecycle()
val proxyModels by viewModel.proxyModels.collectAsStateWithLifecycle()
val selectedProxy by viewModel.selectedProxy.collectAsStateWithLifecycle()
val proxyModel by viewModel.proxyModel.collectAsStateWithLifecycle()
var showLanguageDialog by remember { mutableStateOf(false) } var showLanguageDialog by remember { mutableStateOf(false) }
Scaffold( Scaffold(
@ -159,6 +181,25 @@ fun SettingsScreen(
item { item {
LanguageCard(onClick = { showLanguageDialog = true }) LanguageCard(onClick = { showLanguageDialog = true })
} }
item {
ProxyGatewayCard(
running = proxyRunning,
apiKey = proxyApiKey,
error = proxyError,
debug = proxyDebug,
debugRaw = proxyDebugRaw,
proxyTargets = viewModel.proxyTargets,
selectedProxy = selectedProxy,
onSelectProxy = viewModel::selectProxy,
models = proxyModels,
selectedModel = proxyModel,
onSelectModel = viewModel::selectProxyModel,
onRefreshModels = viewModel::refreshProxyModels,
onToggle = viewModel::setProxyEnabled,
onTestTrae = viewModel::testTraeStream,
onTestWorkBuddy = viewModel::testWorkBuddyStream
)
}
item { item {
TipsCard(onClick = { onOpenTips() }) TipsCard(onClick = { onOpenTips() })
} }
@ -181,6 +222,181 @@ fun SettingsScreen(
} }
} }
@Composable
private fun ProxyGatewayCard(
running: Boolean,
apiKey: String,
error: String?,
debug: String?,
debugRaw: String?,
proxyTargets: List<ProxyTarget>,
selectedProxy: ProxyTarget,
onSelectProxy: (ProxyTarget) -> Unit,
models: List<TraeModelProvider.TraeModel>,
selectedModel: String,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit,
onToggle: (Boolean) -> Unit,
onTestTrae: () -> Unit,
onTestWorkBuddy: () -> Unit
) {
val context = LocalContext.current
var proxyMenuExpanded by remember { mutableStateOf(false) }
var modelMenuExpanded by remember { mutableStateOf(false) }
Card(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "🛰️",
style = MaterialTheme.typography.titleLarge
)
Spacer(modifier = Modifier.width(12.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = "代理网关",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
Text(
text = if (running) "运行中 · http://127.0.0.1:8787/v1" else "已停止",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
Text(
text = "API Key:$apiKey",
style = MaterialTheme.typography.bodySmall,
color = InkMuted,
modifier = Modifier.padding(top = 2.dp)
)
if (error != null) {
Text(
text = error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(top = 2.dp)
)
}
Row(modifier = Modifier.padding(top = 4.dp)) {
TextButton(onClick = onTestTrae) {
Text("测试 Trae 流")
}
TextButton(onClick = onTestWorkBuddy) {
Text("测试 WorkBuddy 流")
}
}
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "代理",
style = MaterialTheme.typography.labelSmall,
color = InkMuted
)
Spacer(modifier = Modifier.width(8.dp))
Box {
TextButton(onClick = { proxyMenuExpanded = true }) {
Text(selectedProxy.label + " ▾")
}
DropdownMenu(
expanded = proxyMenuExpanded,
onDismissRequest = { proxyMenuExpanded = false }
) {
proxyTargets.forEach { t ->
DropdownMenuItem(
text = { Text(t.label) },
onClick = {
onSelectProxy(t)
proxyMenuExpanded = false
}
)
}
}
}
}
Row(
modifier = Modifier.padding(top = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "模型",
style = MaterialTheme.typography.labelSmall,
color = InkMuted
)
Spacer(modifier = Modifier.width(8.dp))
Box {
TextButton(onClick = { modelMenuExpanded = true }) {
Text(selectedModel + " ▾")
}
DropdownMenu(
expanded = modelMenuExpanded,
onDismissRequest = { modelMenuExpanded = false }
) {
models.forEach { m ->
DropdownMenuItem(
text = { Text(m.label ?: m.id) },
onClick = {
onSelectModel(m.id)
modelMenuExpanded = false
}
)
}
}
}
TextButton(onClick = onRefreshModels) {
Text("刷新")
}
}
if (debug != null) {
Spacer(modifier = Modifier.padding(top = 8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = "探针摘要",
style = MaterialTheme.typography.labelSmall,
color = InkMuted,
modifier = Modifier.weight(1f)
)
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
clipboard.setPrimaryClip(ClipData.newPlainText("代理网关探针输出", debugRaw ?: debug))
Toast.makeText(context, "已复制全部原始输出", Toast.LENGTH_SHORT).show()
}) {
Text("复制原始")
}
}
Box(
modifier = Modifier
.fillMaxWidth()
.heightIn(max = 220.dp)
.clip(RoundedCornerShape(12.dp))
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f))
.verticalScroll(rememberScrollState())
.padding(10.dp)
) {
Text(
text = debug,
style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace),
color = InkMuted
)
}
}
}
Switch(
checked = running,
onCheckedChange = onToggle
)
}
}
}
@Composable @Composable
private fun LanguageCard(onClick: () -> Unit) { private fun LanguageCard(onClick: () -> Unit) {
val context = LocalContext.current val context = LocalContext.current
@ -416,9 +632,23 @@ private fun UpdateCard(
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = InkMuted color = InkMuted
) )
if (phase.error != null) {
Spacer(modifier = Modifier.padding(top = 4.dp))
Text(
text = phase.error,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error
)
}
Spacer(modifier = Modifier.padding(top = 8.dp)) Spacer(modifier = Modifier.padding(top = 8.dp))
Button(onClick = { viewModel.install(phase.file) }) { Button(onClick = { viewModel.install(phase.file) }) {
Text(stringResource(R.string.update_install)) Text(
if (phase.error == null) {
stringResource(R.string.update_install)
} else {
"重试安装"
}
)
} }
} }
is UpdateViewModel.Phase.NeedInstallPermission -> { is UpdateViewModel.Phase.NeedInstallPermission -> {
@ -437,6 +667,9 @@ private fun UpdateCard(
}) { }) {
Text(stringResource(R.string.update_enable_permission)) Text(stringResource(R.string.update_enable_permission))
} }
TextButton(onClick = { viewModel.install(phase.file) }) {
Text("已开启,继续安装")
}
} }
is UpdateViewModel.Phase.Error -> { is UpdateViewModel.Phase.Error -> {
Text( Text(

View File

@ -2,15 +2,33 @@ package com.rainy.token.ui.settings
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.rainy.token.data.proxy.LocalProxyServer
import com.rainy.token.data.proxy.ProviderRegion
import com.rainy.token.data.proxy.ProxyUpstreamStream
import com.rainy.token.data.proxy.ProxyServerConfig
import com.rainy.token.data.proxy.TraeChatProxy
import com.rainy.token.data.proxy.TraeModelProvider
import com.rainy.token.data.proxy.WorkBuddyModelProvider
import com.rainy.token.data.proxy.WorkBuddyChatProxy
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.CredentialStatus import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.service.ServiceType import com.rainy.token.domain.service.ServiceType
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import java.util.UUID
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.withContext
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.Json
import javax.inject.Inject import javax.inject.Inject
/** /**
@ -19,12 +37,295 @@ import javax.inject.Inject
*/ */
@HiltViewModel @HiltViewModel
class SettingsViewModel @Inject constructor( class SettingsViewModel @Inject constructor(
private val credentialRepository: CredentialRepository private val credentialRepository: CredentialRepository,
private val localProxyServer: LocalProxyServer,
private val traeChatProxy: TraeChatProxy,
private val workBuddyChatProxy: WorkBuddyChatProxy,
private val traeModelProvider: TraeModelProvider,
private val workBuddyModelProvider: WorkBuddyModelProvider,
) : ViewModel() { ) : ViewModel() {
private val _uiState = MutableStateFlow(SettingsUiState()) private val _uiState = MutableStateFlow(SettingsUiState())
val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow() val uiState: StateFlow<SettingsUiState> = _uiState.asStateFlow()
/** 本地反代服务运行状态。 */
val proxyRunning: StateFlow<Boolean> = localProxyServer.isRunning
/** 当前本机 API Key(每次进程随机生成,仅提示用)。 */
val proxyApiKey: StateFlow<String> = MutableStateFlow(generateProxyKey())
/** 启动失败时的错误信息。 */
private val _proxyError = MutableStateFlow<String?>(null)
val proxyError: StateFlow<String?> = _proxyError.asStateFlow()
/** 启动/停止本地反代服务。 */
fun setProxyEnabled(enabled: Boolean) {
if (enabled) {
localProxyServer.start(ProxyServerConfig(apiKey = proxyApiKey.value))
.onSuccess { _proxyError.value = null }
.onFailure { e -> _proxyError.value = e.message ?: "代理网关启动失败" }
} else {
localProxyServer.stop()
_proxyError.value = null
}
}
/** 代理调试输出(结构化摘要)。 */
private val _proxyDebug = MutableStateFlow<String?>(null)
val proxyDebug: StateFlow<String?> = _proxyDebug.asStateFlow()
/** 代理调试完整原始输出(请求 + 响应帧),供一键复制。 */
private val _proxyDebugRaw = MutableStateFlow<String?>(null)
val proxyDebugRaw: StateFlow<String?> = _proxyDebugRaw.asStateFlow()
/** 代理目标(用于测试和模型选择;真实转发仍由 ProviderRouter 自动路由)。 */
val proxyTargets: List<ProxyTarget> = ProxyTarget.entries
private val _selectedProxy = MutableStateFlow(ProxyTarget.TRAE_CN)
val selectedProxy: StateFlow<ProxyTarget> = _selectedProxy.asStateFlow()
private val _proxyModels = MutableStateFlow<List<TraeModelProvider.TraeModel>>(traeModelProvider.modelsFor(ProviderRegion.CN))
val proxyModels: StateFlow<List<TraeModelProvider.TraeModel>> = _proxyModels.asStateFlow()
private val _proxyModel = MutableStateFlow(traeModelProvider.selectedFor(ProviderRegion.CN))
val proxyModel: StateFlow<String> = _proxyModel.asStateFlow()
/** 切换当前测试目标代理。 */
fun selectProxy(target: ProxyTarget) {
_selectedProxy.value = target
val region = target.region()
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
_proxyModel.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.selectedFor(region)
else -> workBuddyModelProvider.selectedFor(region)
}
}
/** 切换当前代理的测试模型并持久化。 */
fun selectProxyModel(id: String) {
val target = _selectedProxy.value
val region = target.region()
when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.setSelectedFor(region, id)
else -> workBuddyModelProvider.setSelectedFor(region, id)
}
_proxyModel.value = id
}
/** 拉取当前代理可用模型;失败保留内置列表。 */
fun refreshProxyModels() {
val target = _selectedProxy.value
val region = target.region()
viewModelScope.launch {
val ok = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.refreshFor(region)
else -> workBuddyModelProvider.refreshFor(region)
}
_proxyModels.value = when (target) {
ProxyTarget.TRAE_CN, ProxyTarget.TRAE_INTL -> traeModelProvider.modelsFor(region)
else -> workBuddyModelProvider.modelsFor(region)
}
if (!ok) _proxyDebug.value = "模型列表更新失败(当前显示内置列表)"
}
}
/** 用 Trae CN 默认账号发一条 stream=true 测试。 */
fun testTraeStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 Trae CN 流..."
_proxyDebugRaw.value = null
val model = traeModelProvider.selectedFor(ProviderRegion.CN)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = traeChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {
_proxyDebug.value = "Trae CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"Trae CN",
body,
"POST https://trae-api-cn.mchost.guru/api/agent/v3/llm_utils_chat"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "Trae CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
/** 用 WorkBuddy CN 默认账号发一条 stream=true 测试。 */
fun testWorkBuddyStream() {
viewModelScope.launch {
_proxyDebug.value = "正在请求 WorkBuddy CN 流..."
_proxyDebugRaw.value = null
val model = workBuddyModelProvider.selectedFor(ProviderRegion.CN)
val body = """{"model":"$model","stream":true,"messages":[{"role":"user","content":"只回复OK两个字"}],"max_tokens":100}"""
try {
val stream = workBuddyChatProxy.openStreamingChat(body, null, ProviderRegion.CN)
if (stream == null) {
_proxyDebug.value = "WorkBuddy CN 未配置或未登录"
return@launch
}
val result = readStreamSample(
stream,
"WorkBuddy CN",
body,
"POST https://copilot.tencent.com/v2/chat/completions"
)
_proxyDebug.value = result.summary
_proxyDebugRaw.value = result.raw
} catch (e: kotlinx.coroutines.CancellationException) {
throw e
} catch (e: Exception) {
_proxyDebug.value = "WorkBuddy CN 异常:" + (e.message ?: "未知错误")
_proxyDebugRaw.value = null
}
}
}
private suspend fun readStreamSample(
stream: ProxyUpstreamStream,
label: String,
requestBody: String,
requestLine: String,
): StreamProbeResult = withContext(Dispatchers.IO) {
val lines = mutableListOf<String>()
val raw = StringBuilder()
raw.append(label).append(" HTTP ").append(stream.status)
raw.append(" Content-Type=").append(stream.contentType).append("\n")
raw.append("请求: ").append(requestLine).append("\n")
raw.append("请求体: ").append(requestBody).append("\n")
raw.append("---- 上游响应原始帧 ----\n")
try {
val reader = stream.input.bufferedReader()
repeat(120) {
val line = reader.readLine() ?: return@repeat
lines.add(line)
raw.append(line).append("\n")
if (line.trim() == "data: [DONE]") return@repeat
if (raw.length > 12000) return@repeat
}
} finally {
stream.close()
}
StreamProbeResult(
summary = summarizeProbe(label, stream.status, stream.contentType, requestBody, requestLine, lines),
raw = raw.toString()
)
}
private fun summarizeProbe(
label: String,
status: Int,
contentType: String,
requestBody: String,
requestLine: String,
lines: List<String>,
): String {
val sb = StringBuilder()
sb.append(label).append(" 探针摘要")
sb.append("\n请求: ").append(requestLine)
runCatching {
val body = json.parseToJsonElement(requestBody) as? JsonObject
if (body != null) {
val model = (body["model"] as? JsonPrimitive)?.contentOrNull ?: "?"
val count = (body["messages"] as? JsonArray)?.size ?: 0
val stream = (body["stream"] as? JsonPrimitive)?.contentOrNull ?: "?"
sb.append("\n 模型=").append(model)
sb.append(" · messages=").append(count)
sb.append(" · stream=").append(stream)
(body["tools"] as? JsonArray)?.let { sb.append(" · tools=").append(it.size) }
}
}
sb.append("\n回复: HTTP ").append(status).append(" · ").append(contentType)
var events = 0
val texts = mutableListOf<String>()
val models = linkedSetOf<String>()
val usage = mutableListOf<String>()
val errors = mutableListOf<String>()
for (line in lines) {
val trimmed = line.trim()
if (!trimmed.startsWith("data:")) continue
val payload = trimmed.removePrefix("data:").trim()
if (payload == "[DONE]") continue
val el = runCatching { json.parseToJsonElement(payload) as? JsonObject }.getOrNull() ?: continue
events++
collectProbeText(el, texts)
collectProbeModel(el, models)
collectProbeUsage(el, usage)
(el["error"] as? JsonObject)?.let { e ->
val msg = (e["message"] as? JsonPrimitive)?.contentOrNull ?: e.toString()
errors.add(msg)
}
}
if (events > 0) sb.append("\n事件帧: ").append(events)
if (models.isNotEmpty()) sb.append("\n模型: ").append(models.joinToString(" / "))
if (texts.isNotEmpty()) {
val joined = texts.joinToString("").trim().replace("\n", " ").take(200)
sb.append("\n文本(截断): ").append(if (joined.isEmpty()) "(空)" else joined)
}
if (usage.isNotEmpty()) sb.append("\n用量: ").append(usage.distinct().joinToString(" · "))
if (errors.isNotEmpty()) sb.append("\n错误: ").append(errors.take(2).joinToString(" | "))
return sb.toString()
}
private fun collectProbeText(el: JsonObject, out: MutableList<String>) {
fun add(v: JsonElement?) {
when (v) {
is JsonPrimitive -> if (v.isString) out.add(v.content)
is JsonObject -> {
(v["content"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["text"] as? JsonPrimitive)?.takeIf { it.isString }?.let { out.add(it.content) }
(v["delta"] as? JsonObject)?.let { add(it) }
}
is JsonArray -> v.forEach { add(it) }
else -> Unit
}
}
(el["data"] as? JsonObject)?.let { add(it) }
(el["content"] as? JsonElement)?.let { add(it) }
(el["text"] as? JsonElement)?.let { add(it) }
(el["message"] as? JsonObject)?.let { add(it) }
(el["choices"] as? JsonArray)?.forEach { c ->
val co = c as? JsonObject ?: return@forEach
(co["delta"] as? JsonObject)?.let { add(it) }
(co["message"] as? JsonObject)?.let { add(it) }
}
}
private fun collectProbeModel(el: JsonObject, out: MutableSet<String>) {
for (key in listOf("model_name", "modelName", "config_name", "model", "provider_model_name", "modelProviderName")) {
(el[key] as? JsonPrimitive)?.contentOrNull?.takeIf { it.isNotBlank() }?.let { out.add(it) }
}
(el["data"] as? JsonObject)?.let { collectProbeModel(it, out) }
(el["message"] as? JsonObject)?.let { collectProbeModel(it, out) }
}
private fun collectProbeUsage(el: JsonObject, out: MutableList<String>) {
val usage = (el["usage"] as? JsonObject) ?: (el["token_usage"] as? JsonObject) ?: return
fun num(key: String, label: String) {
(usage[key] as? JsonPrimitive)?.let { out.add(label + "=" + it.content) }
}
num("prompt_tokens", "输入")
num("completion_tokens", "输出")
num("total_tokens", "合计")
}
private val json = Json { ignoreUnknownKeys = true }
private fun generateProxyKey(): String =
"rainy-" + UUID.randomUUID().toString().replace("-", "").take(16)
init { init {
refresh() refresh()
} }
@ -48,6 +349,23 @@ class SettingsViewModel @Inject constructor(
} }
} }
enum class ProxyTarget(val key: String, val label: String) {
TRAE_CN("trae_cn", "Trae CN"),
TRAE_INTL("trae_intl", "Trae INTL"),
WORKBUDDY_CN("wb_cn", "WorkBuddy CN"),
WORKBUDDY_INTL("wb_intl", "WorkBuddy INTL");
fun region(): ProviderRegion = when (this) {
TRAE_CN, WORKBUDDY_CN -> ProviderRegion.CN
else -> ProviderRegion.INTL
}
}
data class StreamProbeResult(
val summary: String,
val raw: String,
)
data class SettingsUiState( data class SettingsUiState(
val loading: Boolean = false, val loading: Boolean = false,
val credentialStatuses: List<CredentialStatusLine> = emptyList() val credentialStatuses: List<CredentialStatusLine> = emptyList()

View File

@ -30,8 +30,8 @@ class UpdateViewModel @Inject constructor(
object Latest : Phase object Latest : Phase
data class Available(val info: UpdateRepository.UpdateInfo) : Phase data class Available(val info: UpdateRepository.UpdateInfo) : Phase
data class Downloading(val progress: Float) : Phase data class Downloading(val progress: Float) : Phase
data class Ready(val file: File) : Phase data class Ready(val file: File, val error: String? = null) : Phase
object NeedInstallPermission : Phase data class NeedInstallPermission(val file: File) : Phase
data class Error(val message: String) : Phase data class Error(val message: String) : Phase
} }
@ -80,19 +80,33 @@ class UpdateViewModel @Inject constructor(
} }
fun install(file: File) { fun install(file: File) {
if (context.packageManager.canRequestPackageInstalls()) { val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
.getOrDefault(false)
if (!canInstall) {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
return
}
_uiState.update { it.copy(phase = Phase.Ready(file)) } _uiState.update { it.copy(phase = Phase.Ready(file)) }
val authority = context.packageName + ".fileprovider" val authority = context.packageName + ".fileprovider"
val uri = FileProvider.getUriForFile(context, authority, file) val uri = try {
val intent = Intent(Intent.ACTION_VIEW).apply { FileProvider.getUriForFile(context, authority, file)
} catch (e: Exception) {
_uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
return
}
val actionView = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive") setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK) addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
} }
runCatching { context.startActivity(intent) }.onFailure { e -> val started = runCatching { context.startActivity(actionView) }
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) } if (started.isFailure) {
val actionInstall = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
}
runCatching { context.startActivity(actionInstall) }.onFailure { e ->
_uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
} }
} else {
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
} }
} }
} }

View File

@ -1,5 +1,7 @@
package com.rainy.token.ui.webview package com.rainy.token.ui.webview
import android.content.Intent
import android.net.Uri
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.CookieManager import android.webkit.CookieManager
import android.webkit.WebChromeClient 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.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold 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.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.key
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
@ -54,6 +63,7 @@ import com.rainy.token.ui.components.asString
fun WebViewLoginScreen( fun WebViewLoginScreen(
service: ServiceType, service: ServiceType,
accountId: String? = null, accountId: String? = null,
region: String? = null,
onBack: () -> Unit, onBack: () -> Unit,
onLoginSucceeded: (ServiceType, String?) -> Unit, onLoginSucceeded: (ServiceType, String?) -> Unit,
/** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */ /** 用户已保存的面板基址(Sub2API 的 loginUrl 为空,靠它提供登录入口与可信 host)。 */
@ -62,10 +72,14 @@ fun WebViewLoginScreen(
) { ) {
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例) // configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
LaunchedEffect(service, configuredBaseUrl, accountId) { LaunchedEffect(service, configuredBaseUrl, accountId) {
viewModel.bind(service, configuredBaseUrl, accountId) viewModel.bind(service, configuredBaseUrl, accountId, region)
} }
val uiState by viewModel.uiState.collectAsStateWithLifecycle() val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() } 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,供新建账号模式回填) // 登录成功自动回调(携带实际写入的账号 ID,供新建账号模式回填)
LaunchedEffect(uiState.loginSucceeded) { LaunchedEffect(uiState.loginSucceeded) {
@ -97,6 +111,17 @@ fun WebViewLoginScreen(
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.Filled.ArrowBack, contentDescription = stringResource(R.string.action_back)) 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() .fillMaxSize()
.padding(innerPadding)) { .padding(innerPadding)) {
if (uiState.loginUrl.isNotEmpty()) { 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( AndroidView(
factory = { context -> factory = { context ->
WebView(context).apply { WebView(context).apply {
@ -152,8 +193,9 @@ fun WebViewLoginScreen(
} }
fun handleOverrideUrl(url: String): Boolean { fun handleOverrideUrl(url: String): Boolean {
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt // Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
if (url.startsWith("http://127.0.0.1:18080/authorize")) { // (部分流程会从 https 跳到 http,或把参数放 fragment)
if (isTraeLoginCallback(url)) {
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}") DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
viewModel.onTraeCallback(url) viewModel.onTraeCallback(url)
return true return true
@ -161,8 +203,24 @@ fun WebViewLoginScreen(
return false 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?) { override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
pageLoading = true
pageError = null
DebugLog.i("TraeLogin", "pageStarted: ${url?.toSafeLogUrl()}") 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) super.onPageStarted(view, url, favicon)
} }
@ -173,13 +231,70 @@ fun WebViewLoginScreen(
viewModel.onPageFinished(url) viewModel.onPageFinished(url)
DebugLog.i("TraeLogin", "pageFinished: ${url.toSafeLogUrl()}") 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) loadUrl(uiState.loginUrl)
} }
}, },
modifier = Modifier.fillMaxSize() 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 { } else {
// loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻) // loginUrl 为空 = ViewModel 正在生成授权地址(Trae/WorkBuddy 需要片刻)
Box( Box(

View File

@ -21,6 +21,7 @@ import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import com.rainy.token.R import com.rainy.token.R
import com.rainy.token.data.debug.DebugLog 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.WebViewSessionSaver
import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.domain.model.Credential import com.rainy.token.domain.model.Credential
@ -57,14 +58,25 @@ class WebViewLoginViewModel @Inject constructor(
/** WorkBuddy 轮询协程,bind 新服务时取消。 */ /** WorkBuddy 轮询协程,bind 新服务时取消。 */
private var workBuddyPollingJob: Job? = null 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 = 新建账号。 */ /** 目标账号(多账号服务);null = 新建账号。 */
private var accountId: String? = 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?.cancel()
workBuddyPollingJob = null workBuddyPollingJob = null
this.accountId = accountId?.takeIf { it.isNotBlank() } this.accountId = accountId?.takeIf { it.isNotBlank() }
this.region = region?.takeIf { it.isNotBlank() }?.uppercase()
val config = ServiceConfigProvider.get(service) val config = ServiceConfigProvider.get(service)
_uiState.update { _uiState.update {
@ -77,6 +89,11 @@ class WebViewLoginViewModel @Inject constructor(
) )
} }
// 每次进入登录页强制清掉 WebView 全局登录态,避免沿用上一个账号的 Cookie
if (service == ServiceType.TRAE || service == ServiceType.WORKBUDDY) {
clearWebViewSession()
}
when (service) { when (service) {
ServiceType.TRAE -> bindTrae() ServiceType.TRAE -> bindTrae()
ServiceType.WORKBUDDY -> bindWorkBuddy() ServiceType.WORKBUDDY -> bindWorkBuddy()
@ -133,9 +150,24 @@ class WebViewLoginViewModel @Inject constructor(
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒), // 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。 // 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
val deviceId = traeDeviceId() 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 { val authUrl = buildString {
append("https://www.trae.cn/authorization?") append(authRoot).append("?")
append("login_version=1&auth_from=solo&login_channel=native_ide&") append("login_version=1&auth_from=solo&login_channel=native_ide&")
append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&") append("auth_type=local&client_id=en1oxy7wnw8j9n&redirect=0&")
append("login_trace_id=$traceId&") 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_os_version=1.0&x_app_version=0.1.43&")
append("x_app_type=stable&plugin_version=2.3.62834") append("x_app_type=stable&plugin_version=2.3.62834")
} }
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…") DebugLog.i("TraeLogin", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
_uiState.update { it.copy(loginUrl = authUrl) } return authUrl
} }
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */ /** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
@ -174,37 +206,78 @@ class WebViewLoginViewModel @Inject constructor(
if (service != ServiceType.TRAE) return if (service != ServiceType.TRAE) return
if (_uiState.value.loginSucceeded) 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 { val parsed = url.toHttpUrlOrNull() ?: run {
DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败") DebugLog.w("TraeLogin", "onTraeCallback: URL 解析失败")
return return
} }
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志) // 只记录参数名(不含 token 值,避免把 token 明文落调试日志);query+both
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}") DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}")
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串 // Trae 回调可能走两套:
val userJwtRaw = parsed.queryParameter("userJwt") // 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
?: parsed.queryParameter("user_jwt") // 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
?: run { val userJwtRaw = param("userJwt")
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数") ?: param("user_jwt")
return ?: ""
} viewModelScope.launch {
val userJwt = try { val userJwt = runCatching {
Json.parseToJsonElement(userJwtRaw).jsonObject Json.parseToJsonElement(userJwtRaw).jsonObject
} catch (e: Exception) { }.getOrNull() ?: JsonObject(emptyMap())
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
return 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()) { if (token.isNullOrBlank()) {
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})") DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
return _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 一致)。 // 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
val deviceId = traeDeviceId() val deviceId = traeDeviceId()
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。 // 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
val userId = parsed.queryParameter("userInfo")?.let { raw -> val parsedUserId = param("userInfo")?.let { raw ->
runCatching { runCatching {
val u = Json.parseToJsonElement(raw).jsonObject val u = Json.parseToJsonElement(raw).jsonObject
u["userId"]?.jsonPrimitive?.contentOrNull u["userId"]?.jsonPrimitive?.contentOrNull
@ -213,17 +286,15 @@ class WebViewLoginViewModel @Inject constructor(
?: u["accountId"]?.jsonPrimitive?.contentOrNull ?: u["accountId"]?.jsonPrimitive?.contentOrNull
}.getOrNull() }.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( val credential = Credential.TraeCredential(
service = ServiceType.TRAE, service = ServiceType.TRAE,
jwt = token, jwt = token,
region = "CN", region = if (region == "INTL") "INTL" else "CN",
apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
deviceId = deviceId, deviceId = deviceId,
userId = userId?.takeIf { it.isNotBlank() }, userId = parsedUserId?.takeIf { it.isNotBlank() },
lastVerifiedAt = System.currentTimeMillis() lastVerifiedAt = System.currentTimeMillis()
) )
val effectiveId = credentialRepository.save(credential, accountId) 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 ───────────────────────────────────────────────────────── // ─── WorkBuddy ─────────────────────────────────────────────────────────
private fun bindWorkBuddy() { private fun bindWorkBuddy() {
viewModelScope.launch { viewModelScope.launch {
try { 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() val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI") .url(workBuddyBase + "/v2/plugin/auth/state?platform=" + workBuddyPlatform)
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") .addHeader("User-Agent", workBuddyUserAgent)
.addHeader("Origin", workBuddyOrigin)
.addHeader("Referer", workBuddyOrigin + "/")
.addHeader("X-Client-ID", "codebuddy-cli")
.addHeader("X-Client-Version", "2.143.1")
.addHeader("X-Product", "SaaS")
.addHeader("Accept", "application/json, text/plain, */*")
.addHeader("X-Requested-With", "XMLHttpRequest")
.addHeader("Content-Type", "application/json") .addHeader("Content-Type", "application/json")
.post("{}".toRequestBody("application/json".toMediaTypeOrNull())) .post("{}".toRequestBody("application/json".toMediaTypeOrNull()))
.build() .build()
@ -261,6 +394,7 @@ class WebViewLoginViewModel @Inject constructor(
val state = data?.get("state")?.jsonPrimitive?.contentOrNull val state = data?.get("state")?.jsonPrimitive?.contentOrNull
val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull val authURL = data?.get("authUrl")?.jsonPrimitive?.contentOrNull
?: 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()) { if (state.isNullOrBlank() || authURL.isNullOrBlank()) {
_uiState.update { _uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic)) it.copy(error = UiText.Resource(R.string.error_network_generic))
@ -269,6 +403,10 @@ class WebViewLoginViewModel @Inject constructor(
} }
_uiState.update { it.copy(loginUrl = authURL) } _uiState.update { it.copy(loginUrl = authURL) }
workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) } workBuddyPollingJob = viewModelScope.launch { pollWorkBuddyToken(state) }
if (region == "INTL") {
// 国际版登录页在系统浏览器完成,凭据由轮询自动回填
openExternalBrowser()
}
} catch (_: Exception) { } catch (_: Exception) {
_uiState.update { _uiState.update {
it.copy(error = UiText.Resource(R.string.error_network_generic)) 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) { private suspend fun pollWorkBuddyToken(state: String) {
val timeoutMs = 120_000L val timeoutMs = workBuddyPollTimeoutMs
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
while (System.currentTimeMillis() - startTime < timeoutMs) { while (System.currentTimeMillis() - startTime < timeoutMs) {
if (_uiState.value.loginSucceeded) return if (_uiState.value.loginSucceeded) return
try { try {
val request = Request.Builder() val request = Request.Builder()
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state") .url(workBuddyBase + "/v2/plugin/auth/token?state=$state")
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2") .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() .get()
.build() .build()
val body = withContext(Dispatchers.IO) { val body = withContext(Dispatchers.IO) {
@ -295,16 +441,34 @@ class WebViewLoginViewModel @Inject constructor(
resp.close() resp.close()
s s
} }
DebugLog.i("WorkBuddyLogin", "poll: state=${state.take(6)}… bodyLen=${body.length}")
val json = Json.parseToJsonElement(body).jsonObject val json = Json.parseToJsonElement(body).jsonObject
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1 val codeText = json["code"]?.jsonPrimitive?.contentOrNull
if (code == 0) { val code = codeText?.toIntOrNull() ?: json["code"]?.jsonPrimitive?.intOrNull ?: -1
val data = json["data"]?.jsonObject val rawData = json["data"]?.jsonObject ?: json.jsonObject
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull DebugLog.i(
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull "WorkBuddyLogin",
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull "poll: code=$codeText bodyKeys=${json.keys} dataKeys=${rawData.keys}"
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull )
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull if (code == 0 || codeText == "0") {
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L // 兼容多种字段: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()) { if (!accessToken.isNullOrBlank()) {
val expiresAt = System.currentTimeMillis() + expiresIn * 1000 val expiresAt = System.currentTimeMillis() + expiresIn * 1000
// 刚成功登录的凭据视为已验证(见 Trae 同款注释) // 刚成功登录的凭据视为已验证(见 Trae 同款注释)
@ -312,6 +476,7 @@ class WebViewLoginViewModel @Inject constructor(
service = ServiceType.WORKBUDDY, service = ServiceType.WORKBUDDY,
accessToken = accessToken, accessToken = accessToken,
refreshToken = refreshToken ?: "", refreshToken = refreshToken ?: "",
region = if (region == "INTL") "INTL" else "CN",
expiresAt = expiresAt, expiresAt = expiresAt,
lastVerifiedAt = System.currentTimeMillis() lastVerifiedAt = System.currentTimeMillis()
) )
@ -324,11 +489,13 @@ class WebViewLoginViewModel @Inject constructor(
loginAccountId = effectiveId.ifBlank { null } loginAccountId = effectiveId.ifBlank { null }
) )
} }
DebugLog.i("WorkBuddyLogin", "poll: 凭证已保存,登录完成")
return 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) 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 ──────────────────────────────────────────────────── // ─── Page lifecycle ────────────────────────────────────────────────────
fun onPageFinished(url: String) { fun onPageFinished(url: String) {
@ -515,6 +753,10 @@ private const val SUB2_NONCE_KEY = "nonce"
/** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */ /** OpenCode dashboard URL 中的 workspaceId:`/workspace/{id}/go`。 */
private val OPENCODE_WORKSPACE_REGEX = Regex("""/workspace/([^/?#]+)/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 = """ internal fun buildProbeScript(nonce: String): String = """
(function () { (function () {
try { try {
@ -545,5 +787,7 @@ data class WebViewLoginUiState(
val pendingManualConfirm: Boolean = false, val pendingManualConfirm: Boolean = false,
val error: UiText? = null, val error: UiText? = null,
/** Trae/WorkBuddy 登录实际写入的账号 ID(新建账号模式时用于回填信用编辑页)。 */ /** 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_clear">清除</string>
<string name="action_save">保存</string> <string name="action_save">保存</string>
<string name="action_login_in_app">软件内登录</string> <string name="action_login_in_app">软件内登录</string>
<string name="action_mobile_browser_login">手机浏览器登录</string>
<string name="action_jump">跳转</string> <string name="action_jump">跳转</string>
<string name="action_prev_page">◀ 上一页</string> <string name="action_prev_page">◀ 上一页</string>
<string name="action_next_page">下一页 ▶</string> <string name="action_next_page">下一页 ▶</string>
@ -227,6 +228,7 @@
<string name="checkin_success">签到成功</string> <string name="checkin_success">签到成功</string>
<string name="checkin_already">今日已签到</string> <string name="checkin_already">今日已签到</string>
<string name="checkin_already_streak">今日已签到(连签 %1$d 天)</string> <string name="checkin_already_streak">今日已签到(连签 %1$d 天)</string>
<string name="checkin_wb_no_campaign">暂无可领取的签到活动</string>
<string name="checkin_failed">签到失败,请检查凭据</string> <string name="checkin_failed">签到失败,请检查凭据</string>
<string name="checkin_unsupported">该服务不支持签到</string> <string name="checkin_unsupported">该服务不支持签到</string>
<string name="checkin_action_done">今日已签到</string> <string name="checkin_action_done">今日已签到</string>
@ -423,6 +425,13 @@
<string name="error_select_model_first">请先选择模型</string> <string name="error_select_model_first">请先选择模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string> <string name="error_cookie_not_found">未抓到 Cookie,请确认已登录</string>
<string name="error_login_url_missing">未配置登录 URL</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_login_timeout">登录超时,请重试</string>
<string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string> <string name="error_oauth_url_prefix">URL 应以 %1$s 开头</string>
<string name="error_oauth_auth_failed">授权失败: %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> <string name="error_token_refresh_generic">token 刷新失败,请重新登录或重新导入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ --> <!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登录</string>
<string name="title_webview_login">登录 %1$s</string> <string name="title_webview_login">登录 %1$s</string>
<string name="dialog_login_done_title">完成登录了吗?</string> <string name="dialog_login_done_title">完成登录了吗?</string>
<string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string> <string name="dialog_login_done_body">如果你已经在页面上完成登录但页面没有跳转,可点击下方按钮让我抓取 Cookie 并保存登录态。</string>
@ -784,4 +794,24 @@
<string name="update_install_permission_tip">需要开启「安装未知应用」权限</string> <string name="update_install_permission_tip">需要开启「安装未知应用」权限</string>
<string name="update_enable_permission">去开启</string> <string name="update_enable_permission">去开启</string>
<string name="update_failed">更新失败:%1$s</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> </resources>

View File

@ -19,6 +19,7 @@
<string name="action_clear">清除</string> <string name="action_clear">清除</string>
<string name="action_save">儲存</string> <string name="action_save">儲存</string>
<string name="action_login_in_app">軟體內登入</string> <string name="action_login_in_app">軟體內登入</string>
<string name="action_mobile_browser_login">手機瀏覽器登入</string>
<string name="action_jump">跳轉</string> <string name="action_jump">跳轉</string>
<string name="action_prev_page">◀ 上一頁</string> <string name="action_prev_page">◀ 上一頁</string>
<string name="action_next_page">下一頁 ▶</string> <string name="action_next_page">下一頁 ▶</string>
@ -227,6 +228,7 @@
<string name="checkin_success">簽到成功</string> <string name="checkin_success">簽到成功</string>
<string name="checkin_already">今日已簽到</string> <string name="checkin_already">今日已簽到</string>
<string name="checkin_already_streak">今日已簽到(連簽 %1$d 天)</string> <string name="checkin_already_streak">今日已簽到(連簽 %1$d 天)</string>
<string name="checkin_wb_no_campaign">暫無可領取的簽到活動</string>
<string name="checkin_failed">簽到失敗,請檢查憑證</string> <string name="checkin_failed">簽到失敗,請檢查憑證</string>
<string name="checkin_unsupported">該服務不支援簽到</string> <string name="checkin_unsupported">該服務不支援簽到</string>
<string name="checkin_action_done">今日已簽到</string> <string name="checkin_action_done">今日已簽到</string>
@ -423,6 +425,13 @@
<string name="error_select_model_first">請先選擇模型</string> <string name="error_select_model_first">請先選擇模型</string>
<string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string> <string name="error_cookie_not_found">未抓到 Cookie,請確認已登入</string>
<string name="error_login_url_missing">未配置登入 URL</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_login_timeout">登入逾時,請重試</string>
<string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string> <string name="error_oauth_url_prefix">URL 應以 %1$s 開頭</string>
<string name="error_oauth_auth_failed">授權失敗: %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> <string name="error_token_refresh_generic">token 重新整理失敗,請重新登入或重新匯入 auth.json</string>
<!-- ══════════ WebView 登录 ══════════ --> <!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">清除登入</string>
<string name="title_webview_login">登入 %1$s</string> <string name="title_webview_login">登入 %1$s</string>
<string name="dialog_login_done_title">完成登入了嗎?</string> <string name="dialog_login_done_title">完成登入了嗎?</string>
<string name="dialog_login_done_body">如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。</string> <string name="dialog_login_done_body">如果你已經在頁面上完成登入但頁面沒有跳轉,可點選下方按鈕讓我抓取 Cookie 並儲存登入態。</string>
@ -784,4 +794,24 @@
<string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string> <string name="update_install_permission_tip">需要開啟「安裝未知應用」權限</string>
<string name="update_enable_permission">前往開啟</string> <string name="update_enable_permission">前往開啟</string>
<string name="update_failed">更新失敗:%1$s</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> </resources>

View File

@ -23,6 +23,7 @@
<string name="action_clear">Clear</string> <string name="action_clear">Clear</string>
<string name="action_save">Save</string> <string name="action_save">Save</string>
<string name="action_login_in_app">Sign in within app</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_jump">Go</string>
<string name="action_prev_page">◀ Prev</string> <string name="action_prev_page">◀ Prev</string>
<string name="action_next_page">Next ▶</string> <string name="action_next_page">Next ▶</string>
@ -230,6 +231,7 @@
<string name="checkin_success">Check-in successful</string> <string name="checkin_success">Check-in successful</string>
<string name="checkin_already">Already checked in today</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_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_failed">Check-in failed, please check credentials</string>
<string name="checkin_unsupported">This service does not support check-in</string> <string name="checkin_unsupported">This service does not support check-in</string>
<string name="checkin_action_done">Checked in today</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_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_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_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_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_url_prefix">URL should start with %1$s</string>
<string name="error_oauth_auth_failed">Authorization failed: %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> <string name="error_token_refresh_generic">Token refresh failed. Please log in again or import auth.json.</string>
<!-- ══════════ WebView 登录 ══════════ --> <!-- ══════════ WebView 登录 ══════════ -->
<string name="webview_clear_login">Clear login</string>
<string name="title_webview_login">Sign in to %1$s</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_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> <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_install_permission_tip">Please allow installing apps from unknown sources</string>
<string name="update_enable_permission">Open settings</string> <string name="update_enable_permission">Open settings</string>
<string name="update_failed">Update failed: %1$s</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> </resources>

143
docs/PROXY-ROADMAP.md Normal file
View File

@ -0,0 +1,143 @@
# RainyToken 反代网关开发路线图(PROXY-ROADMAP)
> 分支:dev-proxy
> 目标:在雨晴Token App 内嵌一个多提供商 OpenAI 兼容反代网关,支持双区、签到、反代、多账号池,并尽量覆盖 agent2api / fleet-kit 等开源项目已支持的平台。
---
## 1. 最终目标
App 内启动一个本地 HTTP 服务(默认 `127.0.0.1:8787`),对外暴露:
- `GET /v1/models`
- `POST /v1/chat/completions`(OpenAI 兼容,流式/非流式)
- `POST /v1/responses`(Codex Responses API)
- `POST /v1/messages`(Anthropic Messages,Claude Code 直连)
请求按「模型名 → Provider + Region + 账号」路由,复用现有 `CredentialRepository` / `SecureStorage` / 各 Repository。
## 2. 架构
```
LocalProxyServer (127.0.0.1:8787)
│
├── /v1/chat/completions
├── /v1/responses
├── /v1/messages
└── /v1/models
│
▼
ProviderRouter(模型名 → Provider + Region + Account)
│
┌──────┼──────────┬─────────────┬──────────────┐
▼ ▼ ▼ ▼ ▼
WBAdpt TraeAdpt QoderAdpt RaccoonAdpt OfficialPassthrough
(CN/INTL)(CN/INTL)(CN) (CN) (DeepSeek/Gemini/Kimi…)
│
▼
AccountPool(多账号轮询 + 熔断冷却 + 会话粘性)
│
▼
StreamNormalizer(各家上游 SSE 分片 → 标准 OpenAI/Anthropic 流)
```
## 3. 已编码骨架(data/proxy)
- `ProviderKind.kt`:全部计划支持的 Provider 枚举
- `ProviderRegion.kt`:CN / INTL
- `ProxyModels.kt`:统一 Chat 请求 / 响应 / 模型 / 用量
- `ProviderAdapter.kt`:统一 Provider 适配器接口
- `AccountPool.kt`:账号池骨架(round-robin,后续加权)
- `RegionRouter.kt`:模型 → Provider/Region 路由骨架
- `LocalProxyServer.kt`:本地服务接口 + 配置
## 3.5 当前进度(dev-proxy 分支)
| 提交 | 内容 |
|---|---|
| 85c6cf4 | M1a:Ktor 本地服务 + Sub2API 透传 |
| 11b9065 | M1b:WorkBuddy CN / Trae CN 适配 + 设置页开关 |
| a1605e6 | M2:账号池轮询 + 会话粘性 + 双区域路由 |
| 1609e21 | 审计修复:鉴权头/401/模型前缀剥离 + 流式入口骨架 |
**已打通**:
- 设置页「代理网关」开关,默认 127.0.0.1:8787,本机 Key:rainytoken
- 路由前缀:wbcn- / workbuddy-cn / codebuddy- → WorkBuddy CN;wbintl- / workbuddy-intl → WorkBuddy INTL;traecn- / trae-cn / trae- → Trae CN;traeintl- / trae-intl → Trae INTL;其余 → Sub2API
- 账号池按区内匹配 + round-robin + 会话粘性(X-Conversation-Id / user)
- stream=true 已接实时字节流转发(respondOutputStream);各上游私有 SSE 信封仍待归一
**未完成**:
- 各家私有 SSE 信封 → 标准 OpenAI/Anthropic 流的 StreamNormalizer 仍待实现
- WorkBuddy INTL / Trae SOLO/Work 实际协议归一化
- M3 agent2api 全家桶(Qoder / 小浣熊 / CatPaw / AutoClaw / Cline / Accio / ZCode / CodeArts / Loomy)
- Qoder 需 COSY 请求签名/编码(ed25519 类原语),已列为攻坚项
- M4 长尾 + 官方透传 + 网关配置 UI 完善(已做:随机 API Key / 启动失败提示;待做:端口配置 / 状态持久化)
## 4. 里程碑
### M0:地基(当前)
- 新增 `data/proxy` 骨架,能编译
- 新增本路线图
- 验收:`./gradlew :app:assembleDebug` 绿
### M1:先打通“已经有凭据体系”的三家
- WorkBuddy 国内版 Chat 适配器(参考 workbuddy2api / workbuddy-gateway)
- Trae CN Chat 适配器(参考 Trae2api-cn / trae2api)
- Sub2API 透传(本身是 OpenAI 兼容协议)
- 本地 HTTP 服务可用,任意 OpenAI 客户端可连
- 验收:真机 + 真凭据,WorkBuddy / Trae / Sub2API 各能回复一条消息
### M2:双区 + 多账号治理
- WorkBuddy 国际版、Trae INTL/Solo/Work 适配器
- RegionRouter 按模型家族自动选区(glm/deepseek/kimi → CN,gpt/claude/gemini → INTL)
- AccountPool 加权轮询 + 429 熔断 + 会话粘性
- 验收:国内/国际账号混挂,同模型自动走对应区,单号故障自动换号
### M3:覆盖 agent2api 全家桶
- Qoder CN、小浣熊、CatPaw、AutoClaw CN/INTL、Cline、Accio CN/INTL、ZCode CN/INTL、CodeArts、Loomy
- 每家一个 Adapter,抄 agent2api `providers/` 目录
- 验收:每家至少能在有凭据的情况下完成一次对话;签到/余额尽量复用现有页
### M4:长尾 + 官方直连
- 灵犀、团结AI、TokenDance、Kimi Code、MiniMax、Gemini
- 官方 OpenAI 兼容平台统一走 `OfficialPassthrough`(配置 base_url + key)
- 设置页新增「代理网关」管理:端口、启动/停止、API Key、运行状态
- 验收:所有支持平台可在 UI 中启用/禁用
## 5. Provider 支持矩阵(规划)
| Provider | 区域 | Chat | 签到 | 参考实现 |
|---|---|---|---|---|
| WorkBuddy | CN / INTL | ✅ | CN 有 | agent2api / fleet-kit / proxy-hub |
| Trae / SOLO / Work | CN / INTL | ✅ | CN 有 | agent2api / proxy-hub |
| Qoder | CN | ✅ | CN 有 | agent2api / proxy-hub |
| 小浣熊 | CN | ✅ | 桌面登录积分 | agent2api / fleet-kit |
| CatPaw | CN | ✅ | - | agent2api |
| AutoClaw | CN / INTL | ✅ | CN 有 | agent2api |
| Cline | INTL | ✅ | - | agent2api |
| Accio | CN / INTL | ✅ | - | agent2api |
| ZCode | CN / INTL | ✅ | - | agent2api |
| CodeArts | CN | ✅ | 每日福利 | agent2api |
| Loomy | CN | ✅ | 每日积分 | agent2api / fleet-kit |
| 灵犀 / 团结AI / TokenDance | CN | ✅ | 待确认 | fleet-kit |
| Kimi Code / MiniMax / Gemini | INTL/CN | ✅ | - | 官方 API 透传 |
## 6. 关键风险与对策
1. **上游协议会变**:所有解析做防御式多候选扫描(项目现有惯例);接口变动时快速适配。
2. **设备指纹/风控**:Trae 签到 9074、WorkBuddy 设备指纹;沿用现有 `checkinDeviceId` / `derive_id` 思路。
3. **Token 单次轮换**:WorkBuddy refresh 单次消费,沿用 `RefreshWriteSession` 落库再用的机制。
4. **SSE 分片差异**:各家流式事件格式不同,用统一 `StreamNormalizer` 归一。
5. **Android 后台**:若只给本机内客户端用,App 前台运行即可;若希望后台常驻,需前台服务 + 通知。
## 7. 参考仓库
- agent2api:https://github.com/aimod-cc/agent2api
- workbuddy2api-hub:https://github.com/ardeyouxipianyi/workbuddy2api-hub
- workbuddy-gateway:https://github.com/CangShui/workbuddy-gateway
- workbuddy2api-panel:https://github.com/linguo2625469/workbuddy2api-panel
- Trae2api-cn:https://github.com/autumnsentiment/Trae2api-cn
- trae2api (Go):https://github.com/JeffHu0912/trae2api
- proxy-hub:https://github.com/1416277987/proxy-hub
- fleet-kit:https://github.com/cpufreestyle/fleet-kit

View File

@ -15,6 +15,9 @@ navigationCompose = "2.9.8"
# Network (Retrofit 2.11.0 配 OkHttp 4.12.0 — 稳定组合,避开 Retrofit 3.x + OkHttp 5.x 的前沿兼容性问题) # Network (Retrofit 2.11.0 配 OkHttp 4.12.0 — 稳定组合,避开 Retrofit 3.x + OkHttp 5.x 的前沿兼容性问题)
retrofit = "2.11.0" retrofit = "2.11.0"
# Ktor (本地反代 HTTP 服务)
ktor = "3.0.3"
okhttp = "4.12.0" okhttp = "4.12.0"
kotlinxSerializationJson = "1.7.3" kotlinxSerializationJson = "1.7.3"
retrofitKotlinxSerializationConverter = "1.0.0" retrofitKotlinxSerializationConverter = "1.0.0"
@ -67,6 +70,10 @@ androidx-navigation-compose = { group = "androidx.navigation", name = "navigatio
retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" } retrofit = { group = "com.squareup.retrofit2", name = "retrofit", version.ref = "retrofit" }
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" } okhttp-logging-interceptor = { group = "com.squareup.okhttp3", name = "logging-interceptor", version.ref = "okhttp" }
ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = "ktor" }
ktor-server-cio = { group = "io.ktor", name = "ktor-server-cio", version.ref = "ktor" }
ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" }
kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } kotlinx-serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" }
retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" } retrofit-kotlinx-serialization-converter = { group = "com.jakewharton.retrofit", name = "retrofit2-kotlinx-serialization-converter", version.ref = "retrofitKotlinxSerializationConverter" }

View File

@ -1,6 +1,6 @@
{ {
"versionCode": 42, "versionCode": 40,
"versionName": "1.7.22", "versionName": "1.7.20",
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b", "apkUrl": "http://124.222.97.212:8443/attachments/28bb378e-c34a-4f55-b17a-c74f76b596a7",
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到" "notes": "国际版 Trae 显示美元用量额度 $0.00/$1.00 + 月请求配额"
} }