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:
parent
47d361b527
commit
fb2e60d4a0
@ -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>>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
@ -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 = "%"
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -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? =
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
)
|
||||
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Loading…
Reference in New Issue
Block a user