Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 618eecf676 | |||
| 8207285b23 | |||
| 82bd04597d | |||
| 5850fc452c | |||
| c6a75f8fce | |||
| a384309689 | |||
| 8692014340 | |||
| 15c6bc24c4 | |||
| 460bed7279 | |||
| ecbc86fdf2 | |||
| 05b5107f46 | |||
| bccd7bad83 | |||
| c5120c5952 | |||
| 2c5a3b2055 | |||
| c76193e892 | |||
| 6edd68f80e | |||
| 750b9d9f90 | |||
| 0f4d68033e | |||
| 7c7a23f097 | |||
| 7c3809e879 | |||
| 7ad49e994d | |||
| 8f40360b9f | |||
| 08a3b42a82 | |||
| aab50bbe78 | |||
| faf7f40524 | |||
| 342788056b | |||
| b479cb2a7a | |||
| 6e0d67afb7 | |||
| 8d68c3b280 | |||
| 71f3f8d554 | |||
| dfd91a5355 | |||
| 149fc5f125 | |||
| 95cde916ca |
4
.gitignore
vendored
4
.gitignore
vendored
@ -23,7 +23,3 @@ release.jks
|
|||||||
tmp_cc/
|
tmp_cc/
|
||||||
goal.md
|
goal.md
|
||||||
/reference/
|
/reference/
|
||||||
|
|
||||||
# 调试临时文件(可能包含真实接口响应/账号数据,禁止提交)
|
|
||||||
_tmp_*
|
|
||||||
build_compile.log
|
|
||||||
|
|||||||
@ -16,8 +16,8 @@ android {
|
|||||||
applicationId = "com.rainy.token"
|
applicationId = "com.rainy.token"
|
||||||
minSdk = 31
|
minSdk = 31
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 42
|
versionCode = 14
|
||||||
versionName = "1.7.22"
|
versionName = "1.6.4"
|
||||||
|
|
||||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
vectorDrawables {
|
vectorDrawables {
|
||||||
@ -214,12 +214,6 @@ 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)
|
||||||
|
|
||||||
|
|||||||
@ -3,7 +3,6 @@
|
|||||||
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" />
|
||||||
|
|
||||||
|
|||||||
@ -1,87 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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()
|
|
||||||
}
|
|
||||||
@ -1,70 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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>
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
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()),
|
|
||||||
}
|
|
||||||
@ -1,6 +0,0 @@
|
|||||||
package com.rainy.token.data.proxy
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 区域:CN=国内版(中国大陆域名),INTL=国际版(海外域名)。
|
|
||||||
*/
|
|
||||||
enum class ProviderRegion { CN, INTL }
|
|
||||||
@ -1,293 +0,0 @@
|
|||||||
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() ?: ""
|
|
||||||
}
|
|
||||||
@ -1,56 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,15 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,30 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,135 +0,0 @@
|
|||||||
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,
|
|
||||||
)
|
|
||||||
@ -1,319 +0,0 @@
|
|||||||
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",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,191 +0,0 @@
|
|||||||
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 档)"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,127 +0,0 @@
|
|||||||
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() },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@ -1,141 +0,0 @@
|
|||||||
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"),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -58,79 +58,6 @@ 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())
|
||||||
@ -139,12 +66,9 @@ class TraeRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
|
val regionKey = credential.region.uppercase().takeIf { it == "INTL" } ?: "CN"
|
||||||
val url = if (regionKey == "INTL") {
|
val url = when (regionKey) {
|
||||||
val host = credential.apiHost?.takeIf { it.startsWith("http") }?.trimEnd('/')
|
"INTL" -> "https://grow-normal.trae.ai/trae/api/v1/pay/ide_user_ent_usage"
|
||||||
?: "https://grow-normal.trae.ai"
|
else -> "https://api.trae.cn/trae/api/v2/pay/ide_user_ent_usage"
|
||||||
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)
|
||||||
@ -200,8 +124,6 @@ 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) 权威汇总
|
||||||
@ -234,28 +156,6 @@ 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()
|
||||||
@ -286,7 +186,7 @@ class TraeRepository constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (packCount > 0 && !isDollarBilling) {
|
if (packCount > 0) {
|
||||||
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)
|
||||||
@ -836,16 +736,3 @@ 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
|
|
||||||
)
|
|
||||||
|
|||||||
@ -56,7 +56,7 @@ class UpdateRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 下载 APK 到缓存目录;同版本已下载完成时直接复用,不重复下载。 */
|
/** 下载 APK 到缓存目录。onProgress 回调下载字节数/总字节数(total 可能为 -1)。 */
|
||||||
suspend fun downloadApk(
|
suspend fun downloadApk(
|
||||||
info: UpdateInfo,
|
info: UpdateInfo,
|
||||||
onProgress: (downloaded: Long, total: Long) -> Unit
|
onProgress: (downloaded: Long, total: Long) -> Unit
|
||||||
@ -64,11 +64,6 @@ 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) {
|
||||||
@ -76,7 +71,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()
|
||||||
part.outputStream().buffered().use { output ->
|
file.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
|
||||||
@ -89,8 +84,6 @@ 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)
|
||||||
|
|||||||
@ -53,26 +53,6 @@ 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)
|
||||||
@ -99,7 +79,7 @@ class WorkBuddyRepository constructor(
|
|||||||
var lastBody = ""
|
var lastBody = ""
|
||||||
while (attempts < 3) {
|
while (attempts < 3) {
|
||||||
attempts++
|
attempts++
|
||||||
val request = if (hostMode == 0) summaryRequest(credential, access) else codebuddyRequest(credential, access)
|
val request = if (hostMode == 0) summaryRequest(access) else codebuddyRequest(access)
|
||||||
val resp = try {
|
val resp = try {
|
||||||
okHttpClient.newCall(request).execute()
|
okHttpClient.newCall(request).execute()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
@ -121,7 +101,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(credential, refresh) ?: return@withContext Result.failure(
|
val refreshed = tryRefesh(refresh) ?: return@withContext Result.failure(
|
||||||
RepositoryError.InvalidCredential()
|
RepositoryError.InvalidCredential()
|
||||||
)
|
)
|
||||||
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
|
// 暂存轮换后的凭据,随本次成功提交(RefreshWriteSession 机制)
|
||||||
@ -161,7 +141,7 @@ class WorkBuddyRepository constructor(
|
|||||||
|
|
||||||
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
|
// 细粒度账户明细(官网个人中心同源数据):每个资源包的已用/总量/剩余/到期时间。
|
||||||
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
|
// 独立请求,失败仅降级(保留 summary 的简化 extras),不影响余额主流程。
|
||||||
val detailExtras = runCatching { fetchResourceDetailExtras(credential, access) }.getOrNull()
|
val detailExtras = runCatching { fetchResourceDetailExtras(access) }.getOrNull()
|
||||||
val mergedExtras = if (detailExtras != null) {
|
val mergedExtras = if (detailExtras != null) {
|
||||||
parsed.extras + detailExtras
|
parsed.extras + detailExtras
|
||||||
} else {
|
} else {
|
||||||
@ -186,9 +166,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(credential: Credential.WorkBuddyCredential, accessToken: String): Map<String, String>? {
|
private fun fetchResourceDetailExtras(accessToken: String): Map<String, String>? {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(credential.resourceUrl())
|
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
|
||||||
.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")
|
||||||
@ -207,18 +187,18 @@ class WorkBuddyRepository constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
|
/** workbuddy.cn 官网个人中心实测端点(2026-09 确认 200)。 */
|
||||||
private fun summaryRequest(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
|
private fun summaryRequest(accessToken: String): Request =
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url(credential.summaryUrl())
|
.url("https://www.workbuddy.cn/billing/meter/get-user-resource-summary")
|
||||||
.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(credential: Credential.WorkBuddyCredential, accessToken: String): Request =
|
private fun codebuddyRequest(accessToken: String): Request =
|
||||||
Request.Builder()
|
Request.Builder()
|
||||||
.url(credential.resourceUrl())
|
.url("https://www.codebuddy.cn/v2/billing/meter/get-user-resource")
|
||||||
.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))
|
||||||
@ -228,9 +208,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(credential: Credential.WorkBuddyCredential, refreshToken: String): RefreshedWorkBuddy? {
|
private fun tryRefesh(refreshToken: String): RefreshedWorkBuddy? {
|
||||||
val request = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url(credential.refreshUrl())
|
.url("https://copilot.tencent.com/v2/plugin/auth/token/refresh")
|
||||||
.addHeader("X-Refresh-Token", refreshToken)
|
.addHeader("X-Refresh-Token", refreshToken)
|
||||||
.post("{}".toRequestBody(jsonMedia))
|
.post("{}".toRequestBody(jsonMedia))
|
||||||
.build()
|
.build()
|
||||||
@ -459,9 +439,7 @@ private fun parseResourceDetail(body: String): Map<String, String>? {
|
|||||||
/** 今日已签到(幂等) */
|
/** 今日已签到(幂等) */
|
||||||
ALREADY,
|
ALREADY,
|
||||||
/** 签到失败(服务端给了原因,见 message) */
|
/** 签到失败(服务端给了原因,见 message) */
|
||||||
FAILED,
|
FAILED
|
||||||
/** 国际站无签到活动(活动未开启/未上线,非错误) */
|
|
||||||
NO_CAMPAIGN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -488,12 +466,6 @@ 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(
|
||||||
@ -525,12 +497,6 @@ 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)
|
||||||
@ -564,15 +530,8 @@ 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()
|
||||||
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」。
|
// 已签到语义与上游 cpa-multi-plugins 对齐:code=10001 或文案含「已签/今日/already」。
|
||||||
|
val lowerMsg = msg.lowercase()
|
||||||
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")
|
||||||
@ -587,12 +546,6 @@ 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
|
||||||
@ -642,23 +595,10 @@ 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(credential.originBase() + path)
|
.url("https://www.codebuddy.cn" + path)
|
||||||
.addHeader("Authorization", "Bearer " + access)
|
.addHeader("Authorization", "Bearer " + access)
|
||||||
.addHeader("Accept", "application/json, text/plain, */*")
|
.addHeader("Accept", "application/json")
|
||||||
.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 {
|
||||||
@ -675,7 +615,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(credential, refresh) ?: return AuthedPostResult.Failed(rotated)
|
val refreshed = tryRefesh(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 {
|
||||||
|
|||||||
@ -20,15 +20,6 @@ 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
|
||||||
@ -232,70 +223,6 @@ 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
|
||||||
|
|||||||
@ -79,10 +79,8 @@ 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 / api-us-east.trae.ai) */
|
/** 区域:"CN"(api.trae.cn)/ "INTL"(grow-normal.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=旧凭据未保存 */
|
||||||
@ -108,8 +106,6 @@ 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
|
||||||
|
|||||||
@ -102,20 +102,5 @@ 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) }
|
||||||
}
|
}
|
||||||
@ -1,8 +1,5 @@
|
|||||||
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
|
||||||
@ -66,6 +63,7 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 应用导航图。
|
* 应用导航图。
|
||||||
@ -92,16 +90,13 @@ 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}®ion={region}"
|
const val WEBVIEW_LOGIN = "webview_login/{type}?account={account}"
|
||||||
fun webviewLogin(type: ServiceType, accountId: String? = null, region: String? = null): String = buildString {
|
fun webviewLogin(type: ServiceType, accountId: String? = null): String =
|
||||||
append("webview_login/").append(type.name)
|
if (accountId.isNullOrBlank()) {
|
||||||
if (!accountId.isNullOrBlank()) {
|
"webview_login/${type.name}"
|
||||||
append("?account=").append(Uri.encode(accountId))
|
} else {
|
||||||
|
"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) =
|
||||||
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
"webview_page?url=${Uri.encode(url)}&title=${Uri.encode(title)}"
|
||||||
@ -142,13 +137,6 @@ 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()
|
||||||
@ -238,7 +226,6 @@ 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) },
|
||||||
@ -249,26 +236,21 @@ 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, region ->
|
onOpenRecharge = { type ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
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, region ->
|
onRecharge = { type ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
|
||||||
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -374,7 +356,7 @@ private fun CompactNavHost() {
|
|||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
onBack = guardedPop,
|
onBack = guardedPop,
|
||||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { navController.navigate(Routes.CODEX_OAUTH) }
|
||||||
)
|
)
|
||||||
@ -387,21 +369,14 @@ 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 回传给上一层(凭据编辑页),新建账号模式用于回填
|
||||||
@ -430,7 +405,6 @@ 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,
|
||||||
@ -443,12 +417,10 @@ private fun CompactNavHost() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
onConfigureCredential = { svc -> navController.navigate(credentialEntry(svc)) },
|
||||||
onStartWebViewLogin = { svc, acc, region -> navController.navigate(Routes.webviewLogin(svc, acc, region)) },
|
onStartWebViewLogin = { svc, acc -> navController.navigate(Routes.webviewLogin(svc, acc)) },
|
||||||
onOpenRecharge = { svc, region ->
|
onOpenRecharge = { svc ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
|
||||||
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -489,7 +461,6 @@ 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()) {
|
||||||
@ -507,11 +478,9 @@ 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, region ->
|
onOpenRecharge = { type ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
detailPane = DetailPane.WebPage(url = url, title = rechargeLabel)
|
||||||
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
|
||||||
@ -567,7 +536,6 @@ 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,
|
||||||
@ -575,12 +543,10 @@ private fun ExpandedDetailPane(
|
|||||||
onBack = onClose,
|
onBack = onClose,
|
||||||
onSwitchAccount = onSwitchAccount,
|
onSwitchAccount = onSwitchAccount,
|
||||||
onConfigureCredential = { onClose() },
|
onConfigureCredential = { onClose() },
|
||||||
onStartWebViewLogin = { _, _, _ -> },
|
onStartWebViewLogin = { _, _ -> },
|
||||||
onOpenRecharge = { svc, region ->
|
onOpenRecharge = { svc ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(svc, region)
|
ServiceConfigProvider.get(svc).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
val intlTrae = svc == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
|
||||||
if (intlTrae) context.openTraeUrl(url) else navController.navigate(Routes.webviewPage(url, rechargeLabel))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -688,16 +654,13 @@ 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, region ->
|
onRecharge = { type ->
|
||||||
val url = ServiceConfigProvider.externalUrlFor(type, region)
|
ServiceConfigProvider.get(type).externalUrl?.let { url ->
|
||||||
if (url != null) {
|
apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
||||||
val intlTrae = type == ServiceType.TRAE && region.equals("INTL", ignoreCase = true)
|
|
||||||
if (intlTrae) context.openTraeUrl(url) else apiNavController.navigate(Routes.webviewPage(url, rechargeLabel))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@ -729,8 +692,8 @@ private fun ExpandedDetailPane(
|
|||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
onBack = { apiNavController.popBackStack() },
|
onBack = { apiNavController.popBackStack() },
|
||||||
onStartWebViewLogin = { svc, acc, region ->
|
onStartWebViewLogin = { svc, acc ->
|
||||||
apiNavController.navigate(Routes.webviewLogin(svc, acc, region))
|
apiNavController.navigate(Routes.webviewLogin(svc, acc))
|
||||||
},
|
},
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { apiNavController.navigate(Routes.CODEX_OAUTH) }
|
||||||
@ -744,21 +707,14 @@ 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()) {
|
||||||
@ -861,8 +817,8 @@ private fun ExpandedDetailPane(
|
|||||||
service = type,
|
service = type,
|
||||||
accountId = accountId,
|
accountId = accountId,
|
||||||
onBack = { settingsNavController.popBackStack() },
|
onBack = { settingsNavController.popBackStack() },
|
||||||
onStartWebViewLogin = { svc, acc, region ->
|
onStartWebViewLogin = { svc, acc ->
|
||||||
settingsNavController.navigate(Routes.webviewLogin(svc, acc, region))
|
settingsNavController.navigate(Routes.webviewLogin(svc, acc))
|
||||||
},
|
},
|
||||||
onWebViewLoginSuccess = { },
|
onWebViewLoginSuccess = { },
|
||||||
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
onStartCodexOAuth = { settingsNavController.navigate(Routes.CODEX_OAUTH) }
|
||||||
@ -876,21 +832,14 @@ 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()) {
|
||||||
|
|||||||
@ -85,7 +85,7 @@ import java.util.Locale
|
|||||||
fun ApiManagementScreen(
|
fun ApiManagementScreen(
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onConfigure: (ServiceType) -> Unit,
|
onConfigure: (ServiceType) -> Unit,
|
||||||
onRecharge: (ServiceType, String?) -> Unit,
|
onRecharge: (ServiceType) -> 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, row.region) }
|
onRecharge = { onRecharge(row.service) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -316,9 +316,9 @@ private fun ApiRowCard(
|
|||||||
onDelete: () -> Unit,
|
onDelete: () -> Unit,
|
||||||
onRecharge: () -> Unit
|
onRecharge: () -> Unit
|
||||||
) {
|
) {
|
||||||
// 有官方充值/订阅页的服务才展示充值入口(Trae 按账号区域分流)
|
// 有官方充值/订阅页的服务(ServiceConfig.externalUrl)才展示充值入口
|
||||||
val rechargeUrl = remember(row.service, row.region) {
|
val rechargeUrl = remember(row.service) {
|
||||||
runCatching { ServiceConfigProvider.externalUrlFor(row.service, row.region) }.getOrNull()
|
runCatching { ServiceConfigProvider.get(row.service).externalUrl }.getOrNull()
|
||||||
}
|
}
|
||||||
Card(
|
Card(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|||||||
@ -63,15 +63,6 @@ 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
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -167,9 +158,7 @@ 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
|
||||||
|
|
||||||
|
|||||||
@ -138,7 +138,7 @@ fun DashboardScreen(
|
|||||||
onOpenUsageDetail: () -> Unit,
|
onOpenUsageDetail: () -> Unit,
|
||||||
onOpenCcgoUsageDetail: () -> Unit = {},
|
onOpenCcgoUsageDetail: () -> Unit = {},
|
||||||
onOpenHeatmap: () -> Unit = {},
|
onOpenHeatmap: () -> Unit = {},
|
||||||
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
|
onOpenRecharge: (ServiceType) -> 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, String?) -> Unit,
|
onOpenRecharge: (ServiceType) -> 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 || card.service == ServiceType.TRAE) {
|
onOpenRecharge = if (ServiceConfigProvider.get(card.service).externalUrl != null) {
|
||||||
{ onOpenRecharge(card.service, card.region) }
|
{ onOpenRecharge(card.service) }
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
|
|||||||
@ -287,8 +287,7 @@ 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(
|
||||||
@ -464,9 +463,7 @@ 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
|
||||||
|
|||||||
@ -146,10 +146,6 @@ 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) {
|
||||||
@ -180,7 +176,7 @@ private fun TraeSplitMainBalance(balance: ServiceBalance) {
|
|||||||
|
|
||||||
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
|
/** 首页 Trae 分区一行:左侧分区名,右侧小计 + 单位。 */
|
||||||
@Composable
|
@Composable
|
||||||
private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits") {
|
private fun TraeSplitLine(label: String, value: Double?) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalAlignment = Alignment.Bottom,
|
verticalAlignment = Alignment.Bottom,
|
||||||
@ -200,7 +196,7 @@ private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits
|
|||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.width(4.dp))
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = unit,
|
text = "Credits",
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = inkMuted(),
|
color = inkMuted(),
|
||||||
modifier = Modifier.padding(bottom = 2.dp)
|
modifier = Modifier.padding(bottom = 2.dp)
|
||||||
@ -209,42 +205,6 @@ private fun TraeSplitLine(label: String, value: Double?, unit: String = "Credits
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@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) {
|
||||||
|
|||||||
@ -57,7 +57,6 @@ 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
|
||||||
@ -67,7 +66,6 @@ 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
|
||||||
@ -116,8 +114,8 @@ fun ServiceDetailScreen(
|
|||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
onSwitchAccount: (ServiceType, String?) -> Unit = { _, _ -> },
|
||||||
onConfigureCredential: (ServiceType) -> Unit,
|
onConfigureCredential: (ServiceType) -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
|
onStartWebViewLogin: (ServiceType, String?) -> Unit,
|
||||||
onOpenRecharge: (ServiceType, String?) -> Unit = { _, _ -> },
|
onOpenRecharge: (ServiceType) -> Unit = { },
|
||||||
viewModel: ServiceDetailViewModel = hiltViewModel()
|
viewModel: ServiceDetailViewModel = hiltViewModel()
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
LaunchedEffect(service, accountId) { viewModel.bind(service, accountId) }
|
||||||
@ -131,7 +129,6 @@ 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()
|
||||||
|
|
||||||
@ -238,50 +235,37 @@ 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 {
|
||||||
// 国际版:单张用量卡(套餐/美元额度/月请求配额),不再重复展示签到卡和积分账户卡
|
if (uiState.hasCredential) {
|
||||||
item { TraeIntlUsageCard(uiState.state) }
|
CheckinCard(
|
||||||
item { TraeUsageEventsCard(traeUsageEvents) }
|
state = checkinState,
|
||||||
} else {
|
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
||||||
// 每日签到卡;未配置凭据时展示引导卡,避免暴露必然失败的签到按钮
|
streakDays = checkinInfo?.streakDays ?: 0L,
|
||||||
item {
|
isTrae = true,
|
||||||
if (uiState.hasCredential) {
|
onCheckin = { viewModel.checkin() }
|
||||||
CheckinCard(
|
)
|
||||||
state = checkinState,
|
} else {
|
||||||
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
CheckinNeedCredentialCard()
|
||||||
streakDays = checkinInfo?.streakDays ?: 0L,
|
|
||||||
isTrae = true,
|
|
||||||
onCheckin = { viewModel.checkin() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CheckinNeedCredentialCard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
|
|
||||||
item { TraePacksCard(uiState.state) }
|
|
||||||
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
|
|
||||||
item { TraeAccountInfoCard(uiState.state) }
|
|
||||||
}
|
}
|
||||||
|
// 权益包积分明细(拆开显示每包总额/已用/剩余);无 entitlement.* 数据时该卡不渲染
|
||||||
|
item { TraePacksCard(uiState.state) }
|
||||||
|
// 账户信息(总积分/已消耗/计费方式等,本地化标签,不暴露英文原始键)
|
||||||
|
item { TraeAccountInfoCard(uiState.state) }
|
||||||
}
|
}
|
||||||
ServiceType.WORKBUDDY -> {
|
ServiceType.WORKBUDDY -> {
|
||||||
val wbIntl = uiState.region != null && uiState.region.equals("INTL", ignoreCase = true)
|
item {
|
||||||
if (wbIntl) {
|
if (uiState.hasCredential) {
|
||||||
// 国际版没有独立签到体系:积分来自网页/客户端活跃使用,直接展示说明卡
|
CheckinCard(
|
||||||
item { WorkBuddyIntlNoCheckinCard() }
|
state = checkinState,
|
||||||
} else {
|
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
||||||
item {
|
streakDays = checkinInfo?.streakDays ?: 0L,
|
||||||
if (uiState.hasCredential) {
|
isTrae = false,
|
||||||
CheckinCard(
|
onCheckin = { viewModel.checkin() }
|
||||||
state = checkinState,
|
)
|
||||||
todayCheckedIn = checkinInfo?.todayCheckedIn == true,
|
} else {
|
||||||
streakDays = checkinInfo?.streakDays ?: 0L,
|
CheckinNeedCredentialCard()
|
||||||
isTrae = false,
|
|
||||||
onCheckin = { viewModel.checkin() }
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
CheckinNeedCredentialCard()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
|
// 资源明细(资源 ID 美化为「资源 #007」+ 余额;套餐/冻结/付费信息归账户信息)
|
||||||
@ -325,17 +309,14 @@ 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, uiState.region) },
|
onStartWebViewLogin = { onStartWebViewLogin(service, accountId) },
|
||||||
onOpenRecharge = if (config.externalUrl != null || service == ServiceType.TRAE) {
|
onOpenRecharge = config.externalUrl?.let { { onOpenRecharge(service) } },
|
||||||
{ 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) }
|
||||||
@ -1110,41 +1091,6 @@ 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) {
|
||||||
@ -1155,24 +1101,20 @@ private fun BalanceBigNumber(balance: ServiceBalance?, service: ServiceType) {
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (service == ServiceType.TRAE && balance.extras?.get("billing_mode") == "dollar") {
|
Row(verticalAlignment = Alignment.Bottom) {
|
||||||
TraeIntlBigNumber(balance.extras)
|
Text(
|
||||||
} else {
|
text = formatAmount(balance.amount),
|
||||||
Row(verticalAlignment = Alignment.Bottom) {
|
style = MaterialTheme.typography.displayLarge,
|
||||||
Text(
|
fontWeight = FontWeight.Bold,
|
||||||
text = formatAmount(balance.amount),
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
style = MaterialTheme.typography.displayLarge,
|
)
|
||||||
fontWeight = FontWeight.Bold,
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
color = MaterialTheme.colorScheme.onSurface
|
Text(
|
||||||
)
|
text = balance.unit,
|
||||||
Spacer(modifier = Modifier.width(6.dp))
|
style = MaterialTheme.typography.titleLarge,
|
||||||
Text(
|
color = inkMuted(),
|
||||||
text = balance.unit,
|
modifier = Modifier.padding(bottom = 8.dp)
|
||||||
style = MaterialTheme.typography.titleLarge,
|
)
|
||||||
color = inkMuted(),
|
|
||||||
modifier = Modifier.padding(bottom = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!balance.isAvailable) {
|
if (!balance.isAvailable) {
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
@ -1562,37 +1504,6 @@ 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)
|
||||||
@ -1620,216 +1531,6 @@ 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) {
|
||||||
@ -2135,7 +1836,6 @@ 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?,
|
||||||
@ -2256,18 +1956,6 @@ 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,
|
||||||
|
|||||||
@ -8,7 +8,6 @@ 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
|
||||||
@ -37,8 +36,7 @@ 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)
|
||||||
@ -77,10 +75,6 @@ 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)
|
||||||
|
|
||||||
@ -118,34 +112,6 @@ 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
|
||||||
@ -449,7 +415,6 @@ 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 }
|
||||||
@ -489,16 +454,6 @@ 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 -> {
|
||||||
@ -628,9 +583,6 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (type == ServiceType.TRAE && _uiState.value.region.equals("INTL", ignoreCase = true)) {
|
|
||||||
preloadUsageEvents(type, accountId)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1053,20 +1005,10 @@ 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 {
|
||||||
|
|||||||
@ -156,12 +156,10 @@ fun AccountManagementScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
items(uiState.accounts, key = { it.account.id }) { item ->
|
items(uiState.accounts, key = { it.id }) { account ->
|
||||||
val account = item.account
|
val ordinal = accountOrdinal(uiState.accounts, 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,
|
||||||
@ -264,7 +262,6 @@ 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,
|
||||||
@ -309,26 +306,6 @@ 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 {
|
||||||
|
|||||||
@ -4,7 +4,6 @@ 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
|
||||||
@ -47,22 +46,13 @@ 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 = items,
|
accounts = accounts,
|
||||||
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
|
// 账号列表可能为空(用户刚删光),保持消息残留会误导,这里清掉
|
||||||
message = if (items.isEmpty()) null else it.message
|
message = if (accounts.isEmpty()) null else it.message
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -100,13 +90,8 @@ 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<AccountItem> = emptyList(),
|
val accounts: List<ServiceAccountInfo> = emptyList(),
|
||||||
val message: UiText? = null
|
val message: UiText? = null
|
||||||
)
|
)
|
||||||
@ -82,7 +82,7 @@ fun CredentialEditScreen(
|
|||||||
service: ServiceType,
|
service: ServiceType,
|
||||||
accountId: String? = null,
|
accountId: String? = null,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onStartWebViewLogin: (ServiceType, String?, String?) -> Unit,
|
onStartWebViewLogin: (ServiceType, String?) -> Unit,
|
||||||
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
onWebViewLoginSuccess: (ServiceType) -> Unit,
|
||||||
onStartCodexOAuth: () -> Unit = {},
|
onStartCodexOAuth: () -> Unit = {},
|
||||||
viewModel: CredentialEditViewModel = hiltViewModel()
|
viewModel: CredentialEditViewModel = hiltViewModel()
|
||||||
@ -153,31 +153,13 @@ 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, uiState.traeRegion) },
|
onStartInAppLogin = { onStartWebViewLogin(ServiceType.TRAE, accountId) },
|
||||||
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) {
|
||||||
@ -187,9 +169,7 @@ fun CredentialEditScreen(
|
|||||||
hasExisting = uiState.hasExisting,
|
hasExisting = uiState.hasExisting,
|
||||||
onAccessChange = viewModel::updateWorkBuddyAccess,
|
onAccessChange = viewModel::updateWorkBuddyAccess,
|
||||||
onRefreshChange = viewModel::updateWorkBuddyRefresh,
|
onRefreshChange = viewModel::updateWorkBuddyRefresh,
|
||||||
region = uiState.workBuddyRegion,
|
onStartInAppLogin = { onStartWebViewLogin(ServiceType.WORKBUDDY, accountId) },
|
||||||
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) {
|
||||||
@ -287,7 +267,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, null) },
|
onStartInAppLogin = { onStartWebViewLogin(ServiceType.OPENCODE_GO, accountId) },
|
||||||
onShowHelp = { showGoHelp = true },
|
onShowHelp = { showGoHelp = true },
|
||||||
onApiKeyChange = viewModel::updateTriggerApiKey,
|
onApiKeyChange = viewModel::updateTriggerApiKey,
|
||||||
onSaveApiKey = { viewModel.saveTriggerApiKey() }
|
onSaveApiKey = { viewModel.saveTriggerApiKey() }
|
||||||
@ -797,17 +777,13 @@ 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)
|
||||||
@ -816,37 +792,11 @@ 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,
|
||||||
@ -873,12 +823,6 @@ 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,
|
||||||
@ -972,11 +916,9 @@ 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
|
||||||
) {
|
) {
|
||||||
@ -986,37 +928,11 @@ private fun WorkBuddyCredentialForm(
|
|||||||
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_mobile_browser_login))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
OutlinedButton(
|
|
||||||
onClick = onStartInAppLogin,
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
) {
|
|
||||||
Text(stringResource(R.string.action_login_in_app))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.field_trae_region),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.outline
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
RegionButton(
|
|
||||||
label = stringResource(R.string.option_region_cn),
|
|
||||||
selected = region.uppercase() != "INTL",
|
|
||||||
onSelect = { onRegionChange("CN") }
|
|
||||||
)
|
|
||||||
RegionButton(
|
|
||||||
label = stringResource(R.string.option_region_intl),
|
|
||||||
selected = region.uppercase() == "INTL",
|
|
||||||
onSelect = { onRegionChange("INTL") }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
OutlinedTextField(
|
OutlinedTextField(
|
||||||
value = access,
|
value = access,
|
||||||
|
|||||||
@ -26,7 +26,6 @@ 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
|
||||||
|
|
||||||
@ -127,7 +126,6 @@ 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(),
|
||||||
@ -300,82 +298,6 @@ 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) }
|
||||||
@ -388,11 +310,7 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
fun saveTraeCredential() {
|
fun saveTraeCredential() {
|
||||||
val type = serviceType ?: return
|
val type = serviceType ?: return
|
||||||
val current = _uiState.value
|
val current = _uiState.value
|
||||||
var jwt = current.traeJwt.trim()
|
val 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
|
||||||
@ -408,9 +326,6 @@ 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,
|
||||||
@ -438,10 +353,6 @@ 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
|
||||||
@ -460,11 +371,10 @@ 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, region = "CN"))
|
(existing ?: Credential.WorkBuddyCredential(service = type, accessToken = access, refreshToken = refresh))
|
||||||
.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
|
||||||
),
|
),
|
||||||
@ -942,7 +852,6 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
traeCheckinDeviceId = "",
|
traeCheckinDeviceId = "",
|
||||||
workBuddyAccess = "",
|
workBuddyAccess = "",
|
||||||
workBuddyRefresh = "",
|
workBuddyRefresh = "",
|
||||||
workBuddyRegion = "CN",
|
|
||||||
sub2BaseUrl = "",
|
sub2BaseUrl = "",
|
||||||
sub2Email = "",
|
sub2Email = "",
|
||||||
sub2Password = "",
|
sub2Password = "",
|
||||||
@ -1062,17 +971,12 @@ 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 = "",
|
||||||
/** Trae 登录回调携带的账号 API 主机(备用;从回调链接自动解析) */
|
/** WorkBuddy:OAuth tokens */
|
||||||
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 = "",
|
||||||
|
|||||||
@ -2,17 +2,7 @@ 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
|
||||||
@ -33,14 +23,11 @@ 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
|
||||||
@ -62,7 +49,6 @@ 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
|
||||||
@ -103,14 +89,6 @@ 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(
|
||||||
@ -181,25 +159,6 @@ 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() })
|
||||||
}
|
}
|
||||||
@ -222,181 +181,6 @@ 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
|
||||||
@ -632,23 +416,9 @@ 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(
|
Text(stringResource(R.string.update_install))
|
||||||
if (phase.error == null) {
|
|
||||||
stringResource(R.string.update_install)
|
|
||||||
} else {
|
|
||||||
"重试安装"
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is UpdateViewModel.Phase.NeedInstallPermission -> {
|
is UpdateViewModel.Phase.NeedInstallPermission -> {
|
||||||
@ -667,9 +437,6 @@ 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(
|
||||||
|
|||||||
@ -2,33 +2,15 @@ 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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -37,295 +19,12 @@ 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()
|
||||||
}
|
}
|
||||||
@ -349,23 +48,6 @@ 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()
|
||||||
|
|||||||
@ -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, val error: String? = null) : Phase
|
data class Ready(val file: File) : Phase
|
||||||
data class NeedInstallPermission(val file: File) : Phase
|
object NeedInstallPermission : Phase
|
||||||
data class Error(val message: String) : Phase
|
data class Error(val message: String) : Phase
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,33 +80,19 @@ class UpdateViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun install(file: File) {
|
fun install(file: File) {
|
||||||
val canInstall = runCatching { context.packageManager.canRequestPackageInstalls() }
|
if (context.packageManager.canRequestPackageInstalls()) {
|
||||||
.getOrDefault(false)
|
_uiState.update { it.copy(phase = Phase.Ready(file)) }
|
||||||
if (!canInstall) {
|
val authority = context.packageName + ".fileprovider"
|
||||||
_uiState.update { it.copy(phase = Phase.NeedInstallPermission(file)) }
|
val uri = FileProvider.getUriForFile(context, authority, file)
|
||||||
return
|
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||||
}
|
|
||||||
_uiState.update { it.copy(phase = Phase.Ready(file)) }
|
|
||||||
val authority = context.packageName + ".fileprovider"
|
|
||||||
val uri = try {
|
|
||||||
FileProvider.getUriForFile(context, authority, file)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
_uiState.update { it.copy(phase = Phase.Ready(file, "安装文件无效:" + (e.message ?: "未知错误"))) }
|
|
||||||
return
|
|
||||||
}
|
|
||||||
val actionView = Intent(Intent.ACTION_VIEW).apply {
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
|
||||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
|
||||||
}
|
|
||||||
val started = runCatching { context.startActivity(actionView) }
|
|
||||||
if (started.isFailure) {
|
|
||||||
val actionInstall = Intent(Intent.ACTION_INSTALL_PACKAGE).apply {
|
|
||||||
setDataAndType(uri, "application/vnd.android.package-archive")
|
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(actionInstall) }.onFailure { e ->
|
runCatching { context.startActivity(intent) }.onFailure { e ->
|
||||||
_uiState.update { it.copy(phase = Phase.Ready(file, "无法启动安装器:" + (e.message ?: "未知错误"))) }
|
_uiState.update { it.copy(phase = Phase.Error(e.message ?: "cannot start installer")) }
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
_uiState.update { it.copy(phase = Phase.NeedInstallPermission) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
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
|
||||||
@ -12,15 +10,12 @@ 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
|
||||||
@ -34,12 +29,8 @@ 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
|
||||||
@ -63,7 +54,6 @@ 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)。 */
|
||||||
@ -72,14 +62,10 @@ fun WebViewLoginScreen(
|
|||||||
) {
|
) {
|
||||||
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
// configuredBaseUrl 可选:不传时 ViewModel 自行从凭据仓库读取(Sub2API 自托管实例)
|
||||||
LaunchedEffect(service, configuredBaseUrl, accountId) {
|
LaunchedEffect(service, configuredBaseUrl, accountId) {
|
||||||
viewModel.bind(service, configuredBaseUrl, accountId, region)
|
viewModel.bind(service, configuredBaseUrl, accountId)
|
||||||
}
|
}
|
||||||
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) {
|
||||||
@ -111,17 +97,6 @@ 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))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -130,22 +105,6 @@ 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 {
|
||||||
@ -193,9 +152,8 @@ fun WebViewLoginScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun handleOverrideUrl(url: String): Boolean {
|
fun handleOverrideUrl(url: String): Boolean {
|
||||||
// Trae OAuth 回调:拦截 127.0.0.1/localhost:18080/authorize,提取 userJwt
|
// Trae OAuth 回调:拦截 127.0.0.1:18080/authorize,提取 userJwt
|
||||||
// (部分流程会从 https 跳到 http,或把参数放 fragment)
|
if (url.startsWith("http://127.0.0.1:18080/authorize")) {
|
||||||
if (isTraeLoginCallback(url)) {
|
|
||||||
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
|
DebugLog.i("TraeLogin", "回调命中: ${url.toSafeLogUrl()}")
|
||||||
viewModel.onTraeCallback(url)
|
viewModel.onTraeCallback(url)
|
||||||
return true
|
return true
|
||||||
@ -203,24 +161,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -231,70 +173,13 @@ 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(
|
||||||
|
|||||||
@ -21,7 +21,6 @@ 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
|
||||||
@ -58,25 +57,14 @@ 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, region: String? = null) {
|
fun bind(service: ServiceType, configuredBaseUrl: String? = null, accountId: 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 {
|
||||||
@ -89,11 +77,6 @@ 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()
|
||||||
@ -150,24 +133,9 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
// 登录 URL 与签到 claim 用同一个值。不能每次随机(会话绑定不一致会被风控拒),
|
||||||
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
// 也不可用 userJwt.ClientID(固定客户端标识,所有用户相同,必被按设备限流)。
|
||||||
val deviceId = traeDeviceId()
|
val deviceId = traeDeviceId()
|
||||||
val callbackUrl = buildTraeAuthUrl(LOGIN_WEBVIEW_CALLBACK_PORT)
|
val callbackUrl = "http://127.0.0.1:18080/authorize"
|
||||||
_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(authRoot).append("?")
|
append("https://www.trae.cn/authorization?")
|
||||||
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&")
|
||||||
@ -180,8 +148,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", "buildTraeAuthUrl: port=$callbackPort len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
DebugLog.i("TraeLogin", "bindTrae: 授权URL已生成 len=${authUrl.length} deviceId持久化=${deviceId.take(6)}…")
|
||||||
return authUrl
|
_uiState.update { it.copy(loginUrl = authUrl) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
/** Trae 签到用的持久化设备 ID:首次生成 19 位数字并保存,此后跨登录复用。 */
|
||||||
@ -206,95 +174,56 @@ 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 值,避免把 token 明文落调试日志);query+both
|
// 只记录参数名(不含 token 值,避免把 userJwt 明文落调试日志)
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,query=${callbackUri.query?.split("&")?.map { it.substringBefore("=") } ?: emptyList()} fragmentKeys=${callbackUri.fragment?.split("&")?.map { it.substringBefore("=") } ?: emptyList()}")
|
DebugLog.i("TraeLogin", "onTraeCallback: 收到回调,参数=${parsed.queryParameterNames}")
|
||||||
// Trae 回调可能走两套:
|
// Trae 回调中参数名可能为 userJwt 或 user_jwt,值是 JSON 字符串
|
||||||
// 1) userJwt/user_jwt 直接带 Token+RefreshToken(正常流程)
|
val userJwtRaw = parsed.queryParameter("userJwt")
|
||||||
// 2) 只带 refreshToken(老流程),需要调用 ExchangeToken 兑换 Cloud-IDE-JWT
|
?: parsed.queryParameter("user_jwt")
|
||||||
val userJwtRaw = param("userJwt")
|
?: run {
|
||||||
?: param("user_jwt")
|
DebugLog.w("TraeLogin", "onTraeCallback: 无 userJwt/user_jwt 参数")
|
||||||
?: ""
|
return
|
||||||
|
}
|
||||||
|
val userJwt = try {
|
||||||
|
Json.parseToJsonElement(userJwtRaw).jsonObject
|
||||||
|
} catch (e: Exception) {
|
||||||
|
DebugLog.w("TraeLogin", "onTraeCallback: userJwt JSON 解析失败: ${e.message}")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
||||||
|
if (token.isNullOrBlank()) {
|
||||||
|
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但 userJwt 无 Token(userJwtKeys=${userJwt.keys})")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
DebugLog.i("TraeLogin", "onTraeCallback: userJwt 解析成功 token非空=${token.isNotBlank()}")
|
||||||
|
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
|
||||||
|
// 注意不能用 userJwt.ClientID——那是固定客户端标识(en1oxy7wnw8j9n),所有
|
||||||
|
// 用户相同,作 x-device-id 会被服务端按设备风控拒绝(「参与用户太多」)。
|
||||||
|
val deviceId = traeDeviceId()
|
||||||
|
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
|
||||||
|
val userId = parsed.queryParameter("userInfo")?.let { raw ->
|
||||||
|
runCatching {
|
||||||
|
val u = Json.parseToJsonElement(raw).jsonObject
|
||||||
|
u["userId"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: u["user_id"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: u["id"]?.jsonPrimitive?.contentOrNull
|
||||||
|
?: u["accountId"]?.jsonPrimitive?.contentOrNull
|
||||||
|
}.getOrNull()
|
||||||
|
}
|
||||||
|
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (userId.isNullOrBlank()) "null" else "ok"}")
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val userJwt = runCatching {
|
// 刚成功登录的凭据视为已验证:lastVerifiedAt 记为当前时间,
|
||||||
Json.parseToJsonElement(userJwtRaw).jsonObject
|
// 否则首页/列表会显示"未验证/未登录"直到进详情页拉余额后才正常
|
||||||
}.getOrNull() ?: JsonObject(emptyMap())
|
|
||||||
|
|
||||||
var token = userJwt["Token"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: userJwt["token"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: userJwt["AccessToken"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: userJwt["accessToken"]?.jsonPrimitive?.contentOrNull
|
|
||||||
var refresh = userJwt["RefreshToken"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: userJwt["refreshToken"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: param("refreshToken")
|
|
||||||
?: param("refresh_token")
|
|
||||||
?: param("data")
|
|
||||||
val clientId = userJwt["ClientID"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: userJwt["clientId"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: param("clientId")
|
|
||||||
?: param("client_id")
|
|
||||||
?: "en1oxy7wnw8j9n"
|
|
||||||
|
|
||||||
// 老流程:回调只有 refreshToken,先兑换 Cloud-IDE-JWT
|
|
||||||
if (token.isNullOrBlank() && !refresh.isNullOrBlank()) {
|
|
||||||
val exchangeHost = param("host")?.takeIf { it.startsWith("http") }
|
|
||||||
?: if (region == "INTL") "https://a0ai-api-sg.byteintlapi.com" else "https://api.trae.cn"
|
|
||||||
val exchanged = exchangeTraeRefreshToken(refresh, clientId, exchangeHost)
|
|
||||||
if (exchanged != null) {
|
|
||||||
token = exchanged.token
|
|
||||||
refresh = exchanged.refreshToken
|
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: refreshToken 兑换成功(host=${exchangeHost})")
|
|
||||||
} else {
|
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: refreshToken 兑换失败(host=${exchangeHost})")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (token.isNullOrBlank()) {
|
|
||||||
DebugLog.w("TraeLogin", "onTraeCallback: 收到回调但无 Token(userJwtKeys=${userJwt.keys})")
|
|
||||||
_uiState.update {
|
|
||||||
it.copy(error = UiText.Resource(R.string.error_login_token_missing))
|
|
||||||
}
|
|
||||||
return@launch
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设备绑定 ID:登录会话绑定的 App 持久化设备 ID(与授权 URL device_id 一致)。
|
|
||||||
val deviceId = traeDeviceId()
|
|
||||||
// 用户 ID:从回调 userInfo 参数提取,签到按 trae-check 口径拼 ${deviceId}-${userId}。
|
|
||||||
val parsedUserId = param("userInfo")?.let { raw ->
|
|
||||||
runCatching {
|
|
||||||
val u = Json.parseToJsonElement(raw).jsonObject
|
|
||||||
u["userId"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["user_id"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["id"]?.jsonPrimitive?.contentOrNull
|
|
||||||
?: u["accountId"]?.jsonPrimitive?.contentOrNull
|
|
||||||
}.getOrNull()
|
|
||||||
}
|
|
||||||
DebugLog.i("TraeLogin", "onTraeCallback: userId=${if (parsedUserId.isNullOrBlank()) "null" else "ok"}")
|
|
||||||
|
|
||||||
val credential = Credential.TraeCredential(
|
val credential = Credential.TraeCredential(
|
||||||
service = ServiceType.TRAE,
|
service = ServiceType.TRAE,
|
||||||
jwt = token,
|
jwt = token,
|
||||||
region = if (region == "INTL") "INTL" else "CN",
|
region = "CN",
|
||||||
apiHost = param("host")?.takeIf { it.startsWith("http") }?.trimEnd('/'),
|
|
||||||
deviceId = deviceId,
|
deviceId = deviceId,
|
||||||
userId = parsedUserId?.takeIf { it.isNotBlank() },
|
userId = userId?.takeIf { it.isNotBlank() },
|
||||||
lastVerifiedAt = System.currentTimeMillis()
|
lastVerifiedAt = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
val effectiveId = credentialRepository.save(credential, accountId)
|
val effectiveId = credentialRepository.save(credential, accountId)
|
||||||
@ -305,76 +234,14 @@ 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(workBuddyBase + "/v2/plugin/auth/state?platform=" + workBuddyPlatform)
|
.url("https://copilot.tencent.com/v2/plugin/auth/state?platform=CLI")
|
||||||
.addHeader("User-Agent", workBuddyUserAgent)
|
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||||
.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()
|
||||||
@ -394,7 +261,6 @@ 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))
|
||||||
@ -403,10 +269,6 @@ 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))
|
||||||
@ -415,24 +277,16 @@ class WebViewLoginViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 每 2 秒轮询一次 token 接口,超时按站点 Profile(CN 5 分钟 / INTL 15 分钟)。 */
|
/** 每 2 秒轮询一次 token 接口,120 秒超时。 */
|
||||||
private suspend fun pollWorkBuddyToken(state: String) {
|
private suspend fun pollWorkBuddyToken(state: String) {
|
||||||
val timeoutMs = workBuddyPollTimeoutMs
|
val timeoutMs = 120_000L
|
||||||
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(workBuddyBase + "/v2/plugin/auth/token?state=$state")
|
.url("https://copilot.tencent.com/v2/plugin/auth/token?state=$state")
|
||||||
.addHeader("User-Agent", workBuddyUserAgent)
|
.addHeader("User-Agent", "CLI/2.63.2 CodeBuddy/2.63.2")
|
||||||
.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) {
|
||||||
@ -441,34 +295,16 @@ 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 codeText = json["code"]?.jsonPrimitive?.contentOrNull
|
val code = json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
||||||
val code = codeText?.toIntOrNull() ?: json["code"]?.jsonPrimitive?.intOrNull ?: -1
|
if (code == 0) {
|
||||||
val rawData = json["data"]?.jsonObject ?: json.jsonObject
|
val data = json["data"]?.jsonObject
|
||||||
DebugLog.i(
|
val accessToken = data?.get("accessToken")?.jsonPrimitive?.contentOrNull
|
||||||
"WorkBuddyLogin",
|
?: data?.get("access_token")?.jsonPrimitive?.contentOrNull
|
||||||
"poll: code=$codeText bodyKeys=${json.keys} dataKeys=${rawData.keys}"
|
val refreshToken = data?.get("refreshToken")?.jsonPrimitive?.contentOrNull
|
||||||
)
|
?: data?.get("refresh_token")?.jsonPrimitive?.contentOrNull
|
||||||
if (code == 0 || codeText == "0") {
|
val expiresIn = data?.get("expiresIn")?.jsonPrimitive?.longOrNull
|
||||||
// 兼容多种字段:accessToken/access_token/token/authToken,refreshToken/refresh_token,expiresIn/expires_at
|
?: data?.get("expires_in")?.jsonPrimitive?.longOrNull ?: 7200L
|
||||||
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 同款注释)
|
||||||
@ -476,7 +312,6 @@ 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()
|
||||||
)
|
)
|
||||||
@ -489,13 +324,11 @@ 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 (e: Exception) {
|
} catch (_: Exception) {
|
||||||
DebugLog.w("WorkBuddyLogin", "poll: 异常=${e.message ?: "unknown"}")
|
// 网络异常时继续轮询,直到超时
|
||||||
}
|
}
|
||||||
delay(2000)
|
delay(2000)
|
||||||
}
|
}
|
||||||
@ -528,77 +361,6 @@ 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) {
|
||||||
@ -753,10 +515,6 @@ 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 {
|
||||||
@ -787,7 +545,5 @@ 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
|
|
||||||
)
|
)
|
||||||
|
|||||||
@ -19,7 +19,6 @@
|
|||||||
<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>
|
||||||
@ -228,7 +227,6 @@
|
|||||||
<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>
|
||||||
@ -425,13 +423,6 @@
|
|||||||
<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>
|
||||||
@ -456,7 +447,6 @@
|
|||||||
<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>
|
||||||
@ -794,24 +784,4 @@
|
|||||||
<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>
|
||||||
@ -19,7 +19,6 @@
|
|||||||
<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>
|
||||||
@ -228,7 +227,6 @@
|
|||||||
<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>
|
||||||
@ -425,13 +423,6 @@
|
|||||||
<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>
|
||||||
@ -456,7 +447,6 @@
|
|||||||
<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>
|
||||||
@ -794,24 +784,4 @@
|
|||||||
<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>
|
||||||
@ -23,7 +23,6 @@
|
|||||||
<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>
|
||||||
@ -231,7 +230,6 @@
|
|||||||
<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>
|
||||||
@ -428,13 +426,6 @@
|
|||||||
<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>
|
||||||
@ -459,7 +450,6 @@
|
|||||||
<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>
|
||||||
@ -799,24 +789,4 @@
|
|||||||
<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>
|
||||||
@ -1,143 +0,0 @@
|
|||||||
# 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
|
|
||||||
@ -15,9 +15,6 @@ 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"
|
||||||
@ -70,10 +67,6 @@ 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" }
|
||||||
|
|
||||||
|
|||||||
10
update.json
10
update.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"versionCode": 40,
|
"versionCode": 42,
|
||||||
"versionName": "1.7.20",
|
"versionName": "1.7.22",
|
||||||
"apkUrl": "http://124.222.97.212:8443/attachments/28bb378e-c34a-4f55-b17a-c74f76b596a7",
|
"apkUrl": "http://124.222.97.212:8443/attachments/03955cf3-112f-4453-bac7-91bba405e40b",
|
||||||
"notes": "国际版 Trae 显示美元用量额度 $0.00/$1.00 + 月请求配额"
|
"notes": "Trae/WorkBuddy INTL 手机浏览器登录并自动回填;WorkBuddy CN 软件内登录;WorkBuddy INTL 余额走 workbuddy.ai;国际版无独立签到"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user