diff --git a/AGENTS.md b/AGENTS.md index be672cd..24cfcdc 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。**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 — 未实现 diff --git a/README.md b/README.md index 0c5f384..679c320 100644 --- a/README.md +++ b/README.md @@ -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 刷新、解析错误等详细日志,无需连接电脑 | --- 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 3a7aa39..a1b42cb 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 @@ -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> = 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 = 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( + 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( + 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() } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt index e558678..af6b743 100644 --- a/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt +++ b/app/src/main/java/com/rainy/token/domain/usecase/RefreshBalanceUseCase.kt @@ -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> = + codexRepositoryProvider.get().fetchModels() + + /** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */ + suspend fun triggerCodexUsage(model: String): Result = + codexRepositoryProvider.get().triggerUsage(model) } \ No newline at end of file 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 e80f9e4..bf15b44 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 @@ -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, + 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, + 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("关闭") + } + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt index ba2b37b..146a3c9 100644 --- a/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/servicedetail/ServiceDetailViewModel.kt @@ -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 = _uiState.asStateFlow() + /** Codex 一键激活用量状态 */ + private val _triggerState = MutableStateFlow(TriggerState.Idle) + val triggerState: StateFlow = _triggerState.asStateFlow() + + /** Codex 可用模型列表 */ + private val _models = MutableStateFlow>(emptyList()) + val models: StateFlow> = _models.asStateFlow() + + /** 用户选中的模型(持久化) */ + private val _selectedModel = MutableStateFlow(null) + val selectedModel: StateFlow = _selectedModel.asStateFlow() + + /** 模型列表加载状态 */ + private val _modelsLoading = MutableStateFlow(false) + val modelsLoading: StateFlow = _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) { + prefs().edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply() + } + + private fun loadModelsCache(): List = + 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 -) \ No newline at end of file +) + +/** 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() +} \ No newline at end of file