feat: OCGO & Ollama 一键激活用量 + 设置页 API Key 输入

- OpenCodeGoRepository: 新增 fetchModels() + triggerUsage(),调 opencode.ai/zen/v1/chat/completions
- OllamaRepository: 新增 fetchModels() + triggerUsage(),调 ollama.com/v1/chat/completions
- SessionCredential: 新增 apiKey 字段,用于一键激活用量
- RefreshBalanceUseCase: 新增 fetchOpenCodeGoModels/triggerOpenCodeGoUsage/fetchOllamaModels/triggerOllamaUsage
- ServiceDetailViewModel: 通用化 triggerUsage(),支持 Codex/OCGO/Ollama 三服务,各服务独立 SharedPreferences 持久化
- ServiceDetailScreen: 三服务均显示模型选择器 + 激活按钮 + 响应弹窗
- CredentialEditScreen/ViewModel: OCGO & Ollama 凭据表单新增 API Key 输入区域
- AGENTS.md & README.md: 同步新功能描述
This commit is contained in:
WaterRain 2026-07-20 09:46:39 +00:00
parent 79edb053cc
commit 29877c00ce
No known key found for this signature in database
10 changed files with 451 additions and 42 deletions

View File

@ -21,10 +21,10 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
**服务**:
- ✅ DeepSeek — REST API `GET /user/balance`,API Key 认证
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`
- ✅ OpenCode Go — OkHttp 抓 dashboard HTML,解析 `rollingUsage`/`weeklyUsage`/`monthlyUsage`。**一键激活用量**:OCGO 详情页可向 `opencode.ai/zen/v1/chat/completions` 发送简短请求(`content:hello`)触发用量统计,API Key 从设置页手动填写(存 `SessionCredential.apiKey`),模型列表从 `models.dev/api.json`(provider=`opencode`)动态获取,用户选择持久化到 `SharedPreferences("ocgo_trigger_prefs")`,响应经 `parseChatResponse()` 提取回复文本+用量统计后弹窗展示。
- ✅ 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.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)
- ✅ Ollama Pro — Cookie 认证,OkHttp 抓 `ollama.com/settings` HTML,正则解析 plan/session(5h)/weekly 百分比 + `data-time` 重置时间 + `data-model` 模型级请求次数;无官方 API(ollama/ollama#12532)。**一键激活用量**:Ollama 详情页可向 `ollama.com/v1/chat/completions` 发送简短请求(`content:hello`)触发用量统计,API Key 从设置页手动填写(存 `SessionCredential.apiKey`),模型列表从 `models.dev/api.json`(provider=`ollama-cloud`)动态获取,用户选择持久化到 `SharedPreferences("ollama_trigger_prefs")`,响应经 `parseOllamaChatResponse()` 提取回复文本+用量统计后弹窗展示。
- ✅ 文案统一:所有服务标签均使用中文("每周"统一代替 "weekly"/"Weekly"/"weekly")
- ❌ OpenCode Zen / 小米 MiMo — 未实现
@ -50,7 +50,7 @@ CommandCode Go 走 JSON API 抓取用量数据,Codex / ChatGPT Plus 通过 aut
**凭据回显红线**:
> ⚠️ `CredentialEditViewModel.load()` 首次加载已有凭据时,需针对每种 `Credential` 子类显式编写回显分支。
> 当前覆盖:`ApiKeyCredential`(API Key 输入框)、`SessionCredential`(Cookie 输入框 / `ollamaCookie` 字段)、`CodexCredential`(auth.json 输入框)。
> 当前覆盖:`ApiKeyCredential`(API Key 输入框)、`SessionCredential`(Cookie 输入框 / `ollamaCookie` 字段 / `apiKey` 字段 → `triggerApiKey`)、`CodexCredential`(auth.json 输入框)。
> 新增凭据类型(如 OpenCode Zen / MiMo)时必须同步添加对应的 `load()` 回显分支,否则用户保存后看不到已存内容。
**ViewModel 加载机制红线**:

View File

@ -53,6 +53,7 @@ Android AI 余额与用量查询 APP —— 统一查看 DeepSeek、OpenCode Go
| 🎀 **雨晴粉主题** | Material Design 3 · 草莓粉 #FF85A2 · 樱粉 #FFD1DC |
| 🔐 **Codex OAuth 登录** | 无头模式 OAuth PKCE:APP 生成授权链接 → 外部浏览器登录 → 粘贴回调 URL 完成授权,无需手动导出 auth.json |
| ⚡ **Codex 一键激活用量** | Codex 详情页可向 ChatGPT API 发送简短请求触发用量统计;模型列表从 models.dev 动态获取并持久化,支持手动刷新;响应弹窗可复制 |
| ⚡ **OCGO / Ollama 一键激活用量** | OCGO 详情页可向 `opencode.ai/zen/v1` 发送请求,Ollama 详情页可向 `ollama.com/v1` 发送请求触发用量统计;API Key 在设置页手动填写,模型列表从 models.dev 动态获取 |
| 🐛 **调试日志** | APP 内「调试日志」页面,查看 Repository 网络请求、Token 刷新、解析错误等详细日志,无需连接电脑 |
---

View File

@ -1,13 +1,20 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.text.SimpleDateFormat
import java.util.Locale
@ -36,8 +43,13 @@ class OllamaRepository(
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
companion object {
private const val TAG = "Ollama"
private const val SETTINGS_URL = "https://ollama.com/settings"
private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://ollama.com/v1/chat/completions"
private const val USER_AGENT =
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36"
@ -213,4 +225,123 @@ class OllamaRepository(
}
}
/**
* 从 models.dev/api.json 获取 Ollama Cloud 可用模型列表(provider key = "ollama-cloud")。
* 该 API 不需要认证。
*/
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "rainy-token/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 对象")
val provider = root["ollama-cloud"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError("未找到 Ollama Cloud 模型列表")
}
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))
}
}
/**
* 一键激活用量:用 API Key 向 Ollama Cloud chat completions API 发送简短请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OLLAMA)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
if (credential !is Credential.SessionCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val apiKey = credential.apiKey
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
}
DebugLog.i(TAG, "triggerUsage: model=$model")
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(CHAT_API)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.header("User-Agent", "rainy-token/0.1")
.post(requestBody).build()
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) {
return@withContext Result.failure<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
Result.success(parseOllamaChatResponse(bodyStr, model))
}
} 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))
}
}
}
/**
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
*/
internal fun parseOllamaChatResponse(responseBody: String, model: String): String {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
val firstChoice = choices?.firstOrNull() as? JsonObject
val message = firstChoice?.get("message") as? JsonObject
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
val usage = root?.get("usage") as? JsonObject
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
val sb = StringBuilder()
sb.appendLine("✓ 激活成功")
sb.appendLine("模型: $model")
sb.appendLine()
sb.appendLine("回复:")
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
if (promptTokens != null || completionTokens != null) {
sb.appendLine()
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
}
sb.toString().trim()
} catch (_: Exception) {
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
}
}

View File

@ -1,14 +1,23 @@
package com.rainy.token.data.repository
import com.rainy.token.data.cache.BalanceCache
import com.rainy.token.data.debug.DebugLog
import com.rainy.token.domain.model.Credential
import com.rainy.token.domain.model.ServiceBalance
import com.rainy.token.domain.service.ServiceConfigProvider
import com.rainy.token.domain.service.ServiceType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import java.io.IOException
import java.net.URLEncoder
import javax.inject.Singleton
@ -33,6 +42,8 @@ class OpenCodeGoRepository(
private val balanceCache: BalanceCache
) {
private val json = Json { ignoreUnknownKeys = true }
suspend fun fetchBalance(): Result<ServiceBalance> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential())
@ -124,7 +135,97 @@ class OpenCodeGoRepository(
}
}
/**
* 从 models.dev/api.json 获取 OpenCode 可用模型列表(provider key = "opencode")。
* 该 API 不需要认证。
*/
suspend fun fetchModels(): Result<List<String>> = withContext(Dispatchers.IO) {
try {
val request = Request.Builder().url(MODELS_API)
.header("Accept", "application/json")
.header("User-Agent", "rainy-token/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 对象")
val provider = root["opencode"] as? JsonObject
val modelsObj = provider?.get("models") as? JsonObject
modelsObj?.keys?.toList()?.sorted()
?: throw RepositoryError.ParseError("未找到 OpenCode 模型列表")
}
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))
}
}
/**
* 一键激活用量:用 API Key 向 OpenCode chat completions API 发送简短请求。
* @param model 用户选择的模型 slug
* @return Result.success(响应摘要文本) — 供 UI 展示
*/
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
if (credential !is Credential.SessionCredential)
return@withContext Result.failure(RepositoryError.InvalidCredential("凭据类型不匹配"))
val apiKey = credential.apiKey
if (apiKey.isNullOrBlank()) {
return@withContext Result.failure(RepositoryError.InvalidCredential("未配置 API Key,请在设置中填写"))
}
DebugLog.i(TAG, "triggerUsage: model=$model")
val requestBody = """{"model":"$model","messages":[{"role":"user","content":"hello"}],"max_tokens":50}"""
.toRequestBody("application/json".toMediaType())
val request = Request.Builder().url(CHAT_API)
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.header("Authorization", "Bearer $apiKey")
.header("User-Agent", "rainy-token/0.1")
.post(requestBody).build()
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) {
return@withContext Result.failure<String>(
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
)
}
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
Result.success(parseChatResponse(bodyStr, model))
}
} 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))
}
}
companion object {
private const val TAG = "OCGO"
private const val MODELS_API = "https://models.dev/api.json"
private const val CHAT_API = "https://opencode.ai/zen/v1/chat/completions"
private val SCRAPED_FIELDS = listOf("rollingUsage", "weeklyUsage", "monthlyUsage")
/**
@ -213,3 +314,35 @@ class OpenCodeGoRepository(
internal data class ScrapedWindow(val usagePercent: Int, val resetInSec: Long)
}
}
/**
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
*/
internal fun parseChatResponse(responseBody: String, model: String): String {
val json = Json { ignoreUnknownKeys = true }
return try {
val root = json.parseToJsonElement(responseBody) as? JsonObject
val choices = root?.get("choices") as? kotlinx.serialization.json.JsonArray
val firstChoice = choices?.firstOrNull() as? JsonObject
val message = firstChoice?.get("message") as? JsonObject
val content = (message?.get("content") as? JsonPrimitive)?.contentOrNull
val usage = root?.get("usage") as? JsonObject
val promptTokens = (usage?.get("prompt_tokens") as? JsonPrimitive)?.contentOrNull
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
val sb = StringBuilder()
sb.appendLine("✓ 激活成功")
sb.appendLine("模型: $model")
sb.appendLine()
sb.appendLine("回复:")
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
if (promptTokens != null || completionTokens != null) {
sb.appendLine()
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
}
sb.toString().trim()
} catch (_: Exception) {
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
}
}

View File

@ -43,6 +43,8 @@ sealed class Credential {
val workspaceId: String? = null,
/** Ollama Pro 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */
val ollamaCookie: String? = null,
/** OpenCode Go / Ollama Pro 专用:API Key,用于一键激活用量 */
val apiKey: String? = null,
override val lastVerifiedAt: Long = 0L
) : Credential()

View File

@ -45,4 +45,20 @@ class RefreshBalanceUseCase @Inject constructor(
/** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */
suspend fun triggerCodexUsage(model: String): Result<String> =
codexRepositoryProvider.get().triggerUsage(model)
/** 获取 OpenCode Go 可用模型列表 */
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
openCodeGoRepositoryProvider.get().fetchModels()
/** 一键激活 OpenCode Go 用量 */
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
openCodeGoRepositoryProvider.get().triggerUsage(model)
/** 获取 Ollama Cloud 可用模型列表 */
suspend fun fetchOllamaModels(): Result<List<String>> =
ollamaRepositoryProvider.get().fetchModels()
/** 一键激活 Ollama Cloud 用量 */
suspend fun triggerOllamaUsage(model: String): Result<String> =
ollamaRepositoryProvider.get().triggerUsage(model)
}

View File

@ -102,9 +102,10 @@ fun ServiceDetailScreen(
val config = ServiceConfigProvider.get(service)
val isManualMode = config.method == FetchMethod.MANUAL
// Codex 服务且有凭据时自动加载模型列表
// Codex / OCGO / Ollama 服务且有凭据时自动加载模型列表
val supportsTrigger = service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA
LaunchedEffect(service, uiState.hasCredential) {
if (service == ServiceType.CODEX && uiState.hasCredential) {
if (supportsTrigger && uiState.hasCredential) {
viewModel.loadModels()
}
}
@ -195,7 +196,7 @@ fun ServiceDetailScreen(
onRefresh = { viewModel.refresh() },
onConfigureCredential = { onConfigureCredential(service) },
onStartWebViewLogin = { onStartWebViewLogin(service) },
onTriggerUsage = { viewModel.triggerCodexUsage() },
onTriggerUsage = { viewModel.triggerUsage() },
onSelectModel = { viewModel.selectModel(it) },
onRefreshModels = { viewModel.loadModels(force = true) }
)
@ -886,8 +887,9 @@ private fun ActionButtons(
onRefreshModels: () -> Unit
) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
// Codex 一键激活用量区域
if (service == ServiceType.CODEX && hasCredential && !isManualMode) {
// 一键激活用量区域(Codex / OCGO / Ollama)
val supportsTrigger = service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA
if (supportsTrigger && hasCredential && !isManualMode) {
// 模型选择器 + 刷新按钮
if (modelsLoading) {
Row(

View File

@ -40,7 +40,7 @@ class ServiceDetailViewModel @Inject constructor(
private val _triggerState = MutableStateFlow<TriggerState>(TriggerState.Idle)
val triggerState: StateFlow<TriggerState> = _triggerState.asStateFlow()
/** Codex 可用模型列表 */
/** 可用模型列表(Codex / OCGO / Ollama 共用) */
private val _models = MutableStateFlow<List<String>>(emptyList())
val models: StateFlow<List<String>> = _models.asStateFlow()
@ -56,16 +56,25 @@ class ServiceDetailViewModel @Inject constructor(
private const val CODEX_PREFS = "codex_trigger_prefs"
private const val KEY_SELECTED_MODEL = "selected_model"
private const val KEY_MODELS_CACHE = "models_cache"
private const val OCGO_PREFS = "ocgo_trigger_prefs"
private const val OLLAMA_PREFS = "ollama_trigger_prefs"
}
private fun prefsNameFor(service: ServiceType): String = when (service) {
ServiceType.CODEX -> CODEX_PREFS
ServiceType.OPENCODE_GO -> OCGO_PREFS
ServiceType.OLLAMA -> OLLAMA_PREFS
else -> CODEX_PREFS
}
fun bind(service: ServiceType) {
if (_serviceType.value == service) return
_serviceType.value = service
// 恢复 Codex 持久化状态
if (service == ServiceType.CODEX) {
val savedModel = loadSelectedModel()
// 恢复持久化状态
if (service == ServiceType.CODEX || service == ServiceType.OPENCODE_GO || service == ServiceType.OLLAMA) {
val savedModel = loadSelectedModel(service)
if (savedModel != null) _selectedModel.value = savedModel
val cachedModels = loadModelsCache()
val cachedModels = loadModelsCache(service)
if (cachedModels.isNotEmpty()) _models.value = cachedModels
}
loadFromCache()
@ -111,31 +120,34 @@ class ServiceDetailViewModel @Inject constructor(
}
/**
* 加载 Codex 可用模型列表(优先从缓存读取,首次才请求网络)。
* 加载可用模型列表(优先从缓存读取,首次才请求网络)。
* @param force true 时强制从网络刷新
*/
fun loadModels(force: Boolean = false) {
val service = _serviceType.value ?: return
if (!force && _models.value.isNotEmpty()) return
viewModelScope.launch {
_modelsLoading.value = true
val result = refreshBalanceUseCase.fetchCodexModels()
val result = when (service) {
ServiceType.CODEX -> refreshBalanceUseCase.fetchCodexModels()
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.fetchOpenCodeGoModels()
ServiceType.OLLAMA -> refreshBalanceUseCase.fetchOllamaModels()
else -> Result.failure(RepositoryError.Unknown(IllegalArgumentException("不支持模型列表")))
}
result
.onSuccess { list ->
_models.value = list
// 持久化模型列表缓存
persistModelsCache(list)
// 如果之前选中的模型不在新列表里,选第一个
persistModelsCache(service, list)
val current = _selectedModel.value
if (current == null || !list.contains(current)) {
_selectedModel.value = list.firstOrNull()
persistSelectedModel(list.firstOrNull())
persistSelectedModel(service, list.firstOrNull())
}
}
.onFailure { error ->
DebugLog.e("Codex", "加载模型列表失败: ${error.message}")
// 网络失败时从缓存读
DebugLog.e(service.displayName, "加载模型列表失败: ${error.message}")
if (_models.value.isEmpty()) {
val cached = loadModelsCache()
val cached = loadModelsCache(service)
if (cached.isNotEmpty()) {
_models.value = cached
if (_selectedModel.value == null) {
@ -151,21 +163,27 @@ class ServiceDetailViewModel @Inject constructor(
/** 用户选择模型(持久化) */
fun selectModel(model: String) {
_selectedModel.value = model
persistSelectedModel(model)
_serviceType.value?.let { persistSelectedModel(it, model) }
}
/**
* 一键激活 Codex 用量:发送 API 请求触发用量统计。
* 无论成功还是失败,都弹出窗口展示完整响应(或错误信息)。
* 一键激活用量:发送 API 请求触发用量统计。
* 支持 Codex / OpenCode Go / Ollama 三个服务。
*/
fun triggerCodexUsage() {
fun triggerUsage() {
val service = _serviceType.value ?: return
val model = _selectedModel.value ?: run {
_triggerState.value = TriggerState.Error("请先选择模型", null)
return
}
viewModelScope.launch {
_triggerState.value = TriggerState.Loading
val result = refreshBalanceUseCase.triggerCodexUsage(model)
val result = when (service) {
ServiceType.CODEX -> refreshBalanceUseCase.triggerCodexUsage(model)
ServiceType.OPENCODE_GO -> refreshBalanceUseCase.triggerOpenCodeGoUsage(model)
ServiceType.OLLAMA -> refreshBalanceUseCase.triggerOllamaUsage(model)
else -> Result.failure(RepositoryError.Unknown(IllegalArgumentException("不支持激活用量")))
}
result
.onSuccess { responseBody ->
_triggerState.value = TriggerState.Success(responseBody)
@ -275,23 +293,23 @@ class ServiceDetailViewModel @Inject constructor(
else -> error.message ?: "未知错误"
}
// ── Codex 模型持久化 ──
// ── 模型持久化(Codex / OCGO / Ollama 各自独立 prefs) ──
private fun prefs(): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(CODEX_PREFS, android.content.Context.MODE_PRIVATE)
private fun prefs(service: ServiceType): android.content.SharedPreferences =
com.rainy.token.RainyTokenApplication.appContext.getSharedPreferences(prefsNameFor(service), android.content.Context.MODE_PRIVATE)
private fun persistSelectedModel(model: String?) {
prefs().edit().putString(KEY_SELECTED_MODEL, model).apply()
private fun persistSelectedModel(service: ServiceType, model: String?) {
prefs(service).edit().putString(KEY_SELECTED_MODEL, model).apply()
}
private fun loadSelectedModel(): String? = prefs().getString(KEY_SELECTED_MODEL, null)
private fun loadSelectedModel(service: ServiceType): String? = prefs(service).getString(KEY_SELECTED_MODEL, null)
private fun persistModelsCache(models: List<String>) {
prefs().edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply()
private fun persistModelsCache(service: ServiceType, models: List<String>) {
prefs(service).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()
private fun loadModelsCache(service: ServiceType): List<String> =
prefs(service).getString(KEY_MODELS_CACHE, "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList()
}
/**

View File

@ -149,11 +149,14 @@ fun CredentialEditScreen(
cookie = uiState.ollamaCookie,
loginUrl = uiState.loginUrl,
hasExisting = uiState.hasExisting,
triggerApiKey = uiState.triggerApiKey,
onCookieChange = viewModel::updateOllamaCookie,
onSave = { viewModel.saveOllamaCredential() },
onTestAndSave = { viewModel.testAndSaveOllama() },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) }
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }
)
} else if (service == ServiceType.OPENCODE_GO) {
OpenCodeGoForm(
@ -161,6 +164,7 @@ fun CredentialEditScreen(
workspaceId = uiState.workspaceId,
loginUrl = uiState.loginUrl,
hasExisting = uiState.hasExisting,
triggerApiKey = uiState.triggerApiKey,
onAuthCookieChange = viewModel::updateAuthCookie,
onWorkspaceIdChange = viewModel::updateWorkspaceId,
onSave = { viewModel.saveOpenCodeGoSession() },
@ -168,7 +172,9 @@ fun CredentialEditScreen(
onImportFromClipboard = { viewModel.importFromClipboard(context) },
onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) },
onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) },
onShowHelp = { showGoHelp = true }
onShowHelp = { showGoHelp = true },
onApiKeyChange = viewModel::updateTriggerApiKey,
onSaveApiKey = { viewModel.saveTriggerApiKey() }
)
} else {
// 通用 WebView 抓取已移除(半成品功能,凭据无法正确映射到 Repository 字段)
@ -311,11 +317,14 @@ private fun OllamaCookieForm(
cookie: String,
loginUrl: String,
hasExisting: Boolean,
triggerApiKey: String,
onCookieChange: (String) -> Unit,
onSave: () -> Unit,
onTestAndSave: () -> Unit,
onCopyLoginUrl: () -> Unit,
onOpenLoginUrl: () -> Unit
onOpenLoginUrl: () -> Unit,
onApiKeyChange: (String) -> Unit,
onSaveApiKey: () -> Unit
) {
Text(text = "Ollama Pro 凭据", style = MaterialTheme.typography.titleMedium)
Text(
@ -361,6 +370,35 @@ private fun OllamaCookieForm(
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
Text(text = "复制登录 URL 到剪贴板")
}
// ── 一键激活用量 API Key ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "一键激活用量 API Key(可选)",
style = MaterialTheme.typography.titleSmall
)
Text(
text = "填入 Ollama Cloud API Key 后,可在详情页一键发送请求激活用量统计。从 ollama.com/settings → API Keys 获取。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = triggerApiKey,
onValueChange = onApiKeyChange,
label = { Text("API Key") },
placeholder = { Text("ollama-xxx 或 sk-xxx") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onSaveApiKey,
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
Text("保存 API Key")
}
}
@Composable
@ -369,6 +407,7 @@ private fun OpenCodeGoForm(
workspaceId: String,
loginUrl: String,
hasExisting: Boolean,
triggerApiKey: String,
onAuthCookieChange: (String) -> Unit,
onWorkspaceIdChange: (String) -> Unit,
onSave: () -> Unit,
@ -376,7 +415,9 @@ private fun OpenCodeGoForm(
onImportFromClipboard: () -> Unit,
onCopyLoginUrl: () -> Unit,
onOpenLoginUrl: () -> Unit,
onShowHelp: () -> Unit
onShowHelp: () -> Unit,
onApiKeyChange: (String) -> Unit,
onSaveApiKey: () -> Unit
) {
Text(text = "OpenCode Go 凭据", style = MaterialTheme.typography.titleMedium)
Text(
@ -431,6 +472,35 @@ private fun OpenCodeGoForm(
TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) {
Text(text = "复制登录 URL 到剪贴板")
}
// ── 一键激活用量 API Key ──
androidx.compose.material3.HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp))
Text(
text = "一键激活用量 API Key(可选)",
style = MaterialTheme.typography.titleSmall
)
Text(
text = "填入 OpenCode Zen API Key 后,可在详情页一键发送请求激活用量统计。从 opencode.ai/settings → API Keys 获取。",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
OutlinedTextField(
value = triggerApiKey,
onValueChange = onApiKeyChange,
label = { Text("API Key") },
placeholder = { Text("opencode-xxx") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
modifier = Modifier.fillMaxWidth()
)
OutlinedButton(
onClick = onSaveApiKey,
modifier = Modifier.fillMaxWidth(),
enabled = triggerApiKey.isNotBlank()
) {
Text("保存 API Key")
}
}
@Composable

View File

@ -96,6 +96,7 @@ class CredentialEditViewModel @Inject constructor(
}
} else "",
ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(),
triggerApiKey = (existing as? Credential.SessionCredential)?.apiKey.orEmpty(),
hasExisting = existing != null
)
}
@ -430,6 +431,38 @@ class CredentialEditViewModel @Inject constructor(
_uiState.update { it.copy(ollamaCookie = value) }
}
fun updateTriggerApiKey(value: String) {
_uiState.update { it.copy(triggerApiKey = value) }
}
/**
* 保存触发用量 API Key(OCGO / Ollama 共用)。
* 合并保存到已有的 SessionCredential 中。
*/
fun saveTriggerApiKey() {
val type = serviceType ?: return
val current = _uiState.value
val trimmedKey = current.triggerApiKey.trim()
viewModelScope.launch {
val existing = credentialRepository.get(type) as? Credential.SessionCredential
val updated = (existing ?: Credential.SessionCredential(
service = type,
cookies = emptyList()
)).copy(
apiKey = trimmedKey.ifBlank { null },
lastVerifiedAt = System.currentTimeMillis()
)
credentialRepository.save(updated)
_uiState.update {
it.copy(
triggerApiKey = trimmedKey,
message = "API Key 已保存",
hasExisting = true
)
}
}
}
/**
* 保存 Ollama Pro 的 Cookie 字符串。
*/
@ -494,7 +527,8 @@ class CredentialEditViewModel @Inject constructor(
authCookie = "",
workspaceId = "",
cookieCount = 0,
ollamaCookie = ""
ollamaCookie = "",
triggerApiKey = ""
)
}
}
@ -565,5 +599,7 @@ data class CredentialEditUiState(
val hasExisting: Boolean = false,
val codexAuthJson: String = "",
val ollamaCookie: String = "",
/** OCGO / Ollama 的一键激活用量 API Key */
val triggerApiKey: String = "",
val message: String? = null
)