feat: Codex 一键激活用量功能 — 模型列表从 models.dev 动态获取,SSE 响应解析弹窗展示
Some checks failed
Release / build (push) Has been cancelled

This commit is contained in:
WaterRain 2026-07-17 01:50:50 +00:00
parent f17d37ae39
commit b4c3402d22
No known key found for this signature in database
6 changed files with 640 additions and 4 deletions

View File

@ -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。**5h 窗口可能被 OpenAI 临时关闭**(`primary_window` 返回 `null`),UI 保留空 5h 槽位 + 主标签动态化(`extras["primary.label"]`),恢复后自动填回。
- ✅ 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"]`),恢复后自动填回。**一键激活用量**:Codex 详情页可向 `chatgpt.com/backend-api/codex/responses` 发送简短请求(`input:hello`)触发用量统计,模型列表从 `models.dev/api.json` 动态获取(无需认证),用户选择持久化到 `SharedPreferences("codex_trigger_prefs")`,请求需 `stream:true`+`store:false`+`ChatGPT-Account-Id` 头(从 access_token JWT 解析),SSE 响应经 `parseSseResponse()` 提取回复文本+用量统计后弹窗展示。
- ✅ 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 — 未实现

View File

@ -51,6 +51,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| ⚡ **Room 数据库** | 用量记录存 Room(indexed on workspaceId+timeCreated),DAO 查询替代全量 JSON 序列化;首次启动自动从旧 DataStore 迁移 |
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 |
---

View File

@ -35,6 +35,8 @@ class CodexRepository(
companion object {
private const val WHAM_USAGE = "https://chatgpt.com/backend-api/wham/usage"
private const val RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses"
private const val MODELS_API = "https://models.dev/api.json"
private const val OAUTH_TOKEN_URL = "https://auth.openai.com/oauth/token"
private const val CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann"
private const val REFRESH_BUFFER_MS = 60L * 60 * 1000
@ -114,6 +116,172 @@ class CodexRepository(
Result.success(balance)
}
/**
* 从 models.dev/api.json 获取 OpenAI 可用模型列表。
* 该 API 不需要认证,返回所有 provider 的模型目录。
* 参考 OpenCode 的 ModelsDev 服务实现。
*/
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "codex-reset-tracker/0.1")
.get().build()
val models = okHttpClient.newCall(request).execute().use { resp ->
if (!resp.isSuccessful) {
DebugLog.e(TAG, "fetchModels: HTTP ${resp.code}")
return@withContext Result.failure(RepositoryError.ServerError(resp.code))
}
val root = json.parseToJsonElement(resp.body?.string() ?: throw RepositoryError.ParseError("响应体为空")) as? JsonObject
?: throw RepositoryError.ParseError("响应根节点不是 JSON 对象")
// 结构: { "openai": { "models": { "gpt-5.6": {...}, ... } } }
val openaiProvider = root["openai"] as? JsonObject
val modelsObj = openaiProvider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError("未找到 OpenAI 模型列表")
}
if (models.isEmpty()) {
return@withContext Result.failure(RepositoryError.ParseError("模型列表为空"))
}
DebugLog.i(TAG, "fetchModels: 获取到 ${models.size} 个模型")
Result.success(models)
} catch (e: IOException) {
DebugLog.e(TAG, "fetchModels 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: RepositoryError) {
Result.failure(e)
} catch (e: Throwable) {
DebugLog.e(TAG, "fetchModels 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
/** 从 access_token JWT 中解析 chatgpt_account_id */
private fun extractAccountId(accessToken: String): String? {
return try {
val parts = accessToken.split(".")
if (parts.size != 3) return null
// JWT payload 是 Base64Url 编码
val payload = android.util.Base64.decode(
parts[1].replace("-", "+").replace("_", "/").padEnd(4, '='),
android.util.Base64.DEFAULT
).toString(Charsets.UTF_8)
val json = this.json.parseToJsonElement(payload) as? JsonObject ?: return null
(json["chatgpt_account_id"] as? JsonPrimitive)?.contentOrNull
?: ((json["https://api.openai.com/auth"] as? JsonObject)?.get("chatgpt_account_id") as? JsonPrimitive)?.contentOrNull
?: (json["organizations"] as? kotlinx.serialization.json.JsonArray)
?.firstOrNull()?.let { (it as? JsonObject)?.get("id") as? JsonPrimitive }?.contentOrNull
} catch (_: Exception) {
null
}
}
/**
* 一键激活用量:向 ChatGPT responses API 发送一条请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应体原文) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.CODEX)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
if (credential !is Credential.CodexCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val effectiveCred = ensureToken(credential)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("token 刷新失败"))
val accountId = extractAccountId(effectiveCred.accessToken)
DebugLog.i(TAG, "triggerUsage: accountId=$accountId, model=$model")
val requestBody = """{"model":"$model","input":[{"role":"user","content":[{"type":"input_text","text":"hello"}]}],"stream":true,"store":false}"""
.toRequestBody("application/json".toMediaType())
val request = buildPostRequest(RESPONSES_URL, effectiveCred.accessToken, requestBody, accountId)
try {
okHttpClient.newCall(request).execute().use { resp ->
val bodyStr = resp.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
if (!resp.isSuccessful) {
DebugLog.e(TAG, "triggerUsage failed: HTTP ${resp.code} body=${bodyStr.take(200)}")
if (resp.code in listOf(401, 403)) {
// token 过期,二次刷新重试
DebugLog.w(TAG, "triggerUsage: 401,尝试二次刷新")
when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
credentialRepository.save(r.cred)
val retryAccountId = extractAccountId(r.cred.accessToken)
val retryRequest = buildPostRequest(RESPONSES_URL, r.cred.accessToken, requestBody, retryAccountId)
okHttpClient.newCall(retryRequest).execute().use { resp2 ->
val body2 = resp2.body?.string() ?: ""
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
if (!resp2.isSuccessful) {
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
return@use Result.failure<String>(
TriggerError(
"HTTP ${resp2.code}",
body2.ifBlank { "无响应体" }
)
)
}
Result.success(parseSseResponse(body2, model))
}
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "triggerUsage: 二次刷新失败: ${r.reason}")
return@use Result.failure(TriggerError("token 刷新失败: ${r.reason}", ""))
}
}
} else {
return@use Result.failure<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
)
}
} else {
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
val formatted = parseSseResponse(bodyStr, model)
Result.success(formatted)
}
}
} catch (e: IOException) {
DebugLog.e(TAG, "triggerUsage 网络异常: ${e.message}")
Result.failure(RepositoryError.Network(e))
} catch (e: Throwable) {
DebugLog.e(TAG, "triggerUsage 异常: ${e::class.simpleName}: ${e.message}")
Result.failure(RepositoryError.Unknown(e))
}
}
/** 确保 access_token 有效,返回刷新后的凭据或 null(刷新失败时) */
private suspend fun ensureToken(credential: Credential.CodexCredential): Credential.CodexCredential? {
if (!tokenNeedsRefresh(credential)) return credential
DebugLog.i(TAG, "ensureToken: access_token 即将过期,尝试刷新")
return when (val r = refreshToken(credential)) {
is RefreshResult.Success -> {
credentialRepository.save(r.cred); r.cred
}
is RefreshResult.Failure -> {
DebugLog.e(TAG, "ensureToken: token 刷新失败: ${r.reason}")
null
}
}
}
/** 构建 responses POST 请求 */
private fun buildPostRequest(url: String, token: String, body: okhttp3.RequestBody, accountId: String? = null): Request =
Request.Builder().url(url)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("OpenAI-Beta", "codex-1")
.header("OAI-Language", "en")
.header("User-Agent", "codex-reset-tracker/0.1")
.header("originator", "codex-reset-tracker")
.header("Authorization", "Bearer $token")
.apply {
if (accountId != null) header("ChatGPT-Account-Id", accountId)
}
.post(body).build()
private fun tokenNeedsRefresh(cred: Credential.CodexCredential): Boolean =
System.currentTimeMillis() >= cred.expiresAt - REFRESH_BUFFER_MS
@ -223,4 +391,54 @@ class CodexRepository(
private fun durationLabel(seconds: Long?): String = when { seconds == null -> "Usage"; seconds / 60.0 >= 10079 -> "每周"; seconds / 60.0 >= 1439 -> "${(seconds / 86400).toInt()}d"; seconds / 60.0 >= 60 -> "${(seconds / 3600).toInt()}h"; else -> "${maxOf(1, (seconds / 60).toInt())}m" }
@Serializable data class OAuthRefreshResponse(@kotlinx.serialization.SerialName("access_token") val accessToken: String, @kotlinx.serialization.SerialName("refresh_token") val refreshToken: String? = null, @kotlinx.serialization.SerialName("expires_in") val expiresIn: Long = 3600, @kotlinx.serialization.SerialName("token_type") val tokenType: String? = null)
}
/** 携带完整响应体的错误类,供 UI 展示服务端返回的详细信息 */
class TriggerError(val summary: String, val responseBody: String) : Exception(summary)
/**
* 将 SSE 流响应解析为简洁的文本摘要。
* 提取:模型回复文本、用量统计(input/output tokens)。
*/
private fun parseSseResponse(sseText: String, model: String): String {
val sseJson = Json { ignoreUnknownKeys = true }
val outputText = StringBuilder()
var inputTokens: String? = null
var outputTokens: String? = null
var responseId: String? = null
for (line in sseText.lines()) {
if (!line.startsWith("data: ")) continue
val jsonStr = line.removePrefix("data: ").trim()
if (jsonStr == "[DONE]") continue
try {
val obj = sseJson.parseToJsonElement(jsonStr) as? JsonObject ?: continue
when (obj["type"]?.toString()?.trim('"')) {
"response.output_text.done" -> {
// 最终文本
(obj["text"] as? JsonPrimitive)?.contentOrNull?.let { outputText.clear(); outputText.append(it) }
}
"response.completed" -> {
val resp = obj["response"] as? JsonObject
responseId = (resp?.get("id") as? JsonPrimitive)?.contentOrNull
val usage = resp?.get("usage") as? JsonObject
inputTokens = (usage?.get("input_tokens") as? JsonPrimitive)?.contentOrNull
outputTokens = (usage?.get("output_tokens") as? JsonPrimitive)?.contentOrNull
}
}
} catch (_: Exception) { }
}
val sb = StringBuilder()
sb.appendLine("✓ 激活成功")
sb.appendLine("模型: $model")
if (responseId != null) sb.appendLine("Response ID: $responseId")
sb.appendLine()
sb.appendLine("回复:")
sb.appendLine(outputText.ifEmpty { "(空)" })
if (inputTokens != null || outputTokens != null) {
sb.appendLine()
sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens")
}
return sb.toString().trim()
}

View File

@ -37,4 +37,12 @@ class RefreshBalanceUseCase @Inject constructor(
ServiceType.CODEX -> retryOnTransientError { codexRepositoryProvider.get().fetchBalance() }
ServiceType.OLLAMA -> retryOnTransientError { ollamaRepositoryProvider.get().fetchBalance() }
}
/** 获取 Codex 可用模型列表 */
suspend fun fetchCodexModels(): Result<List<String>> =
codexRepositoryProvider.get().fetchModels()
/** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */
suspend fun triggerCodexUsage(model: String): Result<String> =
codexRepositoryProvider.get().triggerUsage(model)
}

View File

@ -10,32 +10,44 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@ -83,9 +95,20 @@ fun ServiceDetailScreen(
) {
LaunchedEffect(service) { viewModel.bind(service) }
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val triggerState by viewModel.triggerState.collectAsStateWithLifecycle()
val models by viewModel.models.collectAsStateWithLifecycle()
val selectedModel by viewModel.selectedModel.collectAsStateWithLifecycle()
val modelsLoading by viewModel.modelsLoading.collectAsStateWithLifecycle()
val config = ServiceConfigProvider.get(service)
val isManualMode = config.method == FetchMethod.MANUAL
// Codex 服务且有凭据时自动加载模型列表
LaunchedEffect(service, uiState.hasCredential) {
if (service == ServiceType.CODEX && uiState.hasCredential) {
viewModel.loadModels()
}
}
Scaffold(
containerColor = Color.Transparent,
topBar = {
@ -164,13 +187,40 @@ fun ServiceDetailScreen(
state = uiState.state,
hasCredential = uiState.hasCredential,
isManualMode = isManualMode,
service = service,
triggerState = triggerState,
models = models,
selectedModel = selectedModel,
modelsLoading = modelsLoading,
onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service) }
onStartWebViewLogin = { onStartWebViewLogin(service) },
onTriggerUsage = { viewModel.triggerCodexUsage() },
onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) }
)
}
}
}
// 响应弹窗(成功和失败都弹)
when (triggerState) {
is TriggerState.Success -> ResponseDialog(
title = "✓ 用量已激活",
responseBody = (triggerState as TriggerState.Success).responseBody,
isError = false,
onDismiss = { viewModel.dismissTrigger() }
)
is TriggerState.Error -> ResponseDialog(
title = "请求结果",
responseBody = (triggerState as TriggerState.Error).let {
"错误: ${it.message}" + (it.responseBody?.let { body -> "\n\n响应内容:\n$body" } ?: "")
},
isError = true,
onDismiss = { viewModel.dismissTrigger() }
)
else -> {}
}
}
/**
@ -823,11 +873,121 @@ private fun ActionButtons(
state: State,
hasCredential: Boolean,
isManualMode: Boolean,
service: ServiceType,
triggerState: TriggerState,
models: List<String>,
selectedModel: String?,
modelsLoading: Boolean,
onRefresh: () -> Unit,
onConfigureCredential: () -> Unit,
onStartWebViewLogin: () -> Unit
onStartWebViewLogin: () -> Unit,
onTriggerUsage: () -> Unit,
onSelectModel: (String) -> Unit,
onRefreshModels: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Codex 一键激活用量区域
if (service == ServiceType.CODEX && hasCredential && !isManualMode) {
// 模型选择器 + 刷新按钮
if (modelsLoading) {
Row(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
horizontalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.height(16.dp).width(16.dp),
color = StrawberryPink,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("加载模型列表...", style = MaterialTheme.typography.bodySmall, color = inkMuted())
}
} else if (models.isNotEmpty()) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "选择模型",
style = MaterialTheme.typography.labelMedium,
color = inkMuted()
)
Spacer(modifier = Modifier.weight(1f))
IconButton(
onClick = onRefreshModels,
modifier = Modifier.size(32.dp)
) {
Icon(
imageVector = Icons.Filled.Refresh,
contentDescription = "刷新模型列表",
tint = StrawberryPink,
modifier = Modifier.size(18.dp)
)
}
}
ModelDropdown(
models = models,
selectedModel = selectedModel,
onSelect = onSelectModel
)
Spacer(modifier = Modifier.height(4.dp))
}
// 激活按钮
when (triggerState) {
TriggerState.Loading -> {
Button(
onClick = {},
modifier = Modifier.fillMaxWidth(),
enabled = false,
colors = ButtonDefaults.buttonColors(
containerColor = StrawberryPink,
contentColor = Color.White
)
) {
CircularProgressIndicator(
modifier = Modifier.height(16.dp).width(16.dp),
color = Color.White,
strokeWidth = 2.dp
)
Spacer(modifier = Modifier.width(8.dp))
Text("激活中...")
}
}
is TriggerState.Error -> {
OutlinedButton(
onClick = onTriggerUsage,
modifier = Modifier.fillMaxWidth(),
enabled = state !is State.Loading && selectedModel != null,
colors = ButtonDefaults.outlinedButtonColors(
contentColor = MaterialTheme.colorScheme.error
)
) {
Text("重试激活用量")
}
Text(
text = triggerState.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(horizontal = 4.dp)
)
}
TriggerState.Idle -> {
OutlinedButton(
onClick = onTriggerUsage,
modifier = Modifier.fillMaxWidth(),
enabled = state !is State.Loading && selectedModel != null
) {
Text("⚡ 一键激活用量")
}
}
is TriggerState.Success -> {
// Success 状态由弹窗展示,按钮不显示
}
}
Spacer(modifier = Modifier.height(4.dp))
}
if (hasCredential && !isManualMode) {
Button(
onClick = onRefresh,
@ -882,4 +1042,102 @@ private fun stateToChip(state: State): StatusStyle = when (state) {
private fun formatTime(epochMillis: Long): String {
val sdf = SimpleDateFormat("MM-dd HH:mm", Locale.getDefault())
return sdf.format(Date(epochMillis))
}
// ── Codex 一键激活用量:模型选择器 ──
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ModelDropdown(
models: List<String>,
selectedModel: String?,
onSelect: (String) -> Unit
) {
var expanded by remember { androidx.compose.runtime.mutableStateOf(false) }
val display = selectedModel ?: "请选择模型"
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { expanded = !expanded },
modifier = Modifier.fillMaxWidth()
) {
OutlinedTextField(
value = display,
onValueChange = {},
readOnly = true,
modifier = Modifier.menuAnchor().fillMaxWidth(),
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium
)
ExposedDropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
models.forEach { model ->
DropdownMenuItem(
text = { Text(model) },
onClick = {
onSelect(model)
expanded = false
}
)
}
}
}
}
// ── Codex 一键激活用量:响应弹窗 ──
@Composable
private fun ResponseDialog(
title: String,
responseBody: String,
isError: Boolean,
onDismiss: () -> Unit
) {
val context = androidx.compose.ui.platform.LocalContext.current
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(title, fontWeight = FontWeight.Bold)
},
text = {
Column {
Text(
text = if (isError) "错误详情:" else "API 响应内容:",
style = MaterialTheme.typography.labelMedium,
color = if (isError) MaterialTheme.colorScheme.error else inkMuted()
)
Spacer(modifier = Modifier.height(8.dp))
// 可复制的响应文本
SelectionContainer {
Text(
text = responseBody.take(5000),
style = MaterialTheme.typography.bodySmall,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurface,
modifier = Modifier
.fillMaxWidth()
.verticalScroll(rememberScrollState())
)
}
}
},
confirmButton = {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
TextButton(onClick = {
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
clipboard.setPrimaryClip(android.content.ClipData.newPlainText("API Response", responseBody))
}) {
Text("复制")
}
Button(
onClick = onDismiss,
colors = ButtonDefaults.buttonColors(
containerColor = StrawberryPink,
contentColor = Color.White
)
) {
Text("关闭")
}
}
}
)
}

View File

@ -4,8 +4,10 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.cache.CachedBalance
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.data.repository.CredentialRepository
import com.rainy.token.data.repository.RepositoryError
import com.rainy.token.data.repository.TriggerError
import com.rainy.token.domain.model.CredentialStatus
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
@ -34,9 +36,38 @@ class ServiceDetailViewModel @Inject constructor(
private val _uiState = MutableStateFlow(ServiceDetailUiState())
val uiState: StateFlow<ServiceDetailUiState> = _uiState.asStateFlow()
/** Codex 一键激活用量状态 */
private val _triggerState = MutableStateFlow<TriggerState>(TriggerState.Idle)
val triggerState: StateFlow<TriggerState> = _triggerState.asStateFlow()
/** Codex 可用模型列表 */
private val _models = MutableStateFlow<List<String>>(emptyList())
val models: StateFlow<List<String>> = _models.asStateFlow()
/** 用户选中的模型(持久化) */
private val _selectedModel = MutableStateFlow<String?>(null)
val selectedModel: StateFlow<String?> = _selectedModel.asStateFlow()
/** 模型列表加载状态 */
private val _modelsLoading = MutableStateFlow(false)
val modelsLoading: StateFlow<Boolean> = _modelsLoading.asStateFlow()
companion object {
private const val CODEX_PREFS = "codex_trigger_prefs"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val KEY_MODELS_CACHE = "models_cache"
}
fun bind(service: ServiceType) {
if (_serviceType.value == service) return
_serviceType.value = service
// 恢复 Codex 持久化状态
if (service == ServiceType.CODEX) {
val savedModel = loadSelectedModel()
if (savedModel != null) _selectedModel.value = savedModel
val cachedModels = loadModelsCache()
if (cachedModels.isNotEmpty()) _models.value = cachedModels
}
loadFromCache()
}
@ -79,6 +110,100 @@ class ServiceDetailViewModel @Inject constructor(
}
}
/**
* 加载 Codex 可用模型列表(优先从缓存读取,首次才请求网络)。
* @param force true 时强制从网络刷新
*/
fun loadModels(force: Boolean = false) {
if (!force && _models.value.isNotEmpty()) return
viewModelScope.launch {
_modelsLoading.value = true
val result = refreshBalanceUseCase.fetchCodexModels()
result
.onSuccess { list ->
_models.value = list
// 持久化模型列表缓存
persistModelsCache(list)
// 如果之前选中的模型不在新列表里,选第一个
val current = _selectedModel.value
if (current == null || !list.contains(current)) {
_selectedModel.value = list.firstOrNull()
persistSelectedModel(list.firstOrNull())
}
}
.onFailure { error ->
DebugLog.e("Codex", "加载模型列表失败: ${error.message}")
// 网络失败时从缓存读
if (_models.value.isEmpty()) {
val cached = loadModelsCache()
if (cached.isNotEmpty()) {
_models.value = cached
if (_selectedModel.value == null) {
_selectedModel.value = cached.firstOrNull()
}
}
}
}
_modelsLoading.value = false
}
}
/** 用户选择模型(持久化) */
fun selectModel(model: String) {
_selectedModel.value = model
persistSelectedModel(model)
}
/**
* 一键激活 Codex 用量:发送 API 请求触发用量统计。
* 无论成功还是失败,都弹出窗口展示完整响应(或错误信息)。
*/
fun triggerCodexUsage() {
val model = _selectedModel.value ?: run {
_triggerState.value = TriggerState.Error("请先选择模型", null)
return
}
viewModelScope.launch {
_triggerState.value = TriggerState.Loading
val result = refreshBalanceUseCase.triggerCodexUsage(model)
result
.onSuccess { responseBody ->
_triggerState.value = TriggerState.Success(responseBody)
// 等待 2 秒让服务端处理用量,再刷新余额
kotlinx.coroutines.delay(2000)
refresh()
}
.onFailure { error ->
val msg: String
val respBody: String?
when (error) {
is TriggerError -> {
msg = error.summary
respBody = error.responseBody.ifBlank { null }
}
is RepositoryError.InvalidCredential -> {
msg = error.message ?: "凭据无效,请重新登录"
respBody = null
}
is RepositoryError.Network -> {
msg = "网络异常"
respBody = null
}
else -> {
msg = error.message ?: "未知错误"
respBody = null
}
}
_triggerState.value = TriggerState.Error(msg, respBody)
}
}
}
/** 关闭响应弹窗,回到 Idle */
fun dismissTrigger() {
_triggerState.value = TriggerState.Idle
}
/**
* 手动输入模式:保存用户填的余额值。
*/
@ -149,6 +274,24 @@ class ServiceDetailViewModel @Inject constructor(
is RepositoryError.ParseError -> "数据解析失败: ${error.message}"
else -> error.message ?: "未知错误"
}
// ── Codex 模型持久化 ──
private fun prefs(): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(CODEX_PREFS, android.content.Context.MODE_PRIVATE)
private fun persistSelectedModel(model: String?) {
prefs().edit().putString(KEY_SELECTED_MODEL, model).apply()
}
private fun loadSelectedModel(): String? = prefs().getString(KEY_SELECTED_MODEL, null)
private fun persistModelsCache(models: List<String>) {
prefs().edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply()
}
private fun loadModelsCache(): List<String> =
prefs().getString(KEY_MODELS_CACHE, "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
}
/**
@ -171,4 +314,12 @@ data class ServiceDetailUiState(
val state: State = State.Loading,
val hasCredential: Boolean = false,
val cached: CachedBalance? = null
)
)
/** Codex 一键激活用量状态 */
sealed class TriggerState {
data object Idle : TriggerState()
data object Loading : TriggerState()
data class Success(val responseBody: String) : TriggerState()
data class Error(val message: String, val responseBody: String?) : TriggerState()
}