From fb2e60d4a073b7c76b5a7000f2f18d82f477a574 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Mon, 6 Jul 2026 21:59:18 +0000 Subject: [PATCH] feat: add Ollama Pro Cloud usage service - New ServiceType.OLLAMA enum + ServiceConfig - OllamaRepository: OkHttp + Cookie auth, scrape ollama.com/settings HTML parse plan/session/weekly usage via regex (aria-label + data-time + data-model) - SessionCredential: add ollamaCookie field - NetworkModule: @Provides OllamaRepository - RefreshBalanceUseCase: dispatch OLLAMA - CredentialEditViewModel: load/save/test Ollama cookie - CredentialEditScreen: OllamaCookieForm composable - DashboardScreen: OllamaMainBalance + OllamaUsageWindows cards - ServiceIcon: Ollama emoji placeholder - Widget: shortName/logo/populateServiceRows + DISPLAY_SERVICES} --- .../token/data/repository/OllamaRepository.kt | 215 ++++++++++++++++++ .../java/com/rainy/token/di/NetworkModule.kt | 12 + .../rainy/token/domain/model/Credential.kt | 2 + .../domain/service/ServiceConfigProvider.kt | 6 + .../rainy/token/domain/service/ServiceType.kt | 3 +- .../domain/usecase/RefreshBalanceUseCase.kt | 6 +- .../rainy/token/ui/components/ServiceIcon.kt | 16 ++ .../token/ui/dashboard/DashboardScreen.kt | 73 ++++++ .../ui/servicedetail/ServiceDetailScreen.kt | 4 + .../token/ui/settings/CredentialEditScreen.kt | 75 +++++- .../ui/settings/CredentialEditViewModel.kt | 76 ++++++- .../ui/widget/OpenCodeGoWidgetProvider.kt | 18 +- 12 files changed, 499 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt 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 new file mode 100644 index 0000000..a1c1ce7 --- /dev/null +++ b/app/src/main/java/com/rainy/token/data/repository/OllamaRepository.kt @@ -0,0 +1,215 @@ +package com.rainy.token.data.repository + +import com.rainy.token.data.cache.BalanceCache +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 okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone +import javax.inject.Singleton + +/** + * Ollama Pro Cloud 用量仓库。 + * + * Ollama 没有公开的 Usage API(见 ollama/ollama#12532), + * 本 Repository 通过 OkHttp 搓 Cookie 请求 https://ollama.com/settings, + * 解析返回 HTML 中的用量数据: + * + * - Plan 层级(Pro / Max / Free):Cloud usage 标题旁的 rounded-full badge + * - Session usage 百分比:Session usage 区域的文本 + * - Weekly usage 百分比:Weekly usage 区域的文本 + * - 重置时间:data-time 属性(第 1 个=Session,第 2 个=Weekly) + * - 模型级请求次数:data-model + data-requests 属性 + * + * 认证方式:用户从浏览器 DevTools 复制完整 Cookie 字符串(至少含 `__Secure-session`)。 + */ +@Singleton +class OllamaRepository( + private val okHttpClient: OkHttpClient, + private val credentialRepository: CredentialRepository, + private val balanceCache: BalanceCache +) { + + companion object { + private const val SETTINGS_URL = "https://ollama.com/settings" + 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" + } + + suspend fun fetchBalance(): Result = withContext(Dispatchers.IO) { + val credential = credentialRepository.get(ServiceType.OLLAMA) + ?: return@withContext Result.failure(RepositoryError.InvalidCredential()) + + if (credential !is Credential.SessionCredential) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + val cookie = credential.ollamaCookie + if (cookie.isNullOrBlank()) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + + val request = Request.Builder() + .url(SETTINGS_URL) + .header("User-Agent", USER_AGENT) + .header("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8") + .header("Accept-Language", "en-US,en;q=0.9") + .header("Cookie", cookie) + .get() + .build() + + val response = try { + okHttpClient.newCall(request).execute() + } catch (e: IOException) { + return@withContext Result.failure(RepositoryError.Network(e)) + } catch (e: Throwable) { + return@withContext Result.failure(RepositoryError.Unknown(e)) + } + + response.use { resp -> + if (!resp.isSuccessful) { + if (resp.code == 401 || resp.code == 403) { + return@withContext Result.failure(RepositoryError.InvalidCredential()) + } + return@withContext Result.failure(RepositoryError.ServerError(resp.code)) + } + + val html = resp.body?.string() ?: return@withContext Result.failure( + RepositoryError.ParseError("响应体为空") + ) + + val parsed = parseUsage(html) + if (parsed == null) { + return@withContext Result.failure( + RepositoryError.ParseError( + "解析失败:未找到 Ollama Cloud 用量数据。HTML=${html.length} 字节。Cookie 可能已过期。" + ) + ) + } + + // Session 是主要用量窗口(类似 OCGO 的 rollingUsage) + val sessionPct = parsed.sessionPercent + val weeklyPct = parsed.weeklyPercent + + val extras = buildMap { + put("plan", parsed.plan) + put("session.pct", sessionPct.toString()) + put("weekly.pct", weeklyPct.toString()) + parsed.sessionResetAt?.let { put("session.resetAt", it.toString()) } + parsed.weeklyResetAt?.let { put("weekly.resetAt", it.toString()) } + // 模型级数据序列化为分号分隔 + if (parsed.sessionModels.isNotEmpty()) { + put("session.models", parsed.sessionModels.joinToString(";") { "${it.first},${it.second}" }) + } + if (parsed.weeklyModels.isNotEmpty()) { + put("weekly.models", parsed.weeklyModels.joinToString(";") { "${it.first},${it.second}" }) + } + } + + val balance = ServiceBalance( + service = ServiceType.OLLAMA, + amount = sessionPct.toDouble(), + unit = "%", + isAvailable = true, + totalQuota = null, + nextResetAt = parsed.sessionResetAt, + extras = extras + ) + + balanceCache.put(ServiceType.OLLAMA, balance) + credentialRepository.save(credential.copy(lastVerifiedAt = System.currentTimeMillis())) + + Result.success(balance) + } + } + + /** + * 解析 Ollama settings 页面 HTML,提取用量数据。 + * 正则方案与 hermes-ollama-cloud-usage 和 CodexBar 一致。 + */ + private fun parseUsage(html: String): ParsedUsage? { + // Plan: "Cloud usage" 后的 rounded-full badge 文本 + val planMatch = Regex( + """Cloud usage[^<]*\s*]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE) + .find(html)?.groupValues?.get(1)?.toFloatOrNull() + + // Weekly usage: 同上 + val weeklyPct = Regex("""aria-label="Weekly usage\s+([\d.]+)%\s+used"""", RegexOption.IGNORE_CASE) + .find(html)?.groupValues?.get(1)?.toFloatOrNull() + ?: Regex("""Weekly usage[^<]*>[\s\S]*?([\d.]+)%\s*used""", RegexOption.IGNORE_CASE) + .find(html)?.groupValues?.get(1)?.toFloatOrNull() + + // data-time 属性 → 重置时间戳 + val dataTimes = Regex("""data-time="([^"]+)"""").findAll(html).map { it.groupValues[1] }.toList() + val sessionResetAt = dataTimes.getOrNull(0)?.let { parseIsoTime(it) } + val weeklyResetAt = dataTimes.getOrNull(1)?.let { parseIsoTime(it) } + + // 模型级请求次数: data-model + data-requests + val weeklyStart = html.indexOf("Weekly usage") + val sessionModels = mutableListOf>() + val weeklyModels = mutableListOf>() + Regex("""data-model="([^"]+)"\s+data-requests="(\d+)"""").findAll(html).forEach { match -> + val model = match.groupValues[1] + val requests = match.groupValues[2].toIntOrNull() ?: 0 + if (match.range.first < weeklyStart) { + sessionModels.add(model to requests) + } else { + weeklyModels.add(model to requests) + } + } + + if (sessionPct == null && weeklyPct == null) return null + + return ParsedUsage( + plan = plan ?: "Unknown", + sessionPercent = sessionPct ?: 0f, + weeklyPercent = weeklyPct ?: 0f, + sessionResetAt = sessionResetAt, + weeklyResetAt = weeklyResetAt, + sessionModels = sessionModels, + weeklyModels = weeklyModels + ) + } + + private fun parseIsoTime(iso: String): Long? { + return try { + val sdf = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US) + sdf.timeZone = TimeZone.getTimeZone("UTC") + sdf.parse(iso)?.time + } catch (e: Exception) { + null + } + } + + private data class ParsedUsage( + val plan: String, + val sessionPercent: Float, + val weeklyPercent: Float, + val sessionResetAt: Long?, + val weeklyResetAt: Long?, + val sessionModels: List>, + val weeklyModels: List> + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/di/NetworkModule.kt b/app/src/main/java/com/rainy/token/di/NetworkModule.kt index 895cbe8..5de292b 100644 --- a/app/src/main/java/com/rainy/token/di/NetworkModule.kt +++ b/app/src/main/java/com/rainy/token/di/NetworkModule.kt @@ -14,6 +14,7 @@ import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.CommandCodeUsageRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.data.repository.OpenCodeUsageRepository +import com.rainy.token.data.repository.OllamaRepository import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -148,6 +149,17 @@ object NetworkModule { balanceCache: BalanceCache ): CodexRepository = CodexRepository(okHttpClient, credentialRepository, balanceCache) + /** + * Ollama Pro Cloud 用量仓库:Cookie 认证 + OkHttp 抓 settings HTML 解析。 + */ + @Provides + @Singleton + fun provideOllamaRepository( + okHttpClient: OkHttpClient, + credentialRepository: CredentialRepository, + balanceCache: BalanceCache + ): OllamaRepository = OllamaRepository(okHttpClient, credentialRepository, balanceCache) + /** 余额缓存 DataStore(计划 7.1) */ @Provides @Singleton 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 97e91df..92f4991 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 @@ -41,6 +41,8 @@ sealed class Credential { val authCookie: String? = null, /** OpenCode Go 专用:workspace ID */ val workspaceId: String? = null, + /** Ollama Pro 专用:完整的 Cookie 字符串(用户从浏览器 DevTools 复制) */ + val ollamaCookie: String? = null, override val lastVerifiedAt: Long = 0L ) : Credential() diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt index fd45261..654d686 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceConfigProvider.kt @@ -60,6 +60,12 @@ object ServiceConfigProvider { method = FetchMethod.REST_API, loginUrl = "", displayUnit = "%" + ), + ServiceType.OLLAMA to ServiceConfig( + type = ServiceType.OLLAMA, + method = FetchMethod.WEBVIEW_SCRAPER, + loginUrl = "https://ollama.com/settings", + displayUnit = "%" ) ) diff --git a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt index af5f1a9..52087b2 100644 --- a/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt +++ b/app/src/main/java/com/rainy/token/domain/service/ServiceType.kt @@ -17,7 +17,8 @@ enum class ServiceType( OPENCODE_GO("OpenCode Go", "opencode_go"), COMMANDCODE_GO("CommandCode Go", "commandcode_go"), DEEPSEEK("DeepSeek", "deepseek"), - CODEX("Codex / ChatGPT", "codex"); + CODEX("Codex / ChatGPT", "codex"), + OLLAMA("Ollama Pro", "ollama"); companion object { fun fromStorageKey(key: String): ServiceType? = 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 663ff97..c6dfba3 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 @@ -3,6 +3,7 @@ package com.rainy.token.domain.usecase import com.rainy.token.data.repository.CodexRepository import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.DeepSeekRepository +import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.OpenCodeGoRepository import com.rainy.token.domain.model.ServiceBalance import com.rainy.token.domain.service.ServiceType @@ -13,6 +14,7 @@ import javax.inject.Provider * 唯一的 UseCase。计划架构补充说明: * - 内部按 ServiceType 分发到对应 Repository * - 现阶段实现:DeepSeek(REST API) + OpenCode Go(OkHttp 抓 dashboard)+ CommandCode Go(JSON API) + * + Codex / ChatGPT Plus(wham usage API)+ Ollama Pro(Cookie 抓 settings HTML) * - **使用 Provider 注入**——规避 KSP 2.x 在多个 @Inject constructor Repository * 注入同一 UseCase 时的"could not be resolved"误报(KSP 已知 issue)。 * Provider 让 Hilt 推迟创建 Repository 实例到第一次 .get() 时,KSP 不需要在 @@ -22,12 +24,14 @@ class RefreshBalanceUseCase @Inject constructor( private val deepSeekRepositoryProvider: Provider, private val openCodeGoRepositoryProvider: Provider, private val commandCodeGoRepositoryProvider: Provider, - private val codexRepositoryProvider: Provider + private val codexRepositoryProvider: Provider, + private val ollamaRepositoryProvider: Provider ) { suspend operator fun invoke(service: ServiceType): Result = when (service) { ServiceType.DEEPSEEK -> deepSeekRepositoryProvider.get().fetchBalance() ServiceType.OPENCODE_GO -> openCodeGoRepositoryProvider.get().fetchBalance() ServiceType.COMMANDCODE_GO -> commandCodeGoRepositoryProvider.get().fetchBalance() ServiceType.CODEX -> codexRepositoryProvider.get().fetchBalance() + ServiceType.OLLAMA -> ollamaRepositoryProvider.get().fetchBalance() } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt index c05ee53..9b6de6f 100644 --- a/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt +++ b/app/src/main/java/com/rainy/token/ui/components/ServiceIcon.kt @@ -101,5 +101,21 @@ fun ServiceIcon( ) } } + ServiceType.OLLAMA -> { + // Ollama 暂用 emoji 占位 + Box( + modifier = modifier + .size(size.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF000000), + ), + contentAlignment = Alignment.Center + ) { + androidx.compose.material3.Text( + text = "🦙", + fontSize = (size * 0.5).sp + ) + } + } } } \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt index c624e5c..8265166 100644 --- a/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt +++ b/app/src/main/java/com/rainy/token/ui/dashboard/DashboardScreen.kt @@ -658,6 +658,12 @@ private fun BalanceMainArea(card: DashboardCardUi) { Spacer(modifier = Modifier.height(12.dp)) CodexUsageWindows(balance) } + card.service == ServiceType.OLLAMA -> { + // Ollama Pro 卡片:Session + Weekly 用量百分比 + OllamaMainBalance(balance) + Spacer(modifier = Modifier.height(12.dp)) + OllamaUsageWindows(balance) + } else -> { // DeepSeek 等:主数字 + 单位 Row(verticalAlignment = Alignment.Bottom) { @@ -865,6 +871,72 @@ private fun CodexUsageWindows(balance: com.rainy.token.domain.model.ServiceBalan } } +@Composable +private fun OllamaMainBalance(balance: com.rainy.token.domain.model.ServiceBalance) { + val plan = balance.extras["plan"]?.let { + when (it.lowercase()) { "pro" -> "Pro"; "max" -> "Max"; "free" -> "Free"; else -> it } + } ?: "—" + Row(verticalAlignment = Alignment.Bottom) { + Text( + text = formatAmount(balance.amount), + style = MaterialTheme.typography.displayMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface + ) + Text( + text = "%", + style = MaterialTheme.typography.titleLarge, + color = inkMuted(), + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(bottom = 6.dp, start = 2.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "Session 已用", + style = MaterialTheme.typography.titleMedium, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + Spacer(modifier = Modifier.width(8.dp)) + Text( + text = "· $plan", + style = MaterialTheme.typography.bodySmall, + color = inkMuted(), + modifier = Modifier.padding(bottom = 6.dp) + ) + } +} + +@Composable +private fun OllamaUsageWindows(balance: com.rainy.token.domain.model.ServiceBalance) { + val extras = balance.extras + val sessionPct = extras["session.pct"]?.toFloatOrNull() + val weeklyPct = extras["weekly.pct"]?.toFloatOrNull() + val sessionResetAt = extras["session.resetAt"]?.toLongOrNull() + val weeklyResetAt = extras["weekly.resetAt"]?.toLongOrNull() + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (sessionPct != null) { + CompactUsageRow( + label = "Session", + pct = sessionPct.toInt().coerceIn(0, 100), + resetInSec = sessionResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + ) + } else { + CompactUsageRowEmpty(label = "Session", resetInSec = null) + } + if (weeklyPct != null) { + CompactUsageRow( + label = "每周", + pct = weeklyPct.toInt().coerceIn(0, 100), + resetInSec = weeklyResetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 } + ) + } else { + CompactUsageRowEmpty(label = "每周", resetInSec = null) + } + } +} + private fun normalizeWindowLabel(label: String): String = when (label.lowercase()) { "weekly" -> "每周" else -> label @@ -995,6 +1067,7 @@ private fun secondaryLine(card: DashboardCardUi): String = when (card.service) { ServiceType.OPENCODE_GO -> "WebView 抓取 · 5h 配额" ServiceType.COMMANDCODE_GO -> "JSON API · \$" ServiceType.CODEX -> "ChatGPT Plus · Codex 额度" + ServiceType.OLLAMA -> "Cookie 抓取 · Cloud 配额" } private fun footerText(card: DashboardCardUi): String { 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 fe10c96..5916323 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 @@ -137,6 +137,9 @@ fun ServiceDetailScreen( ServiceType.CODEX -> { // Codex 详情:暂无专用详情卡片,用通用余额展示即可 } + ServiceType.OLLAMA -> { + // Ollama 详情:暂无专用详情卡片,用通用余额展示即可 + } } // 错误信息(如有) @@ -651,6 +654,7 @@ private fun mainCardLabel(service: ServiceType): String = when (service) { ServiceType.OPENCODE_GO -> "5h 实时用量" ServiceType.COMMANDCODE_GO -> "月度余额" ServiceType.CODEX -> "5h 剩余额度" + ServiceType.OLLAMA -> "Session 用量" } private fun stateToChip(state: State): StatusStyle = when (state) { 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 9a7468a..38ea0a0 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 @@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -140,8 +141,19 @@ fun CredentialEditScreen( ) } } else { - // OpenCode Go:用户粘贴 auth cookie + workspaceId(自动抓取) - if (service == ServiceType.OPENCODE_GO) { + // Ollama Pro:用户粘贴完整 Cookie 字符串 + if (service == ServiceType.OLLAMA) { + OllamaCookieForm( + cookie = uiState.ollamaCookie, + loginUrl = uiState.loginUrl, + hasExisting = uiState.hasExisting, + onCookieChange = viewModel::updateOllamaCookie, + onSave = { viewModel.saveOllamaCredential() }, + onTestAndSave = { viewModel.testAndSaveOllama() }, + onCopyLoginUrl = { copyToClipboard(context, uiState.loginUrl) }, + onOpenLoginUrl = { openInBrowser(context, uiState.loginUrl) } + ) + } else if (service == ServiceType.OPENCODE_GO) { OpenCodeGoForm( authCookie = uiState.authCookie, workspaceId = uiState.workspaceId, @@ -297,6 +309,65 @@ fun CredentialEditScreen( } } +@Composable +private fun OllamaCookieForm( + cookie: String, + loginUrl: String, + hasExisting: Boolean, + onCookieChange: (String) -> Unit, + onSave: () -> Unit, + onTestAndSave: () -> Unit, + onCopyLoginUrl: () -> Unit, + onOpenLoginUrl: () -> Unit +) { + Text(text = "Ollama Pro 凭据", style = MaterialTheme.typography.titleMedium) + Text( + text = "需要从浏览器复制完整 Cookie。登录 ollama.com/settings 后,在 DevTools → Network → 请求头里找到 Cookie 行复制。", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.outline + ) + OutlinedTextField( + value = cookie, + onValueChange = onCookieChange, + label = { Text("Cookie") }, + placeholder = { Text("aid=xxx; __Secure-session=yyy") }, + singleLine = false, + minLines = 2, + maxLines = 4, + visualTransformation = PasswordVisualTransformation(), + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions(keyboardType = KeyboardType.Password), + modifier = Modifier.fillMaxWidth() + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = onSave, + modifier = Modifier.weight(1f), + enabled = cookie.isNotBlank() + ) { + Text(text = if (hasExisting) "更新凭据" else "保存凭据") + } + OutlinedButton( + onClick = onTestAndSave, + modifier = Modifier.weight(1f), + enabled = cookie.isNotBlank() + ) { + Text(text = "测试并保存") + } + } + OutlinedButton( + onClick = onOpenLoginUrl, + modifier = Modifier.fillMaxWidth() + ) { + Text(text = "在浏览器中打开 Ollama Settings") + } + TextButton(onClick = onCopyLoginUrl, modifier = Modifier.fillMaxWidth()) { + Text(text = "复制登录 URL 到剪贴板") + } +} + @Composable private fun OpenCodeGoForm( authCookie: String, 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 55acc91..f44f126 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 @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import com.rainy.token.data.repository.CommandCodeGoRepository import com.rainy.token.data.repository.CredentialRepository import com.rainy.token.data.repository.OpenCodeGoRepository +import com.rainy.token.data.repository.OllamaRepository import com.rainy.token.data.repository.RepositoryError import com.rainy.token.domain.model.CookieEntry import com.rainy.token.domain.model.Credential @@ -39,7 +40,8 @@ class CredentialEditViewModel @Inject constructor( private val credentialRepository: CredentialRepository, private val openCodeGoRepositoryProvider: Provider, private val commandCodeGoRepositoryProvider: Provider, - private val refreshBalanceUseCaseProvider: Provider + private val refreshBalanceUseCaseProvider: Provider, + private val ollamaRepositoryProvider: Provider ) : ViewModel() { private val _uiState = MutableStateFlow(CredentialEditUiState()) @@ -93,6 +95,7 @@ class CredentialEditViewModel @Inject constructor( append("}") } } else "", + ollamaCookie = (existing as? Credential.SessionCredential)?.ollamaCookie.orEmpty(), hasExisting = existing != null ) } @@ -427,6 +430,73 @@ class CredentialEditViewModel @Inject constructor( } } + fun updateOllamaCookie(value: String) { + _uiState.update { it.copy(ollamaCookie = value) } + } + + /** + * 保存 Ollama Pro 的 Cookie 字符串。 + */ + fun saveOllamaCredential() { + val type = serviceType ?: return + val current = _uiState.value + if (current.ollamaCookie.isBlank()) { + _uiState.update { it.copy(message = "Cookie 不能为空") } + return + } + viewModelScope.launch { + doSaveOllama(current.ollamaCookie.trim()) + _uiState.update { it.copy(message = "已保存凭据", hasExisting = true) } + } + } + + /** + * 保存并立即测试 Ollama Cloud 连接。 + */ + fun testAndSaveOllama() { + val type = serviceType ?: return + val current = _uiState.value + if (current.ollamaCookie.isBlank()) { + _uiState.update { it.copy(message = "Cookie 不能为空") } + return + } + viewModelScope.launch { + val previous = credentialRepository.get(type) + doSaveOllama(current.ollamaCookie.trim()) + val result = ollamaRepositoryProvider.get().fetchBalance() + if (result.isSuccess) { + val bal = result.getOrNull() + _uiState.update { + it.copy( + message = "连接成功!Session: ${bal?.amount ?: 0}% · ${bal?.extras?.get("plan") ?: "—"}", + hasExisting = true + ) + } + } else { + if (previous != null) credentialRepository.save(previous) else credentialRepository.remove(type) + _uiState.update { + it.copy( + message = "测试失败:${result.exceptionOrNull()?.message}", + hasExisting = previous != null + ) + } + } + } + } + + private suspend fun doSaveOllama(cookie: String) { + val type = serviceType ?: return + val existing = credentialRepository.get(type) as? Credential.SessionCredential + val updated = (existing ?: Credential.SessionCredential( + service = type, + cookies = emptyList() + )).copy( + ollamaCookie = cookie, + lastVerifiedAt = System.currentTimeMillis() + ) + credentialRepository.save(updated) + } + fun deleteCredential() { val type = serviceType ?: return viewModelScope.launch { @@ -439,7 +509,8 @@ class CredentialEditViewModel @Inject constructor( cookieInput = "", authCookie = "", workspaceId = "", - cookieCount = 0 + cookieCount = 0, + ollamaCookie = "" ) } } @@ -509,5 +580,6 @@ data class CredentialEditUiState( val cookieCount: Int = 0, val hasExisting: Boolean = false, val codexAuthJson: String = "", + val ollamaCookie: String = "", val message: String? = null ) \ No newline at end of file diff --git a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt index 941aeca..ccd429c 100644 --- a/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt +++ b/app/src/main/java/com/rainy/token/ui/widget/OpenCodeGoWidgetProvider.kt @@ -181,6 +181,20 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { } } ServiceType.DEEPSEEK -> setEmptyState(views) + ServiceType.OLLAMA -> { + setRowLabel(views, "Session", "每周", "Plan") + populateRow(views, R.id.row1_pct, R.id.row1_bar, R.id.row1_reset, + pct = extras["session.pct"]?.toIntOrNull(), + resetSec = extras["session.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + populateRow(views, R.id.row2_pct, R.id.row2_bar, R.id.row2_reset, + pct = extras["weekly.pct"]?.toIntOrNull(), + resetSec = extras["weekly.resetAt"]?.toLongOrNull()?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 }) + // Plan 信息放在第三行 + val plan = extras["plan"] ?: "—" + views.setTextViewText(R.id.row3_pct, plan) + views.setProgressBar(R.id.row3_bar, 100, 0, false) + views.setTextViewText(R.id.row3_reset, "") + } } } @@ -250,7 +264,7 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { private const val KEY_LAST_AUTO_REFRESH = "last_auto_refresh" private const val KEY_DISPLAY_SERVICE = "display_service" private const val ACTION_SWITCH_SERVICE = "com.rainy.token.action.WIDGET_SWITCH_SERVICE" - private val DISPLAY_SERVICES = listOf(ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO, ServiceType.CODEX) + private val DISPLAY_SERVICES = listOf(ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO, ServiceType.CODEX, ServiceType.OLLAMA) private fun autoRefreshPrefs(context: Context) = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -282,12 +296,14 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() { ServiceType.COMMANDCODE_GO -> "CCGO" ServiceType.CODEX -> "Codex" ServiceType.DEEPSEEK -> "DS" + ServiceType.OLLAMA -> "Ollama" } private fun widgetLogo(service: ServiceType): Int = when (service) { ServiceType.OPENCODE_GO, ServiceType.COMMANDCODE_GO -> R.drawable.ic_opencode_go_logo ServiceType.CODEX -> R.drawable.ic_codex_logo ServiceType.DEEPSEEK -> R.drawable.ic_deepseek_logo + ServiceType.OLLAMA -> R.drawable.ic_opencode_go_logo // 复用,后续可换 Ollama logo } /**