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:
parent
79edb053cc
commit
29877c00ce
@ -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 加载机制红线**:
|
||||
|
||||
@ -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 刷新、解析错误等详细日志,无需连接电脑 |
|
||||
|
||||
---
|
||||
|
||||
@ -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(响应解析失败,但请求已发出)"
|
||||
}
|
||||
}
|
||||
@ -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")
|
||||
|
||||
/**
|
||||
@ -212,4 +313,36 @@ 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(响应解析失败,但请求已发出)"
|
||||
}
|
||||
}
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -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()
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
)
|
||||
Loading…
Reference in New Issue
Block a user