i18n 修复:消除英文环境下 5 处中文泄漏(最终审查 P1)
最终独立审查发现英文环境用户可见中文,全部修复: - 激活成功弹窗:三个 parse 函数(OCGO/Codex/Ollama)改为返回结构化 TriggerSummary, 中文摘要拼接移出 Repository;UI 层 buildTriggerSummaryText() 用资源组装本地化文本 - Codex 窗口标签:formatCodexPrimaryLabel / normalizeWindowLabel 增加「每周/每月」字面量分支, Dashboard 与 Widget 英文环境下正确显示 Weekly/Monthly - Dashboard footer:DashboardCardUi.lastFetchError 改 UiText?,errorToUiText() 按类型映射资源 - 凭据编辑页:ParseError→error_parse_failed(RepositoryError.ParseError 新增公开 detail), 「未知」「JSON格式错误」中文 fallback 改资源 - 服务详情页:triggerUsage else 分支复用 errorMessage() 全类型映射; TriggerError「token刷新失败」前缀资源化;「无响应体」不再展示 - 新增 TriggerSummary 数据类与 12 组中英资源(trigger_*/error_token_refresh_failed/等) - 测试:CodexRepositoryTest 6 用例改为结构化断言,FormatUtilsTest 更新 monthly 映射; 113 个用例全绿,lint 0 Error,资源 426/426 对齐
This commit is contained in:
parent
52b86e95e5
commit
6ac4d06e9f
@ -3,6 +3,7 @@ package com.rainy.token.data.repository
|
|||||||
import com.rainy.token.data.cache.BalanceCache
|
import com.rainy.token.data.cache.BalanceCache
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -203,7 +204,7 @@ class CodexRepository(
|
|||||||
* @param model 用户选择的模型 slug
|
* @param model 用户选择的模型 slug
|
||||||
* @return Result.success(响应体原文) — 供 UI 展示
|
* @return Result.success(响应体原文) — 供 UI 展示
|
||||||
*/
|
*/
|
||||||
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
|
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.CODEX)
|
val credential = credentialRepository.get(ServiceType.CODEX)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Codex 凭据"))
|
||||||
if (credential !is Credential.CodexCredential)
|
if (credential !is Credential.CodexCredential)
|
||||||
@ -239,10 +240,10 @@ class CodexRepository(
|
|||||||
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
|
DebugLog.i(TAG, "triggerUsage retry: HTTP ${resp2.code}, body=${body2.take(500)}")
|
||||||
if (!resp2.isSuccessful) {
|
if (!resp2.isSuccessful) {
|
||||||
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
|
DebugLog.e(TAG, "triggerUsage retry failed: HTTP ${resp2.code}")
|
||||||
return@use Result.failure<String>(
|
return@use Result.failure<TriggerSummary>(
|
||||||
TriggerError(
|
TriggerError(
|
||||||
"HTTP ${resp2.code}",
|
"HTTP ${resp2.code}",
|
||||||
body2.ifBlank { "无响应体" }
|
body2.ifBlank { "" }
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@ -255,8 +256,8 @@ class CodexRepository(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
return@use Result.failure<String>(
|
return@use Result.failure<TriggerSummary>(
|
||||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
|
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -400,7 +401,7 @@ class TriggerError(val summary: String, val responseBody: String) : Exception(su
|
|||||||
* 将 SSE 流响应解析为简洁的文本摘要。
|
* 将 SSE 流响应解析为简洁的文本摘要。
|
||||||
* 提取:模型回复文本、用量统计(input/output tokens)。
|
* 提取:模型回复文本、用量统计(input/output tokens)。
|
||||||
*/
|
*/
|
||||||
internal fun parseSseResponse(sseText: String, model: String): String {
|
internal fun parseSseResponse(sseText: String, model: String): TriggerSummary {
|
||||||
val sseJson = Json { ignoreUnknownKeys = true }
|
val sseJson = Json { ignoreUnknownKeys = true }
|
||||||
val outputText = StringBuilder()
|
val outputText = StringBuilder()
|
||||||
var inputTokens: String? = null
|
var inputTokens: String? = null
|
||||||
@ -429,16 +430,11 @@ internal fun parseSseResponse(sseText: String, model: String): String {
|
|||||||
} catch (_: Exception) { }
|
} catch (_: Exception) { }
|
||||||
}
|
}
|
||||||
|
|
||||||
val sb = StringBuilder()
|
return TriggerSummary(
|
||||||
sb.appendLine("✓ 激活成功")
|
model = model,
|
||||||
sb.appendLine("模型: $model")
|
reply = outputText.toString(),
|
||||||
if (responseId != null) sb.appendLine("Response ID: $responseId")
|
inputTokens = inputTokens,
|
||||||
sb.appendLine()
|
outputTokens = outputTokens,
|
||||||
sb.appendLine("回复:")
|
responseId = responseId
|
||||||
sb.appendLine(outputText.ifEmpty { "(空)" })
|
)
|
||||||
if (inputTokens != null || outputTokens != null) {
|
|
||||||
sb.appendLine()
|
|
||||||
sb.appendLine("用量: input=$inputTokens output=$outputTokens tokens")
|
|
||||||
}
|
|
||||||
return sb.toString().trim()
|
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
|
|||||||
import com.rainy.token.data.debug.DebugLog
|
import com.rainy.token.data.debug.DebugLog
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@ -268,7 +269,7 @@ class OllamaRepository(
|
|||||||
* @param model 用户选择的模型 slug
|
* @param model 用户选择的模型 slug
|
||||||
* @return Result.success(响应摘要文本) — 供 UI 展示
|
* @return Result.success(响应摘要文本) — 供 UI 展示
|
||||||
*/
|
*/
|
||||||
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
|
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.OLLAMA)
|
val credential = credentialRepository.get(ServiceType.OLLAMA)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 Ollama 凭据"))
|
||||||
if (credential !is Credential.SessionCredential)
|
if (credential !is Credential.SessionCredential)
|
||||||
@ -296,8 +297,8 @@ class OllamaRepository(
|
|||||||
val bodyStr = resp.body?.string() ?: ""
|
val bodyStr = resp.body?.string() ?: ""
|
||||||
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
return@withContext Result.failure<String>(
|
return@withContext Result.failure<TriggerSummary>(
|
||||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
|
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
||||||
@ -317,7 +318,7 @@ class OllamaRepository(
|
|||||||
/**
|
/**
|
||||||
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
|
* 解析 Ollama Cloud chat completions 响应,提取回复文本和用量统计。
|
||||||
*/
|
*/
|
||||||
internal fun parseOllamaChatResponse(responseBody: String, model: String): String {
|
internal fun parseOllamaChatResponse(responseBody: String, model: String): TriggerSummary {
|
||||||
val json = Json { ignoreUnknownKeys = true }
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
return try {
|
return try {
|
||||||
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
||||||
@ -330,18 +331,14 @@ internal fun parseOllamaChatResponse(responseBody: String, model: String): Strin
|
|||||||
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
||||||
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
||||||
val sb = StringBuilder()
|
TriggerSummary(
|
||||||
sb.appendLine("✓ 激活成功")
|
model = model,
|
||||||
sb.appendLine("模型: $model")
|
reply = content,
|
||||||
sb.appendLine()
|
inputTokens = promptTokens,
|
||||||
sb.appendLine("回复:")
|
outputTokens = completionTokens,
|
||||||
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
|
totalTokens = totalTokens
|
||||||
if (promptTokens != null || completionTokens != null) {
|
)
|
||||||
sb.appendLine()
|
|
||||||
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
|
|
||||||
}
|
|
||||||
sb.toString().trim()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
|
TriggerSummary(model = model, reply = null, parseFailed = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -4,6 +4,7 @@ import com.rainy.token.data.cache.BalanceCache
|
|||||||
import com.rainy.token.data.debug.DebugLog
|
import com.rainy.token.data.debug.DebugLog
|
||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@ -178,7 +179,7 @@ class OpenCodeGoRepository(
|
|||||||
* @param model 用户选择的模型 slug
|
* @param model 用户选择的模型 slug
|
||||||
* @return Result.success(响应摘要文本) — 供 UI 展示
|
* @return Result.success(响应摘要文本) — 供 UI 展示
|
||||||
*/
|
*/
|
||||||
suspend fun triggerUsage(model: String): Result<String> = withContext(Dispatchers.IO) {
|
suspend fun triggerUsage(model: String): Result<TriggerSummary> = withContext(Dispatchers.IO) {
|
||||||
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
|
val credential = credentialRepository.get(ServiceType.OPENCODE_GO)
|
||||||
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
|
?: return@withContext Result.failure(RepositoryError.InvalidCredential("未找到 OpenCode Go 凭据"))
|
||||||
if (credential !is Credential.SessionCredential)
|
if (credential !is Credential.SessionCredential)
|
||||||
@ -206,8 +207,8 @@ class OpenCodeGoRepository(
|
|||||||
val bodyStr = resp.body?.string() ?: ""
|
val bodyStr = resp.body?.string() ?: ""
|
||||||
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
DebugLog.i(TAG, "triggerUsage: HTTP ${resp.code}, body=${bodyStr.take(500)}")
|
||||||
if (!resp.isSuccessful) {
|
if (!resp.isSuccessful) {
|
||||||
return@withContext Result.failure<String>(
|
return@withContext Result.failure<TriggerSummary>(
|
||||||
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "无响应体" })
|
TriggerError("HTTP ${resp.code}", bodyStr.ifBlank { "" })
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
DebugLog.i(TAG, "triggerUsage: 请求成功,模型=$model")
|
||||||
@ -318,7 +319,7 @@ class OpenCodeGoRepository(
|
|||||||
/**
|
/**
|
||||||
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
|
* 解析 OpenAI 兼容的 chat completions 响应,提取回复文本和用量统计。
|
||||||
*/
|
*/
|
||||||
internal fun parseChatResponse(responseBody: String, model: String): String {
|
internal fun parseChatResponse(responseBody: String, model: String): TriggerSummary {
|
||||||
val json = Json { ignoreUnknownKeys = true }
|
val json = Json { ignoreUnknownKeys = true }
|
||||||
return try {
|
return try {
|
||||||
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
val root = json.parseToJsonElement(responseBody) as? JsonObject
|
||||||
@ -331,18 +332,14 @@ internal fun parseChatResponse(responseBody: String, model: String): String {
|
|||||||
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
val completionTokens = (usage?.get("completion_tokens") as? JsonPrimitive)?.contentOrNull
|
||||||
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
val totalTokens = (usage?.get("total_tokens") as? JsonPrimitive)?.contentOrNull
|
||||||
|
|
||||||
val sb = StringBuilder()
|
TriggerSummary(
|
||||||
sb.appendLine("✓ 激活成功")
|
model = model,
|
||||||
sb.appendLine("模型: $model")
|
reply = content,
|
||||||
sb.appendLine()
|
inputTokens = promptTokens,
|
||||||
sb.appendLine("回复:")
|
outputTokens = completionTokens,
|
||||||
sb.appendLine(content?.ifEmpty { "(空)" } ?: "(无回复内容)")
|
totalTokens = totalTokens
|
||||||
if (promptTokens != null || completionTokens != null) {
|
)
|
||||||
sb.appendLine()
|
|
||||||
sb.appendLine("用量: prompt=$promptTokens completion=$completionTokens total=$totalTokens tokens")
|
|
||||||
}
|
|
||||||
sb.toString().trim()
|
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
"✓ 激活成功\n模型: $model\n\n(响应解析失败,但请求已发出)"
|
TriggerSummary(model = model, reply = null, parseFailed = true)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -23,9 +23,9 @@ sealed class RepositoryError(message: String, cause: Throwable? = null) : Except
|
|||||||
class ServerError(val code: Int, cause: Throwable? = null) :
|
class ServerError(val code: Int, cause: Throwable? = null) :
|
||||||
RepositoryError("服务端异常 (HTTP $code)", cause)
|
RepositoryError("服务端异常 (HTTP $code)", cause)
|
||||||
|
|
||||||
/** 解析失败(HTML 解析结果为空 / JSON 反序列化失败) */
|
/** 解析失败(HTML 解析结果为空 / JSON 反序列化失败),detail 为具体原因 */
|
||||||
class ParseError(message: String, cause: Throwable? = null) :
|
class ParseError(val detail: String, cause: Throwable? = null) :
|
||||||
RepositoryError("解析失败: $message", cause)
|
RepositoryError("解析失败: $detail", cause)
|
||||||
|
|
||||||
/** 未知错误 */
|
/** 未知错误 */
|
||||||
class Unknown(cause: Throwable? = null) : RepositoryError(
|
class Unknown(cause: Throwable? = null) : RepositoryError(
|
||||||
|
|||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.rainy.token.domain.model
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一键激活用量的结构化响应摘要。
|
||||||
|
*
|
||||||
|
* Repository 只负责从响应中提取数据,不拼接展示文案;
|
||||||
|
* UI 层按当前语言环境用 stringResource 组装本地化文本。
|
||||||
|
*/
|
||||||
|
data class TriggerSummary(
|
||||||
|
/** 发起请求时使用的模型 */
|
||||||
|
val model: String,
|
||||||
|
/** 模型回复文本;null 表示响应中不存在回复内容 */
|
||||||
|
val reply: String?,
|
||||||
|
/** 输入 token 数(OCGO/Ollama 为 prompt_tokens,Codex 为 input_tokens) */
|
||||||
|
val inputTokens: String? = null,
|
||||||
|
/** 输出 token 数(OCGO/Ollama 为 completion_tokens,Codex 为 output_tokens) */
|
||||||
|
val outputTokens: String? = null,
|
||||||
|
/** 总 token 数(OCGO/Ollama 的 total_tokens;Codex 无此字段) */
|
||||||
|
val totalTokens: String? = null,
|
||||||
|
/** Codex 响应 ID */
|
||||||
|
val responseId: String? = null,
|
||||||
|
/** 响应解析失败,但请求已成功发出 */
|
||||||
|
val parseFailed: Boolean = false
|
||||||
|
)
|
||||||
@ -10,6 +10,7 @@ import com.rainy.token.data.repository.RefreshWriteSession
|
|||||||
import com.rainy.token.data.repository.RepositoryError
|
import com.rainy.token.data.repository.RepositoryError
|
||||||
import com.rainy.token.data.repository.retryOnTransientError
|
import com.rainy.token.data.repository.retryOnTransientError
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Provider
|
import javax.inject.Provider
|
||||||
@ -55,7 +56,7 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
suspend fun fetchCodexModels(): Result<List<String>> =
|
suspend fun fetchCodexModels(): Result<List<String>> =
|
||||||
codexRepositoryProvider.get().fetchModels()
|
codexRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
suspend fun triggerCodexUsage(model: String): Result<String> =
|
suspend fun triggerCodexUsage(model: String): Result<TriggerSummary> =
|
||||||
withCredentialSession(ServiceType.CODEX) {
|
withCredentialSession(ServiceType.CODEX) {
|
||||||
codexRepositoryProvider.get().triggerUsage(model)
|
codexRepositoryProvider.get().triggerUsage(model)
|
||||||
}
|
}
|
||||||
@ -63,7 +64,7 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
|
suspend fun fetchOpenCodeGoModels(): Result<List<String>> =
|
||||||
openCodeGoRepositoryProvider.get().fetchModels()
|
openCodeGoRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
suspend fun triggerOpenCodeGoUsage(model: String): Result<String> =
|
suspend fun triggerOpenCodeGoUsage(model: String): Result<TriggerSummary> =
|
||||||
withCredentialSession(ServiceType.OPENCODE_GO) {
|
withCredentialSession(ServiceType.OPENCODE_GO) {
|
||||||
openCodeGoRepositoryProvider.get().triggerUsage(model)
|
openCodeGoRepositoryProvider.get().triggerUsage(model)
|
||||||
}
|
}
|
||||||
@ -71,7 +72,7 @@ class RefreshBalanceUseCase @Inject constructor(
|
|||||||
suspend fun fetchOllamaModels(): Result<List<String>> =
|
suspend fun fetchOllamaModels(): Result<List<String>> =
|
||||||
ollamaRepositoryProvider.get().fetchModels()
|
ollamaRepositoryProvider.get().fetchModels()
|
||||||
|
|
||||||
suspend fun triggerOllamaUsage(model: String): Result<String> =
|
suspend fun triggerOllamaUsage(model: String): Result<TriggerSummary> =
|
||||||
withCredentialSession(ServiceType.OLLAMA) {
|
withCredentialSession(ServiceType.OLLAMA) {
|
||||||
ollamaRepositoryProvider.get().triggerUsage(model)
|
ollamaRepositoryProvider.get().triggerUsage(model)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -56,8 +56,13 @@ fun formatResetForWidget(sec: Long, text: DurationText = ChineseDurationText): S
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标准化窗口标签:weekly → 每周,其他原样返回 */
|
/** 标准化窗口标签:weekly/每周 → 每周标签,monthly/每月 → 每月标签,其他原样返回 */
|
||||||
fun normalizeWindowLabel(label: String, weeklyLabel: String = "每周"): String = when (label.lowercase()) {
|
fun normalizeWindowLabel(
|
||||||
"weekly" -> weeklyLabel
|
label: String,
|
||||||
|
weeklyLabel: String = "每周",
|
||||||
|
monthlyLabel: String = "每月"
|
||||||
|
): String = when (label.lowercase()) {
|
||||||
|
"weekly", "每周" -> weeklyLabel
|
||||||
|
"monthly", "每月" -> monthlyLabel
|
||||||
else -> label
|
else -> label
|
||||||
}
|
}
|
||||||
@ -10,6 +10,8 @@ import com.rainy.token.domain.model.CredentialStatus
|
|||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
|
import com.rainy.token.R
|
||||||
|
import com.rainy.token.ui.components.UiText
|
||||||
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
import com.rainy.token.ui.widget.OpenCodeGoWidgetProvider
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
@ -127,10 +129,10 @@ class DashboardViewModel @Inject constructor(
|
|||||||
val cards = ServiceType.entries.map { type ->
|
val cards = ServiceType.entries.map { type ->
|
||||||
val result = results[type]
|
val result = results[type]
|
||||||
val error = result?.exceptionOrNull()
|
val error = result?.exceptionOrNull()
|
||||||
val errMsg = error
|
val errorUi = error
|
||||||
?.takeUnless { it is RepositoryError.CredentialChanged }
|
?.takeUnless { it is RepositoryError.CredentialChanged }
|
||||||
?.message
|
?.let { errorToUiText(it) }
|
||||||
buildCard(localStates.getValue(type), lastFetchError = errMsg)
|
buildCard(localStates.getValue(type), lastFetchError = errorUi)
|
||||||
}
|
}
|
||||||
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
_uiState.update { it.copy(refreshing = false, cards = cards) }
|
||||||
// 刷新成功后更新桌面小组件
|
// 刷新成功后更新桌面小组件
|
||||||
@ -145,7 +147,7 @@ class DashboardViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun buildCard(
|
private fun buildCard(
|
||||||
local: CredentialRepository.LocalState,
|
local: CredentialRepository.LocalState,
|
||||||
lastFetchError: String?
|
lastFetchError: UiText?
|
||||||
): DashboardCardUi = DashboardCardUi(
|
): DashboardCardUi = DashboardCardUi(
|
||||||
service = local.status.service,
|
service = local.status.service,
|
||||||
credentialState = local.status.state,
|
credentialState = local.status.state,
|
||||||
@ -181,8 +183,29 @@ data class DashboardCardUi(
|
|||||||
val credentialState: CredentialStatus.State,
|
val credentialState: CredentialStatus.State,
|
||||||
val credentialFingerprint: String?,
|
val credentialFingerprint: String?,
|
||||||
val cachedBalance: CachedBalance?,
|
val cachedBalance: CachedBalance?,
|
||||||
val lastFetchError: String?
|
val lastFetchError: UiText?
|
||||||
) {
|
) {
|
||||||
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
/** 余额展示主数字。优先取缓存,错误时也展示(不隐藏,让用户看到旧值 + 红点提示)。 */
|
||||||
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
val displayBalance: ServiceBalance? get() = cachedBalance?.balance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 把 Repository 错误映射为本地化文案(错误详情 detail 为技术信息,随资源参数展示)。 */
|
||||||
|
private fun errorToUiText(error: Throwable): UiText = when (error) {
|
||||||
|
is RepositoryError.InvalidCredential ->
|
||||||
|
UiText.Resource(R.string.error_credential_invalid_reconfigure)
|
||||||
|
is RepositoryError.RateLimited -> UiText.Resource(
|
||||||
|
R.string.error_rate_limited_retry,
|
||||||
|
listOf(
|
||||||
|
error.retryAfterSeconds?.let {
|
||||||
|
UiText.Resource(R.string.error_rate_limited_retry_suffix, listOf(it))
|
||||||
|
} ?: ""
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is RepositoryError.Network -> UiText.Resource(R.string.error_network_check)
|
||||||
|
is RepositoryError.ServerError ->
|
||||||
|
UiText.Resource(R.string.error_server_http, listOf(error.code))
|
||||||
|
is RepositoryError.ParseError ->
|
||||||
|
UiText.Resource(R.string.error_parse_failed, listOf(error.detail))
|
||||||
|
else -> error.message?.let { UiText.Dynamic(it) }
|
||||||
|
?: UiText.Resource(R.string.common_unknown)
|
||||||
|
}
|
||||||
|
|||||||
@ -279,11 +279,11 @@ internal fun formatCodexPrimaryLabel(
|
|||||||
usageLabel: String = "用量"
|
usageLabel: String = "用量"
|
||||||
): String = when (raw?.lowercase()) {
|
): String = when (raw?.lowercase()) {
|
||||||
"5h" -> "5h"
|
"5h" -> "5h"
|
||||||
"7d", weeklyLabel -> weeklyLabel
|
"7d", "每周", weeklyLabel -> weeklyLabel
|
||||||
"30d", monthlyLabel -> monthlyLabel
|
"30d", "每月", monthlyLabel -> monthlyLabel
|
||||||
"usage" -> usageLabel
|
"usage" -> usageLabel
|
||||||
null -> "5h"
|
null -> "5h"
|
||||||
else -> raw
|
else -> raw ?: "5h"
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -295,8 +295,13 @@ internal fun CodexUsageWindows(balance: ServiceBalance) {
|
|||||||
.maxOrNull()?.plus(1) ?: 0
|
.maxOrNull()?.plus(1) ?: 0
|
||||||
|
|
||||||
val weeklyLabel = stringResource(R.string.window_every_week)
|
val weeklyLabel = stringResource(R.string.window_every_week)
|
||||||
|
val monthlyLabel = stringResource(R.string.window_every_month)
|
||||||
val windows = (0 until windowCount).map { i ->
|
val windows = (0 until windowCount).map { i ->
|
||||||
val label = normalizeWindowLabel(extras["window_$i.label"] ?: "Usage", weeklyLabel = weeklyLabel)
|
val label = normalizeWindowLabel(
|
||||||
|
extras["window_$i.label"] ?: "Usage",
|
||||||
|
weeklyLabel = weeklyLabel,
|
||||||
|
monthlyLabel = monthlyLabel
|
||||||
|
)
|
||||||
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
|
val remainingPct = extras["window_$i.remainingPct"]?.toIntOrNull()
|
||||||
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
val resetAt = extras["window_$i.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||||
Triple(label, remainingPct, resetAt)
|
Triple(label, remainingPct, resetAt)
|
||||||
@ -526,12 +531,20 @@ internal fun secondaryLineRes(card: DashboardCardUi): Int = when (card.service)
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。
|
* 底部更新时间/错误文案(UiText 形式,UI 层按当前语言解析)。
|
||||||
* 错误分支直接透传 Repository 中文 message(Dynamic,不参与翻译)。
|
* 错误信息由 ViewModel 按 RepositoryError 类型映射为本地化资源。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/** 截断 UiText 到最长 60 字符(仅对 Dynamic 原始文本生效;Resource 长度由资源控制)。 */
|
||||||
|
private fun truncateUiText(text: UiText): UiText = when (text) {
|
||||||
|
is UiText.Dynamic -> UiText.Dynamic(text.value.take(60) + if (text.value.length > 60) "…" else "")
|
||||||
|
else -> text
|
||||||
|
}
|
||||||
|
|
||||||
internal fun footerText(card: DashboardCardUi): UiText {
|
internal fun footerText(card: DashboardCardUi): UiText {
|
||||||
if (card.lastFetchError != null) {
|
val error = card.lastFetchError
|
||||||
val msg = card.lastFetchError.take(60)
|
if (error != null) {
|
||||||
return UiText.Dynamic("⚠ $msg${if (card.lastFetchError.length > 60) "…" else ""}")
|
// 截断保护:错误信息最长展示 60 字符(以 UiText 形式保留,UI 层按当前语言解析)
|
||||||
|
return UiText.Resource(R.string.dashboard_error_prefix, listOf(truncateUiText(error)))
|
||||||
}
|
}
|
||||||
val fetchedAt = card.cachedBalance?.fetchedAt
|
val fetchedAt = card.cachedBalance?.fetchedAt
|
||||||
?: return UiText.Resource(R.string.footer_never_fetched)
|
?: return UiText.Resource(R.string.footer_never_fetched)
|
||||||
|
|||||||
@ -61,6 +61,7 @@ import androidx.lifecycle.compose.LifecycleEventEffect
|
|||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.domain.service.FetchMethod
|
import com.rainy.token.domain.service.FetchMethod
|
||||||
import com.rainy.token.domain.service.ServiceConfigProvider
|
import com.rainy.token.domain.service.ServiceConfigProvider
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
@ -218,7 +219,7 @@ fun ServiceDetailScreen(
|
|||||||
when (triggerState) {
|
when (triggerState) {
|
||||||
is TriggerState.Success -> ResponseDialog(
|
is TriggerState.Success -> ResponseDialog(
|
||||||
title = stringResource(R.string.trigger_success_title),
|
title = stringResource(R.string.trigger_success_title),
|
||||||
responseBody = (triggerState as TriggerState.Success).responseBody,
|
responseBody = buildTriggerSummaryText((triggerState as TriggerState.Success).summary),
|
||||||
isError = false,
|
isError = false,
|
||||||
onDismiss = { viewModel.dismissTrigger() }
|
onDismiss = { viewModel.dismissTrigger() }
|
||||||
)
|
)
|
||||||
@ -1122,6 +1123,43 @@ private fun ModelDropdown(
|
|||||||
|
|
||||||
// ── Codex 一键激活用量:响应弹窗 ──
|
// ── Codex 一键激活用量:响应弹窗 ──
|
||||||
|
|
||||||
|
/** 把结构化的激活摘要组装为本地化文本(Repository 不再拼接展示文案)。 */
|
||||||
|
@Composable
|
||||||
|
private fun buildTriggerSummaryText(summary: TriggerSummary): String = buildString {
|
||||||
|
appendLine(stringResource(R.string.trigger_success_header))
|
||||||
|
appendLine(stringResource(R.string.trigger_success_model, summary.model))
|
||||||
|
summary.responseId?.let { appendLine(stringResource(R.string.trigger_success_response_id, it)) }
|
||||||
|
appendLine()
|
||||||
|
appendLine(stringResource(R.string.trigger_reply_header))
|
||||||
|
if (summary.parseFailed) {
|
||||||
|
appendLine(stringResource(R.string.trigger_parse_failed))
|
||||||
|
} else {
|
||||||
|
appendLine(
|
||||||
|
summary.reply?.ifEmpty { stringResource(R.string.trigger_reply_empty) }
|
||||||
|
?: stringResource(R.string.trigger_reply_missing)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (summary.inputTokens != null || summary.outputTokens != null) {
|
||||||
|
appendLine()
|
||||||
|
appendLine(
|
||||||
|
if (summary.totalTokens != null) {
|
||||||
|
stringResource(
|
||||||
|
R.string.trigger_usage_prompt_completion_total,
|
||||||
|
summary.inputTokens ?: "—",
|
||||||
|
summary.outputTokens ?: "—",
|
||||||
|
summary.totalTokens ?: "—"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
stringResource(
|
||||||
|
R.string.trigger_usage_input_output,
|
||||||
|
summary.inputTokens ?: "—",
|
||||||
|
summary.outputTokens ?: "—"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}.trim()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ResponseDialog(
|
private fun ResponseDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import com.rainy.token.data.repository.TriggerError
|
|||||||
import com.rainy.token.domain.model.Credential
|
import com.rainy.token.domain.model.Credential
|
||||||
import com.rainy.token.domain.model.CredentialStatus
|
import com.rainy.token.domain.model.CredentialStatus
|
||||||
import com.rainy.token.domain.model.ServiceBalance
|
import com.rainy.token.domain.model.ServiceBalance
|
||||||
|
import com.rainy.token.domain.model.TriggerSummary
|
||||||
import com.rainy.token.R
|
import com.rainy.token.R
|
||||||
import com.rainy.token.domain.service.ServiceType
|
import com.rainy.token.domain.service.ServiceType
|
||||||
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
import com.rainy.token.domain.usecase.RefreshBalanceUseCase
|
||||||
@ -324,8 +325,8 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
|
if (serviceGen != serviceGeneration || _serviceType.value != service) return@launch
|
||||||
|
|
||||||
result
|
result
|
||||||
.onSuccess { responseBody ->
|
.onSuccess { summary ->
|
||||||
_triggerState.value = TriggerState.Success(responseBody)
|
_triggerState.value = TriggerState.Success(summary)
|
||||||
kotlinx.coroutines.delay(2000)
|
kotlinx.coroutines.delay(2000)
|
||||||
if (serviceGen == serviceGeneration && _serviceType.value == service) {
|
if (serviceGen == serviceGeneration && _serviceType.value == service) {
|
||||||
refresh()
|
refresh()
|
||||||
@ -336,7 +337,15 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
val responseBody: String?
|
val responseBody: String?
|
||||||
when (error) {
|
when (error) {
|
||||||
is TriggerError -> {
|
is TriggerError -> {
|
||||||
message = UiText.Dynamic(error.summary)
|
val prefix = "token 刷新失败: "
|
||||||
|
message = if (error.summary.startsWith(prefix)) {
|
||||||
|
UiText.Resource(
|
||||||
|
R.string.error_token_refresh_failed,
|
||||||
|
listOf(error.summary.removePrefix(prefix))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
UiText.Dynamic(error.summary)
|
||||||
|
}
|
||||||
responseBody = error.responseBody.ifBlank { null }
|
responseBody = error.responseBody.ifBlank { null }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -359,8 +368,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) {
|
message = if ((error as? RepositoryError.Unknown)?.cause is IllegalArgumentException) {
|
||||||
UiText.Resource(R.string.error_trigger_not_supported)
|
UiText.Resource(R.string.error_trigger_not_supported)
|
||||||
} else {
|
} else {
|
||||||
error.message?.let { UiText.Dynamic(it) }
|
errorMessage(error)
|
||||||
?: UiText.Resource(R.string.common_unknown)
|
|
||||||
}
|
}
|
||||||
responseBody = null
|
responseBody = null
|
||||||
}
|
}
|
||||||
@ -529,7 +537,7 @@ class ServiceDetailViewModel @Inject constructor(
|
|||||||
is RepositoryError.ServerError ->
|
is RepositoryError.ServerError ->
|
||||||
UiText.Resource(R.string.error_server_http, listOf(error.code))
|
UiText.Resource(R.string.error_server_http, listOf(error.code))
|
||||||
is RepositoryError.ParseError ->
|
is RepositoryError.ParseError ->
|
||||||
UiText.Resource(R.string.error_parse_failed, listOf(error.message ?: ""))
|
UiText.Resource(R.string.error_parse_failed, listOf(error.detail))
|
||||||
else -> error.message?.let { UiText.Dynamic(it) }
|
else -> error.message?.let { UiText.Dynamic(it) }
|
||||||
?: UiText.Resource(R.string.common_unknown)
|
?: UiText.Resource(R.string.common_unknown)
|
||||||
}
|
}
|
||||||
@ -579,6 +587,6 @@ data class ServiceDetailUiState(
|
|||||||
sealed class TriggerState {
|
sealed class TriggerState {
|
||||||
data object Idle : TriggerState()
|
data object Idle : TriggerState()
|
||||||
data object Loading : TriggerState()
|
data object Loading : TriggerState()
|
||||||
data class Success(val responseBody: String) : TriggerState()
|
data class Success(val summary: TriggerSummary) : TriggerState()
|
||||||
data class Error(val message: UiText, val responseBody: String?) : TriggerState()
|
data class Error(val message: UiText, val responseBody: String?) : TriggerState()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -170,7 +170,15 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
is RepositoryError.ServerError ->
|
is RepositoryError.ServerError ->
|
||||||
UiText.Resource(R.string.error_server, listOf(err.code))
|
UiText.Resource(R.string.error_server, listOf(err.code))
|
||||||
is RepositoryError.Network ->
|
is RepositoryError.Network ->
|
||||||
UiText.Resource(R.string.error_network, listOf(err.cause?.message ?: "未知"))
|
UiText.Resource(
|
||||||
|
R.string.error_network,
|
||||||
|
listOf(
|
||||||
|
err.cause?.message?.let { UiText.Dynamic(it) }
|
||||||
|
?: UiText.Resource(R.string.common_unknown)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
is RepositoryError.ParseError ->
|
||||||
|
UiText.Resource(R.string.error_parse_failed, listOf(err.detail))
|
||||||
else -> err?.message?.let { UiText.Dynamic(it) }
|
else -> err?.message?.let { UiText.Dynamic(it) }
|
||||||
?: UiText.Resource(R.string.common_unknown)
|
?: UiText.Resource(R.string.common_unknown)
|
||||||
}
|
}
|
||||||
@ -371,7 +379,10 @@ class CredentialEditViewModel @Inject constructor(
|
|||||||
it.copy(
|
it.copy(
|
||||||
message = UiText.Resource(
|
message = UiText.Resource(
|
||||||
R.string.error_auth_json_parse,
|
R.string.error_auth_json_parse,
|
||||||
listOf(e.message ?: "JSON 格式错误")
|
listOf(
|
||||||
|
e.message?.let { UiText.Dynamic(it) }
|
||||||
|
?: UiText.Resource(R.string.error_json_format)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -193,7 +193,11 @@ class OpenCodeGoWidgetProvider : AppWidgetProvider() {
|
|||||||
val weeklyLabel = context.getString(R.string.window_every_week)
|
val weeklyLabel = context.getString(R.string.window_every_week)
|
||||||
val monthlyLabel = context.getString(R.string.window_every_month)
|
val monthlyLabel = context.getString(R.string.window_every_month)
|
||||||
val windows = (0 until windowCount).map { index ->
|
val windows = (0 until windowCount).map { index ->
|
||||||
val label = normalizeWindowLabel(extras["window_$index.label"] ?: "Usage", weeklyLabel = weeklyLabel)
|
val label = normalizeWindowLabel(
|
||||||
|
extras["window_$index.label"] ?: "Usage",
|
||||||
|
weeklyLabel = weeklyLabel,
|
||||||
|
monthlyLabel = monthlyLabel
|
||||||
|
)
|
||||||
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
|
val remaining = extras["window_$index.remainingPct"]?.toIntOrNull()
|
||||||
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
val resetAt = extras["window_$index.resetAt"]?.toLongOrNull()?.takeIf { it > 0 }
|
||||||
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
Triple(label, remaining?.let { (100 - it).coerceIn(0, 100) }, resetAt?.let { (it - System.currentTimeMillis()) / 1000 }?.takeIf { it > 0 })
|
||||||
|
|||||||
@ -73,6 +73,7 @@
|
|||||||
<string name="footer_hours_ago">Updated %1$d h ago</string>
|
<string name="footer_hours_ago">Updated %1$d h ago</string>
|
||||||
<string name="footer_updated_at">Updated %1$s</string>
|
<string name="footer_updated_at">Updated %1$s</string>
|
||||||
<string name="dashboard_drag_hint">💡 Long-press a card to reorder</string>
|
<string name="dashboard_drag_hint">💡 Long-press a card to reorder</string>
|
||||||
|
<string name="dashboard_error_prefix">⚠ %1$s</string>
|
||||||
<string name="dashboard_footer_privacy">Stored locally & encrypted, nothing is uploaded</string>
|
<string name="dashboard_footer_privacy">Stored locally & encrypted, nothing is uploaded</string>
|
||||||
<string name="dialog_add_widget_title">Add home screen widget?</string>
|
<string name="dialog_add_widget_title">Add home screen widget?</string>
|
||||||
<string name="dialog_add_widget_body">This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.</string>
|
<string name="dialog_add_widget_body">This will ask the launcher to add the RainyToken widget. Some systems may still show their own confirmation dialog.</string>
|
||||||
@ -214,6 +215,7 @@
|
|||||||
<string name="msg_connect_success_balance">Connected! Balance: %1$s %2$s</string>
|
<string name="msg_connect_success_balance">Connected! Balance: %1$s %2$s</string>
|
||||||
<string name="msg_connect_success_saved">Connected, credentials saved</string>
|
<string name="msg_connect_success_saved">Connected, credentials saved</string>
|
||||||
<string name="error_api_key_empty">API Key cannot be empty</string>
|
<string name="error_api_key_empty">API Key cannot be empty</string>
|
||||||
|
<string name="error_json_format">Invalid JSON format</string>
|
||||||
<string name="error_credential_rejected">Service rejected this credential (401/403)</string>
|
<string name="error_credential_rejected">Service rejected this credential (401/403)</string>
|
||||||
<string name="error_credential_changed_test">Credentials changed during test</string>
|
<string name="error_credential_changed_test">Credentials changed during test</string>
|
||||||
<string name="error_rate_limited">Too many requests (429)</string>
|
<string name="error_rate_limited">Too many requests (429)</string>
|
||||||
@ -248,9 +250,19 @@
|
|||||||
|
|
||||||
<!-- ══════════ 触发用量 ══════════ -->
|
<!-- ══════════ 触发用量 ══════════ -->
|
||||||
<string name="trigger_success_title">✓ Usage activated</string>
|
<string name="trigger_success_title">✓ Usage activated</string>
|
||||||
|
<string name="trigger_success_header">✓ Activation successful</string>
|
||||||
<string name="trigger_result_title">Request result</string>
|
<string name="trigger_result_title">Request result</string>
|
||||||
<string name="trigger_error_prefix">Error: %1$s</string>
|
<string name="trigger_error_prefix">Error: %1$s</string>
|
||||||
<string name="trigger_response_body">Response body:\n%1$s</string>
|
<string name="trigger_response_body">Response body:\n%1$s</string>
|
||||||
|
<string name="trigger_success_model">Model: %1$s</string>
|
||||||
|
<string name="trigger_success_response_id">Response ID: %1$s</string>
|
||||||
|
<string name="trigger_reply_header">Reply:</string>
|
||||||
|
<string name="trigger_reply_empty">(empty)</string>
|
||||||
|
<string name="trigger_reply_missing">(no reply content)</string>
|
||||||
|
<string name="trigger_parse_failed">(response parsing failed, but the request was sent)</string>
|
||||||
|
<string name="trigger_usage_prompt_completion_total">Usage: prompt=%1$s completion=%2$s total=%3$s tokens</string>
|
||||||
|
<string name="trigger_usage_input_output">Usage: input=%1$s output=%2$s tokens</string>
|
||||||
|
<string name="error_token_refresh_failed">Token refresh failed: %1$s</string>
|
||||||
|
|
||||||
<!-- ══════════ WebView 登录 ══════════ -->
|
<!-- ══════════ WebView 登录 ══════════ -->
|
||||||
<string name="title_webview_login">Sign in to %1$s</string>
|
<string name="title_webview_login">Sign in to %1$s</string>
|
||||||
|
|||||||
@ -76,6 +76,7 @@
|
|||||||
<string name="footer_hours_ago">%1$d 小时前更新</string>
|
<string name="footer_hours_ago">%1$d 小时前更新</string>
|
||||||
<string name="footer_updated_at">%1$s 更新</string>
|
<string name="footer_updated_at">%1$s 更新</string>
|
||||||
<string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string>
|
<string name="dashboard_drag_hint">💡 长按卡片可拖拽排序</string>
|
||||||
|
<string name="dashboard_error_prefix">⚠ %1$s</string>
|
||||||
<string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string>
|
<string name="dashboard_footer_version" translatable="false">RainyToken · v1.0</string>
|
||||||
<string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string>
|
<string name="dashboard_footer_privacy">本地加密存储,不上传任何数据</string>
|
||||||
<string name="dialog_add_widget_title">添加桌面小组件?</string>
|
<string name="dialog_add_widget_title">添加桌面小组件?</string>
|
||||||
@ -218,6 +219,7 @@
|
|||||||
<string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string>
|
<string name="msg_connect_success_balance">连接成功!余额: %1$s %2$s</string>
|
||||||
<string name="msg_connect_success_saved">连接成功,凭据已保存</string>
|
<string name="msg_connect_success_saved">连接成功,凭据已保存</string>
|
||||||
<string name="error_api_key_empty">API Key 不能为空</string>
|
<string name="error_api_key_empty">API Key 不能为空</string>
|
||||||
|
<string name="error_json_format">JSON 格式错误</string>
|
||||||
<string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string>
|
<string name="error_credential_rejected">服务拒绝该凭据 (401/403)</string>
|
||||||
<string name="error_credential_changed_test">测试期间凭据已变更</string>
|
<string name="error_credential_changed_test">测试期间凭据已变更</string>
|
||||||
<string name="error_rate_limited">请求过于频繁 (429)</string>
|
<string name="error_rate_limited">请求过于频繁 (429)</string>
|
||||||
@ -252,9 +254,19 @@
|
|||||||
|
|
||||||
<!-- ══════════ 触发用量 ══════════ -->
|
<!-- ══════════ 触发用量 ══════════ -->
|
||||||
<string name="trigger_success_title">✓ 用量已激活</string>
|
<string name="trigger_success_title">✓ 用量已激活</string>
|
||||||
|
<string name="trigger_success_header">✓ 激活成功</string>
|
||||||
<string name="trigger_result_title">请求结果</string>
|
<string name="trigger_result_title">请求结果</string>
|
||||||
<string name="trigger_error_prefix">错误: %1$s</string>
|
<string name="trigger_error_prefix">错误: %1$s</string>
|
||||||
<string name="trigger_response_body">响应内容:\n%1$s</string>
|
<string name="trigger_response_body">响应内容:\n%1$s</string>
|
||||||
|
<string name="trigger_success_model">模型: %1$s</string>
|
||||||
|
<string name="trigger_success_response_id">响应 ID: %1$s</string>
|
||||||
|
<string name="trigger_reply_header">回复:</string>
|
||||||
|
<string name="trigger_reply_empty">(空)</string>
|
||||||
|
<string name="trigger_reply_missing">(无回复内容)</string>
|
||||||
|
<string name="trigger_parse_failed">(响应解析失败,但请求已发出)</string>
|
||||||
|
<string name="trigger_usage_prompt_completion_total">用量: prompt=%1$s completion=%2$s total=%3$s tokens</string>
|
||||||
|
<string name="trigger_usage_input_output">用量: input=%1$s output=%2$s tokens</string>
|
||||||
|
<string name="error_token_refresh_failed">token 刷新失败:%1$s</string>
|
||||||
|
|
||||||
<!-- ══════════ WebView 登录 ══════════ -->
|
<!-- ══════════ WebView 登录 ══════════ -->
|
||||||
<string name="title_webview_login">登录 %1$s</string>
|
<string name="title_webview_login">登录 %1$s</string>
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import kotlinx.serialization.json.JsonObject
|
|||||||
import kotlinx.serialization.json.JsonPrimitive
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
||||||
@ -236,11 +238,12 @@ class CodexRepositoryTest {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = parseSseResponse(sse, "gpt-5.6")
|
val result = parseSseResponse(sse, "gpt-5.6")
|
||||||
assertTrue(result.contains("Hello, world!"))
|
assertEquals("Hello, world!", result.reply)
|
||||||
assertTrue(result.contains("input=100"))
|
assertEquals("100", result.inputTokens)
|
||||||
assertTrue(result.contains("output=50"))
|
assertEquals("50", result.outputTokens)
|
||||||
assertTrue(result.contains("resp_123"))
|
assertEquals("resp_123", result.responseId)
|
||||||
assertTrue(result.contains("gpt-5.6"))
|
assertEquals("gpt-5.6", result.model)
|
||||||
|
assertFalse(result.parseFailed)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -251,9 +254,10 @@ class CodexRepositoryTest {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = parseSseResponse(sse, "o4-mini")
|
val result = parseSseResponse(sse, "o4-mini")
|
||||||
assertTrue(result.contains("(空)"))
|
assertEquals("", result.reply)
|
||||||
assertTrue(result.contains("input=10"))
|
assertEquals("10", result.inputTokens)
|
||||||
assertTrue(result.contains("output=5"))
|
assertEquals("5", result.outputTokens)
|
||||||
|
assertNull(result.totalTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -265,16 +269,18 @@ class CodexRepositoryTest {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = parseSseResponse(sse, "gpt-5.6")
|
val result = parseSseResponse(sse, "gpt-5.6")
|
||||||
assertTrue(result.contains("Hi there"))
|
assertEquals("Hi there", result.reply)
|
||||||
// Should not contain usage line when no usage data
|
// Should be null when no usage data present
|
||||||
assertTrue(!result.contains("用量:"))
|
assertNull(result.inputTokens)
|
||||||
|
assertNull(result.outputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parseSseResponse handles empty input`() {
|
fun `parseSseResponse handles empty input`() {
|
||||||
val result = parseSseResponse("", "gpt-5.6")
|
val result = parseSseResponse("", "gpt-5.6")
|
||||||
assertTrue(result.contains("(空)"))
|
assertEquals("", result.reply)
|
||||||
assertTrue(result.contains("gpt-5.6"))
|
assertEquals("gpt-5.6", result.model)
|
||||||
|
assertNull(result.responseId)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -288,9 +294,9 @@ class CodexRepositoryTest {
|
|||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = parseSseResponse(sse, "o4-mini")
|
val result = parseSseResponse(sse, "o4-mini")
|
||||||
assertTrue(result.contains("Test reply"))
|
assertEquals("Test reply", result.reply)
|
||||||
assertTrue(result.contains("input=200"))
|
assertEquals("200", result.inputTokens)
|
||||||
assertTrue(result.contains("output=100"))
|
assertEquals("100", result.outputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@ -304,18 +310,22 @@ class CodexRepositoryTest {
|
|||||||
|
|
||||||
val result = parseSseResponse(sse, "gpt-5.6")
|
val result = parseSseResponse(sse, "gpt-5.6")
|
||||||
// Malformed line should be skipped, valid ones parsed
|
// Malformed line should be skipped, valid ones parsed
|
||||||
assertTrue(result.contains("Good"))
|
assertEquals("Good", result.reply)
|
||||||
assertTrue(result.contains("input=1"))
|
assertEquals("1", result.inputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `parseSseResponse contains activation success header`() {
|
fun `parseSseResponse returns structured metadata`() {
|
||||||
val sse = """
|
val sse = """
|
||||||
data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}}
|
data: {"type":"response.completed","response":{"id":"r1","usage":{"input_tokens":"0","output_tokens":"0"}}}
|
||||||
data: [DONE]
|
data: [DONE]
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
val result = parseSseResponse(sse, "gpt-5.6")
|
val result = parseSseResponse(sse, "gpt-5.6")
|
||||||
assertTrue(result.contains("✓ 激活成功"))
|
assertEquals("r1", result.responseId)
|
||||||
|
assertEquals("0", result.inputTokens)
|
||||||
|
assertEquals("0", result.outputTokens)
|
||||||
|
assertEquals("", result.reply)
|
||||||
|
assertFalse(result.parseFailed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,12 +112,19 @@ class FormatUtilsTest {
|
|||||||
assertEquals("每周", normalizeWindowLabel("weekly"))
|
assertEquals("每周", normalizeWindowLabel("weekly"))
|
||||||
assertEquals("每周", normalizeWindowLabel("Weekly"))
|
assertEquals("每周", normalizeWindowLabel("Weekly"))
|
||||||
assertEquals("每周", normalizeWindowLabel("WEEKLY"))
|
assertEquals("每周", normalizeWindowLabel("WEEKLY"))
|
||||||
|
assertEquals("每周", normalizeWindowLabel("每周"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `normalizeWindowLabel non-weekly passes through`() {
|
fun `normalizeWindowLabel monthly maps to monthly label`() {
|
||||||
|
assertEquals("每月", normalizeWindowLabel("monthly"))
|
||||||
|
assertEquals("Monthly", normalizeWindowLabel("MONTHLY", monthlyLabel = "Monthly"))
|
||||||
|
assertEquals("每月", normalizeWindowLabel("每月"))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `normalizeWindowLabel unknown passes through`() {
|
||||||
assertEquals("5h", normalizeWindowLabel("5h"))
|
assertEquals("5h", normalizeWindowLabel("5h"))
|
||||||
assertEquals("monthly", normalizeWindowLabel("monthly"))
|
|
||||||
assertEquals("30d", normalizeWindowLabel("30d"))
|
assertEquals("30d", normalizeWindowLabel("30d"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user