fix: Codex JsonNull 崩溃 + 5h 窗口优雅降级
- CodexRepository: 所有 ?.jsonObject/?.jsonPrimitive → as? JsonObject/as? JsonPrimitive 防止 API 返回 JSON null 时 IllegalArgumentException 闪退 - CodexRepository: parseUsageWindows() 外包 try/catch → ParseError 兜底 - CodexRepository: extras 新增 primary.label 供 UI 动态显示主窗口标签 - ServiceBalanceCards: CodexMainBalance 标签动态化 + CodexUsageWindows 保留 5h 空槽位 - ServiceDetailScreen: mainCardLabel 改为 "用量" + CodexUsageCard 保留 5h 空槽位 - Widget: Codex 窗口动态解析 + 5h 始终占第一行 - AGENTS.md: 新增 JsonNull 安全红线 + Codex 5h 降级说明
This commit is contained in:
parent
9d06644225
commit
f17d37ae39
@ -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` 子类显式编写回显分支。
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 用量"
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
Reference in New Issue
Block a user