feat: Codex 一键激活用量功能 — 模型列表从 models.dev 动态获取,SSE 响应解析弹窗展示
Some checks failed
Release / build (push) Has been cancelled
Some checks failed
Release / build (push) Has been cancelled
This commit is contained in:
parent
f17d37ae39
commit
b4c3402d22
@ -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 — 未实现
|
||||
|
||||
@ -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 刷新、解析错误等详细日志,无需连接电脑 |
|
||||
|
||||
---
|
||||
|
||||
@ -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()
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
@ -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("关闭")
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
@ -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()
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user