diff --git a/AGENTS.md b/AGENTS.md index b340680..be672cd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -23,7 +23,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut - ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证 - ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage` - ✅ CommandCode Go — JSON API 抓取用量数据,`CommandCodeUsageRepository` 解析(workspaceId = `"commandcode"`) -- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。 +- ✅ Codex / ChatGPT Plus — 支持 **OAuth PKCE 登录(无头模式)** 或粘贴完整 auth.json(含 refresh_token),调 `chatgpt.com/backend-api/wham/usage`;token 过期前 60 分钟自动刷新。OpenAI 采用 refresh_token 单次轮换机制,被外部工具使用后旧 token 立即失效,需重新 OAuth 登录或导入新 auth.json。**5h 窗口可能被 OpenAI 临时关闭**(`primary_window` 返回 `null`),UI 保留空 5h 槽位 + 主标签动态化(`extras["primary.label"]`),恢复后自动填回。 - ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532) - ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly") - ❌ OpenCode Zen / 小米 MiMo — 未实现 @@ -41,6 +41,12 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut - ✅ `DebugLog` — 内存 ring buffer(200 条,线程安全),所有 Repository 关键路径(网络请求、Token 刷新、解析错误)均写入日志;设置页提供「调试日志」入口,无需连电脑即可在 APP 内查看 ERROR/WARN/INFO 三级日志。 - ✅ `RepositoryError.InvalidCredential` 支持自定义 `detail`,Codex `RefreshResult` 密封类明确区分刷新成功/失败原因,错误信息可透传到 Dashboard 卡片与调试日志。 +**JsonNull 安全红线**: + +> ⚠️ kotlinx.serialization 中 `?.jsonObject` / `?.jsonPrimitive` 对 JSON 显式 `null` 值无效——返回的是 `JsonNull` 对象(非 Kotlin null),扩展函数内部类型检查会抛 `IllegalArgumentException` 导致闪退。 +> **必须用 `as? JsonObject` / `as? JsonPrimitive` 替代**,安全转换能正确处理 `JsonNull`(返回 null)。 +> `parseUsageWindows()` 等解析方法外部必须包 `try/catch` → `RepositoryError.ParseError`,防止 API 响应结构变化导致异常逃逸。 + **凭据回显红线**: > ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。 diff --git a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt index 3193c82..3a7aa39 100644 --- a/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/CodexRepository.kt @@ -10,6 +10,8 @@ import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.floatOrNull import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive @@ -84,7 +86,12 @@ class CodexRepository( } else return@withContext Result.failure(e) } catch (e: Throwable) { return@withContext Result.failure(RepositoryError.Unknown(e)) } - val windows = parseUsageWindows(usageResult) + val windows = try { + parseUsageWindows(usageResult) + } catch (e: Exception) { + DebugLog.e(TAG, "解析 Codex 用量失败: ${e::class.simpleName}: ${e.message}") + return@withContext Result.failure(RepositoryError.ParseError("Codex 用量响应格式异常: ${e.message ?: e::class.simpleName}")) + } if (windows.isEmpty()) return@withContext Result.failure(RepositoryError.ParseError("未找到 Codex 用量窗口数据")) val config = ServiceConfigProvider.get(ServiceType.CODEX) @@ -95,9 +102,10 @@ class CodexRepository( put("window_$i.remainingPct", w.remainingPct.toString()) put("window_$i.resetAt", w.resetAt?.toString() ?: "") } - usageResult["plan_type"]?.jsonPrimitive?.content?.let { put("plan", it) } - usageResult["credits"]?.jsonObject?.let { c -> - c["balance"]?.jsonPrimitive?.floatOrNull?.let { put("usageCredits", it.toString()) } + put("primary.label", primary.label) + (usageResult["plan_type"] as? JsonPrimitive)?.contentOrNull?.let { put("plan", it) } + (usageResult["credits"] as? JsonObject)?.let { c -> + (c["balance"] as? JsonPrimitive)?.floatOrNull?.let { put("usageCredits", it.toString()) } } } val balance = ServiceBalance(ServiceType.CODEX, (100 - primary.remainingPct).coerceIn(0, 100).toDouble(), config.displayUnit, true, null, null, primary.resetAt, extras) @@ -154,7 +162,11 @@ class CodexRepository( if (errorBody == null) return "HTTP $httpCode,无错误详情" // 尝试提取 error.message 字段 val msg = try { - json.parseToJsonElement(errorBody).jsonObject["error"]?.jsonObject?.get("message")?.jsonPrimitive?.content + (json.parseToJsonElement(errorBody) as? JsonObject)?.let { root -> + (root["error"] as? JsonObject)?.let { err -> + (err["message"] as? JsonPrimitive)?.contentOrNull + } + } } catch (_: Exception) { null } return when { msg != null && msg.contains("already been used") -> @@ -183,7 +195,8 @@ class CodexRepository( DebugLog.e(TAG, "fetchJson failed: HTTP ${it.code} ${it.message} url=$url") throw if (it.code in listOf(401, 403)) RepositoryError.InvalidCredential("HTTP ${it.code}") else RepositoryError.ServerError(it.code) } - return json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")).jsonObject + return (json.parseToJsonElement(it.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject) + ?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象") } } @@ -194,15 +207,15 @@ class CodexRepository( fun addWindows(rl: JsonObject?) { if (rl == null) return for (key in listOf("primary_window", "secondary_window")) { - val w = rl[key]?.jsonObject ?: continue - val usedPct = w["used_percent"]?.jsonPrimitive?.floatOrNull ?: continue + val w = rl[key] as? JsonObject ?: continue + val usedPct = (w["used_percent"] as? JsonPrimitive)?.floatOrNull ?: continue val remaining = (100 - usedPct).toInt().coerceIn(0, 100) - result.add(UsageWindow(durationLabel(w["limit_window_seconds"]?.jsonPrimitive?.longOrNull), remaining, w["reset_at"]?.jsonPrimitive?.longOrNull?.times(1000L))) + result.add(UsageWindow(durationLabel((w["limit_window_seconds"] as? JsonPrimitive)?.longOrNull), remaining, (w["reset_at"] as? JsonPrimitive)?.longOrNull?.times(1000L))) } } - addWindows(data["rate_limit"]?.jsonObject) + addWindows(data["rate_limit"] as? JsonObject) (data["additional_rate_limits"] as? kotlinx.serialization.json.JsonArray)?.forEach { item -> - if (item is JsonObject) addWindows(item["rate_limit"]?.jsonObject) + if (item is JsonObject) addWindows(item["rate_limit"] as? JsonObject) } return result } diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt index a15dcc1..b8b69ae 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/ServiceBalanceCards.kt @@ -226,6 +226,7 @@ internal fun CodexMainBalance(balance: ServiceBalance) { val plan = balance.extras["plan"]?.let { when (it) { "plus" -> "Plus"; "pro" -> "Pro"; "free" -> "Free"; else -> it.replaceFirstChar { c -> c.uppercaseChar() } } } ?: "—" + val primaryLabel = formatCodexPrimaryLabel(balance.extras["primary.label"]) Row(verticalAlignment = Alignment.Bottom) { Text( text = formatAmount(balance.amount), @@ -242,7 +243,7 @@ internal fun CodexMainBalance(balance: ServiceBalance) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "5h 已用", + text = "$primaryLabel 已用", style = MaterialTheme.typography.titleMedium, color = inkMuted(), modifier = Modifier.padding(bottom = 6.dp) @@ -257,6 +258,16 @@ internal fun CodexMainBalance(balance: ServiceBalance) { } } +/** 把 Codex API 返回的 primary label 转成中文短标签 */ +internal fun formatCodexPrimaryLabel(raw: String?): String = when (raw?.lowercase()) { + "5h" -> "5h" + "7d", "每周" -> "每周" + "30d", "每月" -> "每月" + "usage" -> "用量" + null -> "5h" + else -> raw +} + @Composable internal fun CodexUsageWindows(balance: ServiceBalance) { val extras = balance.extras @@ -272,10 +283,17 @@ internal fun CodexUsageWindows(balance: ServiceBalance) { Triple(label, remainingPct, resetAt) } + // 判断是否有 5h 窗口(用于保留空槽位) + val has5h = windows.any { it.first.contains("5") && it.first.contains("小时") } + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { if (windows.isEmpty()) { CompactUsageRowEmpty(label = "Usage", resetInSec = null) } else { + // 如果没有 5h 窗口,在顶部插入一个空的 5h 槽位(保留位置,等恢复后自动填充) + if (!has5h) { + CompactUsageRowEmpty(label = "5 小时", resetInSec = null) + } windows.forEach { (label, remainingPct, resetAt) -> if (remainingPct != null) { val usedPct = (100 - remainingPct).coerceIn(0, 100) diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt index 2671b83..e80f9e4 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailScreen.kt @@ -410,6 +410,9 @@ private fun CodexUsageCard(state: State) { val windows = remember(extras) { extractCodexWindows(extras) } if (windows.isEmpty()) return + // 判断是否有 5h 窗口 + val has5h = windows.any { it.label.contains("5") && it.label.contains("小时") } + Card( modifier = Modifier.fillMaxWidth(), shape = RoundedCornerShape(20.dp), @@ -434,6 +437,20 @@ private fun CodexUsageCard(state: State) { } } Spacer(modifier = Modifier.height(12.dp)) + + // 没有 5h 窗口时,在顶部插入空槽位 + if (!has5h) { + UsageWindowRow( + label = "5 小时", + pct = null, + resetInSec = null, + decimals = 2 + ) + Spacer(modifier = Modifier.height(14.dp)) + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + Spacer(modifier = Modifier.height(14.dp)) + } + windows.forEachIndexed { index, window -> if (index > 0) { Spacer(modifier = Modifier.height(14.dp)) @@ -850,7 +867,7 @@ private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.DEEPSEEK -> "当前余额" ServiceType.OPENCODE_GO -> "5h 实时用量" ServiceType.COMMANDCODE_GO -> "月度余额" - ServiceType.CODEX -> "5h 已用量" + ServiceType.CODEX -> "用量" ServiceType.OLLAMA -> "Session 用量" } diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index a42e728..7c172c6 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -177,20 +177,34 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { resetSec = extras["monthly.resetInSec"]?.toLongOrNull()) } ServiceType.CODEX -> { - val windows = (0 until 3).map { index -> - val label = normalizeWindowLabel(extras["window_$index.label"] ?: if (index == 0) "5h" else "Usage") + val windowCount = extras.keys + .mapNotNull { key -> key.removePrefix("window_").substringBefore('.').toIntOrNull() } + .distinct().maxOrNull()?.plus(1) ?: 0 + val windows = (0 until windowCount).map { index -> + val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage") val remaining = extras["window_$index.remainingPct"]?.toIntOrNull() val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 } Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) } - setRowLabel(views, windows.getOrNull(0)?.first ?: "5h", windows.getOrNull(1)?.first ?: "周", windows.getOrNull(2)?.first ?: "月") + // 判断是否有 5h 窗口 + val has5h = windows.any { it.first.contains("5") } + // 始终保留 5h 槽位在第一行 + val row1 = if (!has5h) { + Triple("5h", null, null) + } else { + windows.firstOrNull { it.first.contains("5") } ?: windows.getOrNull(0) ?: Triple("5h", null, null) + } + val otherWindows = windows.filter { it != row1 } + val row2 = otherWindows.getOrNull(0) ?: Triple("每周", null, null) + val row3 = otherWindows.getOrNull(1) ?: Triple("每月", null, null) + setRowLabel(views, row1.first, row2.first, row3.first) listOf( Triple(R.id.row1_pct, R.id.row1_bar, R.id.row1_reset), Triple(R.id.row2_pct, R.id.row2_bar, R.id.row2_reset), Triple(R.id.row3_pct, R.id.row3_bar, R.id.row3_reset) ).forEachIndexed { index, ids -> - val window = windows.getOrNull(index) - populateRow(views, ids.first, ids.second, ids.third, pct = window?.second, resetSec = window?.third) + val window = when (index) { 0 -> row1; 1 -> row2; else -> row3 } + populateRow(views, ids.first, ids.second, ids.third, pct = window.second, resetSec = window.third) } } ServiceType.DEEPSEEK -> setEmptyState(views)