From 29877c00ceb779677166ba333697a82be18de08b Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:46:39 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20OCGO=20&=20Ollama=20=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E6=BF=80=E6=B4=BB=E7=94=A8=E9=87=8F=20+=20=E8=AE=BE=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=20API=20Key=20=E8=BE=93=E5=85=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: 同步新功能描述 --- AGENTS.md | 6 +- README.md | 1 + .../token/data/repository/OllamaRepository.kt | 131 +++++++++++++++++ .../data/repository/OpenCodeGoRepository.kt | 133 ++++++++++++++++++ .../rainy/token/domain/model/Credential.kt | 2 + .../domain/usecase/RefreshBalanceUseCase.kt | 16 +++ .../ui/servicedetail/ServiceDetailScreen.kt | 12 +- .../servicedetail/ServiceDetailViewModel.kt | 76 ++++++---- .../token/ui/settings/CredentialEditScreen.kt | 78 +++++++++- .../ui/settings/CredentialEditViewModel.kt | 38 ++++- 10 files changed, 451 insertions(+), 42 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b54bf67..cccc396 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 加载机制红线**: diff --git a/README.md b/README.md index c7bb517..bd23c2b 100644 --- a/README.md +++ b/README.md @@ -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 刷新、解析错误等详细日志,无需连接电脑 | --- diff --git a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt index 8abad0e..0cd1a1b 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -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> = 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 = 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( + 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(响应解析失败,但请求已发出)" + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt index 4f62275..35ab46b 100644 --- a/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt +++ b/app/src/main/java/com/rainy/token/data/repository/OpenCodeGoRepository.kt @@ -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 = 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> = 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 = 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( + 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(响应解析失败,但请求已发出)" + } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/domain/model/Credential.kt b/app/src/main/java/com/rainy/token/domain/model/Credential.kt index 92f4991..f0f1bf9 100644 --- a/app/src/main/java/com/rainy/token/domain/model/Credential.kt +++ b/app/src/main/java/com/rainy/token/domain/model/Credential.kt @@ -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() 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 af6b743..ade5b6d 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 @@ -45,4 +45,20 @@ class RefreshBalanceUseCase @Inject constructor( /** 一键激活 Codex 用量:向 ChatGPT API 发送请求,返回完整响应体 */ suspend fun triggerCodexUsage(model: String): Result = codexRepositoryProvider.get().triggerUsage(model) + + /** 获取 OpenCode Go 可用模型列表 */ + suspend fun fetchOpenCodeGoModels(): Result> = + openCodeGoRepositoryProvider.get().fetchModels() + + /** 一键激活 OpenCode Go 用量 */ + suspend fun triggerOpenCodeGoUsage(model: String): Result = + openCodeGoRepositoryProvider.get().triggerUsage(model) + + /** 获取 Ollama Cloud 可用模型列表 */ + suspend fun fetchOllamaModels(): Result> = + ollamaRepositoryProvider.get().fetchModels() + + /** 一键激活 Ollama Cloud 用量 */ + suspend fun triggerOllamaUsage(model: String): Result = + ollamaRepositoryProvider.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 8c32095..a0262e2 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 @@ -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( 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 146a3c9..1804a68 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 @@ -40,7 +40,7 @@ class ServiceDetailViewModel @Inject constructor( private val _triggerState = MutableStateFlow(TriggerState.Idle) val triggerState: StateFlow = _triggerState.asStateFlow() - /** Codex 可用模型列表 */ + /** 可用模型列表(Codex / OCGO / Ollama 共用) */ private val _models = MutableStateFlow>(emptyList()) val models: StateFlow> = _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) { - prefs().edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply() + private fun persistModelsCache(service: ServiceType, models: List) { + prefs(service).edit().putString(KEY_MODELS_CACHE, models.joinToString("\n")).apply() } - private fun loadModelsCache(): List = - prefs().getString(KEY_MODELS_CACHE, "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() + private fun loadModelsCache(service: ServiceType): List = + prefs(service).getString(KEY_MODELS_CACHE, "")?.split("\n")?.filter { it.isNotBlank() } ?: emptyList() } /** diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt index 83a30de..141693f 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditScreen.kt @@ -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 diff --git a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt index ade0462..80d7f61 100644 --- a/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt +++ b/app/src/main/java/com/rainy/token/ui/settings/CredentialEditViewModel.kt @@ -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 ) \ No newline at end of file