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}
This commit is contained in:
WaterRain 2026-07-06 21:59:18 +00:00
parent 47d361b527
commit fb2e60d4a0
No known key found for this signature in database
12 changed files with 499 additions and 7 deletions

View File

@ -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<ServiceBalance> = 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[^<]*</span>\s*<span[^>]*class="[^"]*rounded-full[^"]*"[^>]*>\s*(pro|max|free)\s*</span""",
RegexOption.IGNORE_CASE
).find(html)
val plan = planMatch?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
?: run {
// Fallback: 任意 rounded-full + text-neutral badge
val fallback = Regex(
"""<span[^>]*class="[^"]*rounded-full[^"]*text-neutral[^"]*"[^>]*>\s*\n?\s*(pro|max|free)\s*</span""",
RegexOption.IGNORE_CASE
).find(html)
fallback?.groupValues?.get(1)?.trim()?.replaceFirstChar { it.uppercaseChar() }
}
// Session usage: 先找 aria-label,再找 visible text
val sessionPct = Regex("""aria-label="Session usage\s+([\d.]+)%\s+used"""", RegexOption.IGNORE_CASE)
.find(html)?.groupValues?.get(1)?.toFloatOrNull()
?: Regex("""Session usage[^<]*>[\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<Pair<String, Int>>()
val weeklyModels = mutableListOf<Pair<String, Int>>()
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<Pair<String, Int>>,
val weeklyModels: List<Pair<String, Int>>
)
}

View File

@ -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

View File

@ -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()

View File

@ -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 = "%"
)
)

View File

@ -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? =

View File

@ -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<DeepSeekRepository>,
private val openCodeGoRepositoryProvider: Provider<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val codexRepositoryProvider: Provider<CodexRepository>
private val codexRepositoryProvider: Provider<CodexRepository>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
) {
suspend operator fun invoke(service: ServiceType): Result<ServiceBalance> = 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()
}
}

View File

@ -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
)
}
}
}
}

View File

@ -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 {

View File

@ -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) {

View File

@ -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,

View File

@ -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<OpenCodeGoRepository>,
private val commandCodeGoRepositoryProvider: Provider<CommandCodeGoRepository>,
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>
private val refreshBalanceUseCaseProvider: Provider<RefreshBalanceUseCase>,
private val ollamaRepositoryProvider: Provider<OllamaRepository>
) : 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
)

View File

@ -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
}
/**