diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 11190b993..e2f31588e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -765,6 +765,10 @@ dependencies { androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.compose.bom)) + // 单元测试中真实 org.json(Android 桩在 JVM 测试里会抛 Stub! 异常); + // 统计 usage 归一化测试需要解析 JSONObject。 + testImplementation("org.json:json:20240303") + // Apache POI - for Document processing (DOC, DOCX, etc.) implementation(libs.poi) implementation(libs.poi.ooxml) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt index 4a6ec391f..2ac9aa961 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt @@ -1110,7 +1110,13 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0) _perRequestTokenCounts.value = Pair(input, output) }, - onNonFatalError = onNonFatalError + onNonFatalError = onNonFatalError, + statsCategory = + if (isSubTask) { + com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT + } else { + com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT + } ) val revisableStream = responseStream as? TextStreamEventCarrier @@ -1211,10 +1217,6 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestInputTokenCount = 0L currentRequestOutputTokenCount = 0L currentRequestCachedInputTokenCount = 0L - apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count - apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel) AppLogger.d( TAG, @@ -2330,7 +2332,13 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestCachedInputTokenCount = cachedInput.coerceAtLeast(0) _perRequestTokenCounts.value = Pair(input, output) }, - onNonFatalError = onNonFatalError + onNonFatalError = onNonFatalError, + statsCategory = + if (isSubTask) { + com.ai.assistance.operit.data.stats.TokenStatCategory.SUBAGENT + } else { + com.ai.assistance.operit.data.stats.TokenStatCategory.CHAT + } ) // 更新状态为接收中 @@ -2425,10 +2433,6 @@ class EnhancedAIService private constructor(private val context: Context) { currentRequestInputTokenCount = 0L currentRequestOutputTokenCount = 0L currentRequestCachedInputTokenCount = 0L - apiPreferences.updateTokensForProviderModel(serviceForFunction.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count - apiPreferences.incrementRequestCountForProviderModel(serviceForFunction.providerModel) AppLogger.d( TAG, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt index f15de6c36..9d03a604b 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/enhance/ConversationService.kt @@ -269,7 +269,8 @@ class ConversationService( summaryService.sendMessage( context = context, chatHistory = preparedHistory, - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.SUMMARY ) // 收集流中的所有内容 @@ -320,18 +321,7 @@ class ConversationService( return "Conversation Summary: Unable to generate valid summary." } - // 将总结token计数添加到用户偏好分析的token统计中 - try { - AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") - apiPreferences.updateTokensForProviderModel(summaryService.providerModel, inputTokens, outputTokens, cachedInputTokens) - - // Update request count for summary generation - apiPreferences.incrementRequestCountForProviderModel(summaryService.providerModel) - - AppLogger.d(TAG, "已将总结token统计添加到用户偏好分析token计数中") - } catch (e: Exception) { - AppLogger.e(TAG, "更新token统计失败", e) - } + AppLogger.d(TAG, "总结生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") return summaryContent } catch (e: Exception) { @@ -368,28 +358,18 @@ class ConversationService( chatHistory = preparedHistory, modelParameters = modelParameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.TITLE ).collect { content -> contentBuilder.append(content) } val title = sanitizeConversationTitle( ChatUtils.removeThinkingContent(contentBuilder.toString().trim()) ) - try { - val inputTokens = titleService.inputTokenCount - val cachedInputTokens = titleService.cachedInputTokenCount - val outputTokens = titleService.outputTokenCount - apiPreferences.updateTokensForProviderModel( - titleService.providerModel, - inputTokens, - outputTokens, - cachedInputTokens - ) - apiPreferences.incrementRequestCountForProviderModel(titleService.providerModel) - AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") - } catch (e: Exception) { - AppLogger.e(TAG, "更新标题生成token统计失败", e) - } + val inputTokens = titleService.inputTokenCount + val cachedInputTokens = titleService.cachedInputTokenCount + val outputTokens = titleService.outputTokenCount + AppLogger.d(TAG, "标题生成使用了输入token: $inputTokens, 缓存token: $cachedInputTokens, 输出token: $outputTokens") title } catch (e: Exception) { @@ -1132,7 +1112,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} val stream = translationService.sendMessage( context = context, chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = translationPrompt), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { content -> @@ -1191,7 +1172,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} val stream = summaryService.sendMessage( context = context, chatHistory = chatHistory + PromptTurn(kind = PromptTurnKind.USER, content = descriptionPrompt), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { content -> @@ -1249,7 +1231,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } @@ -1294,7 +1277,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } @@ -1337,7 +1321,8 @@ ${FunctionalPrompts.translationUserPrompt(targetLanguage, text)} service.sendMessage( context = context, chatHistory = listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), - modelParameters = modelParameters + modelParameters = modelParameters, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ).collect { chunk -> result.append(chunk) } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt index 01117ff4d..b8da5dee3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLibrary.kt @@ -256,22 +256,12 @@ object MemoryLibrary { val stream = aiService.sendMessage( context = context, - chatHistory = messages + chatHistory = messages, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY ) stream.collect { content -> result.append(content) } } - - // 更新 token 统计 - apiPreferences?.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - - // Update request count - apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel) - + // 解析 AI 返回的 JSON 并更新记忆 parseAndApplyCategorization(result.toString(), memories, repository) } @@ -670,21 +660,12 @@ object MemoryLibrary { val stream = aiService.sendMessage( context = context, - chatHistory = messages + chatHistory = messages, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.MEMORY ) stream.collect { content -> result.append(content) } } - apiPreferences?.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - - // Update request count - apiPreferences?.incrementRequestCountForProviderModel(aiService.providerModel) - return parseAnalysisResult(ChatUtils.removeThinkingContent(result.toString())) } catch (e: CancellationException) { throw e diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt index 9d7f1e856..739664187 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIService.kt @@ -5,6 +5,8 @@ import com.ai.assistance.operit.core.chat.hooks.PromptTurn import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ToolPrompt +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.data.stats.TokenStatCategory import com.ai.assistance.operit.util.stream.Stream /** AI服务接口,定义与不同AI提供商进行交互的标准方法 */ @@ -44,8 +46,14 @@ interface AIService { * @param enableThinking 是否启用思考模式 * @param stream 是否使用流式输出,true为流式,false为非流式(但返回值仍为Stream) * @param availableTools 可用工具列表(用于Tool Call API),如果为null则使用系统提示词中的工具描述 - * @param onTokensUpdated Token更新回调 + * @param onTokensUpdated Token更新回调(UI 计数通道,可能携带估算值) + * @param onUsageReported 规范化 usage 上报回调(统计账本通道;只在解析到 + * provider 真实 usage/本地实测计数时回调,估算值不上报;可被多次调用, + * 第二次参数为 provider 内部尝试序号 attempt(从 1 开始,内部重试递增), + * 记录方按 attempt 聚合:同一 attempt 取最后一次,不同 attempt 累加) * @param onNonFatalError 非致命错误回调 + * @param enableRetry 是否允许内部重试 + * @param statsCategory 业务分类(统计账本);null 表示调用方未声明(按 OTHER 记录) * @return 流式响应内容的Stream(无论stream参数如何,都返回Stream) */ suspend fun sendMessage( @@ -57,17 +65,24 @@ interface AIService { availableTools: List? = null, preserveThinkInHistory: Boolean = false, // 新增参数,控制是否保留历史中的思考过程 onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit = { _, _, _ -> }, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, onNonFatalError: suspend (error: String) -> Unit = {}, - enableRetry: Boolean = true + enableRetry: Boolean = true, + statsCategory: TokenStatCategory? = null ): Stream /** * 测试与AI服务的连接 * * @param context Android Context + * @param onUsageReported 与 [sendMessage] 相同的 usage 上报回调;实现内部 + * 通过 sendMessage 发起测试模型调用时必须透传,使探测用量进入统计账本。 * @return 成功时返回成功信息,失败时返回包含错误的Result */ - suspend fun testConnection(context: Context): Result + suspend fun testConnection( + context: Context, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)? = null + ): Result /** * 精确计算下一次请求的输入Token数量 diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt index d9489cd51..29253a8b5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/AIServiceFactory.kt @@ -255,7 +255,11 @@ object AIServiceFactory { } /** - * 创建AI服务实例 + * 创建AI服务实例(统一统计记录边界)。 + * + * 所有服务(包括连接测试器直接创建的探测服务)都在这里统一包装 + * [TokenTrackingAIService]:任何 sendMessage/testConnection 调用都会落入 + * 统计账本,业务分类由调用方通过 sendMessage 的 statsCategory 声明。 * * @param config 模型配置数据 * @param modelConfigManager 模型配置管理器,用于多API Key模式 @@ -266,6 +270,19 @@ object AIServiceFactory { config: ModelConfigData, modelConfigManager: ModelConfigManager, context: Context + ): AIService { + val rawService = buildService(config, modelConfigManager, context) + return TokenTrackingAIService( + delegate = rawService, + context = context, + configId = config.id, + ) + } + + private fun buildService( + config: ModelConfigData, + modelConfigManager: ModelConfigManager, + context: Context ): AIService { val providerTypeId = config.apiProviderTypeId.trim() ToolPkgAiProviderRegistry.get(providerTypeId)?.let { provider -> @@ -300,7 +317,21 @@ object AIServiceFactory { return when (providerType) { // OpenAI格式,支持原生和兼容OpenAI API的服务 - ApiProviderType.OPENAI, + ApiProviderType.OPENAI -> + OpenAIProvider( + apiEndpoint = config.apiEndpoint, + apiKeyProvider = apiKeyProvider, + modelName = config.modelName, + client = httpClient, + customHeaders = customHeaders, + providerType = providerType, + supportsVision = supportsVision, + supportsAudio = supportsAudio, + supportsVideo = supportsVideo, + enableToolCall = enableToolCall, + includeUsageInStream = true, + ) + ApiProviderType.OPENAI_GENERIC, ApiProviderType.OPENAI_LOCAL -> OpenAIProvider( diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index 4bae16334..7af9a741a 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -39,6 +39,9 @@ import org.json.JSONArray import org.json.JSONObject /** Anthropic Claude API的实现,处理Claude特有的API格式 */ +internal fun shouldPropagateClaudeCancellation(isManuallyCancelled: Boolean): Boolean = + isManuallyCancelled + class ClaudeProvider( private val apiEndpoint: String, private val apiKeyProvider: ApiKeyProvider, @@ -183,6 +186,15 @@ class ClaudeProvider( private fun parseAnthropicUsage(usage: JSONObject?): AnthropicUsageCounts? { usage ?: return null + // 评审 P1-5:显式全零 payload 也是“已观察到的 usage”——按字段存在判断, + // 不能按 “>0” 过滤;P2-1:Long 解析,旧 UI 计数边界饱和 Int。 + val hasAny = + usage.has("input_tokens") || usage.has("prompt_tokens") || + usage.has("cache_read_input_tokens") || usage.has("cached_tokens") || + usage.has("cache_creation_input_tokens") || usage.has("cache_creation") || + usage.has("output_tokens") || usage.has("completion_tokens") + if (!hasAny) return null + val cachedInputTokens = when { usage.has("cache_read_input_tokens") -> usage.optLong("cache_read_input_tokens", 0L) usage.optJSONObject("input_tokens_details") != null -> @@ -225,7 +237,10 @@ class ClaudeProvider( usage: JSONObject?, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, source: String, - overwriteOutputTokens: Boolean + overwriteOutputTokens: Boolean, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1, + completeSnapshot: Boolean = false ): Boolean { val parsed = parseAnthropicUsage(usage) ?: return false @@ -248,6 +263,15 @@ class ClaudeProvider( parsed.cachedInputTokens, tokenCacheManager.outputTokenCount ) + onUsageReported?.invoke( + // 流式 start/delta 是部分更新(省略字段保留旧值);非流式最终响应是 + // 完整快照中 null 表示明确未知,覆盖该 attempt 的旧值。 + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.anthropic( + usage, + completeSnapshot, + ) ?: return true, + attemptNumber + ) return true } @@ -1393,8 +1417,10 @@ class ClaudeProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -1569,7 +1595,10 @@ class ClaudeProvider( usage = json.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "non_streaming_json", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isBlank() && !usageApplied) { throw IOException(context.getString(R.string.provider_error_parsing_failed)) @@ -1581,6 +1610,11 @@ class ClaudeProvider( tokenCacheManager.outputTokenCount ) } + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } return@withContext } @@ -1597,7 +1631,10 @@ class ClaudeProvider( usage = json.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "non_streaming_response", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isNotBlank() && !usageApplied) { onTokensUpdated( @@ -1668,7 +1705,9 @@ class ClaudeProvider( usage = jsonResponse.optJSONObject("message")?.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "message_start", - overwriteOutputTokens = false + overwriteOutputTokens = false, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1 ) } "content_block_start" -> { @@ -1818,7 +1857,9 @@ class ClaudeProvider( usage = jsonResponse.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "message_delta", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, ) } "message_stop" -> { @@ -1856,6 +1897,12 @@ class ClaudeProvider( } } + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } + if (!emittedAny && nonSseJsonLinesBuffer.isNotBlank()) { val buffered = nonSseJsonLinesBuffer.toString().trim() AppLogger.w( @@ -1878,7 +1925,10 @@ class ClaudeProvider( usage = wholeJson.optJSONObject("usage"), onTokensUpdated = onTokensUpdated, source = "buffered_json_fallback", - overwriteOutputTokens = true + overwriteOutputTokens = true, + onUsageReported = onUsageReported, + attemptNumber = retryCount + 1, + completeSnapshot = true ) if (resultText.isNotBlank() && !usageApplied) { onTokensUpdated( @@ -1921,6 +1971,13 @@ class ClaudeProvider( } } + // Cancellation can race with fallback parsing after the stream loop. Recheck at + // the final success boundary so a manually cancelled request is never completed. + if (shouldPropagateClaudeCancellation(isManuallyCancelled)) { + throw UserCancellationException( + context.getString(R.string.openai_error_request_cancelled) + ) + } AppLogger.d("AIService", "【Claude】请求成功完成") logFinalOutput(receivedContent, "Claude final output summary: ") return@stream @@ -1965,7 +2022,8 @@ class ClaudeProvider( R.string.openai_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.provider_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) @@ -1985,7 +2043,10 @@ class ClaudeProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { // 通过发送一条短消息来测试完整的连接、认证和API端点。 // 这比getModelsList更可靠,因为它直接命中了聊天API。 @@ -1997,6 +2058,7 @@ class ClaudeProvider( emptyList(), false, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) @@ -2006,6 +2068,9 @@ class ClaudeProvider( stream.collect { _ -> } Result.success(context.getString(R.string.openai_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { AppLogger.e("AIService", "连接测试失败", e) Result.failure(IOException(context.getString(R.string.openai_connection_test_failed, e.message ?: ""), e)) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt index 35718f1c5..38652cd02 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DeepseekProvider.kt @@ -43,7 +43,7 @@ class DeepseekProvider( supportsVision = supportsVision, supportsAudio = supportsAudio, supportsVideo = supportsVideo, - enableToolCall = enableToolCall + enableToolCall = enableToolCall, ) { /** @@ -81,6 +81,9 @@ class DeepseekProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } // DeepSeek Thinking Mode 默认开启,关闭时也必须显式发送 thinking.type=disabled。 applyThinkingParamsIfNeeded(jsonObject) @@ -469,10 +472,12 @@ class DeepseekProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { // 直接调用父类的sendMessage实现 - return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onNonFatalError, enableRetry) + return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onUsageReported, onNonFatalError, enableRetry, statsCategory) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index 4fff01c21..88ea79813 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -9,6 +9,7 @@ import com.ai.assistance.operit.data.model.ModelOption import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ToolPrompt import com.ai.assistance.operit.data.model.ParameterCategory +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer import com.ai.assistance.operit.data.preferences.ApiPreferences import com.ai.assistance.operit.util.ChatUtils import com.ai.assistance.operit.util.ChatMarkupRegex @@ -35,6 +36,7 @@ import java.net.SocketTimeoutException import java.net.URL import java.net.UnknownHostException import java.util.UUID +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking @@ -1069,8 +1071,10 @@ class GeminiProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -1174,10 +1178,10 @@ class GeminiProvider( // 根据stream参数处理响应 if (stream) { // 处理流式响应 - processStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent) + processStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent, onUsageReported, retryCount + 1) } else { // 处理非流式响应并转换为Stream - processNonStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent) + processNonStreamingResponse(context, response, streamCollector, requestId, onTokensUpdated, receivedContent, onUsageReported, retryCount + 1) } } finally { response.close() @@ -1212,7 +1216,8 @@ class GeminiProvider( R.string.gemini_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.provider_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) @@ -1407,7 +1412,9 @@ class GeminiProvider( streamCollector: StreamCollector, requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - receivedContent: StringBuilder + receivedContent: StringBuilder, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { AppLogger.d(TAG, "开始处理响应流") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1450,7 +1457,7 @@ class GeminiProvider( val json = JSONObject(data) jsonCount++ - val content = extractContentFromJson(context, json, requestId, onTokensUpdated) + val content = extractContentFromJson(context, json, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("提取SSE内容,长度: ${content.length}") @@ -1459,6 +1466,8 @@ class GeminiProvider( // 只发送新增的内容 streamCollector.emit(content) } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1515,7 +1524,9 @@ class GeminiProvider( context, jsonObject, requestId, - onTokensUpdated + onTokensUpdated, + onUsageReported, + attemptNumber ) if (content.isNotEmpty()) { contentCount++ @@ -1536,6 +1547,8 @@ class GeminiProvider( isCollectingJson = false completeJsonBuilder.clear() } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1575,7 +1588,7 @@ class GeminiProvider( for (i in 0 until jsonContent.length()) { val jsonObject = jsonContent.optJSONObject(i) ?: continue jsonCount++ - val content = extractContentFromJson(context, jsonObject, requestId, onTokensUpdated) + val content = extractContentFromJson(context, jsonObject, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("从最终JSON数组[$i]提取内容,长度: ${content.length}") @@ -1586,7 +1599,7 @@ class GeminiProvider( } is JSONObject -> { jsonCount++ - val content = extractContentFromJson(context, jsonContent, requestId, onTokensUpdated) + val content = extractContentFromJson(context, jsonContent, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { contentCount++ logDebug("从最终JSON对象提取内容,长度: ${content.length}") @@ -1595,6 +1608,8 @@ class GeminiProvider( } } } + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1614,6 +1629,8 @@ class GeminiProvider( logDebug("未检测到内容,发送空格") streamCollector.emit(" ") } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logError("处理响应时发生异常: ${e.message}", e) throw e @@ -1629,7 +1646,9 @@ class GeminiProvider( streamCollector: StreamCollector, requestId: String, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - receivedContent: StringBuilder + receivedContent: StringBuilder, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { AppLogger.d(TAG, "开始处理非流式响应") val responseBody = response.body ?: throw IOException(context.getString(R.string.gemini_response_empty)) @@ -1642,7 +1661,7 @@ class GeminiProvider( val json = JSONObject(responseText) // 提取内容 - val content = extractContentFromJson(context, json, requestId, onTokensUpdated) + val content = extractContentFromJson(context, json, requestId, onTokensUpdated, onUsageReported, attemptNumber) if (content.isNotEmpty()) { receivedContent.append(content) @@ -1662,6 +1681,8 @@ class GeminiProvider( streamCollector.emit("") isInThinkingMode = false } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logError("处理非流式响应时发生异常: ${e.message}", e) throw e @@ -1675,7 +1696,9 @@ class GeminiProvider( context: Context, json: JSONObject, requestId: String, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ): String { val contentBuilder = StringBuilder() val searchSourcesBuilder = StringBuilder() @@ -1684,6 +1707,40 @@ class GeminiProvider( try { throwIfGeminiErrorPayload(context, json) + // 提取实际的token使用数据:必须先于 candidates/content 的提前返回执行, + // 否则“无 candidates 但带 usageMetadata”的响应(如 prompt 被拦截)会漏记用量。 + var serverUsageApplied = false + val usageMetadata = json.optJSONObject("usageMetadata") + if (usageMetadata != null) { + val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0L) + val cachedContentTokenCount = usageMetadata.optLong("cachedContentTokenCount", 0L) + val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) + + val hasServerUsage = + usageMetadata.has("promptTokenCount") || + usageMetadata.has("cachedContentTokenCount") || + usageMetadata.has("candidatesTokenCount") + if (hasServerUsage) { + serverUsageApplied = true + // 更新实际的token计数 + val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) + tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) + tokenCacheManager.setOutputTokens(candidatesTokenCount) + + logDebug("API实际Token使用: 输入=$actualInputTokens, 缓存=$cachedContentTokenCount, 输出=$candidatesTokenCount") + + // 更新回调,使用实际的token统计 + onTokensUpdated( + tokenCacheManager.totalInputTokenCount, + tokenCacheManager.cachedInputTokenCount, + tokenCacheManager.outputTokenCount + ) + onUsageReported?.let { callback -> + ProviderUsageNormalizer.gemini(usageMetadata)?.let { callback(it, attemptNumber) } + } + } + } + // 提取候选项 val candidates = json.optJSONArray("candidates") if (candidates == null || candidates.length() == 0) { @@ -1883,14 +1940,18 @@ class GeminiProvider( logDebug("提取文本,长度=${text.length}") } - // 估算token - val tokens = ChatUtils.estimateTokenCount(text) - tokenCacheManager.addOutputTokens(tokens) - onTokensUpdated( - tokenCacheManager.totalInputTokenCount, - tokenCacheManager.cachedInputTokenCount, - tokenCacheManager.outputTokenCount - ) + // 估算token:本 chunk 已应用服务器累计实际值时不再叠加估算, + // 否则会在 setOutputTokens 的累计实际值之上重复计数(原实现靠 + // 末尾覆盖避免重复,usage 提取提前后需显式跳过) + if (!serverUsageApplied) { + val tokens = ChatUtils.estimateTokenCount(text) + tokenCacheManager.addOutputTokens(tokens) + onTokensUpdated( + tokenCacheManager.totalInputTokenCount, + tokenCacheManager.cachedInputTokenCount, + tokenCacheManager.outputTokenCount + ) + } } } @@ -1898,32 +1959,6 @@ class GeminiProvider( appendGeminiThoughtSignatureMeta(contentBuilder, signature) } - // 提取实际的token使用数据 - val usageMetadata = json.optJSONObject("usageMetadata") - if (usageMetadata != null) { - val promptTokenCount = usageMetadata.optLong("promptTokenCount", 0L) - val cachedContentTokenCount = usageMetadata.optLong("cachedContentTokenCount", 0L) - val candidatesTokenCount = usageMetadata.optLong("candidatesTokenCount", 0L) - - val hasServerUsage = - promptTokenCount > 0 || cachedContentTokenCount > 0 || candidatesTokenCount > 0 - if (hasServerUsage) { - // 更新实际的token计数 - val actualInputTokens = (promptTokenCount - cachedContentTokenCount).coerceAtLeast(0) - tokenCacheManager.updateActualTokens(actualInputTokens, cachedContentTokenCount) - tokenCacheManager.setOutputTokens(candidatesTokenCount) - - logDebug("API实际Token使用: 输入=$actualInputTokens, 缓存=$cachedContentTokenCount, 输出=$candidatesTokenCount") - - // 更新回调,使用实际的token统计 - onTokensUpdated( - tokenCacheManager.totalInputTokenCount, - tokenCacheManager.cachedInputTokenCount, - tokenCacheManager.outputTokenCount - ) - } - } - // 将搜索来源拼接到内容最前面 val finalContent = if (searchSourcesBuilder.isNotEmpty()) { searchSourcesBuilder.toString() + contentBuilder.toString() @@ -1932,6 +1967,8 @@ class GeminiProvider( } return finalContent + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -1950,7 +1987,10 @@ class GeminiProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { // 通过发送一条短消息来测试完整的连接、认证和API端点。 // 这比getModelsList更可靠,因为它直接命中了聊天API。 @@ -1964,6 +2004,7 @@ class GeminiProvider( false, null, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) @@ -1978,6 +2019,9 @@ class GeminiProvider( // 某些情况下,即使连接成功,也可能不会返回任何数据(例如,如果模型只处理了提示而没有生成响应)。 // 因此,只要不抛出异常,我们就认为连接成功。 Result.success(context.getString(R.string.gemini_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { logError("连接测试失败", e) Result.failure(IOException(context.getString(R.string.gemini_connection_test_failed, e.message ?: ""), e)) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt index 5e4f2a829..f16b0949f 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt @@ -40,7 +40,7 @@ open class KimiProvider( supportsVision = supportsVision, supportsAudio = supportsAudio, supportsVideo = supportsVideo, - enableToolCall = enableToolCall + enableToolCall = enableToolCall, ) { override fun createRequestBody( @@ -65,6 +65,9 @@ open class KimiProvider( val baseRequestBodyJson = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) val jsonObject = JSONObject(baseRequestBodyJson) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } applyThinkingParams(jsonObject) return createJsonRequestBody(jsonObject.toString()) } @@ -72,6 +75,9 @@ open class KimiProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) + if (stream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } applyThinkingParams(jsonObject) for (param in modelParameters) { @@ -426,8 +432,10 @@ open class KimiProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { return super.sendMessage( context, @@ -438,8 +446,10 @@ open class KimiProvider( availableTools, preserveThinkInHistory, onTokensUpdated, + onUsageReported, onNonFatalError, - enableRetry + enableRetry, + statsCategory ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt index 23980e2c6..7e7687cb3 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LlamaProvider.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.io.File +import java.io.IOException class LlamaProvider( private val context: Context, @@ -113,7 +114,10 @@ class LlamaProvider( return ModelListFetcher.getLlamaLocalModels(context) } - override suspend fun testConnection(context: Context): Result = withContext(Dispatchers.IO) { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result = withContext(Dispatchers.IO) { if (!LlamaSession.isAvailable()) { return@withContext Result.failure(Exception(LlamaSession.getUnavailableReason())) } @@ -170,20 +174,23 @@ class LlamaProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = stream { isCancelled = false if (!LlamaSession.isAvailable()) { emit("${context.getString(R.string.llama_error_prefix)}: ${LlamaSession.getUnavailableReason()}") - return@stream + // 致命错误:保留用户可见错误文本后以失败终止(统计边界记为 FAILED) + throw IOException("${context.getString(R.string.llama_error_prefix)}: ${LlamaSession.getUnavailableReason()}") } val modelFile = getModelFile(context, modelName) if (!modelFile.exists()) { emit("${context.getString(R.string.llama_error_prefix)}: ${context.getString(R.string.llama_error_model_file_not_exist, modelFile.absolutePath)}") - return@stream + throw IOException("${context.getString(R.string.llama_error_prefix)}: ${context.getString(R.string.llama_error_model_file_not_exist, modelFile.absolutePath)}") } val s = withContext(Dispatchers.IO) { @@ -191,7 +198,7 @@ class LlamaProvider( } if (s == null) { emit(context.getString(R.string.llama_error_session_create_failed)) - return@stream + throw IOException(context.getString(R.string.llama_error_session_create_failed)) } val effectiveEnableToolCall = shouldUseToolCall(availableTools) @@ -218,7 +225,7 @@ class LlamaProvider( } if (prompt.isNullOrBlank()) { emit(context.getString(R.string.llama_error_chat_template_failed)) - return@stream + throw IOException(context.getString(R.string.llama_error_chat_template_failed)) } logLargeString("Final prompt before llama generation: ", prompt) @@ -289,52 +296,68 @@ class LlamaProvider( val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() - val success = withContext(Dispatchers.IO) { - s.generateStream(prompt, requestedMaxNewTokens) { token -> - if (isCancelled) { - false - } else { - outputTokenCount += 1L - _outputTokenCount = outputTokenCount - - if (effectiveEnableToolCall) { - toolCallOutputBuffer.append(token) + val usageReporter = LocalUsageReporter( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_LLAMA, + onUsageReported, + ) + usageReporter.runReportingFinally({ _inputTokenCount }, { _outputTokenCount }) { + val success = withContext(Dispatchers.IO) { + s.generateStream(prompt, requestedMaxNewTokens) { token -> + if (isCancelled) { + false } else { - finalOutputBuffer.append(token) - runBlocking { emit(token) } - } + outputTokenCount += 1L + _outputTokenCount = outputTokenCount + + if (effectiveEnableToolCall) { + toolCallOutputBuffer.append(token) + } else { + finalOutputBuffer.append(token) + runBlocking { emit(token) } + } - kotlin.runCatching { - kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + kotlin.runCatching { + kotlinx.coroutines.runBlocking { + onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + } } - } - true + true + } } } - } - if (effectiveEnableToolCall) { - val normalizedPayload = withContext(Dispatchers.IO) { - kotlin.runCatching { - s.parseToolCallResponse(toolCallOutputBuffer.toString()) - }.getOrNull() - } - val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( - normalizedPayload ?: toolCallOutputBuffer.toString() + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + usageReporter = usageReporter, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + cancelMessage = context.getString(R.string.llama_error_request_cancelled), + emitToolResult = { + if (effectiveEnableToolCall) { + val normalizedPayload = withContext(Dispatchers.IO) { + kotlin.runCatching { + s.parseToolCallResponse(toolCallOutputBuffer.toString()) + }.getOrNull() + } + val converted = StructuredToolCallBridge.convertToolCallPayloadToXml( + normalizedPayload ?: toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } + } + }, + failWith = { + kotlin.runCatching { + onNonFatalError(context.getString(R.string.llama_error_inference_failed)) + } + emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") + throw IOException(context.getString(R.string.llama_error_inference_failed)) + }, ) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) - } - } - - if (!success && !isCancelled) { - kotlin.runCatching { - onNonFatalError(context.getString(R.string.llama_error_inference_failed)) - } - emit("\n\n${context.getString(R.string.llama_error_inference_tag)}") } AppLogger.i(TAG, "llama.cpp推理完成,输出token数: $_outputTokenCount") diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt new file mode 100644 index 000000000..e0ac41560 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEnd.kt @@ -0,0 +1,98 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.util.exceptions.UserCancellationException +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.withContext + +internal class LocalUsageReporter( + private val source: String, + private val onUsageReported: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, +) { + private val reported = AtomicBoolean(false) + + suspend fun report(inputTokens: Long, outputTokens: Long) { + val callback = onUsageReported ?: return + if (!reported.compareAndSet(false, true)) return + withContext(NonCancellable) { + try { + callback( + ProviderUsageNormalizer.local( + uncachedInputTokens = inputTokens, + outputTokens = outputTokens, + source = source, + ), + 1, + ) + } catch (_: Exception) { + // Usage accounting must not replace the generation result or cancellation cause. + } + } + } + + suspend fun runReportingFinally( + inputTokens: () -> Long, + outputTokens: () -> Long, + block: suspend () -> T, + ): T = try { + block() + } finally { + report(inputTokens(), outputTokens()) + } +} + +/** + * 本地 provider(Llama/MNN)生成结束的统一顺序契约(评审 P2-3 修复)。 + * + * 顺序即契约,供两个 provider 共用并单独测试: + * 1. **取消优先**:native 生成返回后,先判定 [cancelled]——取消时先上报已实测的 + * usage,再抛 [UserCancellationException],**绝不**转换/emit 不完整的工具 XML; + * 2. **失败次之**:未取消但 [success] = false 时,先上报已实测 usage,再由 + * [failWith] 终止(保留用户可见错误文本并以失败异常结束),失败路径同样 + * **绝不**转换/emit 工具缓冲; + * 3. **成功最后**:仅成功路径([success] = true)由 [emitToolResult] 处理工具 + * 缓冲(解析 + emit),随后上报 usage。 + * + * 背景:旧实现先转换/emit 工具缓冲再检查 isCancelled,取消时会向调用方发出 + * 半截工具 XML,下游可能按完整工具调用执行导致错误落账。 + */ +internal object LocalGenerationEnd { + + /** + * @param cancelled 用户是否已取消(cancelStreaming 触发 native 停止)。 + * @param success native 生成是否正常结束(false = 失败或取消)。 + * @param usageReporter 本次生成共享的一次性 usage 上报器。 + * @param inputTokens 已实测输入 token 数(tokenizer 计数)。 + * @param outputTokens 已生成输出 token 数(逐 token 实测)。 + * @param cancelMessage 取消异常的用户可见消息。 + * @param emitToolResult 仅成功路径的工具缓冲处理(解析/转换/emit)。 + * @param failWith 失败时的终止动作(错误文本 + 抛 IOException 等)。 + */ + suspend fun end( + cancelled: Boolean, + success: Boolean, + usageReporter: LocalUsageReporter, + inputTokens: Long, + outputTokens: Long, + cancelMessage: String, + emitToolResult: suspend () -> Unit, + failWith: suspend () -> Unit, + ) { + if (cancelled) { + // 取消优先:先保留已实测 usage,再以取消异常结束——不 emit 工具缓冲 + usageReporter.report(inputTokens, outputTokens) + throw UserCancellationException(cancelMessage) + } + if (!success) { + // 失败次之:不转换/emit 不完整的工具缓冲,先上报已实测 usage 再终止 + usageReporter.report(inputTokens, outputTokens) + failWith() + return + } + // 成功最后:仅成功路径处理工具缓冲(解析 + emit),随后上报 usage + emitToolResult() + usageReporter.report(inputTokens, outputTokens) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt index deb30cee1..77fc0bec5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/MNNProvider.kt @@ -22,6 +22,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream +import java.io.IOException +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking import org.json.JSONArray import org.json.JSONObject @@ -601,8 +603,10 @@ class MNNProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = stream { isCancelled = false @@ -612,14 +616,12 @@ class MNNProvider( // 初始化模型 val initResult = initModel() if (initResult.isFailure) { - emit(context.getString(R.string.mnn_generic_error, initResult.exceptionOrNull()?.message ?: "")) - return@stream + // 致命错误:抛出让统计边界记为 FAILED;用户可见错误文本由下方 + // catch 统一 emit(含具体原因),避免重复格式化 + throw IOException(initResult.exceptionOrNull()?.message ?: "") } - val session = llmSession ?: run { - emit(context.getString(R.string.mnn_session_not_initialized)) - return@stream - } + val session = llmSession ?: throw IOException(context.getString(R.string.mnn_session_not_initialized)) // 应用模型参数(采样参数) applyModelParameters(session, modelParameters) @@ -676,48 +678,71 @@ class MNNProvider( val toolCallOutputBuffer = StringBuilder() val finalOutputBuffer = StringBuilder() val emitDirectly = !useInternalToolCall - val success = session.generateStream(safeHistory, requestedMaxNewTokens) { token -> - if (isCancelled) { - false - } else { - outputTokenCount += 1L - _outputTokenCount = outputTokenCount - - if (emitDirectly) { - finalOutputBuffer.append(token) - runBlocking { emit(token) } + val usageReporter = LocalUsageReporter( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.SOURCE_MNN, + onUsageReported, + ) + usageReporter.runReportingFinally({ _inputTokenCount }, { _outputTokenCount }) { + val success = session.generateStream(safeHistory, requestedMaxNewTokens) { token -> + if (isCancelled) { + false } else { - toolCallOutputBuffer.append(token) - } - - kotlin.runCatching { - kotlinx.coroutines.runBlocking { - onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + outputTokenCount += 1L + _outputTokenCount = outputTokenCount + + if (emitDirectly) { + finalOutputBuffer.append(token) + runBlocking { emit(token) } + } else { + toolCallOutputBuffer.append(token) } - } - true - } - } + kotlin.runCatching { + kotlinx.coroutines.runBlocking { + onTokensUpdated(_inputTokenCount, 0L, _outputTokenCount) + } + } - if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { - val converted = StructuredToolCallBridge.convertToolCallPayloadToXml(toolCallOutputBuffer.toString()) - if (converted.isNotBlank()) { - finalOutputBuffer.append(converted) - emit(converted) + true + } } - } - if (!success && !isCancelled) { - emit(context.getString(R.string.mnn_reasoning_error)) + LocalGenerationEnd.end( + cancelled = isCancelled, + success = success, + usageReporter = usageReporter, + inputTokens = _inputTokenCount, + outputTokens = _outputTokenCount, + cancelMessage = context.getString(R.string.mnn_error_request_cancelled), + emitToolResult = { + if (useInternalToolCall && toolCallOutputBuffer.isNotEmpty()) { + val converted = + StructuredToolCallBridge.convertToolCallPayloadToXml( + toolCallOutputBuffer.toString() + ) + if (converted.isNotBlank()) { + finalOutputBuffer.append(converted) + emit(converted) + } + } + }, + failWith = { + throw IOException(context.getString(R.string.mnn_reasoning_error)) + }, + ) } AppLogger.i(TAG, "MNN LLM推理完成,输出token数: $_outputTokenCount") logFinalOutput(finalOutputBuffer, "Final MNN output summary: ") + } catch (e: CancellationException) { + // 取消原样传播,不 emit 错误文本 + throw e } catch (e: Exception) { AppLogger.e(TAG, "发送消息时出错", e) + // 致命错误:保留用户可见错误文本后继续上抛(统计边界记为 FAILED) emit(context.getString(R.string.mnn_generic_error, e.message ?: "")) + throw e } finally { requestTempFiles.forEach { file -> runCatching { file.delete() } @@ -725,7 +750,10 @@ class MNNProvider( } } - override suspend fun testConnection(context: Context): Result = withContext(Dispatchers.IO) { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result = withContext(Dispatchers.IO) { try { // 检查模型名称 if (modelName.isEmpty()) { @@ -960,15 +988,12 @@ class MNNProvider( val prompt = buildPrompt(flattenedHistory) return countTokens(prompt).toLong() } - val session = llmSession ?: run { val prompt = buildPrompt(flattenedHistory) return countTokens(prompt).toLong() } - val modelDir = getModelDir(context, modelName) val maxAllTokens = cachedModelMaxAllTokens ?: readModelMaxAllTokens(modelDir).also { cachedModelMaxAllTokens = it } - val maxPromptTokens = (maxAllTokens - 512).coerceAtLeast(128) val safeHistory = trimHistoryToTokenBudget(session, flattenedHistory, maxPromptTokens) return kotlin.runCatching { session.countTokensWithHistory(safeHistory).toLong() } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt index 582f87637..280b17ea7 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ModelConfigConnectionTester.kt @@ -93,7 +93,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = "Hi")), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } @@ -134,7 +135,8 @@ object ModelConfigConnectionTester { parameters, stream = false, availableTools = availableTools, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } @@ -161,7 +163,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { ImagePoolManager.removeImage(imageId) @@ -189,7 +192,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { MediaPoolManager.removeMedia(audioId) @@ -217,7 +221,8 @@ object ModelConfigConnectionTester { listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { } } finally { MediaPoolManager.removeMedia(videoId) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index d7369319d..b6c5ba001 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -98,7 +98,8 @@ open class OpenAIProvider( protected val supportsVision: Boolean = false, // 是否支持图片处理 protected val supportsAudio: Boolean = false, // 是否支持音频输入 protected val supportsVideo: Boolean = false, // 是否支持视频输入 - val enableToolCall: Boolean = false // 是否启用Tool Call接口 + val enableToolCall: Boolean = false, // 是否启用Tool Call接口 + private val includeUsageInStream: Boolean = false, ) : AIService { // private val client: OkHttpClient = HttpClientFactory.instance @@ -141,16 +142,26 @@ open class OpenAIProvider( private suspend fun applyUsageToCounters( usage: JSONObject?, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val parsed = OpenAIResponsesPayloadAdapter.parseUsageCounts(usage) ?: return - tokenCacheManager.updateActualTokens(parsed.actualInputTokens, parsed.cachedInputTokens) - tokenCacheManager.setOutputTokens(parsed.outputTokens) + tokenCacheManager.updateActualTokens(parsed.actualInputTokens.toLong(), parsed.cachedInputTokens.toLong()) + tokenCacheManager.setOutputTokens(parsed.outputTokens.toLong()) onTokensUpdated( - parsed.totalInputTokens, - parsed.cachedInputTokens, + parsed.totalInputTokens.toLong(), + parsed.cachedInputTokens.toLong(), tokenCacheManager.outputTokenCount ) + onUsageReported?.invoke( + if (useResponsesApi) { + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.openAiResponses(usage) + } else { + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.openAiChatCompletions(usage) + } ?: return, + attemptNumber + ) } private fun buildOpenAiErrorDetail(error: JSONObject, fallback: String): String { @@ -232,7 +243,10 @@ open class OpenAIProvider( ) } - override suspend fun testConnection(context: Context): Result { + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { return try { val testHistory = listOf( @@ -246,12 +260,16 @@ open class OpenAIProvider( emptyList(), false, onTokensUpdated = { _, _, _ -> }, + onUsageReported = onUsageReported, onNonFatalError = {}, enableRetry = false ) stream.collect { _ -> } Result.success(context.getString(R.string.openai_connection_success)) + } catch (e: kotlinx.coroutines.CancellationException) { + // 取消必须原样传播,不能变成 Result.failure + throw e } catch (e: Exception) { AppLogger.e("AIService", "连接测试失败", e) Result.failure(IOException(context.getString(R.string.openai_connection_test_failed, e.message ?: ""), e)) @@ -631,6 +649,9 @@ open class OpenAIProvider( val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) // 根据stream参数设置 + if (stream && includeUsageInStream) { + jsonObject.put("stream_options", JSONObject().put("include_usage", true)) + } // 添加已启用的模型参数 for (param in modelParameters) { @@ -2007,7 +2028,9 @@ open class OpenAIProvider( jsonResponse: JSONObject, state: StreamingState, emitter: StreamEmitter, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val eventType = jsonResponse.optString("type", "") @@ -2176,7 +2199,7 @@ open class OpenAIProvider( } closeAllOpenToolCalls(state, emitter) - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) } "response.failed", "response.error" -> { @@ -2298,12 +2321,14 @@ open class OpenAIProvider( jsonResponse: JSONObject, state: StreamingState, emitter: StreamEmitter, - onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val usage = jsonResponse.optJSONObject("usage") val choices = jsonResponse.optJSONArray("choices") if (choices == null || choices.length() == 0) { - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) return } @@ -2358,17 +2383,19 @@ open class OpenAIProvider( } } - applyUsageToCounters(usage, onTokensUpdated) + applyUsageToCounters(usage, onTokensUpdated, onUsageReported, attemptNumber) } /** - * 处理流式响应 + * 处理 OpenAI 流式响应 */ private suspend fun processStreamingResponse( reader: java.io.BufferedReader, emitter: StreamEmitter, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, - context: Context + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? = null, + attemptNumber: Int = 1 ) { val state = StreamingState() @@ -2406,7 +2433,7 @@ open class OpenAIProvider( throwIfOpenAiErrorPayload(context, jsonResponse) if (useResponsesApi) { - processResponsesStreamingEvent(context, jsonResponse, state, emitter, onTokensUpdated) + processResponsesStreamingEvent(context, jsonResponse, state, emitter, onTokensUpdated, onUsageReported, attemptNumber) continue } @@ -2416,7 +2443,9 @@ open class OpenAIProvider( continue } } - processResponseChunk(jsonResponse, state, emitter, onTokensUpdated) + processResponseChunk(jsonResponse, state, emitter, onTokensUpdated, onUsageReported, attemptNumber) + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -2464,8 +2493,10 @@ open class OpenAIProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { val eventChannel = MutableSharedStream(replay = Int.MAX_VALUE) val responseStream = stream { @@ -2588,7 +2619,9 @@ open class OpenAIProvider( reader, emitter, onTokensUpdated, - context + context, + onUsageReported, + attemptNumber ) } else { AppLogger.d("AIService", "[req=$requestTraceId] 【发送消息】开始读取非流式响应") @@ -2672,9 +2705,11 @@ open class OpenAIProvider( } } - applyUsageToCounters(jsonResponse.optJSONObject("usage"), onTokensUpdated) + applyUsageToCounters(jsonResponse.optJSONObject("usage"), onTokensUpdated, onUsageReported, attemptNumber) AppLogger.d("AIService", "[req=$requestTraceId] 【发送消息】非流式响应处理完成") + } catch (e: CancellationException) { + throw e } catch (e: IOException) { throw e } catch (e: Exception) { @@ -2732,7 +2767,8 @@ open class OpenAIProvider( R.string.openai_error_connection_timeout, maxRetries, lastException?.message ?: context.getString(R.string.openai_error_network_interrupted) - ) + ), + lastException ) } return responseStream.withEventChannel(eventChannel) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt index db8c52f52..0b9efbec4 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt @@ -269,10 +269,10 @@ class OpenAIResponsesProvider( object OpenAIResponsesPayloadAdapter { data class UsageCounts( - val totalInputTokens: Long, - val actualInputTokens: Long, - val cachedInputTokens: Long, - val outputTokens: Long + val totalInputTokens: Int, + val actualInputTokens: Int, + val cachedInputTokens: Int, + val outputTokens: Int ) data class ParsedResponseOutput( @@ -294,23 +294,33 @@ object OpenAIResponsesPayloadAdapter { fun parseUsageCounts(usage: JSONObject?): UsageCounts? { usage ?: return null - val totalInputTokens = usage.optLong("prompt_tokens", usage.optLong("input_tokens", 0L)) - val outputTokens = usage.optLong("completion_tokens", usage.optLong("output_tokens", 0L)) + // 评审 P1-5:显式全零 payload 也是“已观察到的 usage”——按字段存在判断, + // 不能按 “>0” 过滤;P2-1:数值全程 Long 解析,只在旧 UI 计数边界饱和 Int。 + val hasInput = usage.has("prompt_tokens") || usage.has("input_tokens") + val hasOutput = usage.has("completion_tokens") || usage.has("output_tokens") val cachedDetails = usage.optJSONObject("prompt_tokens_details") ?: usage.optJSONObject("input_tokens_details") + val hasCached = usage.has("cached_tokens") || cachedDetails?.has("cached_tokens") == true + if (!hasInput && !hasOutput && !hasCached) return null + + val totalInputTokens = usage.optLong("prompt_tokens", usage.optLong("input_tokens", -1)) + .saturateToInt() + val outputTokens = usage.optLong("completion_tokens", usage.optLong("output_tokens", -1)) + .saturateToInt() val cachedInputTokens = - cachedDetails?.optLong("cached_tokens", usage.optLong("cached_tokens", 0L)) - ?: usage.optLong("cached_tokens", 0L) - val actualInputTokens = (totalInputTokens - cachedInputTokens).coerceAtLeast(0L) + (cachedDetails?.optLong("cached_tokens", -1)?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1)) + .coerceAtLeast(0) + .saturateToInt() + val actualInputTokens = (totalInputTokens - cachedInputTokens).coerceAtLeast(0) - return if (totalInputTokens > 0 || outputTokens > 0 || cachedInputTokens > 0) { - UsageCounts(totalInputTokens, actualInputTokens, cachedInputTokens, outputTokens) - } else { - null - } + return UsageCounts(totalInputTokens, actualInputTokens, cachedInputTokens, outputTokens) } + /** 旧 UI 计数边界(P2-1):Long 饱和为 Int,绝不回绕为负。 */ + private fun Long.saturateToInt(): Int = coerceIn(0L, Int.MAX_VALUE.toLong()).toInt() + fun toResponsesRequest(chatStyleRequest: JSONObject): JSONObject { val converted = JSONObject(chatStyleRequest.toString()) diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt index 45429edde..de589af10 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt @@ -189,10 +189,12 @@ class QwenAIProvider( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream { // 直接调用父类的sendMessage实现,它已经包含了续写逻辑和stream参数处理 - return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onNonFatalError, enableRetry) + return super.sendMessage(context, chatHistory, modelParameters, enableThinking, stream, availableTools, preserveThinkInHistory, onTokensUpdated, onUsageReported, onNonFatalError, enableRetry, statsCategory) } } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt index 919acde4e..13980e3bd 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/RateLimitedAIService.kt @@ -22,8 +22,10 @@ class RateLimitedAIService( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = com.ai.assistance.operit.util.stream.stream { rateLimiter?.acquire() concurrencySemaphore?.acquire() @@ -38,8 +40,10 @@ class RateLimitedAIService( availableTools = availableTools, preserveThinkInHistory = preserveThinkInHistory, onTokensUpdated = onTokensUpdated, + onUsageReported = onUsageReported, onNonFatalError = onNonFatalError, - enableRetry = enableRetry + enableRetry = enableRetry, + statsCategory = statsCategory ).collect { chunk -> emit(chunk) } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt new file mode 100644 index 000000000..e1be5e861 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/TokenTrackingAIService.kt @@ -0,0 +1,348 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.data.model.ModelOption +import com.ai.assistance.operit.data.model.ModelParameter +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordSource +import com.ai.assistance.operit.data.model.ToolPrompt +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenUsageRepository +import com.ai.assistance.operit.util.AppLogger +import com.ai.assistance.operit.util.stream.RevisableTextStream +import com.ai.assistance.operit.util.stream.SharedStream +import com.ai.assistance.operit.util.stream.Stream +import com.ai.assistance.operit.util.stream.StreamCollector +import com.ai.assistance.operit.util.stream.TextStreamEvent +import com.ai.assistance.operit.util.stream.TextStreamEventCarrier +import com.ai.assistance.operit.util.stream.TimeoutException +import java.io.InterruptedIOException +import java.net.SocketTimeoutException +import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.withContext + +/** Adds one compact Room statistics row around every logical [AIService] request. */ +class TokenTrackingAIService( + private val delegate: AIService, + context: Context, + private val configId: String, +) : AIService { + private val repository = TokenUsageRepository.getInstance(context.applicationContext) + + override val inputTokenCount: Long get() = delegate.inputTokenCount + override val cachedInputTokenCount: Long get() = delegate.cachedInputTokenCount + override val outputTokenCount: Long get() = delegate.outputTokenCount + override val providerModel: String get() = delegate.providerModel + + override fun resetTokenCounts() = delegate.resetTokenCounts() + override fun cancelStreaming() = delegate.cancelStreaming() + override suspend fun getModelsList(context: Context): Result> = + delegate.getModelsList(context) + + override suspend fun calculateInputTokens( + chatHistory: List, + availableTools: List?, + ): Long = delegate.calculateInputTokens(chatHistory, availableTools) + + override fun release() = delegate.release() + + override suspend fun sendMessage( + context: Context, + chatHistory: List, + modelParameters: List>, + enableThinking: Boolean, + stream: Boolean, + availableTools: List?, + preserveThinkInHistory: Boolean, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, + onNonFatalError: suspend (error: String) -> Unit, + enableRetry: Boolean, + statsCategory: TokenStatCategory?, + ): Stream { + val request = RequestTracker( + configId = configId, + providerModel = providerModel, + category = statsCategory ?: TokenStatCategory.OTHER, + ) + val inner = try { + delegate.sendMessage( + context = context, + chatHistory = chatHistory, + modelParameters = modelParameters, + enableThinking = enableThinking, + stream = stream, + availableTools = availableTools, + preserveThinkInHistory = preserveThinkInHistory, + onTokensUpdated = onTokensUpdated, + onUsageReported = { usage, attempt -> + request.onUsage(usage, attempt) + forwardUsageObserver(onUsageReported, usage, attempt) + }, + onNonFatalError = onNonFatalError, + enableRetry = enableRetry, + statsCategory = statsCategory, + ) + } catch (t: Throwable) { + persist(request.finish(classify(t))) + throw t + } + return wrapStream(inner, request) + } + + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (ProviderUsageSnapshot, attempt: Int) -> Unit)?, + ): Result { + val request = + RequestTracker(configId, providerModel, TokenStatCategory.CONNECTION_TEST) + return try { + val result = delegate.testConnection(context) { usage, attempt -> + request.onUsage(usage, attempt) + forwardUsageObserver(onUsageReported, usage, attempt) + } + persist( + request.finish( + result.exceptionOrNull()?.let(::classify) ?: TokenStatStatus.COMPLETED + ) + ) + result + } catch (e: CancellationException) { + persist(request.finish(classify(e))) + throw e + } catch (t: Throwable) { + persist(request.finish(classify(t))) + Result.failure(t) + } + } + + private suspend fun forwardUsageObserver( + observer: (suspend (ProviderUsageSnapshot, Int) -> Unit)?, + usage: ProviderUsageSnapshot, + attempt: Int, + ) { + val callback = observer ?: return + try { + callback(usage, attempt) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + AppLogger.e(TAG, "usage observer failed", e) + } + } + + private fun wrapStream(inner: Stream, request: RequestTracker): Stream = + if (inner is TextStreamEventCarrier) { + TrackingRevisableStream(inner, inner.eventChannel, request, repository) + } else { + TrackingStream(inner, request, repository) + } + + private class TrackingStream( + private val inner: Stream, + private val request: RequestTracker, + private val repository: TokenUsageRepository, + ) : Stream { + override val isLocked: Boolean get() = inner.isLocked + override val bufferedCount: Int get() = inner.bufferedCount + override suspend fun lock() = inner.lock() + override suspend fun unlock() = inner.unlock() + override fun clearBuffer() = inner.clearBuffer() + + override suspend fun collect(collector: StreamCollector) { + try { + inner.collect { value -> + if (value.isNotEmpty()) request.onFirstToken() + collector.emit(value) + } + } catch (t: Throwable) { + persist(repository, request, request.finish(classify(t))) + throw t + } + persist(repository, request, request.finish(TokenStatStatus.COMPLETED)) + } + } + + private class TrackingRevisableStream( + private val inner: Stream, + override val eventChannel: SharedStream, + private val request: RequestTracker, + private val repository: TokenUsageRepository, + ) : RevisableTextStream { + override val isLocked: Boolean get() = inner.isLocked + override val bufferedCount: Int get() = inner.bufferedCount + override suspend fun lock() = inner.lock() + override suspend fun unlock() = inner.unlock() + override fun clearBuffer() = inner.clearBuffer() + + override suspend fun collect(collector: StreamCollector) { + try { + inner.collect { value -> + if (value.isNotEmpty()) request.onFirstToken() + collector.emit(value) + } + } catch (t: Throwable) { + persist(repository, request, request.finish(classify(t))) + throw t + } + persist(repository, request, request.finish(TokenStatStatus.COMPLETED)) + } + } + + private suspend fun persist(record: TokenUsageRecordEntity) = persist(repository, record) + + private class RequestTracker( + private val configId: String, + private val providerModel: String, + private val category: TokenStatCategory, + ) { + private val startedAtMs = System.currentTimeMillis() + private val lock = Any() + private val attempts = linkedMapOf() + private var firstTokenAtMs: Long? = null + private val finished = AtomicBoolean(false) + + fun onUsage(usage: ProviderUsageSnapshot, attempt: Int) { + synchronized(lock) { + val key = attempt.coerceAtLeast(1) + attempts[key] = merge(attempts[key], usage) + } + } + + fun onFirstToken() { + synchronized(lock) { + if (firstTokenAtMs == null) firstTokenAtMs = System.currentTimeMillis() + } + } + + fun finish(status: TokenStatStatus): TokenUsageRecordEntity { + val endedAtMs = System.currentTimeMillis() + val snapshots = synchronized(lock) { attempts.values.toList() } + val firstToken = synchronized(lock) { firstTokenAtMs } + val separator = providerModel.indexOf(':') + require(separator > 0 && separator < providerModel.lastIndex) { + "provider:model is required for token usage events" + } + return TokenUsageRecordEntity( + occurredAtMs = startedAtMs, + source = TokenUsageRecordSource.REQUEST, + configId = configId, + provider = providerModel.substring(0, separator), + model = providerModel.substring(separator + 1), + category = category.name, + status = status.name, + requestCount = 1L, + uncachedInputTokens = snapshots.sumKnown { it.uncachedInputTokens }, + cachedInputTokens = snapshots.sumKnown { it.cachedInputTokens }, + cacheWriteTokens = snapshots.sumKnown { snapshot -> + if (snapshot.cacheWriteSeparateBilling) snapshot.cacheWriteTokens else 0L + }, + totalInputTokens = snapshots.sumKnown { it.totalInputTokens }, + outputTokens = snapshots.sumKnown { snapshot -> + snapshot.outputTokens?.let { output -> + if (snapshot.reasoningIncludedInOutput == false) { + saturatedAdd(output, snapshot.reasoningTokens ?: 0L) + } else { + output + } + } + }, + reasoningTokens = snapshots.sumKnown { it.reasoningTokens }, + ttftMs = firstToken?.let { (it - startedAtMs).coerceAtLeast(0L) }, + durationMs = firstToken?.let { (endedAtMs - it).coerceAtLeast(0L) }, + ) + } + + fun markPersisted(): Boolean = finished.compareAndSet(false, true) + + private fun merge( + previous: ProviderUsageSnapshot?, + update: ProviderUsageSnapshot, + ): ProviderUsageSnapshot { + if (previous == null || update.completeSnapshot) return update + return update.copy( + uncachedInputTokens = update.uncachedInputTokens ?: previous.uncachedInputTokens, + cachedInputTokens = update.cachedInputTokens ?: previous.cachedInputTokens, + cacheWriteTokens = update.cacheWriteTokens ?: previous.cacheWriteTokens, + totalInputTokens = update.totalInputTokens ?: previous.totalInputTokens, + outputTokens = update.outputTokens ?: previous.outputTokens, + reasoningTokens = update.reasoningTokens ?: previous.reasoningTokens, + ) + } + } + + companion object { + private const val TAG = "TokenTrackingAIService" + private const val MAX_CAUSE_DEPTH = 8 + + private suspend fun persist( + repository: TokenUsageRepository, + request: RequestTracker, + record: TokenUsageRecordEntity, + ) { + if (request.markPersisted()) persist(repository, record) + } + + private suspend fun persist( + repository: TokenUsageRepository, + record: TokenUsageRecordEntity, + ) { + withContext(Dispatchers.IO + NonCancellable) { + try { + repository.record(record) + } catch (e: Exception) { + AppLogger.e(TAG, "token usage insert failed", e) + } + } + } + + private fun classify(t: Throwable): TokenStatStatus = when { + t is CancellationException && t !is TimeoutCancellationException -> + TokenStatStatus.CANCELLED + isTimeout(t) -> TokenStatStatus.TIMEOUT + else -> TokenStatStatus.FAILED + } + + private fun isTimeout(t: Throwable): Boolean { + var current: Throwable? = t + var depth = 0 + while (current != null && depth < MAX_CAUSE_DEPTH) { + if ( + current is TimeoutCancellationException || + current is TimeoutException || + current is java.util.concurrent.TimeoutException || + current is SocketTimeoutException || + (current is InterruptedIOException && + current.message?.contains("timeout", ignoreCase = true) == true) + ) { + return true + } + current = current.cause + depth++ + } + return false + } + + private fun List.sumKnown( + selector: (ProviderUsageSnapshot) -> Long?, + ): Long? { + if (isEmpty()) return null + var sum = 0L + forEach { snapshot -> + val value = selector(snapshot) ?: return null + sum = if (Long.MAX_VALUE - sum < value) Long.MAX_VALUE else sum + value + } + return sum + } + + private fun saturatedAdd(left: Long, right: Long): Long = + if (Long.MAX_VALUE - left < right) Long.MAX_VALUE else left + right + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt index 0fa121546..d4aea5101 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ToolPkgJsAiProviderService.kt @@ -28,7 +28,7 @@ internal class ToolPkgJsAiProviderService( private val config: ModelConfigData, private val provider: ToolPkgAiProviderRegistration ) : AIService { - private sealed interface ProviderHookValue { + internal sealed interface ProviderHookValue { data object NullValue : ProviderHookValue data class TextValue( @@ -89,6 +89,13 @@ internal class ToolPkgJsAiProviderService( toolPkgPackageManager().cancelToolPkgExecutionsForChat(executionChatId) } + /** + * 测试缝:替换真实包管理器 hook 调用,使 sendMessage 的真实 hook 编排层 + * (intermediate channel、解码、usage 提取、chunk 发射、attempt 语义) + * 可在 JVM 测试中验证;生产为 null(走真实 [PackageManager])。 + */ + internal var mainHookRunnerOverride: ToolPkgMainHookRunner? = null + override suspend fun getModelsList(context: Context): Result> { return runCatching { val decoded = @@ -112,8 +119,10 @@ internal class ToolPkgJsAiProviderService( availableTools: List?, preserveThinkInHistory: Boolean, onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, onNonFatalError: suspend (error: String) -> Unit, - enableRetry: Boolean + enableRetry: Boolean, + statsCategory: com.ai.assistance.operit.data.stats.TokenStatCategory? ): Stream = com.ai.assistance.operit.util.stream.stream { var hasIntermediateTextChunk = false val decoded = @@ -135,14 +144,11 @@ internal class ToolPkgJsAiProviderService( put("enableRetry", enableRetry) }, onIntermediateResult = { intermediateDecoded -> - extractUsage(intermediateDecoded)?.let { usage -> - applyUsage(usage) - onTokensUpdated( - currentInputTokenCount, - currentCachedInputTokenCount, - currentOutputTokenCount - ) - } + applyAndForwardUsage( + intermediateDecoded, + onTokensUpdated, + onUsageReported, + ) extractNonFatalError(intermediateDecoded)?.let { error -> onNonFatalError(error) } @@ -153,15 +159,11 @@ internal class ToolPkgJsAiProviderService( } ) + // 最终结果先 apply/forward usage,再检查致命错误:失败结果里的 usage + // 不能丢(与 intermediate 同一通道、同一 attempt 合并语义);fatal + // 抛出后不会执行下方最终 chunk 发射,因此失败结果不产生最终文本。 + applyAndForwardUsage(decoded, onTokensUpdated, onUsageReported) ensureNoFatalError(decoded) - extractUsage(decoded)?.let { usage -> - applyUsage(usage) - onTokensUpdated( - currentInputTokenCount, - currentCachedInputTokenCount, - currentOutputTokenCount - ) - } extractNonFatalError(decoded)?.let { error -> onNonFatalError(error) } @@ -172,15 +174,33 @@ internal class ToolPkgJsAiProviderService( } } - override suspend fun testConnection(context: Context): Result { - return runCatching { - val decoded = + override suspend fun testConnection( + context: Context, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)? + ): Result { + // 评审 P1-7:中间结果 + 最终结果与普通请求走同一 usage 提取/attempt 转发; + // 取消必须原样传播,不能被 runCatching 吞成 Result.failure + val decoded = + try { invokeProviderFunction( functionName = provider.testConnectionFunctionName, functionSource = provider.testConnectionFunctionSource, event = TOOLPKG_EVENT_AI_PROVIDER_TEST_CONNECTION, - eventPayload = buildBasePayload(context) + eventPayload = buildBasePayload(context), + onIntermediateResult = { intermediateDecoded -> + extractUsage(intermediateDecoded)?.let { usage -> + forwardUsage(usage, onUsageReported) + } + } ) + } catch (e: kotlinx.coroutines.CancellationException) { + throw e + } + // 失败结果里的 usage 同样先转发(不丢),再走致命检查 + extractUsage(decoded)?.let { usage -> + forwardUsage(usage, onUsageReported) + } + return runCatching { ensureNoFatalError(decoded) parseConnectionMessage(decoded) } @@ -219,7 +239,6 @@ internal class ToolPkgJsAiProviderService( eventPayload: JSONObject, onIntermediateResult: (suspend (ProviderHookValue) -> Unit)? = null ): ProviderHookValue = coroutineScope { - val manager = toolPkgPackageManager() val intermediateChannel = if (onIntermediateResult == null) { null @@ -240,26 +259,50 @@ internal class ToolPkgJsAiProviderService( try { val result = withContext(Dispatchers.IO) { - manager.runToolPkgMainHook( - containerPackageName = provider.containerPackageName, - functionName = functionName, - event = event, - pluginId = "${provider.providerId}:$event", - inlineFunctionSource = functionSource, - eventPayload = - jsonObjectToMap( - JSONObject(eventPayload.toString()).put("chatId", executionChatId) - ), - executionContextKey = providerRuntimeContextKey, - runtimeKind = "provider", - dispatchIntermediateOnMain = false, - onIntermediateResult = - intermediateChannel?.let { channel -> - { raw -> - channel.trySend(raw) + val override = mainHookRunnerOverride + if (override != null) { + override.run( + containerPackageName = provider.containerPackageName, + functionName = functionName, + event = event, + pluginId = "${provider.providerId}:$event", + inlineFunctionSource = functionSource, + eventPayload = + jsonObjectToMap( + JSONObject(eventPayload.toString()).put("chatId", executionChatId) + ), + executionContextKey = providerRuntimeContextKey, + runtimeKind = "provider", + onIntermediateResult = + intermediateChannel?.let { channel -> + { raw -> + channel.trySend(raw) + } } - } - ) + ) + } else { + val manager = toolPkgPackageManager() + manager.runToolPkgMainHook( + containerPackageName = provider.containerPackageName, + functionName = functionName, + event = event, + pluginId = "${provider.providerId}:$event", + inlineFunctionSource = functionSource, + eventPayload = + jsonObjectToMap( + JSONObject(eventPayload.toString()).put("chatId", executionChatId) + ), + executionContextKey = providerRuntimeContextKey, + runtimeKind = "provider", + dispatchIntermediateOnMain = false, + onIntermediateResult = + intermediateChannel?.let { channel -> + { raw -> + channel.trySend(raw) + } + } + ) + } } decodeProviderHookValue( result.getOrElse { error -> throw error }?.let { raw -> decodeToolPkgHookResult(raw) } @@ -467,6 +510,23 @@ internal class ToolPkgJsAiProviderService( return null } + /** + * 账本路径的 Long 读取(评审 P2-1):全程 Long,绝不 Int 截断/回绕; + * 负值拒绝为未知(null)。 + */ + private fun JSONObject.optTokenCountLong(vararg keys: String): Long? { + for (key in keys) { + if (!has(key) || isNull(key)) continue + val parsed = when (val raw = opt(key)) { + is Number -> raw.toLong() + is String -> raw.trim().toBigDecimalOrNull()?.toLong() + else -> null + } + if (parsed != null) return parsed.takeIf { it >= 0 } + } + return null + } + private fun ensureNoFatalError(decoded: ProviderHookValue) { when (decoded) { is ProviderHookValue.ObjectValue -> { @@ -494,7 +554,19 @@ internal class ToolPkgJsAiProviderService( } } - private fun extractUsage(decoded: ProviderHookValue): TokenUsage? { + /** + * 提取 usage。usage 协议(评审 P1-6/P2-1,**不猜测 attempt、不继承全局计数**): + * - **新协议**:usage 对象(或顶层)携带 `attempt` / `attemptNumber` + * (provider 内部第几次尝试,从 1 开始)。同 attempt 的多次上报是流式 + * 部分更新(省略字段保留旧值);不同 attempt 分别记账,聚合时累加。 + * - **旧协议**:不携带 attempt 字段。语义为**整个逻辑请求的累计快照** + * (跨内部重试累计的最终数字),固定按 attempt 1 完整快照记账(后报覆盖 + * 先报,绝不把多个无 attempt 上报误累加)。内部按 attempt 逐次上报的 + * 插件必须迁移到新协议。 + * - 账本字段可空:缺省字段 = 未知,**绝不**用全局 current 计数填充(避免 + * 跨 attempt 继承造成虚假累计);负值拒绝为未知。 + */ + internal fun extractUsage(decoded: ProviderHookValue): TokenUsage? { return when (decoded) { is ProviderHookValue.ObjectValue -> extractUsageFromJson(decoded.value) else -> null @@ -504,23 +576,67 @@ internal class ToolPkgJsAiProviderService( private fun extractUsageFromJson(json: JSONObject): TokenUsage? { val usageObject = json.optJSONObject("usage") val source = usageObject ?: json - val input = source.optTokenCount("input", "inputTokens") - val cachedInput = source.optTokenCount("cachedInput", "cachedInputTokens") - val output = source.optTokenCount("output", "outputTokens") + val input = source.optTokenCountLong("input", "inputTokens") + val cachedInput = source.optTokenCountLong("cachedInput", "cachedInputTokens") + val output = source.optTokenCountLong("output", "outputTokens") if (input == null && cachedInput == null && output == null) { return null } + val attemptPresent = source.has("attempt") || source.has("attemptNumber") + val attempt = + source.optTokenCountLong("attempt", "attemptNumber")?.coerceAtLeast(1)?.toInt() ?: 1 return TokenUsage( - input = input ?: currentInputTokenCount, - cachedInput = cachedInput ?: currentCachedInputTokenCount, - output = output ?: currentOutputTokenCount + input = input, + cachedInput = cachedInput, + output = output, + attempt = attempt, + attemptPresent = attemptPresent, + ) + } + + /** + * sendMessage 通道:提取 → 更新 UI 累计计数 → 转发规范化 usage。 + * UI 计数器与账本快照分离(评审 P1-6):缺省字段只保留 UI 侧全局累计值, + * 请求快照保持未知,由外层 request tracker 按 attempt 合并。 + */ + private suspend fun applyAndForwardUsage( + decoded: ProviderHookValue, + onTokensUpdated: suspend (input: Long, cachedInput: Long, output: Long) -> Unit, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, attempt: Int) -> Unit)?, + ) { + extractUsage(decoded)?.let { usage -> + applyUsage(usage) + onTokensUpdated( + currentInputTokenCount, + currentCachedInputTokenCount, + currentOutputTokenCount + ) + forwardUsage(usage, onUsageReported) + } + } + + /** 只转发规范化 usage(testConnection 等无 UI 计数通道的场景共用);接收已解析的 usage,避免重复解析。 */ + private suspend fun forwardUsage( + usage: TokenUsage, + onUsageReported: (suspend (com.ai.assistance.operit.data.stats.ProviderUsageSnapshot, Int) -> Unit)?, + ) { + onUsageReported?.invoke( + com.ai.assistance.operit.data.stats.ProviderUsageNormalizer.toolPkg( + input = usage.input, + cachedInput = usage.cachedInput, + output = usage.output, + // 协议语义:attempt 在场 = 同 attempt 部分更新;缺省 = 整个 + // 逻辑请求的累计完整快照 + completeSnapshot = !usage.attemptPresent, + ), + usage.attempt ) } private fun applyUsage(usage: TokenUsage) { - currentInputTokenCount = usage.input.coerceAtLeast(0L) - currentCachedInputTokenCount = usage.cachedInput.coerceAtLeast(0L) - currentOutputTokenCount = usage.output.coerceAtLeast(0L) + currentInputTokenCount = (usage.input ?: 0L).coerceAtLeast(0L) + currentCachedInputTokenCount = (usage.cachedInput ?: 0L).coerceAtLeast(0L) + currentOutputTokenCount = (usage.output ?: 0L).coerceAtLeast(0L) } private fun extractMessageChunks(decoded: ProviderHookValue): List { @@ -569,10 +685,33 @@ internal class ToolPkgJsAiProviderService( entries.forEach { (key, value) -> put(key, value) } } } +} - private data class TokenUsage( - val input: Long, - val cachedInput: Long, - val output: Long - ) +internal data class TokenUsage( + /** 可空(评审 P1-6):缺省字段 = 未知,绝不继承全局累计计数。 */ + val input: Long?, + val cachedInput: Long?, + val output: Long?, + val attempt: Int = 1, + /** 上报是否显式携带 attempt 字段(新协议);false = 旧协议累计快照。 */ + val attemptPresent: Boolean = false, +) + +/** + * ToolPkg hook 调用抽象(测试缝):与 [PackageManager.runToolPkgMainHook] 相同的 + * 调用面。生产路径由 [ToolPkgJsAiProviderService.mainHookRunnerOverride] 为 null + * 时走真实包管理器;测试注入假 runner 驱动真实 hook 编排层。 + */ +internal fun interface ToolPkgMainHookRunner { + suspend fun run( + containerPackageName: String, + functionName: String, + event: String, + pluginId: String?, + inlineFunctionSource: String?, + eventPayload: Map, + executionContextKey: String?, + runtimeKind: String?, + onIntermediateResult: ((Any?) -> Unit)?, + ): Result } diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index a20ae5d1c..48d7e1445 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -7,6 +7,7 @@ import android.os.Looper import android.util.AtomicFile import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.db.ObjectBoxManager +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import com.ai.assistance.operit.util.OperitPaths import java.io.BufferedInputStream @@ -22,8 +23,6 @@ import java.util.zip.ZipInputStream import java.util.zip.ZipOutputStream import kotlin.system.measureTimeMillis import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -39,7 +38,6 @@ object RawSnapshotBackupManager { private const val TAG = "RawSnapshotBackup" private const val FORMAT_VERSION = 1 - private const val ZIP_PREFIX = "operit_raw_snapshot_" private const val ENTRY_MANIFEST = "manifest.json" @@ -53,7 +51,6 @@ object RawSnapshotBackupManager { private val terminalTopLevelDirNames = setOf("usr", "tmp", "bin") - private val mutex = Mutex() private val mainHandler = Handler(Looper.getMainLooper()) @Serializable @@ -110,7 +107,7 @@ object RawSnapshotBackupManager { options: SnapshotOptions = SnapshotOptions(), onProgress: ((ExportProgressInfo) -> Unit)? = null ): File = withContext(Dispatchers.IO) { - mutex.withLock { + TokenUsageRepository.withDatabaseAccess { AppLogger.i(TAG, "export start (includeTerminalData=${options.includeTerminalData})") withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.PREPARING)) } val exportDir = OperitBackupDirs.rawSnapshotDir() @@ -268,7 +265,7 @@ object RawSnapshotBackupManager { uri: Uri, onProgress: ((RestoreProgress) -> Unit)? = null ) = withContext(Dispatchers.IO) { - mutex.withLock { + TokenUsageRepository.withDatabaseRestore { val cacheZip = File.createTempFile("raw_snapshot_restore_", ".zip", context.cacheDir) val workDir = File(context.cacheDir, "raw_snapshot_restore_work").apply { if (exists()) deleteRecursively() diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt index c8cda8a8e..bb7ba1528 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupManager.kt @@ -3,6 +3,7 @@ package com.ai.assistance.operit.data.backup import android.content.Context import androidx.sqlite.db.SupportSQLiteDatabase import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream @@ -15,7 +16,6 @@ import java.time.format.DateTimeFormatter import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream import kotlinx.coroutines.flow.first -import kotlinx.coroutines.sync.withLock object RoomDatabaseBackupManager { @@ -31,7 +31,7 @@ object RoomDatabaseBackupManager { ) suspend fun pruneExcessBackups(context: Context) { - RoomDatabaseBackupRestoreLock.mutex.withLock { + TokenUsageRepository.withDatabaseAccess { val preferences = RoomDatabaseBackupPreferences.getInstance(context) val maxBackupCount = preferences.getMaxBackupCount() enforceMaxBackupCount(context, keepLatest = maxBackupCount) @@ -39,24 +39,24 @@ object RoomDatabaseBackupManager { } suspend fun backupIfNeeded(context: Context, force: Boolean): BackupResult { - return RoomDatabaseBackupRestoreLock.mutex.withLock { + return TokenUsageRepository.withDatabaseAccess { val preferences = RoomDatabaseBackupPreferences.getInstance(context) val enabled = preferences.isDailyBackupEnabled() val maxBackupCount = preferences.getMaxBackupCount() if (!enabled && !force) { - return@withLock BackupResult(performed = false, skippedReason = "disabled") + return@withDatabaseAccess BackupResult(performed = false, skippedReason = "disabled") } if (force) { val backupFile = createManualBackup(context) enforceMaxBackupCount(context, keepLatest = maxBackupCount) - return@withLock BackupResult(performed = true, backupFile = backupFile) + return@withDatabaseAccess BackupResult(performed = true, backupFile = backupFile) } val today = LocalDate.now().format(DateTimeFormatter.ISO_DATE) val lastValue = preferences.lastBackupDayFlow.first() if (lastValue == today) { - return@withLock BackupResult(performed = false, skippedReason = "already_backed_up_today") + return@withDatabaseAccess BackupResult(performed = false, skippedReason = "already_backed_up_today") } val backupFile = createOrReplaceAutoBackup(context, today) diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt deleted file mode 100644 index 163dae562..000000000 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseBackupRestoreLock.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.ai.assistance.operit.data.backup - -import kotlinx.coroutines.sync.Mutex - -object RoomDatabaseBackupRestoreLock { - val mutex = Mutex() -} diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt index 20018aced..260f2bcf9 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RoomDatabaseRestoreManager.kt @@ -3,15 +3,19 @@ package com.ai.assistance.operit.data.backup import android.content.Context import android.net.Uri import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.stats.TokenUsageRepository import com.ai.assistance.operit.util.AppLogger import java.io.BufferedInputStream import java.io.BufferedOutputStream import java.io.File import java.io.FileInputStream import java.io.FileOutputStream +import java.io.IOException +import java.nio.file.AtomicMoveNotSupportedException +import java.nio.file.Files +import java.nio.file.StandardCopyOption import java.util.zip.ZipInputStream import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext object RoomDatabaseRestoreManager { @@ -63,26 +67,26 @@ object RoomDatabaseRestoreManager { suspend fun restoreFromBackupUri(context: Context, uri: Uri) { withContext(Dispatchers.IO) { - RoomDatabaseBackupRestoreLock.mutex.withLock { - val cacheFile = File.createTempFile("room_db_restore_", ".zip", context.cacheDir) - try { - context.contentResolver.openInputStream(uri)?.use { input -> - FileOutputStream(cacheFile).use { output -> - input.copyTo(output) - } - } ?: throw IllegalStateException("Failed to open uri") + val cacheFile = File.createTempFile("room_db_restore_", ".zip", context.cacheDir) + try { + context.contentResolver.openInputStream(uri)?.use { input -> + FileOutputStream(cacheFile).use { output -> + input.copyTo(output) + } + } ?: throw IllegalStateException("Failed to open uri") + TokenUsageRepository.withDatabaseRestore { restoreFromBackupFileInternal(context, cacheFile) - } finally { - cacheFile.delete() } + } finally { + cacheFile.delete() } } } suspend fun restoreFromBackupFile(context: Context, zipFile: File) { withContext(Dispatchers.IO) { - RoomDatabaseBackupRestoreLock.mutex.withLock { + TokenUsageRepository.withDatabaseRestore { restoreFromBackupFileInternal(context, zipFile) } } @@ -146,23 +150,20 @@ object RoomDatabaseRestoreManager { throw IllegalArgumentException("Invalid backup zip: missing $DB_NAME") } - targetWal.delete() - targetShm.delete() - targetDb.delete() + if (targetWal.exists() && !extractedWal) { + throw IOException("Backup does not contain ${targetWal.name}") + } + if (targetShm.exists() && !extractedShm) { + throw IOException("Backup does not contain ${targetShm.name}") + } - replaceFile(tmpDb, targetDb) + atomicallyReplace(tmpDb, targetDb) if (extractedWal) { - replaceFile(tmpWal, targetWal) - } else { - tmpWal.delete() - targetWal.delete() + atomicallyReplace(tmpWal, targetWal) } if (extractedShm) { - replaceFile(tmpShm, targetShm) - } else { - tmpShm.delete() - targetShm.delete() + atomicallyReplace(tmpShm, targetShm) } } catch (e: Exception) { tmpDb.delete() @@ -183,13 +184,16 @@ object RoomDatabaseRestoreManager { } } - private fun replaceFile(from: File, to: File) { - if (to.exists()) { - to.delete() - } - if (!from.renameTo(to)) { - from.copyTo(to, overwrite = true) - from.delete() + private fun atomicallyReplace(from: File, to: File) { + try { + Files.move( + from.toPath(), + to.toPath(), + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING, + ) + } catch (e: AtomicMoveNotSupportedException) { + throw IOException("Atomic database replacement is unavailable: ${from.name}", e) } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt new file mode 100644 index 000000000..3109b1b69 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/dao/TokenUsageDao.kt @@ -0,0 +1,383 @@ +package com.ai.assistance.operit.data.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity + +data class TokenUsageModelAggregateRow( + val provider: String, + val model: String, + val configId: String?, + /** Exact for request and released-total rows; a lower bound when conversation rows contribute. */ + val requests: Long, + val requestCountKnown: Long, + val usageRows: Long, + val uncachedInputTokens: Long, + val uncachedInputKnown: Long, + val cachedInputTokens: Long, + val cachedInputKnown: Long, + val cacheWriteTokens: Long, + val cacheWriteKnown: Long, + val totalInputTokens: Long, + val totalInputKnown: Long, + val outputTokens: Long, + val outputKnown: Long, + val reasoningTokens: Long, + val reasoningKnown: Long, + val ttftTotalMs: Long, + val ttftSamples: Long, + val durationTotalMs: Long, + val durationSamples: Long, +) { + val providerModel: String + get() = "$provider:$model" +} + +data class TokenUsageBreakdownRow( + val key: String, + val provider: String, + val model: String, + val configId: String?, + val requests: Long, + val requestCountKnown: Long, + val usageRows: Long, + val uncachedInputTokens: Long, + val uncachedInputKnown: Long, + val cachedInputTokens: Long, + val cachedInputKnown: Long, + val cacheWriteTokens: Long, + val cacheWriteKnown: Long, + val totalInputTokens: Long, + val totalInputKnown: Long, + val outputTokens: Long, + val outputKnown: Long, + val reasoningTokens: Long, + val reasoningKnown: Long, + val ttftTotalMs: Long, + val ttftSamples: Long, + val durationTotalMs: Long, + val durationSamples: Long, +) { + val providerModel: String + get() = "$provider:$model" +} + +data class TokenUsageIdentityRow( + val configId: String?, + val provider: String, + val model: String, +) + +data class TokenUsageActivityDayRow( + val localDate: String, + val configId: String?, + val provider: String, + val model: String, + val tokens: Long, +) + +@Dao +abstract class TokenUsageDao { + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertRecord(record: TokenUsageRecordEntity): Long + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun insertRecords(records: List) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertStatsModel(model: TokenStatsModelEntity) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + abstract suspend fun upsertStatsModels(models: List) + + @Query( + """ + SELECT * FROM token_stats_models + WHERE configId = :configId AND provider = :provider AND model = :model + """ + ) + abstract suspend fun getStatsModel( + configId: String, + provider: String, + model: String, + ): TokenStatsModelEntity? + + @Query("SELECT * FROM token_stats_models ORDER BY provider, model, configId") + abstract suspend fun getAllStatsModels(): List + + @Query( + """ + UPDATE token_stats_models + SET billingMode = NULL, + currency = NULL, + inputPricePerMillion = NULL, + cachedInputPricePerMillion = NULL, + cacheWritePricePerMillion = NULL, + outputPricePerMillion = NULL, + pricePerRequest = NULL + WHERE configId = :configId AND provider = :provider AND model = :model + """ + ) + abstract suspend fun clearPricing(configId: String, provider: String, model: String): Int + + @Query( + """ + DELETE FROM token_stats_models + WHERE billingMode IS NULL + AND currency IS NULL + AND inputPricePerMillion IS NULL + AND cachedInputPricePerMillion IS NULL + AND cacheWritePricePerMillion IS NULL + AND outputPricePerMillion IS NULL + AND pricePerRequest IS NULL + """ + ) + abstract suspend fun deleteEmptyStatsModels(): Int + + @Query( + """ + SELECT + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source = 'REQUEST' + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY provider, model, configId + ORDER BY provider, model, configId + """ + ) + abstract suspend fun aggregateRequestModelsForLifetime( + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY provider, model, configId + ORDER BY provider, model, configId + """ + ) + abstract suspend fun aggregateModelsInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + category AS `key`, + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY category, provider, model, configId + ORDER BY category, provider, model, configId + """ + ) + abstract suspend fun aggregateCategoriesInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT + status AS `key`, + provider AS provider, + model AS model, + configId AS configId, + COALESCE(SUM(COALESCE(requestCount, 1)), 0) AS requests, + COUNT(requestCount) AS requestCountKnown, + COUNT(*) AS usageRows, + COALESCE(SUM(uncachedInputTokens), 0) AS uncachedInputTokens, + COUNT(uncachedInputTokens) AS uncachedInputKnown, + COALESCE(SUM(cachedInputTokens), 0) AS cachedInputTokens, + COUNT(cachedInputTokens) AS cachedInputKnown, + COALESCE(SUM(cacheWriteTokens), 0) AS cacheWriteTokens, + COUNT(cacheWriteTokens) AS cacheWriteKnown, + COALESCE(SUM(totalInputTokens), 0) AS totalInputTokens, + COUNT(totalInputTokens) AS totalInputKnown, + COALESCE(SUM(outputTokens), 0) AS outputTokens, + COUNT(outputTokens) AS outputKnown, + COALESCE(SUM(reasoningTokens), 0) AS reasoningTokens, + COUNT(reasoningTokens) AS reasoningKnown, + COALESCE(SUM(ttftMs), 0) AS ttftTotalMs, + COUNT(ttftMs) AS ttftSamples, + COALESCE(SUM(durationMs), 0) AS durationTotalMs, + COUNT(durationMs) AS durationSamples + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY status, provider, model, configId + ORDER BY status, provider, model, configId + """ + ) + abstract suspend fun aggregateStatusesInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + + @Query( + """ + SELECT configId, provider, model + FROM token_usage_records + GROUP BY configId, provider, model + ORDER BY provider, model, configId + """ + ) + abstract suspend fun getObservedIdentities(): List + + @Query( + """ + SELECT DISTINCT provider || ':' || model + FROM token_usage_records + ORDER BY 1 + """ + ) + abstract suspend fun getObservedProviderModels(): List + + @Query( + """ + SELECT + strftime('%Y-%m-%d', occurredAtMs / 1000, 'unixepoch', 'localtime') AS localDate, + configId AS configId, + provider AS provider, + model AS model, + COALESCE(SUM( + COALESCE( + totalInputTokens, + CASE + WHEN uncachedInputTokens IS NOT NULL + AND cachedInputTokens IS NOT NULL + AND cacheWriteTokens IS NOT NULL + THEN uncachedInputTokens + cachedInputTokens + cacheWriteTokens + END, + 0 + ) + COALESCE(outputTokens, 0) + ), 0) AS tokens + FROM token_usage_records + WHERE source IN ('REQUEST', 'CONVERSATION') + AND occurredAtMs >= :startMs AND occurredAtMs < :endMs + AND (:allModels OR (provider || ':' || model) IN (:providerModels)) + AND (:allCategories OR category IN (:categories)) + AND (:allStatuses OR status IN (:statuses)) + GROUP BY localDate, configId, provider, model + ORDER BY localDate, provider, model, configId + """ + ) + abstract suspend fun getActivityDaysInRange( + startMs: Long, + endMs: Long, + providerModels: List, + allModels: Boolean, + categories: List, + allCategories: Boolean, + statuses: List, + allStatuses: Boolean, + ): List + +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt index 86f762fff..7d6bece96 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/db/AppDatabase.kt @@ -10,27 +10,33 @@ import com.ai.assistance.operit.data.dao.ChatContentDao import com.ai.assistance.operit.data.dao.ChatDao import com.ai.assistance.operit.data.dao.MessageDao import com.ai.assistance.operit.data.dao.MessageVariantDao +import com.ai.assistance.operit.data.dao.TokenUsageDao import com.ai.assistance.operit.data.model.ChatEntity import com.ai.assistance.operit.data.model.MessageEntity import com.ai.assistance.operit.data.model.MessageVariantEntity - +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity /** 应用数据库,包含聊天表和消息表 */ @Database( - entities = [ChatEntity::class, MessageEntity::class, MessageVariantEntity::class], - version = 20, + entities = [ + ChatEntity::class, + MessageEntity::class, + MessageVariantEntity::class, + TokenUsageRecordEntity::class, + TokenStatsModelEntity::class, + ], + version = 21, exportSchema = false ) abstract class AppDatabase : RoomDatabase() { - /** 获取聊天DAO */ abstract fun chatDao(): ChatDao /** 获取消息DAO */ abstract fun messageDao(): MessageDao - abstract fun messageVariantDao(): MessageVariantDao - abstract fun chatContentDao(): ChatContentDao + abstract fun tokenUsageDao(): TokenUsageDao companion object { @Volatile @@ -221,6 +227,141 @@ abstract class AppDatabase : RoomDatabase() { } } + /** v20 -> v21: final two-table token statistics schema. Intermediate v21 was unpublished. */ + internal val MIGRATION_20_21 = + object : Migration(20, 21) { + override fun migrate(db: SupportSQLiteDatabase) { + runSql { db.execSQL(it) } + } + + override fun migrate(connection: androidx.sqlite.SQLiteConnection) { + runSql { sql -> + val stmt = connection.prepare(sql) + try { + stmt.step() + } finally { + stmt.close() + } + } + } + + private fun runSql(exec: (String) -> Unit) { + exec( + """ + CREATE TABLE IF NOT EXISTS `token_usage_records` ( + `id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + `importKey` TEXT, + `occurredAtMs` INTEGER, + `source` TEXT NOT NULL, + `configId` TEXT, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + `category` TEXT, + `status` TEXT, + `requestCount` INTEGER, + `uncachedInputTokens` INTEGER, + `cachedInputTokens` INTEGER, + `cacheWriteTokens` INTEGER, + `totalInputTokens` INTEGER, + `outputTokens` INTEGER, + `reasoningTokens` INTEGER, + `ttftMs` INTEGER, + `durationMs` INTEGER + ) + """.trimIndent() + ) + exec( + "CREATE INDEX IF NOT EXISTS `index_token_usage_records_occurredAtMs` " + + "ON `token_usage_records` (`occurredAtMs`)" + ) + exec( + "CREATE INDEX IF NOT EXISTS " + + "`index_token_usage_records_provider_model_configId_occurredAtMs` " + + "ON `token_usage_records` " + + "(`provider`, `model`, `configId`, `occurredAtMs`)" + ) + exec( + "CREATE INDEX IF NOT EXISTS `index_token_usage_records_source_occurredAtMs` " + + "ON `token_usage_records` (`source`, `occurredAtMs`)" + ) + exec( + "CREATE INDEX IF NOT EXISTS " + + "`index_token_usage_records_category_status_occurredAtMs` " + + "ON `token_usage_records` (`category`, `status`, `occurredAtMs`)" + ) + exec( + "CREATE UNIQUE INDEX IF NOT EXISTS " + + "`index_token_usage_records_importKey` " + + "ON `token_usage_records` (`importKey`)" + ) + exec( + """ + CREATE TABLE IF NOT EXISTS `token_stats_models` ( + `configId` TEXT NOT NULL, + `provider` TEXT NOT NULL, + `model` TEXT NOT NULL, + `billingMode` TEXT, + `currency` TEXT, + `inputPricePerMillion` REAL, + `cachedInputPricePerMillion` REAL, + `cacheWritePricePerMillion` REAL, + `outputPricePerMillion` REAL, + `pricePerRequest` REAL, + PRIMARY KEY(`configId`, `provider`, `model`) + ) + """.trimIndent() + ) + // Copy history once so statistics deletion remains independent from chat storage. + exec( + """ + INSERT INTO `token_usage_records` ( + `occurredAtMs`, `source`, `configId`, `provider`, `model`, + `category`, `status`, `requestCount`, `uncachedInputTokens`, + `cachedInputTokens`, `cacheWriteTokens`, `totalInputTokens`, + `outputTokens`, `reasoningTokens`, `ttftMs`, `durationMs` + ) + SELECT + `timestamp`, 'CONVERSATION', NULL, `provider`, `modelName`, + 'CHAT', 'COMPLETED', NULL, MAX(`inputTokens` - `cachedInputTokens`, 0), + `cachedInputTokens`, NULL, `inputTokens`, `outputTokens`, NULL, + NULLIF(`waitDurationMs`, 0), NULLIF(`outputDurationMs`, 0) + FROM `messages` + WHERE `sender` = 'ai' + AND TRIM(`provider`) <> '' + AND TRIM(`modelName`) <> '' + AND (`inputTokens` > 0 OR `cachedInputTokens` > 0 OR `outputTokens` > 0) + """.trimIndent() + ) + exec( + """ + INSERT INTO `token_usage_records` ( + `occurredAtMs`, `source`, `configId`, `provider`, `model`, + `category`, `status`, `requestCount`, `uncachedInputTokens`, + `cachedInputTokens`, `cacheWriteTokens`, `totalInputTokens`, + `outputTokens`, `reasoningTokens`, `ttftMs`, `durationMs` + ) + SELECT + variants.`messageTimestamp`, 'CONVERSATION', NULL, + variants.`provider`, variants.`modelName`, 'CHAT', 'COMPLETED', NULL, + MAX(variants.`inputTokens` - variants.`cachedInputTokens`, 0), + variants.`cachedInputTokens`, NULL, variants.`inputTokens`, + variants.`outputTokens`, NULL, NULLIF(variants.`waitDurationMs`, 0), + NULLIF(variants.`outputDurationMs`, 0) + FROM `message_variants` AS variants + WHERE TRIM(variants.`provider`) <> '' + AND TRIM(variants.`modelName`) <> '' + AND ( + variants.`inputTokens` > 0 + OR variants.`cachedInputTokens` > 0 + OR variants.`outputTokens` > 0 + ) + """.trimIndent() + ) + } + } + + + // 定义从版本2到3的迁移 private val MIGRATION_2_3 = object : Migration(2, 3) { @@ -337,7 +478,8 @@ abstract class AppDatabase : RoomDatabase() { MIGRATION_16_17, MIGRATION_17_18, MIGRATION_18_19, - MIGRATION_19_20 + MIGRATION_19_20, + MIGRATION_20_21 ) // 添加新的迁移 .build() INSTANCE = instance diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt b/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt index cf4bc7957..fb8b6b5ea 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/model/ModelConfigData.kt @@ -168,6 +168,7 @@ data class ModelConfigSummary( val modelName: String = "", val apiEndpoint: String = "", val apiProviderType: ApiProviderType = ApiProviderType.DEEPSEEK, + val apiProviderTypeId: String = apiProviderType.name, val modelIndex: Int = 0 // 当modelName包含多个模型(逗号分隔)时,选择第几个模型(从0开始) ) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt new file mode 100644 index 000000000..fa6f9670f --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenStatsModelEntity.kt @@ -0,0 +1,21 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.Entity +/** User-owned price settings for one provider/model identity. */ +@Entity( + tableName = "token_stats_models", + primaryKeys = ["configId", "provider", "model"], +) +data class TokenStatsModelEntity( + /** Empty means provider/model-wide pricing and the configuration-unscoped identity. */ + val configId: String, + val provider: String, + val model: String, + val billingMode: String? = null, + val currency: String? = null, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt new file mode 100644 index 000000000..565c7c30e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageIdentity.kt @@ -0,0 +1,30 @@ +package com.ai.assistance.operit.data.model + +data class TokenUsageIdentity( + val configId: String?, + val provider: String, + val model: String, +) { + fun encode(): String = if (configId == null) { + listOf(UNSCOPED_PREFIX, provider, model).joinToString(SEPARATOR.toString()) + } else { + listOf(CONFIG_PREFIX, configId, provider, model).joinToString(SEPARATOR.toString()) + } + + companion object { + private const val CONFIG_PREFIX = "config" + private const val UNSCOPED_PREFIX = "unscoped" + private const val SEPARATOR = '\u001f' + + fun decode(value: String): TokenUsageIdentity { + val parts = value.split(SEPARATOR) + return when { + parts.size == 3 && parts[0] == UNSCOPED_PREFIX -> + TokenUsageIdentity(null, parts[1], parts[2]) + parts.size == 4 && parts[0] == CONFIG_PREFIX -> + TokenUsageIdentity(parts[1], parts[2], parts[3]) + else -> throw IllegalArgumentException("invalid token usage identity") + } + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt new file mode 100644 index 000000000..f0e38cec5 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/model/TokenUsageRecordEntity.kt @@ -0,0 +1,47 @@ +package com.ai.assistance.operit.data.model + +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + +/** A token usage fact. Imported cumulative counters have no occurrence time. */ +@Entity( + tableName = "token_usage_records", + indices = [ + Index(value = ["occurredAtMs"]), + Index(value = ["provider", "model", "configId", "occurredAtMs"]), + Index(value = ["source", "occurredAtMs"]), + Index(value = ["category", "status", "occurredAtMs"]), + Index(value = ["importKey"], unique = true), + ], +) +data class TokenUsageRecordEntity( + @PrimaryKey(autoGenerate = true) val id: Long = 0L, + /** Stable only for one-time imported totals; normal request and conversation rows use null. */ + val importKey: String? = null, + val occurredAtMs: Long?, + val source: String, + val configId: String?, + val provider: String, + val model: String, + val category: String?, + val status: String?, + /** Null means a conversation record proves usage but not the exact provider-call count. */ + val requestCount: Long?, + val uncachedInputTokens: Long? = null, + val cachedInputTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val totalInputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val ttftMs: Long? = null, + val durationMs: Long? = null, +) { + val providerModel: String + get() = "$provider:$model" +} + +object TokenUsageRecordSource { + const val REQUEST = "REQUEST" + const val CONVERSATION = "CONVERSATION" +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt index 229775991..86e003ad0 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ApiPreferences.kt @@ -1,10 +1,8 @@ package com.ai.assistance.operit.data.preferences import android.content.Context -import com.ai.assistance.operit.util.AppLogger import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences -import androidx.datastore.preferences.core.MutablePreferences import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.floatPreferencesKey @@ -13,10 +11,15 @@ import androidx.datastore.preferences.core.longPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.ai.assistance.operit.data.model.ApiProviderType +import com.ai.assistance.operit.data.model.BillingMode import com.ai.assistance.operit.data.model.FunctionType import com.ai.assistance.operit.data.model.ModelParameter import com.ai.assistance.operit.data.model.ParameterCategory import com.ai.assistance.operit.data.model.ParameterValueType +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.ReleasedProviderModelKeyDecoder +import com.ai.assistance.operit.data.stats.ModelPriceSettings +import com.ai.assistance.operit.plugins.toolpkg.ToolPkgAiProviderRegistry import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map @@ -30,6 +33,9 @@ import kotlinx.serialization.json.Json private val Context.apiDataStore: DataStore by preferencesDataStore(name = "api_settings") +private fun validReleasedUsdToCnyRate(stored: Float?): Double? = + stored?.takeIf { it.isFinite() && it > 0f }?.toDouble() + class ApiPreferences private constructor(private val context: Context) { // Define our preferences keys @@ -37,6 +43,12 @@ class ApiPreferences private constructor(private val context: Context) { @Volatile private var INSTANCE: ApiPreferences? = null + /** JVM tests can avoid initializing the application-scoped ToolPkg runtime. */ + internal var toolPkgProviderAliasesProvider: (() -> Map)? = null + + /** JVM tests can provide saved ToolPkg provider IDs without reading model-config DataStore. */ + internal var configuredProviderIdsProvider: (suspend () -> List)? = null + fun getInstance(context: Context): ApiPreferences { return INSTANCE ?: synchronized(this) { val instance = ApiPreferences(context.applicationContext) @@ -75,57 +87,47 @@ class ApiPreferences private constructor(private val context: Context) { } // 动态生成供应商:模型的Token键 - fun getTokenInputKey(providerModel: String) = + private fun getTokenInputKey(providerModel: String) = longPreferencesKey("token_input_${providerModel.replace(":", "_")}") - fun getTokenCachedInputKey(providerModel: String) = + private fun getTokenCachedInputKey(providerModel: String) = longPreferencesKey("token_cached_input_${providerModel.replace(":", "_")}") - fun getTokenOutputKey(providerModel: String) = + private fun getTokenOutputKey(providerModel: String) = longPreferencesKey("token_output_${providerModel.replace(":", "_")}") // 模型定价键 - fun getModelInputPriceKey(providerModel: String) = + private fun getModelInputPriceKey(providerModel: String) = floatPreferencesKey("model_input_price_${providerModel.replace(":", "_")}") - fun getModelCachedInputPriceKey(providerModel: String) = + private fun getModelCachedInputPriceKey(providerModel: String) = floatPreferencesKey("model_cached_input_price_${providerModel.replace(":", "_")}") - fun getModelOutputPriceKey(providerModel: String) = + private fun getModelOutputPriceKey(providerModel: String) = floatPreferencesKey("model_output_price_${providerModel.replace(":", "_")}") // 请求次数统计键 - fun getRequestCountKey(providerModel: String) = + private fun getRequestCountKey(providerModel: String) = intPreferencesKey("request_count_${providerModel.replace(":", "_")}") // 计费方式键 - fun getBillingModeKey(providerModel: String) = + private fun getBillingModeKey(providerModel: String) = stringPreferencesKey("billing_mode_${providerModel.replace(":", "_")}") // 按次计费价格键 - fun getPricePerRequestKey(providerModel: String) = + private fun getPricePerRequestKey(providerModel: String) = floatPreferencesKey("price_per_request_${providerModel.replace(":", "_")}") - private val providerNameCandidates = - ApiProviderType.values().map { it.name }.sortedByDescending { it.length } - - private fun decodeProviderModelFromKeySuffix(encoded: String): String { - val matchedProvider = providerNameCandidates.firstOrNull { - encoded == it || encoded.startsWith("${it}_") - } - - return if (matchedProvider != null) { - if (encoded.length == matchedProvider.length) { - matchedProvider - } else { - "$matchedProvider:${encoded.substring(matchedProvider.length + 1)}" - } - } else { - encoded.replace("_", ":") - } - } + private val RELEASED_MODEL_PRICE_KEY_PREFIXES = + listOf( + "model_input_price_", + "model_cached_input_price_", + "model_output_price_", + "billing_mode_", + "price_per_request_", + ) - val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") + private val USD_TO_CNY_EXCHANGE_RATE = floatPreferencesKey("usd_to_cny_exchange_rate") val KEEP_SCREEN_ON = booleanPreferencesKey("keep_screen_on") val FEATURE_TOGGLES_JSON = stringPreferencesKey("feature_toggles_json") @@ -210,6 +212,8 @@ class ApiPreferences private constructor(private val context: Context) { // API 配置默认值 const val DEFAULT_API_ENDPOINT = "https://api.deepseek.com/v1/chat/completions" const val DEFAULT_MODEL_NAME = "deepseek-v4-flash" + + private const val TAG = "ApiPreferences" } @Serializable @@ -519,119 +523,6 @@ class ApiPreferences private constructor(private val context: Context) { } } - // Save Disable Status Tags setting - /** - * 更新指定供应商:模型的token计数 - * @param providerModel 供应商:模型标识符,格式如"DEEPSEEK:deepseek-chat" - * @param inputTokens 新增的输入token - * @param outputTokens 新增的输出token - * @param cachedInputTokens 新增的缓存命中token - */ - suspend fun updateTokensForProviderModel( - providerModel: String, - inputTokens: Long, - outputTokens: Long, - cachedInputTokens: Long = 0L - ) { - context.apiDataStore.edit { preferences -> - val inputKey = getTokenInputKey(providerModel) - val cachedInputKey = getTokenCachedInputKey(providerModel) - val outputKey = getTokenOutputKey(providerModel) - - val currentInputTokens = readTokenCount(preferences, inputKey.name) - val currentCachedInputTokens = readTokenCount(preferences, cachedInputKey.name) - val currentOutputTokens = readTokenCount(preferences, outputKey.name) - - removeTokenCountKeys( - preferences, - inputKey.name, - cachedInputKey.name, - outputKey.name - ) - preferences[inputKey] = currentInputTokens + inputTokens - preferences[cachedInputKey] = currentCachedInputTokens + cachedInputTokens - preferences[outputKey] = currentOutputTokens + outputTokens - } - } - - /** - * 获取指定供应商:模型的输入token数量 - */ - suspend fun getInputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenInputKey(providerModel).name) - } - - /** - * 获取指定供应商:模型的缓存输入token数量 - */ - suspend fun getCachedInputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - } - - /** - * 获取指定供应商:模型的输出token数量 - */ - suspend fun getOutputTokensForProviderModel(providerModel: String): Long { - val preferences = context.apiDataStore.data.first() - return readTokenCount(preferences, getTokenOutputKey(providerModel).name) - } - - /** - * 获取所有供应商:模型的token统计 - * @return Map<供应商:模型, Triple<输入tokens, 输出tokens, 缓存tokens>> - */ - suspend fun getAllProviderModelTokens(): Map> { - val preferences = context.apiDataStore.data.first() - val result = mutableMapOf>() - - // 遍历所有preferences,查找token相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("token_input_")) { - val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("token_input_")) - val inputTokens = readTokenCountValue(value) - val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) - val cachedInputTokens = - readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - if (inputTokens > 0L || outputTokens > 0L || cachedInputTokens > 0L) { - result[providerModel] = Triple(inputTokens, outputTokens, cachedInputTokens) - } - } - } - - return result - } - - /** - * 获取所有供应商:模型的token统计的Flow - * @return Flow>> - */ - val allProviderModelTokensFlow: Flow>> = - context.apiDataStore.data.map { preferences -> - val result = mutableMapOf>() - - // 遍历所有preferences,查找token相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("token_input_")) { - val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("token_input_")) - val inputTokens = readTokenCountValue(value) - val outputTokens = readTokenCount(preferences, getTokenOutputKey(providerModel).name) - val cachedInputTokens = - readTokenCount(preferences, getTokenCachedInputKey(providerModel).name) - if (inputTokens > 0L || outputTokens > 0L || cachedInputTokens > 0L) { - result[providerModel] = Triple(inputTokens, outputTokens, cachedInputTokens) - } - } - } - - result - } - // Save custom system prompt template suspend fun saveCustomSystemPromptTemplate(template: String) { context.apiDataStore.edit { preferences -> @@ -646,45 +537,115 @@ class ApiPreferences private constructor(private val context: Context) { } } - // 重置所有供应商:模型的token计数 - suspend fun resetAllProviderModelTokenCounts() { - context.apiDataStore.edit { preferences -> - val keysToRemove = mutableListOf>() - preferences.asMap().forEach { (key, _) -> - val keyName = key.name - if (keyName.startsWith("token_input_") || keyName.startsWith("token_output_") || keyName.startsWith("token_cached_input_") || keyName.startsWith("request_count_")) { - keysToRemove.add(key) - } + /** One read of the released main token data before ownership moves to Room. */ + suspend fun readTokenStatsMigrationSnapshot(): TokenStatsMigrationSnapshot { + val preferences = context.apiDataStore.data.first() + val counterPrefixes = listOf( + "token_input_", + "token_cached_input_", + "token_output_", + "request_count_", + ) + val encodedTotals = linkedSetOf() + val encodedPrices = linkedSetOf() + preferences.asMap().keys.forEach { key -> + counterPrefixes.firstOrNull { key.name.startsWith(it) }?.let { prefix -> + encodedTotals += key.name.removePrefix(prefix) } - keysToRemove.forEach { key -> - preferences.remove(key) + RELEASED_MODEL_PRICE_KEY_PREFIXES + .firstOrNull { key.name.startsWith(it) } + ?.let { prefix -> encodedPrices += key.name.removePrefix(prefix) } + } + val providerAliases = releasedTokenProviderAliases() + val totals = encodedTotals.mapNotNull { encoded -> + val key = ReleasedProviderModelKeyDecoder.decode(encoded, providerAliases) + val input = readTokenCount(preferences, getTokenInputKey(key.storedProviderModel).name) + val cached = readTokenCount(preferences, getTokenCachedInputKey(key.storedProviderModel).name) + val output = readTokenCount(preferences, getTokenOutputKey(key.storedProviderModel).name) + val requestCount = preferences.asMap().entries + .firstOrNull { it.key.name == getRequestCountKey(key.storedProviderModel).name } + ?.value + .let { it as? Number } + ?.toLong() + ?.coerceAtLeast(0L) + ?: 0L + ReleasedTokenUsageTotal( + provider = key.provider, + model = key.model, + inputTokens = input, + cachedInputTokens = cached, + outputTokens = output, + requestCount = requestCount, + ).takeIf { input > 0L || cached > 0L || output > 0L || requestCount > 0L } + }.groupBy { it.provider to it.model } + .map { (_, totals) -> totals.reduce(ReleasedTokenUsageTotal::plus) } + val prices = encodedPrices.mapNotNull { encoded -> + val key = ReleasedProviderModelKeyDecoder.decode(encoded, providerAliases) + val billingMode = preferences[getBillingModeKey(key.storedProviderModel)]?.let(BillingMode::valueOf) + val inputPrice = positivePrice(preferences[getModelInputPriceKey(key.storedProviderModel)]) + val cachedInputPrice = positivePrice(preferences[getModelCachedInputPriceKey(key.storedProviderModel)]) + val outputPrice = positivePrice(preferences[getModelOutputPriceKey(key.storedProviderModel)]) + val pricePerRequest = positivePrice(preferences[getPricePerRequestKey(key.storedProviderModel)]) + if ( + billingMode == null && + inputPrice == null && + cachedInputPrice == null && + outputPrice == null && + pricePerRequest == null + ) { + return@mapNotNull null } - } + val settings = ModelPriceSettings( + billingMode = billingMode, + currency = if (billingMode == BillingMode.COUNT) PricingCurrency.CNY else PricingCurrency.USD, + inputPricePerMillion = inputPrice, + cachedInputPricePerMillion = cachedInputPrice, + outputPricePerMillion = outputPrice, + pricePerRequest = pricePerRequest, + ) + ReleasedTokenPriceSetting(key.provider, key.model, settings) + }.groupBy { it.provider to it.model } + .map { (identity, prices) -> + val settings = prices.map(ReleasedTokenPriceSetting::settings).distinct() + require(settings.size == 1) { + "Conflicting released prices for ${identity.first}:${identity.second}" + } + ReleasedTokenPriceSetting(identity.first, identity.second, settings.single()) + } + return TokenStatsMigrationSnapshot( + totals = totals, + prices = prices, + usdToCnyRate = validReleasedUsdToCnyRate(preferences[USD_TO_CNY_EXCHANGE_RATE]), + ) } - // 重置指定供应商:模型的token计数 - suspend fun resetProviderModelTokenCounts(providerModel: String) { + /** Remove released keys and every unpublished token-statistics key after import. */ + suspend fun clearMigratedTokenStatsData() { context.apiDataStore.edit { preferences -> - removeTokenCountKeys( - preferences, - getTokenInputKey(providerModel).name, - getTokenCachedInputKey(providerModel).name, - getTokenOutputKey(providerModel).name + val keyPrefixes = listOf( + "token_input_", + "token_cached_input_", + "token_output_", + "request_count_", + "model_input_price_", + "model_cached_input_price_", + "model_cache_write_price_", + "model_output_price_", + "model_pricing_currency_", + "billing_mode_", + "price_per_request_", + "stats_", ) - preferences[getTokenInputKey(providerModel)] = 0L - preferences[getTokenCachedInputKey(providerModel)] = 0L - preferences[getTokenOutputKey(providerModel)] = 0L - preferences[getRequestCountKey(providerModel)] = 0 + val keys = preferences.asMap().keys.filter { key -> + key == USD_TO_CNY_EXCHANGE_RATE || keyPrefixes.any(key.name::startsWith) + } + keys.forEach { key -> + @Suppress("UNCHECKED_CAST") + preferences.remove(key as Preferences.Key) + } } } - private fun removeTokenCountKeys(preferences: MutablePreferences, vararg keyNames: String) { - val names = keyNames.toSet() - preferences.asMap().keys - .filter { it.name in names } - .forEach { preferences.remove(it) } - } - private fun readTokenCount(preferences: Preferences, keyName: String): Long { val values = preferences.asMap().entries .filter { it.key.name == keyName } @@ -701,160 +662,35 @@ class ApiPreferences private constructor(private val context: Context) { } } - // 获取模型输入价格(每百万tokens的美元价格) - suspend fun getModelInputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelInputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 获取模型缓存输入价格(每百万tokens的美元价格) - suspend fun getModelCachedInputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelCachedInputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 获取模型输出价格(每百万tokens的美元价格) - suspend fun getModelOutputPrice(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getModelOutputPriceKey(providerModel)]?.toDouble() ?: 0.0 - } - - // 设置模型输入价格(每百万tokens的美元价格) - suspend fun setModelInputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelInputPriceKey(providerModel)] = price.toFloat() - } - } - - // 设置模型缓存输入价格(每百万tokens的美元价格) - suspend fun setModelCachedInputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelCachedInputPriceKey(providerModel)] = price.toFloat() - } - } - - // 设置模型输出价格(每百万tokens的美元价格) - suspend fun setModelOutputPrice(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getModelOutputPriceKey(providerModel)] = price.toFloat() - } - } - - // ===== Request Count Statistics 请求次数统计相关方法 ===== - - /** - * 增加指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符,格式如"DEEPSEEK:deepseek-chat" - */ - suspend fun incrementRequestCountForProviderModel(providerModel: String) { - context.apiDataStore.edit { preferences -> - val countKey = getRequestCountKey(providerModel) - val currentCount = preferences[countKey] ?: 0 - preferences[countKey] = currentCount + 1 - } - } - - /** - * 获取指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符 - * @return 请求次数 - */ - suspend fun getRequestCountForProviderModel(providerModel: String): Int { - val preferences = context.apiDataStore.data.first() - return preferences[getRequestCountKey(providerModel)] ?: 0 - } - - /** - * 获取所有供应商:模型的请求次数统计 - * @return Map<供应商:模型, 请求次数> - */ - suspend fun getAllProviderModelRequestCounts(): Map { - val preferences = context.apiDataStore.data.first() - val result = mutableMapOf() - - // 遍历所有preferences,查找请求次数相关的key - preferences.asMap().forEach { (key, value) -> - val keyName = key.name - if (keyName.startsWith("request_count_")) { - val providerModel = - decodeProviderModelFromKeySuffix(keyName.removePrefix("request_count_")) - val count = value as? Int ?: 0 - if (count > 0) { - result[providerModel] = count - } + private suspend fun releasedTokenProviderAliases(): Map { + val registered = toolPkgProviderAliasesProvider?.invoke() + ?: ToolPkgAiProviderRegistry.releasedTokenProviderAliases() + val configured = configuredProviderIdsProvider?.invoke() + ?: ModelConfigManager(context).getAllConfigSummaries().map { it.apiProviderTypeId } + val configuredAliases = + configured + .map(String::trim) + .filter(String::isNotEmpty) + .filter { ApiProviderType.fromProviderTypeId(it) == null } + .associateWith { it } + return buildMap { + configuredAliases.forEach { (providerId, identity) -> + put(providerId, identity) + put("TOOLPKG_${providerId.lowercase()}", identity) } + // The active registration supplies the display identity used by new requests. + putAll(registered) } - - return result } - /** - * 重置指定供应商:模型的请求次数 - * @param providerModel 供应商:模型标识符 - */ - suspend fun resetProviderModelRequestCount(providerModel: String) { - context.apiDataStore.edit { preferences -> - preferences[getRequestCountKey(providerModel)] = 0 - } + private fun splitProviderModel(providerModel: String): Pair? { + val separator = providerModel.indexOf(':') + if (separator <= 0 || separator == providerModel.lastIndex) return null + return providerModel.substring(0, separator) to providerModel.substring(separator + 1) } - // ===== Billing Mode 计费方式相关方法 ===== - - /** - * 获取指定供应商:模型的计费方式 - * @param providerModel 供应商:模型标识符 - * @return 计费方式,默认为TOKEN - */ - suspend fun getBillingModeForProviderModel(providerModel: String): com.ai.assistance.operit.data.model.BillingMode { - val preferences = context.apiDataStore.data.first() - val modeString = preferences[getBillingModeKey(providerModel)] - return com.ai.assistance.operit.data.model.BillingMode.fromString(modeString) - } - - /** - * 设置指定供应商:模型的计费方式 - * @param providerModel 供应商:模型标识符 - * @param mode 计费方式 - */ - suspend fun setBillingModeForProviderModel(providerModel: String, mode: com.ai.assistance.operit.data.model.BillingMode) { - context.apiDataStore.edit { preferences -> - preferences[getBillingModeKey(providerModel)] = mode.name - } - } - - // ===== Price Per Request 按次计费价格相关方法 ===== - - /** - * 获取指定供应商:模型的按次计费价格 - * @param providerModel 供应商:模型标识符 - * @return 每次请求的价格,未设置时返回0.0 - */ - suspend fun getPricePerRequestForProviderModel(providerModel: String): Double { - val preferences = context.apiDataStore.data.first() - return preferences[getPricePerRequestKey(providerModel)]?.toDouble() ?: 0.0 - } - - /** - * 设置指定供应商:模型的按次计费价格(人民币) - * @param providerModel 供应商:模型标识符 - * @param price 每次请求的价格 - */ - suspend fun setPricePerRequestForProviderModel(providerModel: String, price: Double) { - context.apiDataStore.edit { preferences -> - preferences[getPricePerRequestKey(providerModel)] = price.toFloat() - } - } - - suspend fun getUsdToCnyExchangeRate(): Double { - val preferences = context.apiDataStore.data.first() - return preferences[USD_TO_CNY_EXCHANGE_RATE]?.toDouble() ?: 7.2 - } - - suspend fun setUsdToCnyExchangeRate(rate: Double) { - context.apiDataStore.edit { preferences -> - preferences[USD_TO_CNY_EXCHANGE_RATE] = rate.toFloat() - } - } + private fun positivePrice(value: Float?): Double? = + value?.toDouble()?.takeIf { it.isFinite() && it > 0.0 } suspend fun saveMaxImageHistoryUserTurns(turns: Int) { context.apiDataStore.edit { preferences -> @@ -885,3 +721,34 @@ class ApiPreferences private constructor(private val context: Context) { } } } + +data class TokenStatsMigrationSnapshot( + val totals: List, + val prices: List, + val usdToCnyRate: Double?, +) + +data class ReleasedTokenUsageTotal( + val provider: String, + val model: String, + val inputTokens: Long, + val cachedInputTokens: Long, + val outputTokens: Long, + val requestCount: Long, +) { + operator fun plus(other: ReleasedTokenUsageTotal): ReleasedTokenUsageTotal { + require(provider == other.provider && model == other.model) + return copy( + inputTokens = Math.addExact(inputTokens, other.inputTokens), + cachedInputTokens = Math.addExact(cachedInputTokens, other.cachedInputTokens), + outputTokens = Math.addExact(outputTokens, other.outputTokens), + requestCount = Math.addExact(requestCount, other.requestCount), + ) + } +} + +data class ReleasedTokenPriceSetting( + val provider: String, + val model: String, + val settings: ModelPriceSettings, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt index ca9a30e2e..d9c627177 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/ModelConfigManager.kt @@ -233,7 +233,8 @@ class ModelConfigManager(private val context: Context) { name = config.name, modelName = config.modelName, apiEndpoint = config.apiEndpoint, - apiProviderType = config.apiProviderType + apiProviderType = config.apiProviderType, + apiProviderTypeId = config.apiProviderTypeId ) ) } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt new file mode 100644 index 000000000..0f72ad05e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ProviderUsageSnapshot.kt @@ -0,0 +1,347 @@ +package com.ai.assistance.operit.data.stats + +import org.json.JSONObject + +/** + * provider 适配层规范化后的 usage 快照(阶段 1 契约 + 诊断标签)。 + * + * - null 字段表示“未知”(provider 未提供),0 表示 provider 确认该分量为 0 + * (例如确认无缓存读取/无缓存写入);任何字段都不得静默把“未知”当作 0; + * token 字段使用 [Long] 承载,聚合以 Long 运算避免 Int 溢出,负值一律拒绝为未知。 + * - [completeSnapshot]:本次上报的语义。true = 完整快照(null 字段 = 明确未知, + * 同一 attempt 合并时覆盖旧值,即“撤销”旧值);false = 部分更新(null = 字段 + * 省略,保留旧值)。流式增量上报(Anthropic message_start/message_delta、 + * ToolPkg 新协议 attempt 内的流式更新)是部分更新;最终响应 usage(OpenAI、 + * Anthropic 非流式、本地实测、ToolPkg 旧协议请求级累计)是完整快照。 + * - [cacheWriteSeparateBilling]:provider 的计费模型是否把“缓存写入”作为独立 + * 计费分量。false = 无独立缓存写入计费概念(OpenAI 兼容系/Gemini/本地/ToolPkg: + * 缓存写入成本已包含在输入单价内,字段缺失不阻碍费用计算);true = 缓存写入 + * 独立计费(Anthropic),此时该分量未知会导致费用未知。默认 true 保持保守: + * 未声明时缺失缓存写入字段仍按未知处理。 + * - [reasoningIncludedInOutput]:true = provider 的 output 计数已包含推理 token + * (计费时不得再加推理);false = 推理独立计数;null = provider 未声明, + * 计费按“已包含”处理以避免重复收费。 + * - [totalInputTokens]:provider 明确上报的**总输入**(含缓存命中/缓存写入)。 + * 当 cached/uncached 拆分未知(如 OpenAI 兼容端点缺 prompt_tokens_details、 + * Gemini 缺 cachedContentTokenCount)时,总输入仍可表达“至少这么多输入”; + * 费用只有在 cached/uncached 单价相同(拆分不影响计费)时才可按总输入计算, + * 否则仍保持未知。拆分已知时 [uncachedInputTokens] + [cachedInputTokens] + * 即总输入,本字段为冗余可空冗余(用于拆分未知场景,绝不伪造 uncached)。 + * - [source] 是诊断用的来源标签(哪个解析路径),不含任何凭据或正文。 + */ +data class ProviderUsageSnapshot( + val uncachedInputTokens: Long? = null, + val cachedInputTokens: Long? = null, + val cacheWriteTokens: Long? = null, + val totalInputTokens: Long? = null, + val outputTokens: Long? = null, + val reasoningTokens: Long? = null, + val reasoningIncludedInOutput: Boolean? = null, + val cacheWriteSeparateBilling: Boolean = true, + /** true = 完整快照(null 覆盖旧值);false = 部分更新(null 保留旧值)。 */ + val completeSnapshot: Boolean = false, + val source: String, +) { + /** 是否有任何已知用量分量(含明确 0;完全无已知字段才为 false)。 */ + fun hasKnownFields(): Boolean = + uncachedInputTokens != null || + cachedInputTokens != null || + cacheWriteTokens != null || + totalInputTokens != null || + outputTokens != null || + reasoningTokens != null +} + +/** + * provider 原始 usage → 阶段 1 契约的归一化(provider 适配层)。 + * + * 语义事实: + * - OpenAI 兼容系:`prompt_tokens`/`completion_tokens` 已包含缓存命中与推理 token, + * 因此 [ProviderUsageSnapshot.cachedInputTokens] 从 `prompt_tokens_details.cached_tokens` + * 提取、[ProviderUsageSnapshot.uncachedInputTokens] 为差值;`completion_tokens` 包含 + * 推理 → [ProviderUsageSnapshot.reasoningIncludedInOutput] = true。 + * - Anthropic:文档明确 `input_tokens` **不含** `cache_read_input_tokens` 与 + * `cache_creation_input_tokens`(总量 = 三者之和),因此三个分量各自独立保留, + * 缓存写入单独计费;`output_tokens` 包含 thinking → 推理已包含在输出。 + * - Gemini:`candidatesTokenCount` 是 response candidates token,`thoughtsTokenCount` + * 是思考 token(官方 API 独立字段,不含在 candidatesTokenCount 内,按输出计费) + * → 计费时输出 = candidates + thoughts。 + * - 本地模型(llama/MNN):没有 provider usage 对象,token 为本地实测计数 + * (tokenizer 计数 + 逐 token 生成计数),缓存分量明确为 0。 + * + * 不保存正文、API key、Cookie 或 endpoint 凭据。 + */ +object ProviderUsageNormalizer { + + const val SOURCE_OPENAI_CHAT_COMPLETIONS = "openai_chat_completions" + const val SOURCE_OPENAI_RESPONSES = "openai_responses" + const val SOURCE_ANTHROPIC = "anthropic" + const val SOURCE_GEMINI = "gemini" + const val SOURCE_LLAMA = "llama_cpp" + const val SOURCE_MNN = "mnn" + const val SOURCE_TOOLPKG = "toolpkg_js" + + /** OpenAI chat/completions 系(含 DeepSeek、Kimi、Qwen、Mistral 等兼容端点)。 + * 单次上报即该 attempt 的完整最终 usage → [completeSnapshot] = true。 */ + fun openAiChatCompletions( + usage: JSONObject?, + completeSnapshot: Boolean = true, + ): ProviderUsageSnapshot? { + usage ?: return null + val totalInput = usage.optLong("prompt_tokens", usage.optLong("input_tokens", -1)) + val output = usage.optLong("completion_tokens", usage.optLong("output_tokens", -1)) + val cached = + usage.optJSONObject("prompt_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1).takeIf { it >= 0 } + val cacheWrite = + usage.optJSONObject("prompt_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + val reasoning = + usage.optJSONObject("output_tokens_details") + ?.optLong("reasoning_tokens", -1) + ?.takeIf { it >= 0 } + + val uncached = if (totalInput >= 0 && cached != null) { + (totalInput - cached).coerceAtLeast(0) + } else { + // cached 拆分未知时不得把总输入确定为 uncached(分类确定性) + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = totalInput.takeIf { it >= 0 }, + outputTokens = output.takeIf { it >= 0 }, + reasoningTokens = reasoning, + reasoningIncludedInOutput = true, + // OpenAI 兼容系缓存写入成本已包含在输入单价内,无独立计费概念 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_OPENAI_CHAT_COMPLETIONS, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** OpenAI Responses API:`input_tokens_details.cached_tokens` + `output_tokens_details.reasoning_tokens`。 + * 单次上报即完整最终 usage → [completeSnapshot] = true。 */ + fun openAiResponses( + usage: JSONObject?, + completeSnapshot: Boolean = true, + ): ProviderUsageSnapshot? { + usage ?: return null + val totalInput = usage.optLong("input_tokens", -1) + val output = usage.optLong("output_tokens", -1) + val cached = + usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + val reasoning = + usage.optJSONObject("output_tokens_details") + ?.optLong("reasoning_tokens", -1) + ?.takeIf { it >= 0 } + val cacheWrite = + usage.optJSONObject("input_tokens_details") + ?.optLong("cache_creation_input_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + + val uncached = if (totalInput >= 0 && cached != null) { + (totalInput - cached).coerceAtLeast(0) + } else { + // cached 拆分未知时不得把总输入确定为 uncached(分类确定性) + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = totalInput.takeIf { it >= 0 }, + outputTokens = output.takeIf { it >= 0 }, + reasoningTokens = reasoning, + reasoningIncludedInOutput = true, + // OpenAI Responses 与 chat/completions 一致:无独立缓存写入计费 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_OPENAI_RESPONSES, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** + * Anthropic Messages API。`input_tokens` 不含缓存分量(官方文档:总量 = + * input_tokens + cache_read_input_tokens + cache_creation_input_tokens), + * 因此 uncached/cached/cacheWrite 直接取各自字段,缓存写入独立计费。 + * + * 流式 message_start/message_delta 是**部分更新**([completeSnapshot] = false, + * 省略字段保留旧值);非流式最终响应是完整快照(true,null 覆盖旧值)。 + */ + fun anthropic( + usage: JSONObject?, + completeSnapshot: Boolean = false, + ): ProviderUsageSnapshot? { + usage ?: return null + val input = usage.optLong("input_tokens", -1).takeIf { it >= 0 } + val cached = + usage.optLong("cache_read_input_tokens", -1).takeIf { it >= 0 } + ?: usage.optJSONObject("input_tokens_details") + ?.optLong("cached_tokens", -1) + ?.takeIf { it >= 0 } + ?: usage.optLong("cached_tokens", -1).takeIf { it >= 0 } + val cacheWrite = + usage.optLong("cache_creation_input_tokens", -1).takeIf { it >= 0 } + ?: usage.optJSONObject("cache_creation") + ?.let { sumNumericFields(it) } + ?.takeIf { it >= 0 } + val output = usage.optLong("output_tokens", -1).takeIf { it >= 0 } + val uncached = input + // 总输入 = input + cache_read + cache_creation(官方文档语义); + // 全部已知才确定总量;无任何缓存分量时总输入即 input_tokens。 + val totalInput = + when { + cached != null && cacheWrite != null && uncached != null -> + uncached + cached + cacheWrite + cached == null && cacheWrite == null -> uncached + else -> null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + totalInputTokens = totalInput, + outputTokens = output, + reasoningTokens = null, + reasoningIncludedInOutput = true, + // Anthropic:缓存创建独立计费;字段缺失即该分量未知 + cacheWriteSeparateBilling = true, + completeSnapshot = completeSnapshot, + source = SOURCE_ANTHROPIC, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** Gemini:`usageMetadata`;`candidatesTokenCount` 为 response candidates token, + * `thoughtsTokenCount` 为思考 token(官方 API 独立字段,按输出计费,不含在 + * candidatesTokenCount 内)→ [reasoningIncludedInOutput] = false。 + * 流式逐 chunk 上报的是服务器累计快照,省略字段不代表撤销 → 保持部分更新。 */ + fun gemini( + usageMetadata: JSONObject?, + completeSnapshot: Boolean = false, + ): ProviderUsageSnapshot? { + usageMetadata ?: return null + val prompt = usageMetadata.optLong("promptTokenCount", -1).takeIf { it >= 0 } + val cached = usageMetadata.optLong("cachedContentTokenCount", -1).takeIf { it >= 0 } + val output = usageMetadata.optLong("candidatesTokenCount", -1).takeIf { it >= 0 } + val thoughts = + if (usageMetadata.has("thoughtsTokenCount")) { + usageMetadata.optLong("thoughtsTokenCount", 0) + } else { + null + } + + val uncached = if (prompt != null && cached != null) { + (prompt - cached).coerceAtLeast(0) + } else { + // cachedContentTokenCount 缺失(cached 拆分未知)时不得把总输入确定为 uncached + null + } + val snapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = null, + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + totalInputTokens = prompt, + outputTokens = output, + reasoningTokens = thoughts, + // P1-4:thoughtsTokenCount 独立于 candidatesTokenCount,计费需另行补加 + reasoningIncludedInOutput = false, + // Gemini 无独立缓存写入计费概念 + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_GEMINI, + ) + return snapshot.takeIf { it.hasKnownFields() } + } + + /** 本地模型(llama.cpp/MNN):本地实测计数,缓存分量明确为 0;单次完整上报。 */ + fun local( + uncachedInputTokens: Long, + outputTokens: Long, + source: String, + ): ProviderUsageSnapshot = + ProviderUsageSnapshot( + uncachedInputTokens = uncachedInputTokens.coerceAtLeast(0L), + cachedInputTokens = 0L, + cacheWriteTokens = 0L, + totalInputTokens = uncachedInputTokens.coerceAtLeast(0L), + outputTokens = outputTokens.coerceAtLeast(0L), + reasoningTokens = null, + reasoningIncludedInOutput = null, + cacheWriteSeparateBilling = false, + completeSnapshot = true, + source = source, + ) + + /** + * ToolPkg JS provider:`input` 视为总量(含缓存命中),uncached 为差值。 + * [completeSnapshot] 由协议版本决定:新协议(携带 attempt)为同 attempt 内 + * 的部分更新;旧协议(无 attempt)为整个逻辑请求的累计完整快照。 + * 字段可空(评审 P1-6):缺省字段 = 未知,绝不继承全局累计计数;跨 attempt + * 聚合时缺失分量保持未知(不猜测)。Long 语义(评审 P2-1),负值拒绝为未知。 + */ + fun toolPkg( + input: Long?, + cachedInput: Long?, + output: Long?, + completeSnapshot: Boolean, + ): ProviderUsageSnapshot { + val validInput = input?.takeIf { it >= 0 } + val validCachedInput = cachedInput?.takeIf { it >= 0 } + val splitIsValid = + validInput != null && validCachedInput != null && validCachedInput <= validInput + val uncached = + if (splitIsValid) validInput!! - validCachedInput!! else null + return ProviderUsageSnapshot( + uncachedInputTokens = uncached, + cachedInputTokens = validCachedInput.takeIf { splitIsValid }, + cacheWriteTokens = null, + totalInputTokens = validInput, + outputTokens = output?.takeIf { it >= 0 }, + reasoningTokens = null, + reasoningIncludedInOutput = null, + cacheWriteSeparateBilling = false, + completeSnapshot = completeSnapshot, + source = SOURCE_TOOLPKG, + ) + } + + private fun sumNumericFields(jsonObject: JSONObject): Long { + var total = 0L + val keys = jsonObject.keys() + while (keys.hasNext()) { + val key = keys.next() + when (val value = jsonObject.opt(key)) { + is Number -> total += value.toLong() + is JSONObject -> total += sumNumericFields(value) + else -> {} + } + } + return total + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt new file mode 100644 index 000000000..2928e232b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoder.kt @@ -0,0 +1,69 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.model.ApiProviderType + +internal data class ReleasedProviderModelKey( + val storedProviderModel: String, + val provider: String, + val model: String, +) + +/** + * Decodes the released DataStore key format `provider:model -> provider_model`. + * + * Registered aliases are matched longest-first so ToolPkg IDs containing `_` keep their + * full identity. A provider that was removed before migration is decoded with the same + * first-separator rule used by the released implementation; its historical name is the + * only identity available in the key itself. + */ +internal object ReleasedProviderModelKeyDecoder { + private val builtInProviderAliases = ApiProviderType.entries.associate { it.name to it.name } + + fun decode( + encoded: String, + additionalProviderAliases: Map = emptyMap(), + ): ReleasedProviderModelKey { + val aliases = buildMap { + putAll(builtInProviderAliases) + additionalProviderAliases.forEach { (rawAlias, rawIdentity) -> + val alias = rawAlias.trim() + val identity = rawIdentity.trim() + require(alias.isNotEmpty() && identity.isNotEmpty()) { + "released token provider aliases must not be blank" + } + val previous = put(alias, identity) + require(previous == null || previous == identity) { + "conflicting released token provider alias: $alias" + } + } + } + val knownProviderAlias = aliases.keys + .sortedByDescending(String::length) + .firstOrNull { encoded == it || encoded.startsWith("${it}_") } + val separator: Int + val providerAlias: String + if (knownProviderAlias != null) { + providerAlias = knownProviderAlias + separator = providerAlias.length + } else { + // Released keys for providers no longer present in the registry only retain + // the original provider:model separator encoded as the first underscore. + separator = encoded.indexOf('_') + require(separator > 0 && separator < encoded.lastIndex) { + "released token key does not contain a provider and model: $encoded" + } + providerAlias = encoded.substring(0, separator) + } + require(separator > 0 && separator < encoded.lastIndex) { + "released token key does not contain a provider and model: $encoded" + } + val model = encoded.substring(separator + 1) + val provider = + if (knownProviderAlias != null) aliases.getValue(providerAlias) else providerAlias + return ReleasedProviderModelKey( + storedProviderModel = "$providerAlias:$model", + provider = provider, + model = model, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt new file mode 100644 index 000000000..0fe9b1386 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenActivityModels.kt @@ -0,0 +1,147 @@ +package com.ai.assistance.operit.data.stats + +import java.time.LocalDate +import java.time.ZoneId +import java.time.temporal.ChronoUnit +import kotlin.math.ceil + +enum class TokenActivityViewMode { DAILY, WEEKLY, CUMULATIVE } + +internal data class TokenActivitySnapshot( + val zone: ZoneId, + val dayTotals: Map, +) + +data class TokenActivityDay(val date: LocalDate, val tokens: Long, val level: Int) + +data class TokenActivityWeek( + val startDate: LocalDate, + val tokens: Long, + val level: Int, + val barHeight: Int, +) + +data class TokenActivityStats( + val totalTokens: Long = 0L, + val peakTokens: Long = 0L, + val currentStreak: Int = 0, + val longestStreak: Int = 0, +) + +data class TokenActivityRangeData( + val daily: List, + val weekly: List, + val cumulative: List, + val stats: TokenActivityStats, +) + +object TokenActivityAggregator { + /** Builds all three activity views from the same explicit calendar range. */ + internal fun rangeData( + snapshot: TokenActivitySnapshot, + range: TokenStatsTimeRange, + ): TokenActivityRangeData { + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(snapshot.zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(snapshot.zone).toLocalDate() + return rangeData(snapshot.dayTotals, start, end) + } + + private fun rangeData( + dayTotals: Map, + start: LocalDate, + end: LocalDate, + ): TokenActivityRangeData { + val dayCount = ChronoUnit.DAYS.between(start, end).toInt() + 1 + val raw = List(dayCount) { index -> + val date = start.plusDays(index.toLong()) + TokenActivityDay(date, dayTotals[date] ?: 0L, 0) + } + val dailyLevels = QuantileLevels.from(raw.map(TokenActivityDay::tokens)) + val daily = raw.map { it.copy(level = dailyLevels.level(it.tokens)) } + + var cumulativeTotal = 0L + val cumulativeRaw = raw.map { + cumulativeTotal = TokenCostCalculator.saturatedAdd(cumulativeTotal, it.tokens) + it.copy(tokens = cumulativeTotal) + } + val cumulativeLevels = QuantileLevels.from(cumulativeRaw.map(TokenActivityDay::tokens)) + val cumulative = cumulativeRaw.map { it.copy(level = cumulativeLevels.level(it.tokens)) } + + val firstWeek = start.minusDays((start.dayOfWeek.value % 7).toLong()) + val lastWeek = end.minusDays((end.dayOfWeek.value % 7).toLong()) + val weekCount = ChronoUnit.WEEKS.between(firstWeek, lastWeek).toInt() + 1 + val weekTotals = LongArray(weekCount) + raw.forEach { day -> + val weekStart = day.date.minusDays((day.date.dayOfWeek.value % 7).toLong()) + val index = ChronoUnit.WEEKS.between(firstWeek, weekStart).toInt() + weekTotals[index] = TokenCostCalculator.saturatedAdd(weekTotals[index], day.tokens) + } + val weekLevels = QuantileLevels.from(weekTotals.toList()) + val heights = barHeights(weekTotals.toList()) + val weekly = List(weekCount) { index -> + TokenActivityWeek( + startDate = firstWeek.plusWeeks(index.toLong()), + tokens = weekTotals[index], + level = weekLevels.level(weekTotals[index]), + barHeight = heights[index], + ) + } + return TokenActivityRangeData(daily, weekly, cumulative, stats(raw)) + } + + private fun stats(days: List): TokenActivityStats { + var total = 0L + var peak = 0L + var run = 0 + var longest = 0 + days.forEach { day -> + total = TokenCostCalculator.saturatedAdd(total, day.tokens) + peak = maxOf(peak, day.tokens) + run = if (day.tokens > 0L) run + 1 else 0 + longest = maxOf(longest, run) + } + var current = 0 + var index = days.lastIndex + while (index >= 0 && days[index].tokens > 0L) { + current++ + index-- + } + return TokenActivityStats(total, peak, current, longest) + } + + private fun barHeights(values: List): IntArray { + val distinct = values.filter { it > 0L }.distinct().sorted() + return IntArray(values.size) { index -> + when { + values[index] <= 0L -> 1 + distinct.size == 1 -> 7 + else -> 2 + distinct.indexOf(values[index]) * 5 / (distinct.size - 1) + } + } + } +} + +private class QuantileLevels(private val thresholds: LongArray) { + fun level(value: Long): Int { + if (value <= 0L) return 0 + for (level in 1..5) if (value <= thresholds[level]) return level + return 5 + } + + companion object { + fun from(values: List): QuantileLevels { + val nonZero = values.filter { it > 0L }.sorted() + if (nonZero.size < 2 || nonZero.firstOrNull() == nonZero.lastOrNull()) { + return QuantileLevels(LongArray(6).also { it[3] = Long.MAX_VALUE }) + } + fun nearest(percentile: Double): Long { + val index = (ceil(nonZero.size * percentile).toInt() - 1) + .coerceIn(0, nonZero.lastIndex) + return nonZero[index] + } + return QuantileLevels( + longArrayOf(0L, nearest(0.25), nearest(0.50), nearest(0.75), nearest(0.95), Long.MAX_VALUE) + ) + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt new file mode 100644 index 000000000..95fd78ce3 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenCostCalculator.kt @@ -0,0 +1,91 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow +import com.ai.assistance.operit.data.model.BillingMode + +object TokenCostCalculator { + fun saturatedAdd(left: Long, right: Long): Long = + if (right > 0L && left > Long.MAX_VALUE - right) Long.MAX_VALUE else left + right + + fun currentCost( + row: TokenUsageModelAggregateRow, + pricing: ResolvedTokenPricing, + targetCurrency: PricingCurrency, + usdToCnyRate: Double, + ): TokenStatsCostSummary { + val nativeAmount: Double + val unknown: Long + if (pricing.billingMode == BillingMode.COUNT) { + nativeAmount = pricing.pricePerRequest * row.requests + unknown = + if (pricing.pricePerRequest > 0.0) { + (row.usageRows - row.requestCountKnown).coerceAtLeast(0L) + } else { + row.usageRows + } + } else { + var amount = 0.0 + var unknownRequests = 0L + fun add(tokens: Long, known: Long, price: Double) { + if (price > 0.0) { + amount += tokens.toDouble() * price / 1_000_000.0 + unknownRequests = maxOf(unknownRequests, row.usageRows - known) + } + } + if ( + pricing.inputPricePerMillion == pricing.cachedInputPricePerMillion && + pricing.inputPricePerMillion == pricing.cacheWritePricePerMillion + ) { + add(row.totalInputTokens, row.totalInputKnown, pricing.inputPricePerMillion) + } else { + add(row.uncachedInputTokens, row.uncachedInputKnown, pricing.inputPricePerMillion) + add(row.cachedInputTokens, row.cachedInputKnown, pricing.cachedInputPricePerMillion) + add(row.cacheWriteTokens, row.cacheWriteKnown, pricing.cacheWritePricePerMillion) + } + add(row.outputTokens, row.outputKnown, pricing.outputPricePerMillion) + nativeAmount = amount + unknown = + if ( + pricing.inputPricePerMillion <= 0.0 && + pricing.cachedInputPricePerMillion <= 0.0 && + pricing.cacheWritePricePerMillion <= 0.0 && + pricing.outputPricePerMillion <= 0.0 + ) { + row.usageRows + } else { + unknownRequests + } + } + val converted = TokenCostCurrency.convertTo( + nativeAmount, + pricing.currency, + targetCurrency, + usdToCnyRate, + ) + return TokenStatsCostSummary( + currency = targetCurrency, + knownAmount = converted, + unknownContributionCount = unknown, + totalContributionCount = row.usageRows, + rateUsed = usdToCnyRate, + originalCurrencyAmounts = + if (nativeAmount > 0.0) mapOf(pricing.currency to nativeAmount) else emptyMap(), + ) + } +} + +object TokenCostCurrency { + const val DEFAULT_USD_TO_CNY_RATE = 7.0 + + fun convertTo( + amount: Double, + source: PricingCurrency, + target: PricingCurrency, + usdToCnyRate: Double, + ): Double = when { + source == target -> amount + source == PricingCurrency.USD -> amount * usdToCnyRate + else -> amount / usdToCnyRate + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt new file mode 100644 index 000000000..dde22e12f --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenPriceResolver.kt @@ -0,0 +1,86 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode + +data class ModelPriceSettings( + val billingMode: BillingMode? = null, + val currency: PricingCurrency? = null, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) { + fun hasAnyUserSetting(): Boolean = + billingMode != null || + currency != null || + inputPricePerMillion != null || + cachedInputPricePerMillion != null || + cacheWritePricePerMillion != null || + outputPricePerMillion != null || + pricePerRequest != null +} + +data class TokenPriceSettingsSnapshot( + val providerModels: Map, + val configs: Map, +) { + fun settingFor(providerModel: String, configId: String?): ModelPriceSettings? { + val model = providerModels[providerModel] + val config = configId?.let { configs[tokenPriceConfigKey(providerModel, it)] } + if (config == null) return model + return ModelPriceSettings( + billingMode = config.billingMode ?: model?.billingMode, + currency = config.currency ?: model?.currency, + inputPricePerMillion = config.inputPricePerMillion ?: model?.inputPricePerMillion, + cachedInputPricePerMillion = + config.cachedInputPricePerMillion ?: model?.cachedInputPricePerMillion, + cacheWritePricePerMillion = + config.cacheWritePricePerMillion ?: model?.cacheWritePricePerMillion, + outputPricePerMillion = config.outputPricePerMillion ?: model?.outputPricePerMillion, + pricePerRequest = config.pricePerRequest ?: model?.pricePerRequest, + ) + } +} + +internal fun tokenPriceConfigKey(providerModel: String, configId: String): String = + "$providerModel\u001f$configId" + +data class ResolvedTokenPricing( + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double, + val cachedInputPricePerMillion: Double, + val cacheWritePricePerMillion: Double, + val outputPricePerMillion: Double, + val pricePerRequest: Double, + val source: PricingSource, +) + +/** Resolves only the current price: user setting first, then the built-in model table. */ +object TokenPriceResolver { + fun resolve( + providerModel: String, + user: ModelPriceSettings?, + ): ResolvedTokenPricing { + val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) + return ResolvedTokenPricing( + billingMode = user?.billingMode ?: defaults.billingMode, + currency = user?.currency ?: defaults.currency, + inputPricePerMillion = user?.inputPricePerMillion ?: defaults.inputPricePerMillion, + cachedInputPricePerMillion = + user?.cachedInputPricePerMillion + ?: user?.inputPricePerMillion + ?: defaults.cachedInputPricePerMillion, + cacheWritePricePerMillion = + user?.cacheWritePricePerMillion + ?: user?.inputPricePerMillion + ?: defaults.inputPricePerMillion, + outputPricePerMillion = user?.outputPricePerMillion ?: defaults.outputPricePerMillion, + pricePerRequest = user?.pricePerRequest ?: defaults.pricePerRequest, + source = if (user?.hasAnyUserSetting() == true) PricingSource.USER else PricingSource.BUILT_IN, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt new file mode 100644 index 000000000..a64e847b6 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatTypes.kt @@ -0,0 +1,37 @@ +package com.ai.assistance.operit.data.stats + +/** + * 事件业务分类(阶段 1 固定契约;统计页默认包含全部分类并允许筛选)。 + * 所有实际模型调用都应落入其中一种,包括连接测试等探测调用。 + */ +enum class TokenStatCategory { + CHAT, + SUBAGENT, + SUMMARY, + TITLE, + MEMORY, + CHARACTER_GENERATION, + CONNECTION_TEST, + OTHER; + + companion object { + fun fromName(name: String?): TokenStatCategory = + entries.firstOrNull { it.name == name } ?: OTHER + } +} + +/** 事件结束状态:正常完成、取消、超时、失败。 */ +enum class TokenStatStatus { + COMPLETED, + CANCELLED, + TIMEOUT, + FAILED; + + companion object { + fun fromName(name: String?): TokenStatStatus = + entries.firstOrNull { it.name == name } ?: FAILED + } +} + +/** Price-resolution provenance kept for calculation tests and diagnostics. */ +enum class PricingSource { BUILT_IN, USER, UNKNOWN } diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt new file mode 100644 index 000000000..4ce483185 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsPreferences.kt @@ -0,0 +1,83 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.doublePreferencesKey +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.longPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import com.ai.assistance.operit.data.collects.PricingCurrency +import kotlinx.coroutines.flow.first + +private val Context.tokenStatsDataStore: DataStore by + preferencesDataStore(name = "token_stats_preferences") + +/** Scalar statistics settings. Structured usage, grouping, and pricing stay in Room. */ +internal class TokenStatsPreferences(context: Context) { + companion object { + private val TARGET_CURRENCY = stringPreferencesKey("target_currency") + private val USD_TO_CNY_RATE = doublePreferencesKey("usd_to_cny_rate") + private val TIME_RANGE_START = longPreferencesKey("time_range_start") + private val TIME_RANGE_END = longPreferencesKey("time_range_end") + private val IMPORTED_AT = longPreferencesKey("imported_at_ms") + } + + private val dataStore = context.applicationContext.tokenStatsDataStore + + suspend fun importedAtMs(): Long? = dataStore.data.first()[IMPORTED_AT] + + suspend fun completeMigration( + importedAtMs: Long, + releasedUsdToCnyRate: Double?, + ) { + dataStore.edit { preferences -> + releasedUsdToCnyRate?.let { rate -> preferences[USD_TO_CNY_RATE] = rate } + preferences[IMPORTED_AT] = importedAtMs + } + } + + suspend fun loadRateWithEstimate(): Pair { + val stored = dataStore.data.first()[USD_TO_CNY_RATE] + return if (stored == null) { + TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE to true + } else { + require(stored.isFinite() && stored > 0.0) { "stored exchange rate is invalid" } + stored to false + } + } + + suspend fun saveRate(rate: Double) { + require(rate.isFinite() && rate > 0.0) { "exchange rate must be positive and finite" } + dataStore.edit { preferences -> preferences[USD_TO_CNY_RATE] = rate } + } + + suspend fun loadTargetCurrency(): PricingCurrency { + val stored = dataStore.data.first()[TARGET_CURRENCY] + return stored?.let { PricingCurrency.valueOf(it) } ?: PricingCurrency.CNY + } + + suspend fun saveTargetCurrency(currency: PricingCurrency) { + dataStore.edit { preferences -> preferences[TARGET_CURRENCY] = currency.name } + } + + suspend fun loadTimeRange(): TokenStatsTimeRange? { + val preferences = dataStore.data.first() + val startMs = preferences[TIME_RANGE_START] ?: return null + val endMs = checkNotNull(preferences[TIME_RANGE_END]) + return TokenStatsTimeRanges.customRange(startMs, endMs) + } + + suspend fun saveTimeRange(range: TokenStatsTimeRange?) { + dataStore.edit { preferences -> + if (range == null) { + preferences.remove(TIME_RANGE_START) + preferences.remove(TIME_RANGE_END) + return@edit + } + preferences[TIME_RANGE_START] = range.startMs + preferences[TIME_RANGE_END] = range.endMs + } + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt new file mode 100644 index 000000000..b578d50bc --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryModels.kt @@ -0,0 +1,121 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency + +data class TokenStatsQueryParams( + val targetCurrency: PricingCurrency = PricingCurrency.CNY, + val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, + val providerModels: Set? = null, + val categories: Set? = null, + val statuses: Set? = null, +) + +data class TokenStatsCostSummary( + val currency: PricingCurrency, + val knownAmount: Double, + val unknownContributionCount: Long, + val totalContributionCount: Long, + val rateUsed: Double, + val originalCurrencyAmounts: Map, +) + +data class TokenStatsTokenAggregate( + val knownSum: Long, + val knownEventCount: Long, + val unknownEventCount: Long, + val totalEventCount: Long, +) { + val isFullyKnown: Boolean get() = unknownEventCount == 0L +} + +data class TokenStatsDurationAggregate( + val knownCount: Long, + val unknownCount: Long, + val totalMs: Long, + val averageMs: Double, +) { + val hasData: Boolean get() = knownCount > 0L +} + +data class TokenStatsPerformance( + val ttft: TokenStatsDurationAggregate, + val generationDuration: TokenStatsDurationAggregate, +) + +data class TokenStatsTotals( + val requests: Long, + val requestCountUnknownContributionCount: Long, + val uncachedInput: TokenStatsTokenAggregate, + val cachedInput: TokenStatsTokenAggregate, + val cacheWrite: TokenStatsTokenAggregate, + val totalInput: TokenStatsTokenAggregate, + val output: TokenStatsTokenAggregate, + val reasoning: TokenStatsTokenAggregate, + val totalTokens: TokenStatsTokenAggregate, + val cost: TokenStatsCostSummary, +) + +data class TokenStatsLifetimeOverview( + val totals: TokenStatsTotals, + val displayModels: List, +) + +data class TokenStatsTrendBucket( + val bucketStartMs: Long, + val bucketEndMs: Long, + val totals: TokenStatsTotals, + val byModel: Map, + val performance: TokenStatsPerformance, +) + +data class TokenStatsModelBucket( + val requests: Long, + val requestCountUnknownContributionCount: Long, + val uncachedInput: Long, + val cachedInput: Long, + val cacheWrite: Long, + val output: Long, + val reasoning: Long, + val totalTokens: Long, + val totalTokensUnknownEventCount: Long, + val unknownTokenEventCount: Long, + val cost: TokenStatsCostSummary, +) + +data class TokenStatsIdentityBreakdown( + val configId: String?, + val provider: String, + val model: String, + val totals: TokenStatsTotals, +) + +data class TokenStatsDisplayModelBreakdown( + val displayModelId: String, + val displayName: String, + val normalizedModel: String, + val totals: TokenStatsTotals, + val identities: List, + val providerModels: List, +) + +data class TokenStatsCategoryBreakdown( + val category: TokenStatCategory, + val totals: TokenStatsTotals, +) + +data class TokenStatsStatusBreakdown( + val status: TokenStatStatus, + val totals: TokenStatsTotals, +) + +data class TokenStatsRangeData( + val range: TokenStatsTimeRange, + val granularity: TokenStatsGranularity, + val eventCount: Long, + val summary: TokenStatsTotals, + val performance: TokenStatsPerformance, + val buckets: List, + val displayModels: List, + val categories: List, + val statuses: List, +) diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt new file mode 100644 index 000000000..c088507e9 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsQueryService.kt @@ -0,0 +1,384 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageBreakdownRow +import com.ai.assistance.operit.data.dao.TokenUsageActivityDayRow +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import java.time.LocalDate +import java.time.ZoneId + +/** SQL-backed statistics queries. Only aggregate rows leave Room. */ +object TokenStatsQueryService { + suspend fun lifetimeOverview( + context: Context, + params: TokenStatsQueryParams, + ): TokenStatsLifetimeOverview { + val repository = TokenUsageRepository.getInstance(context) + return repository.withDao { dao -> + val requestRows = dao.aggregateRequestModelsForLifetime( + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + TokenStatsLifetimeOverview( + totals = combineTotals(requestRows.map { it.toTotals(prices, params) }, params), + displayModels = buildDisplayModels(requestRows, prices, params), + ) + } + } + + suspend fun rangeData( + context: Context, + range: TokenStatsTimeRange, + params: TokenStatsQueryParams, + zone: ZoneId, + ): TokenStatsRangeData { + val repository = TokenUsageRepository.getInstance(context) + return repository.withDao { dao -> + val modelSettings = dao.getAllStatsModels() + val prices = modelSettings.toPriceSnapshot() + val modelRows = dao.aggregateModelsInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val displayModels = buildDisplayModels(modelRows, prices, params) + val summary = combineTotals(displayModels.map(TokenStatsDisplayModelBreakdown::totals), params) + val granularity = TokenStatsTimeRanges.granularityFor(range) + val starts = TokenStatsTimeRanges.bucketStarts(range, granularity, zone) + val buckets = starts.mapIndexed { index, bucketStart -> + val bucketEnd = minOf( + range.endMs, + TokenStatsTimeRanges.bucketEndMs(starts, index, granularity, zone), + ) + val bucketRows = dao.aggregateModelsInRange( + startMs = maxOf(range.startMs, bucketStart), + endMs = bucketEnd, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val models = buildDisplayModels(bucketRows, prices, params) + TokenStatsTrendBucket( + bucketStartMs = bucketStart, + bucketEndMs = bucketEnd, + totals = combineTotals(models.map(TokenStatsDisplayModelBreakdown::totals), params), + byModel = models.associate { it.displayModelId to it.totals.toModelBucket() }, + performance = performanceOf(bucketRows), + ) + } + val categoryRows = dao.aggregateCategoriesInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + val statusRows = dao.aggregateStatusesInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + TokenStatsRangeData( + range = range, + granularity = granularity, + eventCount = summary.totalTokens.totalEventCount, + summary = summary, + performance = performanceOf(modelRows), + buckets = buckets, + displayModels = displayModels, + categories = categoryRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsCategoryBreakdown( + TokenStatCategory.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, + statuses = statusRows.groupBy(TokenUsageBreakdownRow::key).map { (key, rows) -> + TokenStatsStatusBreakdown( + TokenStatStatus.fromName(key), + combineTotals(rows.map { it.asModelRow().toTotals(prices, params) }, params), + ) + }, + ) + } + } + + internal suspend fun activitySnapshot( + context: Context, + range: TokenStatsTimeRange, + params: TokenStatsQueryParams, + zone: ZoneId, + ): TokenActivitySnapshot { + val repository = TokenUsageRepository.getInstance(context) + return repository.withDao { dao -> + val days = dao.getActivityDaysInRange( + startMs = range.startMs, + endMs = range.endMs, + providerModels = params.providerModels.queryValues(), + allModels = params.providerModels == null, + categories = params.categories.namesForQuery(), + allCategories = params.categories == null, + statuses = params.statuses.namesForQuery(), + allStatuses = params.statuses == null, + ) + TokenActivitySnapshot( + zone = zone, + dayTotals = + days.groupBy(TokenUsageActivityDayRow::localDate).mapValues { (_, rows) -> + rows.fold(0L) { total, row -> TokenCostCalculator.saturatedAdd(total, row.tokens) } + }.mapKeys { (date, _) -> LocalDate.parse(date) }, + ) + } + } + + private fun buildDisplayModels( + rows: List, + prices: TokenPriceSettingsSnapshot, + params: TokenStatsQueryParams, + ): List = + rows.groupBy { row -> displayModelIdFor(row.model) } + .map { (displayModelId, groupRows) -> + val identities = groupRows.map { row -> + TokenStatsIdentityBreakdown( + configId = row.configId, + provider = row.provider, + model = row.model, + totals = row.toTotals(prices, params), + ) + } + TokenStatsDisplayModelBreakdown( + displayModelId = displayModelId, + displayName = groupRows.first().model, + normalizedModel = groupRows.first().model.trim().lowercase(), + totals = combineTotals(identities.map(TokenStatsIdentityBreakdown::totals), params), + identities = identities, + providerModels = groupRows.map(TokenUsageModelAggregateRow::providerModel).distinct(), + ) + } + .sortedByDescending { it.totals.totalTokens.knownSum } + + private fun TokenUsageModelAggregateRow.toTotals( + prices: TokenPriceSettingsSnapshot, + params: TokenStatsQueryParams, + ): TokenStatsTotals { + val pricing = TokenPriceResolver.resolve(providerModel, prices.settingFor(providerModel, configId)) + val input = component(uncachedInputTokens, uncachedInputKnown, usageRows) + val cached = component(cachedInputTokens, cachedInputKnown, usageRows) + val cacheWrite = component(cacheWriteTokens, cacheWriteKnown, usageRows) + val totalInput = + if (totalInputKnown > 0L) { + component(totalInputTokens, totalInputKnown, usageRows) + } else { + combineComponents(listOf(input, cached, cacheWrite), usageRows) + } + val output = component(outputTokens, outputKnown, usageRows) + val reasoning = component(reasoningTokens, reasoningKnown, usageRows) + val totalTokens = combineComponents(listOf(totalInput, output), usageRows) + return TokenStatsTotals( + requests = requests, + requestCountUnknownContributionCount = + (usageRows - requestCountKnown).coerceAtLeast(0L), + uncachedInput = input, + cachedInput = cached, + cacheWrite = cacheWrite, + totalInput = totalInput, + output = output, + reasoning = reasoning, + totalTokens = totalTokens, + cost = TokenCostCalculator.currentCost(this, pricing, params.targetCurrency, params.manualRate), + ) + } + + private fun TokenUsageBreakdownRow.asModelRow() = TokenUsageModelAggregateRow( + provider = provider, + model = model, + configId = configId, + requests = requests, + requestCountKnown = requestCountKnown, + usageRows = usageRows, + uncachedInputTokens = uncachedInputTokens, + uncachedInputKnown = uncachedInputKnown, + cachedInputTokens = cachedInputTokens, + cachedInputKnown = cachedInputKnown, + cacheWriteTokens = cacheWriteTokens, + cacheWriteKnown = cacheWriteKnown, + totalInputTokens = totalInputTokens, + totalInputKnown = totalInputKnown, + outputTokens = outputTokens, + outputKnown = outputKnown, + reasoningTokens = reasoningTokens, + reasoningKnown = reasoningKnown, + ttftTotalMs = ttftTotalMs, + ttftSamples = ttftSamples, + durationTotalMs = durationTotalMs, + durationSamples = durationSamples, + ) + + private fun combineTotals( + values: List, + params: TokenStatsQueryParams, + ): TokenStatsTotals { + if (values.isEmpty()) return emptyTotals(params) + return TokenStatsTotals( + requests = values.sumLong(TokenStatsTotals::requests), + requestCountUnknownContributionCount = + values.sumLong(TokenStatsTotals::requestCountUnknownContributionCount), + uncachedInput = values.combineComponents(TokenStatsTotals::uncachedInput), + cachedInput = values.combineComponents(TokenStatsTotals::cachedInput), + cacheWrite = values.combineComponents(TokenStatsTotals::cacheWrite), + totalInput = values.combineComponents(TokenStatsTotals::totalInput), + output = values.combineComponents(TokenStatsTotals::output), + reasoning = values.combineComponents(TokenStatsTotals::reasoning), + totalTokens = values.combineComponents(TokenStatsTotals::totalTokens), + cost = TokenStatsCostSummary( + currency = params.targetCurrency, + knownAmount = values.sumOf { it.cost.knownAmount }, + unknownContributionCount = values.sumLong { it.cost.unknownContributionCount }, + totalContributionCount = values.sumLong { it.cost.totalContributionCount }, + rateUsed = params.manualRate, + originalCurrencyAmounts = values + .flatMap { it.cost.originalCurrencyAmounts.entries } + .groupBy({ it.key }, { it.value }) + .mapValues { (_, amounts) -> amounts.sum() }, + ), + ) + } + + private fun performanceOf(rows: List): TokenStatsPerformance { + val usageRows = rows.sumLong(TokenUsageModelAggregateRow::usageRows) + val ttftSamples = rows.sumLong(TokenUsageModelAggregateRow::ttftSamples) + val ttftTotal = rows.sumLong(TokenUsageModelAggregateRow::ttftTotalMs) + val durationSamples = rows.sumLong(TokenUsageModelAggregateRow::durationSamples) + val durationTotal = rows.sumLong(TokenUsageModelAggregateRow::durationTotalMs) + return TokenStatsPerformance( + ttft = duration(ttftTotal, ttftSamples, usageRows), + generationDuration = duration(durationTotal, durationSamples, usageRows), + ) + } + + private fun TokenStatsTotals.toModelBucket() = TokenStatsModelBucket( + requests, + requestCountUnknownContributionCount, + uncachedInput.knownSum, + cachedInput.knownSum, + cacheWrite.knownSum, + output.knownSum, + reasoning.knownSum, + totalTokens.knownSum, + totalTokens.unknownEventCount, + maxOf( + uncachedInput.unknownEventCount, + cachedInput.unknownEventCount, + output.unknownEventCount, + ), + cost, + ) + + private fun emptyTotals(params: TokenStatsQueryParams): TokenStatsTotals { + val empty = component(0L, 0L, 0L) + return TokenStatsTotals( + 0L, + 0L, + empty, + empty, + empty, + empty, + empty, + empty, + empty, + TokenStatsCostSummary( + params.targetCurrency, + 0.0, + 0L, + 0L, + params.manualRate, + emptyMap(), + ), + ) + } + + private fun component(sum: Long, known: Long, total: Long) = + TokenStatsTokenAggregate(sum, known, (total - known).coerceAtLeast(0L), total) + + private fun combineComponents( + components: List, + contributionCount: Long, + ): TokenStatsTokenAggregate { + val known = components.minOfOrNull(TokenStatsTokenAggregate::knownEventCount) ?: 0L + return TokenStatsTokenAggregate( + knownSum = components.sumLong(TokenStatsTokenAggregate::knownSum), + knownEventCount = known, + unknownEventCount = (contributionCount - known).coerceAtLeast(0L), + totalEventCount = contributionCount, + ) + } + + private fun List.combineComponents( + selector: (TokenStatsTotals) -> TokenStatsTokenAggregate, + ): TokenStatsTokenAggregate { + val values = map(selector) + return TokenStatsTokenAggregate( + knownSum = values.sumLong(TokenStatsTokenAggregate::knownSum), + knownEventCount = values.sumLong(TokenStatsTokenAggregate::knownEventCount), + unknownEventCount = values.sumLong(TokenStatsTokenAggregate::unknownEventCount), + totalEventCount = values.sumLong(TokenStatsTokenAggregate::totalEventCount), + ) + } + + private fun duration(totalMs: Long, samples: Long, contributionCount: Long) = + TokenStatsDurationAggregate( + knownCount = samples, + unknownCount = (contributionCount - samples).coerceAtLeast(0L), + totalMs = totalMs, + averageMs = if (samples > 0L) totalMs.toDouble() / samples else 0.0, + ) + + private fun Iterable.sumLong(selector: (T) -> Long): Long = + fold(0L) { sum, item -> TokenCostCalculator.saturatedAdd(sum, selector(item)) } + + private fun Set?.queryValues(): List = + if (this == null || isEmpty()) listOf("__none__") else toList() + + private fun > Set?.namesForQuery(): List = + if (this == null || isEmpty()) listOf("__none__") else map { it.name } + + private fun displayModelIdFor(model: String): String = "model:${model.trim().lowercase()}" + + private fun List.toPriceSnapshot(): TokenPriceSettingsSnapshot { + val priceRows = filter(TokenStatsModelEntity::hasPriceSetting) + return TokenPriceSettingsSnapshot( + providerModels = priceRows + .filter { it.configId.isEmpty() } + .associate { row -> "${row.provider}:${row.model}" to row.toModelPriceSettings() }, + configs = priceRows + .filter { it.configId.isNotEmpty() } + .associate { row -> + tokenPriceConfigKey("${row.provider}:${row.model}", row.configId) to + row.toModelPriceSettings() + }, + ) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt new file mode 100644 index 000000000..ccef5f39b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsManager.kt @@ -0,0 +1,184 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.model.TokenStatsModelEntity + +enum class TokenStatsPriceScope { PROVIDER_MODEL, CONFIG } + +data class TokenStatsPriceDraft( + val scope: TokenStatsPriceScope, + val provider: String, + val model: String, + val configId: String? = null, + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double? = null, + val cachedInputPricePerMillion: Double? = null, + val cacheWritePricePerMillion: Double? = null, + val outputPricePerMillion: Double? = null, + val pricePerRequest: Double? = null, +) + +data class TokenStatsPriceSetting( + val scope: TokenStatsPriceScope, + val providerModel: String, + val provider: String, + val model: String, + val configId: String?, + val billingMode: BillingMode, + val currency: PricingCurrency, + val inputPricePerMillion: Double?, + val cachedInputPricePerMillion: Double?, + val cacheWritePricePerMillion: Double?, + val outputPricePerMillion: Double?, + val pricePerRequest: Double?, +) + +class TokenStatsSettingsManager(context: Context) { + private val appContext = context.applicationContext + private val repository = TokenUsageRepository.getInstance(appContext) + + fun validatePriceValue(name: String, value: Double?): Double? { + if (value == null) return null + require(value.isFinite() && value > 0.0) { + "$name must be positive and finite, got $value" + } + return value + } + + suspend fun savePrice(draft: TokenStatsPriceDraft) { + val provider = draft.provider.trim() + val model = draft.model.trim() + val configId = draft.configId?.trim().orEmpty() + require(provider.isNotEmpty()) { "provider must not be blank" } + require(model.isNotEmpty()) { "model must not be blank" } + require(draft.scope != TokenStatsPriceScope.CONFIG || configId.isNotEmpty()) { + "configId must not be blank for config pricing" + } + val storageConfigId = + if (draft.scope == TokenStatsPriceScope.PROVIDER_MODEL) "" else configId + repository.withDao { dao -> + val current = + dao.getStatsModel(storageConfigId, provider, model) + ?: TokenStatsModelEntity(storageConfigId, provider, model) + dao.upsertStatsModel( + current.copy( + billingMode = draft.billingMode.name, + currency = draft.currency.name, + inputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("inputPrice", draft.inputPricePerMillion) + } else { + null + }, + cachedInputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cachedInputPrice", draft.cachedInputPricePerMillion) + } else { + null + }, + cacheWritePricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("cacheWritePrice", draft.cacheWritePricePerMillion) + } else { + null + }, + outputPricePerMillion = + if (draft.billingMode == BillingMode.TOKEN) { + validatePriceValue("outputPrice", draft.outputPricePerMillion) + } else { + null + }, + pricePerRequest = + if (draft.billingMode == BillingMode.COUNT) { + validatePriceValue("pricePerRequest", draft.pricePerRequest) + } else { + null + }, + ) + ) + } + } + + suspend fun allPriceSettings(): List { + return repository.withDao { dao -> + dao.getAllStatsModels() + .filter(TokenStatsModelEntity::hasPriceSetting) + .map(TokenStatsModelEntity::toPriceSetting) + .sortedWith( + compareBy( + { it.providerModel.lowercase() }, + { it.scope.ordinal }, + { it.configId.orEmpty().lowercase() }, + ) + ) + } + } + + suspend fun restoreBuiltInPrice(providerModel: String) { + val (provider, model) = splitProviderModel(providerModel) + repository.withDao { dao -> + dao.clearPricing("", provider, model) + dao.deleteEmptyStatsModels() + } + } + + suspend fun resetConfigPrice(providerModel: String, configId: String) { + require(configId.isNotBlank()) { "configId must not be blank" } + val (provider, model) = splitProviderModel(providerModel) + repository.withDao { dao -> + dao.clearPricing(configId, provider, model) + dao.deleteEmptyStatsModels() + } + } + + private fun splitProviderModel(providerModel: String): Pair { + val separator = providerModel.indexOf(':') + require(separator > 0 && separator < providerModel.lastIndex) { + "provider:model is required" + } + return providerModel.substring(0, separator) to providerModel.substring(separator + 1) + } +} + +internal fun TokenStatsModelEntity.hasPriceSetting(): Boolean = + billingMode != null || + currency != null || + inputPricePerMillion != null || + cachedInputPricePerMillion != null || + cacheWritePricePerMillion != null || + outputPricePerMillion != null || + pricePerRequest != null + +internal fun TokenStatsModelEntity.toModelPriceSettings(): ModelPriceSettings = + ModelPriceSettings( + billingMode = billingMode?.let { BillingMode.valueOf(it) }, + currency = currency?.let { PricingCurrency.valueOf(it) }, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + +private fun TokenStatsModelEntity.toPriceSetting(): TokenStatsPriceSetting { + val providerModel = "$provider:$model" + val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) + return TokenStatsPriceSetting( + scope = if (configId.isEmpty()) TokenStatsPriceScope.PROVIDER_MODEL else TokenStatsPriceScope.CONFIG, + providerModel = providerModel, + provider = provider, + model = model, + configId = configId.ifEmpty { null }, + billingMode = billingMode?.let { BillingMode.valueOf(it) } ?: defaults.billingMode, + currency = currency?.let { PricingCurrency.valueOf(it) } ?: defaults.currency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt new file mode 100644 index 000000000..76af029d0 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsSettingsStore.kt @@ -0,0 +1,59 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import com.ai.assistance.operit.data.collects.PricingCurrency + +interface TokenStatsSettingsStore { + suspend fun loadRateWithEstimate(): Pair + + suspend fun saveRate(rate: Double) + + suspend fun loadTargetCurrency(): PricingCurrency + + suspend fun saveTargetCurrency(currency: PricingCurrency) + + suspend fun loadTimeRange(): TokenStatsTimeRange? + + suspend fun saveTimeRange(range: TokenStatsTimeRange?) +} + +/** Statistics-only Preferences implementation; structured data remains in Room. */ +class TokenStatsPreferencesStore(context: Context) : TokenStatsSettingsStore { + private val appContext = context.applicationContext + private val repository = TokenUsageRepository.getInstance(appContext) + private val preferences = TokenStatsPreferences(appContext) + + private suspend fun initialize() { + repository.ensureInitialized() + } + + override suspend fun loadRateWithEstimate(): Pair { + initialize() + return preferences.loadRateWithEstimate() + } + + override suspend fun saveRate(rate: Double) { + initialize() + preferences.saveRate(rate) + } + + override suspend fun loadTargetCurrency(): PricingCurrency { + initialize() + return preferences.loadTargetCurrency() + } + + override suspend fun saveTargetCurrency(currency: PricingCurrency) { + initialize() + preferences.saveTargetCurrency(currency) + } + + override suspend fun loadTimeRange(): TokenStatsTimeRange? { + initialize() + return preferences.loadTimeRange() + } + + override suspend fun saveTimeRange(range: TokenStatsTimeRange?) { + initialize() + preferences.saveTimeRange(range) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt new file mode 100644 index 000000000..c3a72cc62 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRange.kt @@ -0,0 +1,136 @@ +package com.ai.assistance.operit.data.stats + +import java.time.Instant +import java.time.ZoneId +import java.time.ZonedDateTime + +/** + * 时间范围,**半开区间** `[startMs, endMs)`:`startedAtMs == endMs` 的事件 + * 不属于该范围;endMs 是下一边界(如次日 0 点),不是包含式终点。 + */ +data class TokenStatsTimeRange(val startMs: Long, val endMs: Long) { + init { + require(endMs > startMs) { "endMs must be after startMs" } + } + + val durationMs: Long + get() = endMs - startMs +} + +/** 图表桶粒度:10 分钟 / 1 小时 / 1 自然日(本地时区对齐)。 */ +enum class TokenStatsGranularity { + TEN_MINUTES, + HOURLY, + DAILY, +} + +/** + * 日历范围的图表桶对齐计算。 + * + * 桶边界在**本地时间**上对齐(10 分钟整点、整点小时、自然日 0 点),并用 + * java.time 的 plusMinutes/plusHours/plusDays 在本地时区上推进:跨 DST 的 + * 小时/日桶自动得到 23/25 小时的正确 epoch 跨度,且相邻桶起点单调递增、 + * 覆盖无空洞(夏令时重复的小时也会出现两个不同 epoch 的桶)。 + */ +object TokenStatsTimeRanges { + + const val TEN_MINUTES_MS: Long = 10 * 60 * 1000L + const val HOUR_MS: Long = 60 * 60 * 1000L + const val DAY_MS: Long = 24 * HOUR_MS + + /** 防御:自定义范围过大时限制桶数量,避免病态输入拖垮内存/UI。 */ + private const val MAX_BUCKETS = 10_000 + + /** 日历选择器提供显式边界,始终使用半开区间 `[startMs, endMs)`。 */ + fun customRange(startMs: Long, endMs: Long): TokenStatsTimeRange = + TokenStatsTimeRange(startMs, endMs) + + /** + * 按范围时长选择合理桶粒度:≤12h → 10 分钟;≤48h → 1 小时; + * 更长时间(7d/30d/月)→ 1 自然日。自定义范围同样适用。 + */ + fun granularityFor(range: TokenStatsTimeRange): TokenStatsGranularity = + when { + range.durationMs <= 12L * HOUR_MS -> TokenStatsGranularity.TEN_MINUTES + range.durationMs <= 2L * DAY_MS -> TokenStatsGranularity.HOURLY + else -> TokenStatsGranularity.DAILY + } + + /** + * 覆盖 [range] 的桶起点列表(本地时间对齐,升序、不相交)。 + * 最后一个桶的终点是日历对齐的下一个桶起点,可能超出 range.endMs; + * 事件归属按 `[桶起点, 下个桶起点)` 判定,落在范围内的每个事件恰好属于一个桶。 + */ + fun bucketStarts( + range: TokenStatsTimeRange, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): List { + val first = truncateToBucket(Instant.ofEpochMilli(range.startMs).atZone(zone), granularity) + val starts = ArrayList() + var current = first + while (current.toInstant().toEpochMilli() < range.endMs) { + starts += current.toInstant().toEpochMilli() + current = advanceBucket(current, granularity) + if (starts.size > MAX_BUCKETS) { + error( + "range too large for $granularity granularity " + + "(bucket count would exceed $MAX_BUCKETS)" + ) + } + } + return starts + } + + /** 桶 [index] 的结束时间:本地对齐的下一个桶起点(日历推进,非固定毫秒)。 */ + fun bucketEndMs( + bucketStarts: List, + index: Int, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): Long { + if (index + 1 < bucketStarts.size) return bucketStarts[index + 1] + val last = Instant.ofEpochMilli(bucketStarts[index]).atZone(zone) + return advanceBucket(last, granularity).toInstant().toEpochMilli() + } + + /** + * 事件时间戳所属的桶下标(桶起点列表升序)。ts 落在 + * `[第一个桶起点, 最后一个桶终点)` 之外返回 null(防御,正常输入不触发)。 + */ + fun bucketIndexOf( + ts: Long, + bucketStarts: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + ): Int? { + var floor = bucketStarts.binarySearch(ts) + if (floor < 0) floor = -floor - 2 + if (floor < 0) return null + if (bucketEndMs(bucketStarts, floor, granularity, zone) <= ts) return null + return floor + } + + private fun truncateToBucket( + zdt: ZonedDateTime, + granularity: TokenStatsGranularity, + ): ZonedDateTime = + when (granularity) { + TokenStatsGranularity.TEN_MINUTES -> + zdt.withMinute(zdt.minute / 10 * 10).withSecond(0).withNano(0) + TokenStatsGranularity.HOURLY -> + zdt.withMinute(0).withSecond(0).withNano(0) + TokenStatsGranularity.DAILY -> + zdt.toLocalDate().atStartOfDay(zdt.zone) + } + + private fun advanceBucket( + zdt: ZonedDateTime, + granularity: TokenStatsGranularity, + ): ZonedDateTime = + when (granularity) { + TokenStatsGranularity.TEN_MINUTES -> zdt.plusMinutes(10) + TokenStatsGranularity.HOURLY -> zdt.plusHours(1) + TokenStatsGranularity.DAILY -> zdt.plusDays(1) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt new file mode 100644 index 000000000..6e97e6f4b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/data/stats/TokenUsageRepository.kt @@ -0,0 +1,124 @@ +package com.ai.assistance.operit.data.stats + +import android.content.Context +import androidx.room.withTransaction +import com.ai.assistance.operit.data.dao.TokenUsageDao +import com.ai.assistance.operit.data.db.AppDatabase +import com.ai.assistance.operit.data.model.TokenStatsModelEntity +import com.ai.assistance.operit.data.model.TokenUsageIdentity +import com.ai.assistance.operit.data.model.TokenUsageRecordEntity +import com.ai.assistance.operit.data.model.TokenUsageRecordSource +import com.ai.assistance.operit.data.preferences.ApiPreferences +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** Room owner for token usage plus the one-time cumulative-counter import. */ +class TokenUsageRepository private constructor(context: Context) { + companion object { + private const val TAG = "TokenUsageRepository" + + @Volatile + private var instance: TokenUsageRepository? = null + private val databaseAccessMutex = Mutex() + + fun getInstance(context: Context): TokenUsageRepository = + instance ?: synchronized(this) { + instance ?: TokenUsageRepository(context.applicationContext).also { instance = it } + } + + /** + * Prevent token-statistics operations from opening or using Room while a restore replaces + * its database files. The initialization state must be reset before Room is closed. + */ + suspend fun withDatabaseAccess(block: suspend () -> T): T = + databaseAccessMutex.withLock { block() } + + suspend fun withDatabaseRestore(block: suspend () -> T): T = + withDatabaseAccess { + instance?.initializationComplete = false + block() + } + } + + private val appContext = context.applicationContext + private val legacyDataSource = ApiPreferences.getInstance(appContext) + private val statsPreferences = TokenStatsPreferences(appContext) + private var initializationComplete = false + + suspend fun ensureInitialized() = withDatabaseAccess { + ensureInitializedLocked() + } + + /** Resolves the DAO only after the restore barrier and initialization have completed. */ + internal suspend fun withDao(block: suspend (TokenUsageDao) -> T): T = + withDatabaseAccess { + ensureInitializedLocked() + block(AppDatabase.getDatabase(appContext).tokenUsageDao()) + } + + suspend fun record(record: TokenUsageRecordEntity) { + withDao { dao -> dao.insertRecord(record) } + } + + private suspend fun ensureInitializedLocked() { + if (initializationComplete) return + if (statsPreferences.importedAtMs() == null) { + val snapshot = legacyDataSource.readTokenStatsMigrationSnapshot() + val importedAtMs = System.currentTimeMillis() + val activeDatabase = AppDatabase.getDatabase(appContext) + val activeDao = activeDatabase.tokenUsageDao() + activeDatabase.withTransaction { + activeDao.insertRecords(snapshot.totals.map { total -> + TokenUsageRecordEntity( + importKey = TokenUsageIdentity(null, total.provider, total.model).encode(), + occurredAtMs = null, + source = TokenUsageRecordSource.REQUEST, + configId = null, + provider = total.provider, + model = total.model, + category = null, + status = null, + requestCount = total.requestCount, + uncachedInputTokens = + (total.inputTokens - total.cachedInputTokens).coerceAtLeast(0L), + cachedInputTokens = total.cachedInputTokens, + cacheWriteTokens = null, + totalInputTokens = total.inputTokens, + outputTokens = total.outputTokens, + reasoningTokens = null, + ttftMs = null, + durationMs = null, + ) + }) + snapshot.prices.forEach { price -> + val current = + activeDao.getStatsModel("", price.provider, price.model) + ?: TokenStatsModelEntity("", price.provider, price.model) + activeDao.upsertStatsModel( + current.copy( + billingMode = price.settings.billingMode?.name, + currency = price.settings.currency?.name, + inputPricePerMillion = price.settings.inputPricePerMillion, + cachedInputPricePerMillion = price.settings.cachedInputPricePerMillion, + cacheWritePricePerMillion = price.settings.cacheWritePricePerMillion, + outputPricePerMillion = price.settings.outputPricePerMillion, + pricePerRequest = price.settings.pricePerRequest, + ) + ) + } + } + statsPreferences.completeMigration( + importedAtMs = importedAtMs, + releasedUsdToCnyRate = snapshot.usdToCnyRate, + ) + AppLogger.i( + TAG, + "Imported ${snapshot.totals.size} cumulative totals and " + + "${snapshot.prices.size} price settings", + ) + } + legacyDataSource.clearMigratedTokenStatsData() + initializationComplete = true + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt index 79edde76a..63c91d18d 100644 --- a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt +++ b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgAiProviderRegistry.kt @@ -33,6 +33,18 @@ internal object ToolPkgAiProviderRegistry { return providersById.values.sortedBy(ToolPkgAiProviderRegistration::providerId) } + fun releasedTokenProviderAliases(): Map = + buildMap { + list().forEach { registration -> + registration.releasedTokenProviderAliases.forEach { (alias, identity) -> + val previous = put(alias, identity) + require(previous == null || previous == identity) { + "Conflicting ToolPkg token provider alias: $alias" + } + } + } + } + private fun syncToolPkgRegistrations(activeContainers: List) { providersById = activeContainers diff --git a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt index b55ab6eb2..72beb31ce 100644 --- a/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt +++ b/app/src/main/java/com/ai/assistance/operit/plugins/toolpkg/ToolPkgHookBridgeSupport.kt @@ -87,7 +87,22 @@ internal data class ToolPkgAiProviderRegistration( val testConnectionFunctionSource: String? = null, val calculateInputTokensFunctionName: String, val calculateInputTokensFunctionSource: String? = null -) +) { + /** Historical counters use the full provider ID while new requests use the display name. */ + val releasedTokenProviderAliases: Map + get() { + val id = providerId.trim() + require(id.isNotEmpty()) { "ToolPkg AI provider id must not be blank" } + val display = displayName.trim() + require(display.isNotEmpty()) { "ToolPkg AI provider display name must not be blank" } + return mapOf( + id to display, + "TOOLPKG_$id" to display, + "TOOLPKG_${id.lowercase()}" to display, + display to display, + ) + } +} internal fun toolPkgPackageManager(): PackageManager { val application = OperitApplication.instance.applicationContext diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt index e17159bee..870af8002 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageCoordinationDelegate.kt @@ -1058,7 +1058,8 @@ class MessageCoordinationDelegate( modelParameters = modelParameters, enableThinking = false, stream = false, - preserveThinkInHistory = false + preserveThinkInHistory = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.OTHER ) stream.collect { chunk -> contentBuilder.append(chunk) } }.onFailure { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt index 27a94f170..8b55ddff5 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/components/BackupManagementCards.kt @@ -416,7 +416,8 @@ fun ManagementButton( onClick: () -> Unit, modifier: Modifier = Modifier, isDestructive: Boolean = false, - isWarning: Boolean = false + isWarning: Boolean = false, + enabled: Boolean = true ) { val colors = if (isDestructive) { ButtonDefaults.filledTonalButtonColors( @@ -436,6 +437,7 @@ fun ManagementButton( onClick = onClick, modifier = modifier, colors = colors, + enabled = enabled, shape = RoundedCornerShape(14.dp) ) { Icon( diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt index 6ae898fc8..449e4c88c 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/FunctionalConfigScreen.kt @@ -525,7 +525,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -554,7 +555,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -583,7 +585,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -605,7 +608,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -625,7 +629,8 @@ fun FunctionConfigCard( ), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { chunk -> buffer.append(chunk) } buffer.toString() } @@ -655,7 +660,8 @@ fun FunctionConfigCard( ), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ).collect { chunk -> buffer.append(chunk) } buffer.toString() } @@ -668,7 +674,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = "Hi")), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() @@ -684,7 +691,8 @@ fun FunctionConfigCard( listOf(PromptTurn(kind = PromptTurnKind.USER, content = prompt)), parameters, stream = false, - enableRetry = false + enableRetry = false, + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CONNECTION_TEST ) .collect { chunk -> buffer.append(chunk) } buffer.toString() diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt index 25396a557..63bf65c39 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/PersonaCardGenerationScreen.kt @@ -378,7 +378,8 @@ fun PersonaCardGenerationScreen( val stream = aiService.sendMessage( context = context, - chatHistory = (fullHistory + ("user" to prompt)).toPromptTurns() + chatHistory = (fullHistory + ("user" to prompt)).toPromptTurns(), + statsCategory = com.ai.assistance.operit.data.stats.TokenStatCategory.CHARACTER_GENERATION ) Pair(stream, aiService) } @@ -515,18 +516,6 @@ fun PersonaCardGenerationScreen( } } - // Update token and request count statistics - withContext(Dispatchers.IO) { - val apiPreferences = ApiPreferences.getInstance(context) - apiPreferences.updateTokensForProviderModel( - aiService.providerModel, - aiService.inputTokenCount, - aiService.outputTokenCount, - aiService.cachedInputTokenCount - ) - apiPreferences.incrementRequestCountForProviderModel(aiService.providerModel) - } - // 流结束后解析并执行工具 withContext(Dispatchers.IO) { processToolInvocations(rawBuffer.toString(), assistantIndex) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt deleted file mode 100644 index f743e2663..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsComponents.kt +++ /dev/null @@ -1,294 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import androidx.compose.foundation.Canvas -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.geometry.Size -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import com.ai.assistance.operit.R - -@Composable -internal fun TokenUsageSummarySection( - totalChats: Int, - totalMessages: Int, - totalTokens: Long, - totalInputTokens: Long, - totalOutputTokens: Long, - totalCachedInputTokens: Long, - totalRequests: Int, - totalCostText: String, - exchangeRateHint: String? -) { - Card( - modifier = Modifier.fillMaxWidth(), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Text( - text = stringResource(id = R.string.settings_usage_summary), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = stringResource(id = R.string.settings_total_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalTokens.toString(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(id = R.string.settings_total_requests), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalRequests.toString(), - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(id = R.string.settings_total_cost), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalCostText, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - } - - exchangeRateHint?.let { - Spacer(modifier = Modifier.height(8.dp)) - Text( - text = it, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f) - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_total_chats), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalChats.toString(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_total_messages), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - Text( - text = totalMessages.toString(), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onPrimaryContainer - ) - } - - Spacer(modifier = Modifier.height(12.dp)) - - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - SummaryLine( - label = stringResource(id = R.string.settings_input_tokens), - value = totalInputTokens.toString(), - valueColor = MaterialTheme.colorScheme.onPrimaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - SummaryLine( - label = stringResource(id = R.string.settings_output_tokens), - value = totalOutputTokens.toString(), - valueColor = MaterialTheme.colorScheme.onPrimaryContainer, - labelColor = MaterialTheme.colorScheme.onPrimaryContainer - ) - if (totalCachedInputTokens > 0L) { - SummaryLine( - label = stringResource(id = R.string.settings_cached_tokens_label), - value = totalCachedInputTokens.toString(), - valueColor = MaterialTheme.colorScheme.tertiary, - labelColor = MaterialTheme.colorScheme.tertiary - ) - } - } - } - } -} - -@Composable -private fun SummaryLine( - label: String, - value: String, - labelColor: androidx.compose.ui.graphics.Color, - valueColor: androidx.compose.ui.graphics.Color -) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = label, - style = MaterialTheme.typography.bodyMedium, - color = labelColor - ) - Text( - text = value, - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium, - color = valueColor - ) - } -} - -@Composable -internal fun ModelUsageDistributionSection(items: List>) { - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Text( - text = stringResource(id = R.string.settings_model_usage_distribution), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - ModelUsagePieChart( - modifier = Modifier - .fillMaxWidth() - .height(220.dp), - items = items - ) - } -} - -@Composable -private fun ModelUsagePieChart( - modifier: Modifier = Modifier, - items: List> -) { - val total = remember(items) { items.sumOf { it.second }.toDouble().coerceAtLeast(1.0) } - val colors = listOf( - MaterialTheme.colorScheme.primary, - MaterialTheme.colorScheme.secondary, - MaterialTheme.colorScheme.tertiary, - MaterialTheme.colorScheme.error, - MaterialTheme.colorScheme.primaryContainer, - MaterialTheme.colorScheme.secondaryContainer, - MaterialTheme.colorScheme.tertiaryContainer - ) - - Card(modifier = modifier) { - Row( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - horizontalArrangement = Arrangement.spacedBy(16.dp), - verticalAlignment = Alignment.CenterVertically - ) { - Box(modifier = Modifier.size(180.dp), contentAlignment = Alignment.Center) { - Canvas(modifier = Modifier.fillMaxSize()) { - val diameter = size.minDimension - val topLeft = Offset((size.width - diameter) / 2f, (size.height - diameter) / 2f) - val arcSize = Size(diameter, diameter) - var startAngle = -90f - - items.forEachIndexed { index, (_, value) -> - val sweep = ((value.toDouble() / total) * 360.0).toFloat() - if (sweep > 0f) { - drawArc( - color = colors[index % colors.size], - startAngle = startAngle, - sweepAngle = sweep, - useCenter = true, - topLeft = topLeft, - size = arcSize - ) - startAngle += sweep - } - } - } - } - - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp) - ) { - items.take(8).forEachIndexed { index, (name, value) -> - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Canvas(modifier = Modifier.size(10.dp)) { - drawRect(color = colors[index % colors.size]) - } - Text( - text = name, - style = MaterialTheme.typography.bodySmall, - modifier = Modifier.weight(1f), - maxLines = 1 - ) - val percent = (value.toDouble() / total) * 100.0 - Text( - text = String.format("%.1f%%", percent), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt deleted file mode 100644 index 7fd2ad18f..000000000 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/TokenUsageStatisticsScreen.kt +++ /dev/null @@ -1,894 +0,0 @@ -package com.ai.assistance.operit.ui.features.settings.screens - -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Analytics -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.RestartAlt -import androidx.compose.material3.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateMapOf -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.unit.dp -import com.ai.assistance.operit.R -import com.ai.assistance.operit.data.collects.DefaultModelPricingCollect -import com.ai.assistance.operit.data.collects.PricingCurrency -import com.ai.assistance.operit.data.model.BillingMode -import com.ai.assistance.operit.data.preferences.ApiPreferences -import com.ai.assistance.operit.data.repository.ChatHistoryManager -import com.ai.assistance.operit.ui.components.CustomScaffold -import java.util.Locale -import kotlinx.coroutines.launch - -private data class ModelCost( - val amount: Double, - val currency: PricingCurrency -) - -private const val DEFAULT_USD_TO_CNY_RATE = 7.2 - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TokenUsageStatisticsScreen( - onBackPressed: () -> Unit -) { - val context = LocalContext.current - val scope = rememberCoroutineScope() - val apiPreferences = remember { ApiPreferences.getInstance(context) } - val chatHistoryManager = remember { ChatHistoryManager.getInstance(context) } - - var totalChats by remember { mutableStateOf(0) } - var totalMessages by remember { mutableStateOf(0) } - - val providerModelTokenUsage = remember { mutableStateMapOf>() } - val providerModelRequestCounts = remember { mutableStateMapOf() } - val modelPricing = remember { mutableStateMapOf>() } - val modelBillingMode = remember { mutableStateMapOf() } - val modelPricePerRequest = remember { mutableStateMapOf() } - val modelCurrencies = remember { mutableStateMapOf() } - - var showPricingDialog by remember { mutableStateOf(false) } - var selectedModel by remember { mutableStateOf("") } - var showResetDialog by remember { mutableStateOf(false) } - var showResetModelDialog by remember { mutableStateOf(false) } - var resetModel by remember { mutableStateOf("") } - - var usdToCnyRate by remember { mutableStateOf(DEFAULT_USD_TO_CNY_RATE) } - var usdToCnyRateInput by remember { mutableStateOf(DEFAULT_USD_TO_CNY_RATE.toString()) } - - LaunchedEffect(Unit) { - apiPreferences.allProviderModelTokensFlow.collect { tokensMap -> - providerModelTokenUsage.clear() - providerModelTokenUsage.putAll(tokensMap) - - tokensMap.keys.forEach { providerModel -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - modelCurrencies[providerModel] = defaults.currency - - if (!modelPricing.containsKey(providerModel)) { - modelPricing[providerModel] = Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - } - if (!modelBillingMode.containsKey(providerModel)) { - modelBillingMode[providerModel] = defaults.billingMode - } - if (!modelPricePerRequest.containsKey(providerModel)) { - modelPricePerRequest[providerModel] = defaults.pricePerRequest - } - } - } - } - - LaunchedEffect(providerModelTokenUsage.keys.toSet()) { - providerModelTokenUsage.keys.forEach { providerModel -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - - val inputPrice = apiPreferences.getModelInputPrice(providerModel) - val outputPrice = apiPreferences.getModelOutputPrice(providerModel) - val cachedInputPrice = apiPreferences.getModelCachedInputPrice(providerModel) - modelPricing[providerModel] = if ( - inputPrice > 0.0 || outputPrice > 0.0 || cachedInputPrice > 0.0 - ) { - Triple(inputPrice, outputPrice, cachedInputPrice) - } else { - Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - } - - modelBillingMode[providerModel] = apiPreferences.getBillingModeForProviderModel(providerModel) - - val savedPricePerRequest = apiPreferences.getPricePerRequestForProviderModel(providerModel) - modelPricePerRequest[providerModel] = if (savedPricePerRequest > 0.0) { - savedPricePerRequest - } else { - defaults.pricePerRequest - } - } - } - - LaunchedEffect(Unit) { - val requestCounts = apiPreferences.getAllProviderModelRequestCounts() - providerModelRequestCounts.clear() - providerModelRequestCounts.putAll(requestCounts) - } - - LaunchedEffect(Unit) { - runCatching { - totalChats = chatHistoryManager.getTotalChatCount() - totalMessages = chatHistoryManager.getTotalMessageCount() - } - } - - LaunchedEffect(Unit) { - val rate = apiPreferences.getUsdToCnyExchangeRate() - if (rate > 0.0) { - usdToCnyRate = rate - usdToCnyRateInput = rate.toString() - } - } - - val providerModelCosts by remember { - derivedStateOf { - providerModelTokenUsage.mapValues { (providerModel, tokens) -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - val currency = modelCurrencies[providerModel] ?: defaults.currency - val billingMode = modelBillingMode[providerModel] ?: defaults.billingMode - - val amount = when (billingMode) { - BillingMode.TOKEN -> { - val pricing = modelPricing[providerModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val nonCachedInput = (tokens.first - tokens.third).coerceAtLeast(0L) - (nonCachedInput / 1_000_000.0 * pricing.first) + - (tokens.second / 1_000_000.0 * pricing.second) + - (tokens.third / 1_000_000.0 * pricing.third) - } - - BillingMode.COUNT -> { - val pricePerRequest = modelPricePerRequest[providerModel] ?: defaults.pricePerRequest - val requestCount = providerModelRequestCounts[providerModel] ?: 0 - requestCount * pricePerRequest - } - } - - ModelCost(amount = amount, currency = currency) - } - } - } - - val totalInputTokens = providerModelTokenUsage.values.sumOf { it.first } - val totalOutputTokens = providerModelTokenUsage.values.sumOf { it.second } - val totalCachedInputTokens = providerModelTokenUsage.values.sumOf { it.third } - val totalTokens = totalInputTokens + totalOutputTokens - val totalRequests = providerModelRequestCounts.values.sum() - - val totalCostCny = providerModelCosts.values.sumOf { cost -> - convertToCny(cost.amount, cost.currency, usdToCnyRate) - } - - val hasUsdCost = providerModelCosts.values.any { it.currency == PricingCurrency.USD && it.amount > 0.0 } - - val modelUsageDistribution by remember { - derivedStateOf { - providerModelTokenUsage.entries - .map { it.key to (it.value.first + it.value.second) } - .filter { it.second > 0 } - .sortedByDescending { it.second } - } - } - - CustomScaffold( - floatingActionButton = { - FloatingActionButton( - onClick = { showResetDialog = true }, - containerColor = MaterialTheme.colorScheme.errorContainer, - contentColor = MaterialTheme.colorScheme.onErrorContainer - ) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(id = R.string.settings_reset_all_counts) - ) - } - } - ) { paddingValues -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp) - ) { - item { - ExchangeRateSettingsCard( - rateInput = usdToCnyRateInput, - onRateInputChange = { usdToCnyRateInput = it }, - onSave = { - val parsedRate = usdToCnyRateInput.toDoubleOrNull() - if (parsedRate != null && parsedRate > 0.0) { - usdToCnyRate = parsedRate - scope.launch { - apiPreferences.setUsdToCnyExchangeRate(parsedRate) - } - } - } - ) - } - - item { - TokenUsageSummarySection( - totalChats = totalChats, - totalMessages = totalMessages, - totalTokens = totalTokens, - totalInputTokens = totalInputTokens, - totalOutputTokens = totalOutputTokens, - totalCachedInputTokens = totalCachedInputTokens, - totalRequests = totalRequests, - totalCostText = formatCurrencyAmount(totalCostCny, PricingCurrency.CNY), - exchangeRateHint = if (hasUsdCost) { - stringResource( - id = R.string.settings_rate_applied_hint, - usdToCnyRate - ) - } else { - null - } - ) - } - - if (modelUsageDistribution.isNotEmpty()) { - item { - ModelUsageDistributionSection(items = modelUsageDistribution) - } - } - - item { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = stringResource(id = R.string.settings_model_details), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold - ) - Text( - text = stringResource(id = R.string.settings_click_to_edit_pricing), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - val sortedProviderModels = providerModelTokenUsage.entries.sortedBy { it.key } - - if (sortedProviderModels.isEmpty()) { - item { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(32.dp), - horizontalAlignment = Alignment.CenterHorizontally - ) { - Icon( - imageVector = Icons.Default.Analytics, - contentDescription = null, - modifier = Modifier.size(48.dp), - tint = MaterialTheme.colorScheme.onSurfaceVariant - ) - Spacer(modifier = Modifier.height(16.dp)) - Text( - text = stringResource(id = R.string.settings_no_token_records), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } else { - items(sortedProviderModels) { (providerModel, tokens) -> - val defaults = DefaultModelPricingCollect.getDefaultPricing(providerModel) - val (input, output, cached) = tokens - val cost = providerModelCosts[providerModel]?.amount ?: 0.0 - val currency = modelCurrencies[providerModel] ?: defaults.currency - val pricing = modelPricing[providerModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val billingMode = modelBillingMode[providerModel] ?: defaults.billingMode - val requestCount = providerModelRequestCounts[providerModel] ?: 0 - val pricePerRequest = modelPricePerRequest[providerModel] ?: defaults.pricePerRequest - val displayCurrency = PricingCurrency.CNY - - TokenUsageModelCard( - modelName = providerModel, - inputTokens = input, - cachedInputTokens = cached, - outputTokens = output, - requestCount = requestCount, - cost = convertToCny(cost, currency, usdToCnyRate), - inputPrice = convertToCny(pricing.first, currency, usdToCnyRate), - outputPrice = convertToCny(pricing.second, currency, usdToCnyRate), - billingMode = billingMode, - pricePerRequest = convertToCny(pricePerRequest, currency, usdToCnyRate), - currency = displayCurrency, - onClick = { - selectedModel = providerModel - showPricingDialog = true - }, - onResetClick = { - resetModel = providerModel - showResetModelDialog = true - } - ) - } - } - - item { - Spacer(modifier = Modifier.height(96.dp)) - } - } - } - - if (showPricingDialog && selectedModel.isNotEmpty()) { - val defaults = DefaultModelPricingCollect.getDefaultPricing(selectedModel) - val currentPricing = modelPricing[selectedModel] ?: Triple( - defaults.inputPricePerMillion, - defaults.outputPricePerMillion, - defaults.cachedInputPricePerMillion - ) - val currentBillingMode = modelBillingMode[selectedModel] ?: defaults.billingMode - val currentPricePerRequest = modelPricePerRequest[selectedModel] ?: defaults.pricePerRequest - val currency = modelCurrencies[selectedModel] ?: defaults.currency - val editCurrency = PricingCurrency.CNY - val currentPricingCny = Triple( - convertToCny(currentPricing.first, currency, usdToCnyRate), - convertToCny(currentPricing.second, currency, usdToCnyRate), - convertToCny(currentPricing.third, currency, usdToCnyRate) - ) - val currentPricePerRequestCny = convertToCny( - currentPricePerRequest, - currency, - usdToCnyRate - ) - - var billingMode by remember { mutableStateOf(currentBillingMode) } - var inputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.first)) } - var outputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.second)) } - var cachedInputPrice by remember { mutableStateOf(formatEditablePrice(currentPricingCny.third)) } - var pricePerRequest by remember { mutableStateOf(formatEditablePrice(currentPricePerRequestCny)) } - val tokenPriceInputsAreValid = - inputPrice.toDoubleOrNull() != null && - outputPrice.toDoubleOrNull() != null && - cachedInputPrice.toDoubleOrNull() != null - val countPriceInputIsValid = pricePerRequest.toDoubleOrNull() != null - val pricingInputIsValid = when (billingMode) { - BillingMode.TOKEN -> tokenPriceInputsAreValid - BillingMode.COUNT -> countPriceInputIsValid - } - - AlertDialog( - onDismissRequest = { showPricingDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_edit_model_pricing, selectedModel)) - }, - text = { - Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { - Text( - text = stringResource(id = R.string.settings_pricing_currency_hint, editCurrency.code), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - Text( - text = stringResource(id = R.string.settings_billing_mode), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - FilterChip( - selected = billingMode == BillingMode.TOKEN, - onClick = { billingMode = BillingMode.TOKEN }, - label = { Text(stringResource(id = R.string.settings_billing_mode_token)) }, - modifier = Modifier.weight(1f) - ) - FilterChip( - selected = billingMode == BillingMode.COUNT, - onClick = { billingMode = BillingMode.COUNT }, - label = { Text(stringResource(id = R.string.settings_billing_mode_count)) }, - modifier = Modifier.weight(1f) - ) - } - - HorizontalDivider() - - if (billingMode == BillingMode.TOKEN) { - Text( - text = stringResource( - id = R.string.settings_pricing_description_with_currency, - editCurrency.code - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = inputPrice, - onValueChange = { inputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_input_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = cachedInputPrice, - onValueChange = { cachedInputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_cached_input_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - OutlinedTextField( - value = outputPrice, - onValueChange = { outputPrice = it }, - label = { - Text( - "${stringResource(id = R.string.settings_output_price_per_million)} (${editCurrency.code})" - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - } else { - Text( - text = stringResource( - id = R.string.settings_token_price_description_with_currency, - editCurrency.code - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = pricePerRequest, - onValueChange = { pricePerRequest = it }, - label = { - Text( - stringResource( - id = R.string.settings_price_per_request_with_currency, - editCurrency.code - ) - ) - }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - } - } - }, - confirmButton = { - TextButton( - enabled = pricingInputIsValid, - onClick = { - scope.launch { - modelBillingMode[selectedModel] = billingMode - apiPreferences.setBillingModeForProviderModel(selectedModel, billingMode) - - if (billingMode == BillingMode.TOKEN) { - val inputPriceValueCny = inputPrice.toDoubleOrNull() - val outputPriceValueCny = outputPrice.toDoubleOrNull() - val cachedInputPriceValueCny = cachedInputPrice.toDoubleOrNull() - - if ( - inputPriceValueCny != null && - outputPriceValueCny != null && - cachedInputPriceValueCny != null - ) { - val inputPriceValue = convertCnyToPricingCurrency( - inputPriceValueCny, - currency, - usdToCnyRate - ) - val outputPriceValue = convertCnyToPricingCurrency( - outputPriceValueCny, - currency, - usdToCnyRate - ) - val cachedInputPriceValue = convertCnyToPricingCurrency( - cachedInputPriceValueCny, - currency, - usdToCnyRate - ) - - modelPricing[selectedModel] = Triple( - inputPriceValue, - outputPriceValue, - cachedInputPriceValue - ) - apiPreferences.setModelInputPrice(selectedModel, inputPriceValue) - apiPreferences.setModelOutputPrice(selectedModel, outputPriceValue) - apiPreferences.setModelCachedInputPrice( - selectedModel, - cachedInputPriceValue - ) - } - } else { - val pricePerRequestValueCny = pricePerRequest.toDoubleOrNull() - - if (pricePerRequestValueCny != null) { - val pricePerRequestValue = convertCnyToPricingCurrency( - pricePerRequestValueCny, - currency, - usdToCnyRate - ) - modelPricePerRequest[selectedModel] = pricePerRequestValue - apiPreferences.setPricePerRequestForProviderModel( - selectedModel, - pricePerRequestValue - ) - } - } - } - - showPricingDialog = false - } - ) { - Text(stringResource(id = R.string.settings_save)) - } - }, - dismissButton = { - TextButton(onClick = { showPricingDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } - - if (showResetModelDialog && resetModel.isNotEmpty()) { - AlertDialog( - onDismissRequest = { showResetModelDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_reset_model_confirmation)) - }, - text = { - Text(text = stringResource(id = R.string.settings_reset_model_warning, resetModel)) - }, - confirmButton = { - TextButton( - onClick = { - scope.launch { - apiPreferences.resetProviderModelTokenCounts(resetModel) - providerModelRequestCounts.remove(resetModel) - } - showResetModelDialog = false - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(id = R.string.settings_reset)) - } - }, - dismissButton = { - TextButton(onClick = { showResetModelDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } - - if (showResetDialog) { - AlertDialog( - onDismissRequest = { showResetDialog = false }, - title = { - Text(text = stringResource(id = R.string.settings_reset_confirmation)) - }, - text = { - Text(text = stringResource(id = R.string.settings_reset_warning)) - }, - confirmButton = { - TextButton( - onClick = { - scope.launch { - apiPreferences.resetAllProviderModelTokenCounts() - providerModelRequestCounts.clear() - } - showResetDialog = false - }, - colors = ButtonDefaults.textButtonColors( - contentColor = MaterialTheme.colorScheme.error - ) - ) { - Text(stringResource(id = R.string.settings_reset)) - } - }, - dismissButton = { - TextButton(onClick = { showResetDialog = false }) { - Text(stringResource(id = R.string.settings_cancel)) - } - } - ) - } -} - -@Composable -private fun ExchangeRateSettingsCard( - rateInput: String, - onRateInputChange: (String) -> Unit, - onSave: () -> Unit -) { - Card(modifier = Modifier.fillMaxWidth()) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(10.dp) - ) { - Text( - text = stringResource(id = R.string.settings_exchange_rate_title), - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - - Text( - text = stringResource(id = R.string.settings_exchange_rate_subtitle), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - - OutlinedTextField( - value = rateInput, - onValueChange = { onRateInputChange(it) }, - label = { Text(stringResource(id = R.string.settings_usd_to_cny_rate_label)) }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), - modifier = Modifier.fillMaxWidth() - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End - ) { - TextButton(onClick = onSave) { - Text(stringResource(id = R.string.settings_save)) - } - } - } - } -} - -@Composable -private fun TokenUsageModelCard( - modelName: String, - inputTokens: Long, - cachedInputTokens: Long, - outputTokens: Long, - requestCount: Int, - cost: Double, - inputPrice: Double, - outputPrice: Double, - billingMode: BillingMode, - pricePerRequest: Double, - currency: PricingCurrency, - onClick: () -> Unit, - onResetClick: () -> Unit -) { - Card( - modifier = Modifier.fillMaxWidth(), - onClick = onClick - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = modelName, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold - ) - Spacer(modifier = Modifier.height(4.dp)) - AssistChip( - onClick = { }, - label = { - Text( - text = when (billingMode) { - BillingMode.TOKEN -> stringResource(id = R.string.settings_billing_mode_token) - BillingMode.COUNT -> stringResource(id = R.string.settings_billing_mode_count) - }, - style = MaterialTheme.typography.labelSmall - ) - }, - colors = AssistChipDefaults.assistChipColors( - containerColor = when (billingMode) { - BillingMode.TOKEN -> MaterialTheme.colorScheme.secondaryContainer - BillingMode.COUNT -> MaterialTheme.colorScheme.tertiaryContainer - } - ), - modifier = Modifier.height(24.dp) - ) - } - Row(verticalAlignment = Alignment.CenterVertically) { - IconButton(onClick = onResetClick) { - Icon( - imageVector = Icons.Default.RestartAlt, - contentDescription = stringResource(id = R.string.settings_reset_model_counts), - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(18.dp) - ) - } - Icon( - imageVector = Icons.Default.Edit, - contentDescription = stringResource(id = R.string.settings_edit_pricing), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp) - ) - } - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Text( - text = stringResource(id = R.string.settings_request_count), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$requestCount", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - } - - Spacer(modifier = Modifier.height(8.dp)) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween - ) { - Column { - Text( - text = stringResource(id = R.string.settings_input_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$inputTokens", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - if (cachedInputTokens > 0L) { - Text( - text = stringResource(R.string.settings_cached_tokens, cachedInputTokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.tertiary - ) - } - if (billingMode == BillingMode.TOKEN) { - Text( - text = formatPricePerMillion(inputPrice, currency), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Column(horizontalAlignment = Alignment.CenterHorizontally) { - Text( - text = stringResource(id = R.string.settings_output_tokens), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = "$outputTokens", - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Medium - ) - if (billingMode == BillingMode.TOKEN) { - Text( - text = formatPricePerMillion(outputPrice, currency), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - - Column(horizontalAlignment = Alignment.End) { - Text( - text = stringResource(id = R.string.settings_total_cost), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - Text( - text = formatCurrencyAmount(cost, currency), - style = MaterialTheme.typography.bodyMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.primary - ) - if (billingMode == BillingMode.COUNT) { - Text( - text = stringResource( - id = R.string.settings_per_request_cost_with_currency, - currency.symbol, - pricePerRequest - ), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant - ) - } - } - } - } - } -} - -private fun convertToCny(amount: Double, currency: PricingCurrency, usdToCnyRate: Double): Double { - return when (currency) { - PricingCurrency.CNY -> amount - PricingCurrency.USD -> amount * usdToCnyRate - } -} - -private fun convertCnyToPricingCurrency( - amount: Double, - currency: PricingCurrency, - usdToCnyRate: Double -): Double { - return when (currency) { - PricingCurrency.CNY -> amount - PricingCurrency.USD -> amount / usdToCnyRate - } -} - -private fun formatCurrencyAmount(amount: Double, currency: PricingCurrency): String { - return "${currency.symbol}${String.format(Locale.US, "%.2f", amount)}" -} - -private fun formatPricePerMillion(price: Double, currency: PricingCurrency): String { - return "${currency.symbol}${String.format(Locale.US, "%.2f", price)}/1M" -} - -private fun formatEditablePrice(price: Double): String { - return String.format(Locale.US, "%.6f", price).trimEnd('0').trimEnd('.') -} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt new file mode 100644 index 000000000..78ea45886 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/CustomRangePolicy.kt @@ -0,0 +1,27 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import java.time.Instant +import java.time.ZoneId +import java.time.temporal.ChronoUnit + +internal enum class CustomRangeValidation { + VALID, + INVALID_BOUNDS, + TOO_LONG, +} + +internal fun validateCustomRange( + startMs: Long, + endMs: Long, + zone: ZoneId, + maxRangeDays: Long, +): CustomRangeValidation { + if (endMs <= startMs) return CustomRangeValidation.INVALID_BOUNDS + val startDate = Instant.ofEpochMilli(startMs).atZone(zone).toLocalDate() + val exclusiveEndDate = Instant.ofEpochMilli(endMs).atZone(zone).toLocalDate() + return if (ChronoUnit.DAYS.between(startDate, exclusiveEndDate) > maxRangeDays) { + CustomRangeValidation.TOO_LONG + } else { + CustomRangeValidation.VALID + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt new file mode 100644 index 000000000..606e9e708 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenActivitySection.kt @@ -0,0 +1,680 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.graphics.Paint +import android.os.SystemClock +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.stats.TokenActivityDay +import com.ai.assistance.operit.data.stats.TokenActivityViewMode +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange +import java.time.LocalDate +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.abs +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeoutOrNull + +@Composable +internal fun TokenActivitySection( + state: TokenActivityUiState, + dateRange: TokenStatsTimeRange?, + zone: java.time.ZoneId, + onSelectMode: (TokenActivityViewMode) -> Unit, + onSelectDateRange: () -> Unit, +) { + val locale = LocalConfiguration.current.locales[0] + + TokenStatsWhiteCard(Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier.weight(1f), + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + TokenActivityViewMode.entries.forEach { mode -> + val selected = state.viewMode == mode + Text( + text = stringResource( + when (mode) { + TokenActivityViewMode.DAILY -> R.string.token_activity_daily + TokenActivityViewMode.WEEKLY -> R.string.token_activity_weekly + TokenActivityViewMode.CUMULATIVE -> R.string.token_activity_cumulative + } + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = + if (selected) MaterialTheme.colorScheme.primary + else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.clickable { onSelectMode(mode) }, + ) + } + } + Text( + text = dateRange?.let { formatCompactDateRangeLabel(it, zone) }.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + IconButton(onClick = onSelectDateRange) { + Icon( + imageVector = Icons.Default.CalendarToday, + contentDescription = + dateRange?.let { formatDateRangeLabel(it, zone) } + ?: stringResource(R.string.token_stats_date_range), + ) + } + } + + val stats = state.rangeData?.stats + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenActivityStat( + stringResource(R.string.token_activity_total_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.totalTokens), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_peak_tokens), + if (state.loading || stats == null) "–" else formatCompactCount(stats.peakTokens), + Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenActivityStat( + stringResource(R.string.token_activity_current_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.currentStreak), + Modifier.weight(1f), + ) + TokenActivityStat( + stringResource(R.string.token_activity_longest_streak), + if (state.loading || stats == null) "–" else stringResource(R.string.token_activity_days, stats.longestStreak), + Modifier.weight(1f), + ) + } + } + + Crossfade( + targetState = state.viewMode, + animationSpec = tween(150), + label = "token_activity_heatmap", + ) { mode -> + TokenActivityVisualization( + state = state.copy(viewMode = mode), + locale = locale, + modifier = Modifier.fillMaxWidth(), + ) + } + } + } +} + +@Composable +private fun TokenActivityStat(label: String, value: String, modifier: Modifier = Modifier) { + Column( + modifier = modifier + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .padding(TokenStatsSpacing.content), + ) { + Text(value, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text( + label, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun TokenActivityVisualization( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + if (state.loading || state.rangeData == null) { + Box(modifier.fillMaxWidth().height(180.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + return + } + when (state.viewMode) { + TokenActivityViewMode.DAILY -> TokenActivityDailyHeatmap(state, locale, modifier) + TokenActivityViewMode.WEEKLY -> TokenActivityWeeklyChart(state, locale, modifier) + TokenActivityViewMode.CUMULATIVE -> TokenActivityCumulativeChart(state, locale, modifier) + } +} + +@Composable +private fun TokenActivityDailyHeatmap( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = state.rangeData + checkNotNull(data) + + val days = data.daily + val firstDate = days.firstOrNull()?.date + val padding = firstDate?.let { it.dayOfWeek.value % 7 } ?: 0 + val columns = ((padding + days.size + 6) / 7).coerceAtLeast(1) + val grid = remember(days, padding) { + List(columns) { column -> + List(7) { row -> + days.getOrNull(column * 7 + row - padding) + } + } + } + val density = LocalDensity.current + val block = 11.dp + val gap = 3.dp + val stepPx = with(density) { (block + gap).toPx() } + val blockPx = with(density) { block.toPx() } + val radiusPx = with(density) { 3.dp.toPx() } + val width = (block + gap) * columns - gap + val gridHeight = (block + gap) * 7 - gap + val monthLabelHeight = 20.dp + val canvasHeight = gridHeight + monthLabelHeight + val gridHeightPx = with(density) { gridHeight.toPx() } + val monthLabelGapPx = with(density) { 4.dp.toPx() } + val scroll = rememberScrollState() + var selectedDay by remember(days, state.viewMode) { + mutableStateOf(null) + } + var indicatorDay by remember(days, state.viewMode) { + mutableStateOf(null) + } + var indicatorColumn by remember(days, state.viewMode) { + mutableIntStateOf(-1) + } + var indicatorRow by remember(days, state.viewMode) { + mutableIntStateOf(-1) + } + val heatmapColor = MaterialTheme.colorScheme.primary + val heatmapLabelColor = MaterialTheme.colorScheme.onSurfaceVariant + val colors = listOf( + heatmapColor.copy(alpha = 0.08f), + heatmapColor.copy(alpha = 0.20f), + heatmapColor.copy(alpha = 0.36f), + heatmapColor.copy(alpha = 0.52f), + heatmapColor.copy(alpha = 0.72f), + heatmapColor, + ) + val selectionColor = MaterialTheme.colorScheme.primary + val selectionStroke = with(density) { 1.5.dp.toPx() } + val monthLabels = remember(grid, locale) { + val formatter = DateTimeFormatter.ofPattern("MMM", locale) + val raw = buildList { + var previousMonth = -1 + grid.forEachIndexed { index, week -> + val date = week.firstOrNull { it != null }?.date ?: return@forEachIndexed + if (index == 0 || date.monthValue != previousMonth) { + add(TokenActivityMonthLabel(index, formatter.format(date))) + previousMonth = date.monthValue + } + } + } + raw.filterIndexed { index, label -> + when { + index == 0 -> raw.getOrNull(1)?.let { it.column - label.column >= 3 } ?: false + index == raw.lastIndex -> columns - label.column >= 3 + else -> true + } + } + } + val monthPaint = remember(density, heatmapLabelColor) { + Paint().apply { + textSize = with(density) { 12.sp.toPx() } + color = heatmapLabelColor.toArgb() + isAntiAlias = true + } + } + + LaunchedEffect(columns, days, state.viewMode) { + snapshotFlow { scroll.maxValue }.first { it > 0 } + scroll.scrollTo(scroll.maxValue) + } + + Column(modifier) { + Column(Modifier.horizontalScroll(scroll)) { + Canvas( + modifier = Modifier + .size(width, canvasHeight) + // 顺序:查看/滚动仲裁必须先于点击检测收到事件。 + .pointerInput(grid, stepPx, blockPx) { + val viewSpeedThresholdPxPerMs = + with(density) { HEATMAP_VIEW_SPEED_DP_PER_S.dp.toPx() } / 1_000f + + fun updateIndicator(point: Offset) { + val column = (point.x / stepPx).toInt().coerceIn(0, columns - 1) + val row = (point.y / stepPx).toInt().coerceIn(0, 6) + val day = grid.getOrNull(column)?.getOrNull(row) + indicatorDay = day + indicatorColumn = if (day == null) -1 else column + indicatorRow = if (day == null) -1 else row + } + + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + var mode: HeatmapDragMode? = null + var lastPosition = down.position + var lastTime = SystemClock.uptimeMillis() + var totalDx = 0f + var totalDy = 0f + val slop = viewConfiguration.touchSlop + val downTime = lastTime + val longPressMs = viewConfiguration.longPressTimeoutMillis + + while (mode == null) { + val remaining = longPressMs - (SystemClock.uptimeMillis() - downTime) + val event = if (remaining > 0L) { + withTimeoutOrNull(remaining) { awaitPointerEvent() } + } else { + null + } + if (event == null) { + mode = HeatmapDragMode.VIEW + break + } + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + val current = change.position + val now = SystemClock.uptimeMillis() + val dx = current.x - lastPosition.x + val dy = current.y - lastPosition.y + val elapsed = (now - lastTime).coerceAtLeast(1L) + val horizontalSpeed = abs(dx) / elapsed + lastPosition = current + lastTime = now + totalDx += dx + totalDy += dy + if (abs(totalDx) > slop || abs(totalDy) > slop) { + mode = if ( + abs(totalDx) > abs(totalDy) && + horizontalSpeed < viewSpeedThresholdPxPerMs + ) { + HeatmapDragMode.VIEW + } else { + HeatmapDragMode.SCROLL + } + if (mode == HeatmapDragMode.VIEW) change.consume() + } + } + + if (mode == HeatmapDragMode.VIEW) { + updateIndicator(lastPosition) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + updateIndicator(change.position) + change.consume() + if (!change.pressed) break + } + } else if (mode == HeatmapDragMode.SCROLL) { + indicatorDay = null + indicatorColumn = -1 + indicatorRow = -1 + } + } + } + .pointerInput(grid) { + detectTapGestures { point -> + indicatorDay = null + indicatorColumn = -1 + indicatorRow = -1 + if (point.x % stepPx >= blockPx || point.y % stepPx >= blockPx) return@detectTapGestures + val column = (point.x / stepPx).toInt() + val row = (point.y / stepPx).toInt() + val day = grid.getOrNull(column)?.getOrNull(row) + selectedDay = if (selectedDay == day) null else day + } + }, + ) { + grid.forEachIndexed { column, week -> + week.forEachIndexed { row, day -> + if (day != null) drawRoundRect( + color = colors[day.level.coerceIn(0, 5)], + topLeft = Offset(column * stepPx, row * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + ) + } + } + + drawIntoCanvas { canvas -> + val baseline = gridHeightPx + monthLabelGapPx - monthPaint.ascent() + monthLabels.forEach { label -> + canvas.nativeCanvas.drawText( + label.text, + label.column * stepPx, + baseline, + monthPaint, + ) + } + } + + val indicatorValid = when { + indicatorDay != null -> grid.getOrNull(indicatorColumn)?.getOrNull(indicatorRow) != null + else -> false + } + if (indicatorValid && indicatorColumn in 0 until columns && indicatorRow in 0..6) { + drawRoundRect( + color = selectionColor, + topLeft = Offset(indicatorColumn * stepPx, indicatorRow * stepPx), + size = Size(blockPx, blockPx), + cornerRadius = CornerRadius(radiusPx), + style = Stroke(width = selectionStroke * 1.5f), + ) + } + } + } + + Box(Modifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.CenterStart) { + val text = when { + indicatorDay != null -> stringResource( + R.string.token_activity_day_detail, + indicatorDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(indicatorDay!!.tokens), + ) + selectedDay != null -> stringResource( + R.string.token_activity_day_detail, + selectedDay!!.date.format(DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale)), + formatCompactCount(selectedDay!!.tokens), + ) + else -> stringResource(R.string.token_activity_tap_hint) + } + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + stringResource(R.string.token_activity_less), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.width(4.dp)) + colors.forEach { color -> + Box(Modifier.size(block).background(color, RoundedCornerShape(3.dp))) + Spacer(Modifier.width(gap)) + } + Text( + stringResource(R.string.token_activity_more), + fontSize = 12.sp, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TokenActivityWeeklyChart( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.weekly.map { week -> + TokenActivitySeriesPoint( + startDate = week.startDate, + endDate = week.startDate.plusDays(6), + tokens = week.tokens, + ) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.BAR, + locale = locale, + modifier = modifier, + ) { point -> + stringResource( + R.string.token_activity_week_detail, + point.startDate.format(localizedDateFormatter(locale)), + point.endDate.format(localizedDateFormatter(locale)), + formatCompactCount(point.tokens), + ) + } +} + +@Composable +private fun TokenActivityCumulativeChart( + state: TokenActivityUiState, + locale: Locale, + modifier: Modifier = Modifier, +) { + val data = checkNotNull(state.rangeData) + val points = data.cumulative.map { day -> + TokenActivitySeriesPoint(day.date, day.date, day.tokens) + } + TokenActivityTimeSeriesChart( + points = points, + style = TokenActivitySeriesStyle.LINE, + locale = locale, + modifier = modifier, + ) { point -> + stringResource( + R.string.token_activity_cumulative_detail, + point.startDate.format(localizedDateFormatter(locale)), + formatCompactCount(point.tokens), + ) + } +} + +@Composable +private fun TokenActivityTimeSeriesChart( + points: List, + style: TokenActivitySeriesStyle, + locale: Locale, + modifier: Modifier = Modifier, + detailText: @Composable (TokenActivitySeriesPoint) -> String, +) { + val density = LocalDensity.current + val scroll = rememberScrollState() + val pointWidth = if (style == TokenActivitySeriesStyle.BAR) 18.dp else 14.dp + val chartWidth = (pointWidth * points.size).coerceAtLeast(280.dp) + val plotHeight = 124.dp + val labelHeight = 24.dp + val canvasHeight = plotHeight + labelHeight + val stepPx = with(density) { pointWidth.toPx() } + val plotHeightPx = with(density) { plotHeight.toPx() } + val maxTokens = points.maxOfOrNull(TokenActivitySeriesPoint::tokens)?.coerceAtLeast(1L) ?: 1L + val primary = MaterialTheme.colorScheme.primary + val grid = MaterialTheme.colorScheme.outlineVariant + val labelColor = MaterialTheme.colorScheme.onSurfaceVariant + val labelPaint = remember(density, labelColor) { + Paint().apply { + textSize = with(density) { 12.sp.toPx() } + color = labelColor.toArgb() + isAntiAlias = true + } + } + val monthLabels = remember(points, locale) { + val formatter = DateTimeFormatter.ofPattern("MMM", locale) + buildList { + var previousMonth = -1 + points.forEachIndexed { index, point -> + if (index == 0 || point.startDate.monthValue != previousMonth) { + add(TokenActivityMonthLabel(index, formatter.format(point.startDate))) + previousMonth = point.startDate.monthValue + } + } + } + } + var selectedPoint by remember(points, style) { mutableStateOf(null) } + + LaunchedEffect(points, style) { + snapshotFlow { scroll.maxValue }.first { it > 0 } + scroll.scrollTo(scroll.maxValue) + } + + Column(modifier) { + Column(Modifier.horizontalScroll(scroll)) { + Canvas( + modifier = Modifier + .size(chartWidth, canvasHeight) + .pointerInput(points, style, stepPx) { + detectTapGestures { point -> + val index = (point.x / stepPx).toInt() + selectedPoint = points.getOrNull(index) + } + }, + ) { + drawLine( + color = grid, + start = Offset(0f, plotHeightPx), + end = Offset(size.width, plotHeightPx), + strokeWidth = with(density) { 1.dp.toPx() }, + ) + if (style == TokenActivitySeriesStyle.BAR) { + points.forEachIndexed { index, point -> + val height = plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + drawRoundRect( + color = primary.copy(alpha = 0.78f), + topLeft = Offset(index * stepPx + stepPx * 0.2f, plotHeightPx - height), + size = Size(stepPx * 0.6f, height), + cornerRadius = CornerRadius(stepPx * 0.2f), + ) + } + } else if (points.isNotEmpty()) { + val path = Path() + points.forEachIndexed { index, point -> + val x = index * stepPx + stepPx / 2f + val y = plotHeightPx - plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + if (index == 0) path.moveTo(x, y) else path.lineTo(x, y) + } + drawPath( + path = path, + color = primary, + style = Stroke(width = with(density) { 2.dp.toPx() }), + ) + points.forEachIndexed { index, point -> + val x = index * stepPx + stepPx / 2f + val y = plotHeightPx - plotHeightPx * point.tokens.toFloat() / maxTokens.toFloat() + drawCircle(primary, radius = with(density) { 2.5.dp.toPx() }, center = Offset(x, y)) + } + } + selectedPoint?.let { point -> + val index = points.indexOf(point) + if (index >= 0) { + drawLine( + color = primary, + start = Offset(index * stepPx + stepPx / 2f, 0f), + end = Offset(index * stepPx + stepPx / 2f, plotHeightPx), + strokeWidth = with(density) { 1.dp.toPx() }, + ) + } + } + drawIntoCanvas { canvas -> + val baseline = plotHeightPx + with(density) { 16.dp.toPx() } + monthLabels.forEach { label -> + canvas.nativeCanvas.drawText(label.text, label.column * stepPx, baseline, labelPaint) + } + } + } + } + + Box(Modifier.fillMaxWidth().height(28.dp), contentAlignment = Alignment.CenterStart) { + Text( + text = + if (selectedPoint == null) { + stringResource(R.string.token_activity_tap_hint) + } else { + detailText(selectedPoint!!) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } +} + +private data class TokenActivitySeriesPoint( + val startDate: LocalDate, + val endDate: LocalDate, + val tokens: Long, +) + +private enum class TokenActivitySeriesStyle { BAR, LINE } + +private fun localizedDateFormatter(locale: Locale): DateTimeFormatter = + DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(locale) + +private enum class HeatmapDragMode { VIEW, SCROLL } + +private data class TokenActivityMonthLabel(val column: Int, val text: String) + +private const val HEATMAP_VIEW_SPEED_DP_PER_S = 150f diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt new file mode 100644 index 000000000..8dc093390 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsCharts.kt @@ -0,0 +1,591 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.detectHorizontalDragGestures +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.nativeCanvas +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.CustomAccessibilityAction +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.customActions +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.stats.TokenStatsGranularity +import com.ai.assistance.operit.data.stats.TokenStatsTrendBucket +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import kotlin.math.ceil +import kotlin.math.log10 +import kotlin.math.pow + +/** + * 统计图表(阶段 4):纯 Compose Canvas 实现,不引入重型图表依赖。 + * + * 交互契约(避免与页面滚动互抢): + * - 点击与**水平拖动**才选中/切换桶详情([detectTapGestures] + + * [detectHorizontalDragGestures]); + * - 垂直手势不消费,LazyColumn 纵向滚动不受影响; + * - 桶详情以图表下方的 tooltip 卡片呈现(无悬浮层,不遮挡内容)。 + * + * 空桶由阶段 3 聚合器补齐(buckets 已含全零桶),图表直接绘制;全部为 0 + * 时由调用方传入 [emptyText] 显示空提示。unknown 不当作 0:调用方通过 + * [unknownNote] 在 tooltip 里给出“部分数据未知”提示。 + */ + +/** 堆叠柱状图:每桶若干堆叠分量(值 + 颜色)。 */ +@Composable +internal fun TokenStatsStackedBarChart( + modifier: Modifier = Modifier, + buckets: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + formatValue: (Double) -> String, + emptyText: String, + chartLabel: String = "", + stackSelector: (TokenStatsTrendBucket) -> List>, + stackLabels: (TokenStatsTrendBucket) -> List, + /** + * tooltip/无障碍里的“合计”数值。默认 = 堆叠分量之和(诊断口径);调用方可 + * 传入 canonical 合计(如 [com.ai.assistance.operit.data.stats.TokenStatsTotals.totalTokens]) + * 使展示总 Token 与聚合器口径一致,堆叠分量仍作为诊断明细展示。 + */ + stackTotalSelector: (TokenStatsTrendBucket) -> Double = { bucket -> + stackSelector(bucket).sumOf { it.first } + }, + unknownNote: (TokenStatsTrendBucket) -> String? = { null }, + legendItems: List> = emptyList(), +) { + if (buckets.isEmpty()) { + ChartEmptyText(emptyText, modifier) + return + } + val colors = LocalTokenStatsColors.current + var selectedIndex by remember(buckets) { mutableIntStateOf(buckets.lastIndex) } + val density = LocalDensity.current + val d = density.density + val chartHPx = 160f * d + val labelHPx = 20f * d + + // 无障碍文案预取:semantics 块不是 Composable,不能在块内解析资源(P1-8) + val summaryTemplate = stringResource(R.string.token_stats_chart_summary) + val bucketPositionTemplate = stringResource(R.string.token_stats_chart_bucket_of) + val prevBucketLabel = stringResource(R.string.token_stats_chart_prev_bucket) + val nextBucketLabel = stringResource(R.string.token_stats_chart_next_bucket) + + val maxVal = buckets.maxOf { bucket -> stackSelector(bucket).sumOf { it.first } }.coerceAtLeast(0.0) + val refTop = niceCeil(maxVal) + val refHalf = refTop / 2.0 + val scale = refTop + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val availPx = maxWidth.value * d + val barAreaPx = availPx / buckets.size + val barW = barAreaPx * 0.65f + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + .semantics(mergeDescendants = true) { + val selected = buckets[selectedIndex] + val stacks = stackSelector(selected) + val positionText = + String.format(bucketPositionTemplate, selectedIndex + 1, buckets.size) + val summary = String.format( + summaryTemplate, + chartLabel, + bucketTimeLabel(selected.bucketStartMs, granularity, zone), + positionText, + formatValue(stackTotalSelector(selected)), + ) + val rows = stacks.mapIndexedNotNull { index, (value, color) -> + val label = stackLabels(selected).getOrNull(index) ?: "" + if (value > 0.0 || label.isNotEmpty()) { + "${label.ifEmpty { "" }} ${formatValue(value)}".trim() + } else { + null + } + } + contentDescription = chartAccessibilityDescription(summary, rows) + stateDescription = positionText + role = Role.Image + customActions = listOf( + CustomAccessibilityAction(prevBucketLabel) { + previousBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + CustomAccessibilityAction(nextBucketLabel) { + nextBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + ) + } + .focusable() + .pointerInput(buckets) { + detectTapGestures { offset -> + val idx = (offset.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + .pointerInput(buckets) { + detectHorizontalDragGestures { change, _ -> + change.consume() + val idx = (change.position.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(180.dp) + ) { + val chartH = chartHPx + buckets.forEachIndexed { i, bucket -> + val x = i * barAreaPx + (barAreaPx - barW) / 2 + var yBase = chartH + stackSelector(bucket).forEach { (value, color) -> + val h = (value / scale * chartH).toFloat().coerceAtLeast(0f) + drawRect(color, Offset(x, yBase - h), Size(barW, h)) + yBase -= h + } + val (label, show) = bucketLabel(bucket.bucketStartMs, granularity, zone, i, buckets.size) + if (show) { + drawContext.canvas.nativeCanvas.drawText( + label, x + barW / 2, chartH + labelHPx - 4f * d, + android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 10f * d * density.fontScale + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + // 参考线(满刻度与半刻度)+ 数值标签 + val refY = chartH - (refTop / scale * chartH).toFloat() + val refHalfY = chartH - (refHalf / scale * chartH).toFloat() + drawLine(colors.chartGrid, Offset(0f, refY), Offset(size.width, refY), strokeWidth = 0.5f * d) + drawLine(colors.chartGrid, Offset(0f, refHalfY), Offset(size.width, refHalfY), strokeWidth = 0.5f * d) + val paint = android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 8f * d * density.fontScale + textAlign = android.graphics.Paint.Align.LEFT + } + drawContext.canvas.nativeCanvas.drawText(formatValue(refTop), 2f * d, refY - 2f * d, paint) + drawContext.canvas.nativeCanvas.drawText(formatValue(refHalf), 2f * d, refHalfY - 2f * d, paint) + } + } + + if (legendItems.isNotEmpty()) { + ChartLegend(legendItems) + } + + val selected = buckets[selectedIndex] + val stacks = stackSelector(selected) + ChartTooltip( + title = bucketTimeLabel(selected.bucketStartMs, granularity, zone), + rows = stacks.mapIndexedNotNull { index, (value, color) -> + val label = stackLabels(selected).getOrNull(index) ?: "" + if (value > 0.0 || label.isNotEmpty()) { + Triple(color, label, formatValue(value)) + } else { + null + } + }, + total = formatValue(stackTotalSelector(selected)), + unknownNote = unknownNote(selected), + ) + } + } +} + +/** 折线图:每桶一个值;无有效样本的桶不画点、线段断开。 */ +@Composable +internal fun TokenStatsLineChart( + modifier: Modifier = Modifier, + buckets: List, + granularity: TokenStatsGranularity, + zone: ZoneId, + formatValue: (Double) -> String, + emptyText: String, + chartLabel: String = "", + valueSelector: (TokenStatsTrendBucket) -> Double?, + unknownNote: (TokenStatsTrendBucket) -> String? = { null }, +) { + if (buckets.isEmpty()) { + ChartEmptyText(emptyText, modifier) + return + } + val colors = LocalTokenStatsColors.current + var selectedIndex by remember(buckets) { mutableIntStateOf(buckets.lastIndex) } + val density = LocalDensity.current + val d = density.density + val chartHPx = 140f * d + val labelHPx = 20f * d + + // 无障碍文案预取:semantics 块不是 Composable,不能在块内解析资源(P1-8) + val summaryTemplate = stringResource(R.string.token_stats_chart_summary) + val bucketPositionTemplate = stringResource(R.string.token_stats_chart_bucket_of) + val prevBucketLabel = stringResource(R.string.token_stats_chart_prev_bucket) + val nextBucketLabel = stringResource(R.string.token_stats_chart_next_bucket) + + val knownValues = buckets.mapNotNull(valueSelector) + val maxVal = (knownValues.maxOrNull() ?: 0.0).coerceAtLeast(0.0) + val refTop = niceCeil(maxVal) + val refHalf = refTop / 2.0 + val scale = refTop + + BoxWithConstraints(modifier = modifier.fillMaxWidth()) { + val availPx = maxWidth.value * d + val barAreaPx = availPx / buckets.size + + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + .semantics(mergeDescendants = true) { + val selected = buckets[selectedIndex] + val value = valueSelector(selected) + val positionText = + String.format(bucketPositionTemplate, selectedIndex + 1, buckets.size) + val summary = String.format( + summaryTemplate, + chartLabel, + bucketTimeLabel(selected.bucketStartMs, granularity, zone), + positionText, + if (value == null) "" else formatValue(value), + ) + contentDescription = chartAccessibilityDescription(summary, emptyList()) + stateDescription = positionText + role = Role.Image + customActions = listOf( + CustomAccessibilityAction(prevBucketLabel) { + previousBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + CustomAccessibilityAction(nextBucketLabel) { + nextBucketIndex(selectedIndex, buckets.size) + ?.let { selectedIndex = it; true } ?: false + }, + ) + } + .focusable() + .pointerInput(buckets) { + detectTapGestures { offset -> + val idx = (offset.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + .pointerInput(buckets) { + detectHorizontalDragGestures { change, _ -> + change.consume() + val idx = (change.position.x / barAreaPx).toInt().coerceIn(0, buckets.lastIndex) + selectedIndex = idx + } + } + ) { + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(160.dp) + ) { + val points = buckets.mapIndexed { i, bucket -> + val value = valueSelector(bucket) + if (value == null) { + null + } else { + Offset(i * barAreaPx + barAreaPx / 2, chartHPx - (value / scale * chartHPx).toFloat()) + } + } + // 分段连线:null 断段;每段只连接**相邻**有效点(P2 修复, + // 此前一直从段首重复连线导致斜率错误) + lineSegments(points).forEach { (start, end) -> + drawLine(colors.chartAccent, start, end, strokeWidth = 2f * d) + } + points.forEachIndexed { i, point -> + if (point != null) { + drawCircle(colors.chartAccent, radius = 3f * d, center = point) + } + } + buckets.forEachIndexed { i, bucket -> + val (label, show) = bucketLabel(bucket.bucketStartMs, granularity, zone, i, buckets.size) + if (show) { + drawContext.canvas.nativeCanvas.drawText( + label, i * barAreaPx + barAreaPx / 2, chartHPx + labelHPx - 4f * d, + android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 10f * d * density.fontScale + textAlign = android.graphics.Paint.Align.CENTER + } + ) + } + } + val refY = chartHPx - (refTop / scale * chartHPx).toFloat() + val refHalfY = chartHPx - (refHalf / scale * chartHPx).toFloat() + drawLine(colors.chartGrid, Offset(0f, refY), Offset(size.width, refY), strokeWidth = 0.5f * d) + drawLine(colors.chartGrid, Offset(0f, refHalfY), Offset(size.width, refHalfY), strokeWidth = 0.5f * d) + val paint = android.graphics.Paint().apply { + color = colors.chartLabel.toArgb() + textSize = 8f * d * density.fontScale + textAlign = android.graphics.Paint.Align.LEFT + } + drawContext.canvas.nativeCanvas.drawText(formatValue(refTop), 2f * d, refY - 2f * d, paint) + drawContext.canvas.nativeCanvas.drawText(formatValue(refHalf), 2f * d, refHalfY - 2f * d, paint) + } + } + + val selected = buckets[selectedIndex] + val value = valueSelector(selected) + ChartTooltip( + title = bucketTimeLabel(selected.bucketStartMs, granularity, zone), + rows = value?.let { listOf(Triple(colors.chartAccent, "", formatValue(it))) } ?: emptyList(), + total = if (value == null) null else formatValue(value), + unknownNote = unknownNote(selected), + ) + } + } +} + +/** tooltip 卡片(图表下方,不遮挡内容)。 */ +@Composable +private fun ChartTooltip( + title: String, + rows: List>, + total: String?, + unknownNote: String?, +) { + val colors = LocalTokenStatsColors.current + Card( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + shape = RoundedCornerShape(8.dp), + colors = CardDefaults.cardColors(containerColor = colors.tooltipContainer), + ) { + Column(Modifier.padding(8.dp)) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = colors.tooltipContent, + ) + total?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Bold, + color = colors.chartAccent, + ) + } + rows.forEach { (color, label, value) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(8.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.width(4.dp)) + Text( + text = if (label.isNotEmpty()) "$label $value" else value, + style = MaterialTheme.typography.bodySmall, + color = colors.tooltipContent, + ) + } + } + unknownNote?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun ChartLegend(items: List>) { + val colors = LocalTokenStatsColors.current + FlowRow( + modifier = Modifier + .fillMaxWidth() + .padding(top = 4.dp), + horizontalArrangement = Arrangement.Center, + ) { + items.forEachIndexed { i, (label, color) -> + Row(verticalAlignment = Alignment.CenterVertically) { + Box( + Modifier + .size(10.dp) + .background(color, RoundedCornerShape(2.dp)) + ) + Spacer(Modifier.width(3.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = colors.chartLabel, + ) + } + if (i < items.size - 1) Spacer(Modifier.width(12.dp)) + } + } +} + +@Composable +private fun ChartEmptyText(text: String, modifier: Modifier = Modifier) { + val colors = LocalTokenStatsColors.current + Box(modifier = modifier.fillMaxWidth().padding(vertical = 24.dp), contentAlignment = Alignment.Center) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = colors.chartLabel, + ) + } +} + +/** 桶起点时间标签(本地时区对齐,与聚合器同语义)。 */ +private fun bucketTimeLabel(startMs: Long, granularity: TokenStatsGranularity, zone: ZoneId): String { + val zdt = Instant.ofEpochMilli(startMs).atZone(zone) + return when (granularity) { + TokenStatsGranularity.TEN_MINUTES, TokenStatsGranularity.HOURLY -> + DateTimeFormatter.ofPattern("HH:mm").format(zdt) + TokenStatsGranularity.DAILY -> + DateTimeFormatter.ofPattern("MM/dd").format(zdt) + } +} + +/** 底部时间轴标签:首尾 + 均匀抽稀(约 6 个),避免手机宽度拥挤。 */ +private fun bucketLabel( + startMs: Long, + granularity: TokenStatsGranularity, + zone: ZoneId, + index: Int, + total: Int, +): Pair { + if (total <= 1) return bucketTimeLabel(startMs, granularity, zone) to true + val stride = ceil(total / 6.0).toInt().coerceAtLeast(1) + val show = index == 0 || index == total - 1 || index % stride == 0 + return bucketTimeLabel(startMs, granularity, zone) to show +} + +/** 向上取整到“漂亮”刻度(9→10、883→1000、150M→200M),与参考实现一致。 */ +internal fun niceCeil(value: Double): Double { + if (value <= 0.0) return 1.0 + val exp = log10(value).toInt() + val magnitude = 10.0.pow(exp.toDouble()) + val normalized = value / magnitude + val nice = + when { + normalized <= 1.0 -> 1.0 + normalized <= 1.15 -> 1.15 + normalized <= 1.25 -> 1.25 + normalized <= 1.5 -> 1.5 + normalized <= 2.0 -> 2.0 + normalized <= 2.5 -> 2.5 + normalized <= 3.0 -> 3.0 + normalized <= 4.0 -> 4.0 + normalized <= 5.0 -> 5.0 + normalized <= 7.5 -> 7.5 + else -> 10.0 + } + return nice * magnitude +} + +/** Token 数量紧凑格式:1.2K / 3.4M。 */ +internal fun formatCompactCount(value: Long): String = + when { + value >= 1_000_000 -> String.format(java.util.Locale.US, "%.1fM", value / 1_000_000.0) + value >= 1_000 -> String.format(java.util.Locale.US, "%.1fK", value / 1_000.0) + else -> "$value" + } + +/** 千分位格式(图表 tooltip 明细用)。 */ +internal fun formatCountWithComma(value: Long): String = + String.format(java.util.Locale.US, "%,d", value) + +/** 时长格式:<1s 用毫秒,否则秒(1 位小数)。 */ +internal fun formatDuration(ms: Double): String = + if (ms < 1_000.0) { + String.format(java.util.Locale.US, "%.0fms", ms) + } else { + String.format(java.util.Locale.US, "%.1fs", ms / 1_000.0) + } + +// ==== 图表无障碍模型(P1-8,纯函数,供 JVM 测试) ==== + +/** 无障碍“上一桶”目标索引;已在最前或无桶返回 null(边界禁用)。 */ +internal fun previousBucketIndex(current: Int, count: Int): Int? = + if (count <= 1 || current <= 0) null else current - 1 + +/** 无障碍“下一桶”目标索引;已在最后或无桶返回 null(边界禁用)。 */ +internal fun nextBucketIndex(current: Int, count: Int): Int? = + if (count <= 1 || current >= count - 1) null else current + 1 + +/** + * 图表无障碍描述(TalkBack 朗读):[summary] 已由调用方按资源拼好(图表名、 + * 当前桶时间、第 n/m 桶、合计),[rows] 为“标签 值”明细行;无行时只读摘要。 + */ +internal fun chartAccessibilityDescription(summary: String, rows: List): String = + if (rows.isEmpty()) summary else "$summary:${rows.joinToString(",")}" + +/** + * 折线分段(P2):null 断段;每段连接**相邻**有效点(而非从段首重复连线)。 + * 返回线段对列表,供 Canvas 绘制与纯 JVM 测试共用。 + */ +internal fun lineSegments(points: List): List> { + val segments = ArrayList>() + var previous: Offset? = null + for (point in points) { + if (point == null) { + previous = null + } else { + previous?.let { segments += it to point } + previous = point + } + } + return segments +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt new file mode 100644 index 000000000..42781b287 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsColors.kt @@ -0,0 +1,63 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color + +/** + * Token statistics uses the application color scheme directly. The semantic palette + * keeps charts distinguishable without introducing a page-specific visual theme. + */ +data class TokenStatsColors( + val uncachedInput: Color, + val cachedInput: Color, + val cacheWrite: Color, + val output: Color, + val reasoning: Color, + val chartAccent: Color, + val chartGrid: Color, + val chartLabel: Color, + val tooltipContainer: Color, + val tooltipContent: Color, + val modelPalette: List, + val unknownHint: Color, + val estimatedBadgeContainer: Color, +) + +@Composable +fun tokenStatsColors(): TokenStatsColors { + val scheme = MaterialTheme.colorScheme + return TokenStatsColors( + uncachedInput = scheme.primary, + cachedInput = scheme.primaryContainer, + cacheWrite = scheme.secondary, + output = scheme.tertiary, + reasoning = scheme.tertiaryContainer, + chartAccent = scheme.primary, + chartGrid = scheme.outlineVariant, + chartLabel = scheme.onSurfaceVariant, + tooltipContainer = scheme.surfaceVariant, + tooltipContent = scheme.onSurfaceVariant, + modelPalette = listOf( + scheme.primary, + scheme.secondary, + scheme.tertiary, + scheme.primaryContainer, + scheme.secondaryContainer, + scheme.tertiaryContainer, + ), + unknownHint = scheme.error, + estimatedBadgeContainer = scheme.tertiaryContainer, + ) +} + +val LocalTokenStatsColors = staticCompositionLocalOf { + error("TokenStatsColors not provided") +} + +@Composable +fun TokenStatsColorsProvider(content: @Composable () -> Unit) { + CompositionLocalProvider(LocalTokenStatsColors provides tokenStatsColors(), content = content) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt new file mode 100644 index 000000000..b80e31fc2 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsComponents.kt @@ -0,0 +1,1230 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.stats.TokenPriceResolver +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenCostCalculator +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsDurationAggregate +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsPriceScope +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange +import com.ai.assistance.operit.data.stats.TokenStatsTokenAggregate +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.format.FormatStyle +import java.util.Locale +import kotlin.math.roundToInt + +/** Shared spatial scale for the token statistics page. */ +internal object TokenStatsSpacing { + val page = 16.dp + val section = 16.dp + val card = 16.dp + val content = 8.dp +} + +// ==== 通用格式 ==== + +/** 金额:符号 + 4 位小数(图表/明细统一)。 */ +internal fun formatMoney(amount: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.4f", amount)}" + +/** 累计总览的费用使用紧凑的两位小数,保证三项指标可在一行展示。 */ +private fun formatLifetimeMoney(amount: Double, currency: PricingCurrency): String = + "${currency.symbol}${String.format(Locale.US, "%.2f", amount)}" + +internal fun formatCount(value: Long): String = String.format(Locale.US, "%,d", value) + +@Composable +internal fun formatRequestCount(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_minimum, formatCount(value)) + } else { + formatCount(value) + } + +@Composable +internal fun formatRequestCountLabel(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_label_minimum, formatCount(value)) + } else { + stringResource(R.string.settings_request_count_label, value) + } + +@Composable +internal fun formatCompactRequestCountLabel(value: Long, unknownContributionCount: Long): String = + if (unknownContributionCount > 0L) { + stringResource(R.string.token_stats_request_count_compact_minimum, formatCompactCount(value)) + } else { + stringResource(R.string.token_stats_request_count_compact, formatCompactCount(value)) + } + +/** Statistics cards follow the application surface and content colors. */ +@Composable +internal fun TokenStatsWhiteCard( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + Card( + modifier = modifier, + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + content = content, + ) +} + +/** Page-level headings stay visually separate from labels inside cards. */ +@Composable +internal fun TokenStatsSectionHeader( + title: String, + modifier: Modifier = Modifier, + trailing: @Composable RowScope.() -> Unit = {}, +) { + Row( + modifier = modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + trailing() + } +} + +// ==== 生命周期累计总览(不受筛选) ==== + +@Composable +internal fun TokenStatsLifetimeCard( + overview: TokenStatsLifetimeOverview, + currency: PricingCurrency, +) { + val colors = LocalTokenStatsColors.current + val contentColor = MaterialTheme.colorScheme.onPrimaryContainer + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + contentColor = contentColor, + ), + ) { + Column(modifier = Modifier.padding(TokenStatsSpacing.card)) { + val totals = overview.totals + val unknownCostContributions = totals.cost.unknownContributionCount + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + BigNumber( + label = stringResource(R.string.settings_total_requests), + value = + formatRequestCount( + totals.requests, + totals.requestCountUnknownContributionCount, + ), + color = contentColor, + ) + BigNumber( + label = stringResource(R.string.token_stats_tokens_total), + value = formatCompactCount(knownTokenSum(totals)), + color = contentColor, + ) + BigNumber( + label = stringResource(R.string.settings_total_cost), + value = + formatLifetimeMoney( + totals.cost.knownAmount, + currency, + ), + color = contentColor, + alignEnd = true, + ) + } + + if (unknownCostContributions > 0L) { + UnknownHint( + text = stringResource( + R.string.token_stats_unknown_cost, + unknownCostContributions, + ), + color = colors.unknownHint, + ) + } + Spacer(Modifier.height(12.dp)) + + TokenComponentLines(totals = totals, textColor = contentColor) + } + } +} + +@Composable +private fun androidx.compose.foundation.layout.RowScope.BigNumber( + label: String, + value: String, + color: androidx.compose.ui.graphics.Color, + alignEnd: Boolean = false, +) { + Column( + horizontalAlignment = if (alignEnd) Alignment.End else Alignment.Start, + modifier = Modifier.weight(1f), + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = color.copy(alpha = 0.8f), + ) + Text( + text = value, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = color, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + } +} + +@Composable +internal fun EstimatedBadge(text: String, textColor: androidx.compose.ui.graphics.Color) { + val colors = LocalTokenStatsColors.current + Surface( + shape = MaterialTheme.shapes.small, + color = colors.estimatedBadgeContainer, + ) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + color = textColor, + modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp), + ) + } +} + +@Composable +private fun UnknownHint(text: String, color: androidx.compose.ui.graphics.Color) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = color, + modifier = Modifier.padding(top = 4.dp), + ) +} + +@Composable +private fun TokenComponentLines( + totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, + textColor: androidx.compose.ui.graphics.Color, +) { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenLine( + label = stringResource(R.string.token_stats_token_uncached), + aggregate = totals.uncachedInput, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_cached), + aggregate = totals.cachedInput, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_cache_write), + aggregate = totals.cacheWrite, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_output), + aggregate = totals.output, + textColor = textColor, + ) + TokenLine( + label = stringResource(R.string.token_stats_token_reasoning), + aggregate = totals.reasoning, + textColor = textColor, + ) + } +} + +@Composable +private fun TokenLine( + label: String, + aggregate: TokenStatsTokenAggregate, + textColor: androidx.compose.ui.graphics.Color, +) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodySmall, + color = textColor.copy(alpha = 0.85f), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + if (aggregate.unknownEventCount > 0L) { + Text( + text = stringResource( + R.string.token_stats_unknown_part_suffix, + aggregate.unknownEventCount, + ), + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.unknownHint, + ) + Spacer(Modifier.width(6.dp)) + } + Text( + text = formatCompactCount(aggregate.knownSum), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = textColor, + ) + } + } +} + +internal fun knownTokenSum( + totals: com.ai.assistance.operit.data.stats.TokenStatsTotals, +): Long = totals.totalTokens.knownSum + +internal fun saturatedTokenSum(vararg values: Long): Long = + values.fold(0L, TokenCostCalculator::saturatedAdd) + +// ==== 生命周期模型累计 ==== + +@Composable +internal fun TokenStatsLifetimeModelsSection( + models: List, + currency: PricingCurrency, +) { + val sortedModels = models.sortedByDescending { knownTokenSum(it.totals) } + var showAllModels by rememberSaveable { mutableStateOf(false) } + val visibleModels = + if (showAllModels) { + sortedModels + } else { + sortedModels.take(LIFETIME_MODELS_COLLAPSED_COUNT) + } + val totalTokens = sortedModels.fold(0L) { total, model -> + TokenCostCalculator.saturatedAdd(total, knownTokenSum(model.totals)) + } + + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader( + title = stringResource(R.string.token_stats_lifetime_models), + ) { + Text( + text = stringResource(R.string.token_stats_model_count, sortedModels.size), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + TokenStatsWhiteCard(modifier = Modifier.fillMaxWidth()) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + if (totalTokens > 0L) { + TokenStatsModelDistributionPie( + models = sortedModels, + totalTokens = totalTokens, + ) + } + + visibleModels.forEachIndexed { index, model -> + TokenStatsLifetimeModelRow( + model = model, + totalTokens = totalTokens, + color = LocalTokenStatsColors.current.modelPalette[ + index % LocalTokenStatsColors.current.modelPalette.size + ], + currency = currency, + ) + } + + if (sortedModels.size > LIFETIME_MODELS_COLLAPSED_COUNT) { + TextButton( + onClick = { showAllModels = !showAllModels }, + modifier = Modifier.align(Alignment.CenterHorizontally), + ) { + Text( + text = + if (showAllModels) { + stringResource(R.string.token_stats_model_collapse) + } else { + stringResource( + R.string.token_stats_model_show_all, + sortedModels.size, + ) + }, + ) + } + } + } + } + } +} + +private const val LIFETIME_MODELS_COLLAPSED_COUNT = 5 + +@Composable +private fun TokenStatsModelDistributionPie( + models: List, + totalTokens: Long, +) { + val palette = LocalTokenStatsColors.current.modelPalette + val centerColor = MaterialTheme.colorScheme.surface + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Canvas(modifier = Modifier.size(124.dp)) { + var startAngle = -90f + models.forEachIndexed { index, model -> + val sweepAngle = knownTokenSum(model.totals).toFloat() / totalTokens * 360f + if (sweepAngle > 0f) { + drawArc( + color = palette[index % palette.size], + startAngle = startAngle, + sweepAngle = sweepAngle, + useCenter = true, + ) + startAngle += sweepAngle + } + } + drawCircle( + color = centerColor, + radius = size.minDimension * 0.22f, + ) + } + } +} + +@Composable +private fun TokenStatsLifetimeModelRow( + model: TokenStatsDisplayModelBreakdown, + totalTokens: Long, + color: Color, + currency: PricingCurrency, +) { + val tokens = knownTokenSum(model.totals) + val percentage = + if (totalTokens > 0L) { + (tokens.toDouble() / totalTokens * 100).roundToInt() + } else { + 0 + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Canvas(modifier = Modifier.size(10.dp)) { + drawCircle(color = color) + } + Spacer(Modifier.width(TokenStatsSpacing.content)) + Column(modifier = Modifier.weight(1f)) { + Text( + text = model.displayName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource( + R.string.token_stats_lifetime_model_value, + formatCompactCount(tokens), + percentage, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = formatMoney(model.totals.cost.knownAmount, currency), + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.chartAccent, + fontWeight = FontWeight.Medium, + ) + } +} + +// ==== 筛选栏 ==== + +/** 当前日期范围内活动、图表和模型明细共用的查询条件。 */ +@OptIn(ExperimentalLayoutApi::class) +@Composable +internal fun TokenStatsFilterBar( + selectedModels: Set, + availableModels: List, + knownModelNames: Map, + selectedCategories: Set?, + selectedStatuses: Set?, + onToggleModel: (String) -> Unit, + onSelectAllModels: () -> Unit, + onToggleCategory: (TokenStatCategory) -> Unit, + onClearAllCategories: () -> Unit, + onToggleStatus: (TokenStatStatus) -> Unit, + onClearAllStatuses: () -> Unit, +) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + Text( + text = stringResource(R.string.token_stats_filters), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + // 用两列呈现,让每个条件都能完整表达自身含义。 + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + ModelFilterDropdown( + selectedModels, + availableModels, + knownModelNames, + onToggleModel, + onSelectAllModels, + Modifier.weight(1f), + ) + CategoryFilterDropdown( + selectedCategories, + onToggleCategory, + onClearAllCategories, + Modifier.weight(1f), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + StatusFilterDropdown( + selectedStatuses, + onToggleStatus, + onClearAllStatuses, + Modifier.fillMaxWidth(), + ) + } + } + } +} + +internal fun formatDateRangeLabel(range: TokenStatsTimeRange, zone: ZoneId): String { + val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM).withLocale(Locale.getDefault()) + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(zone).toLocalDate() + return if (start == end) start.format(formatter) else "${start.format(formatter)} - ${end.format(formatter)}" +} + +internal fun formatCompactDateRangeLabel(range: TokenStatsTimeRange, zone: ZoneId): String { + val formatter = DateTimeFormatter.ofPattern("M/d", Locale.getDefault()) + val start = java.time.Instant.ofEpochMilli(range.startMs).atZone(zone).toLocalDate() + val end = java.time.Instant.ofEpochMilli(range.endMs - 1L).atZone(zone).toLocalDate() + return if (start == end) start.format(formatter) else "${start.format(formatter)}-${end.format(formatter)}" +} + +@Composable +internal fun TokenStatsCurrencyDropdown( + selected: PricingCurrency, + onSelect: (PricingCurrency) -> Unit, + modifier: Modifier = Modifier, +) { + FilterDropdown( + label = selected.code, + modifier = modifier, + ) { dismiss -> + PricingCurrency.entries.forEach { currency -> + DropdownMenuItem( + text = { + Text( + text = currency.code, + fontWeight = if (currency == selected) FontWeight.Bold else FontWeight.Normal, + ) + }, + onClick = { + dismiss() + onSelect(currency) + }, + ) + } + } +} + +@Composable +private fun ModelFilterDropdown( + selectedModels: Set, + availableModels: List, + knownModelNames: Map, + onToggleModel: (String) -> Unit, + onSelectAllModels: () -> Unit, + modifier: Modifier = Modifier, +) { + // 可选项 = 当前范围可用模型 + 已被选中但被筛选出当前结果的模型(P1-5) + val options: List> = remember(availableModels, selectedModels, knownModelNames) { + val byId = availableModels.associateBy { it.displayModelId } + buildList { + availableModels.forEach { add(it.displayModelId to it.displayName) } + selectedModels.forEach { id -> + if (id !in byId) add(id to (knownModelNames[id] ?: id)) + } + } + } + FilterDropdown( + modifier = modifier, + label = if (selectedModels.isEmpty()) { + stringResource( + R.string.token_stats_filter_model_label, + stringResource(R.string.token_stats_filter_all_models), + ) + } else { + stringResource( + R.string.token_stats_filter_model_label, + stringResource(R.string.token_stats_filter_models_count, selectedModels.size), + ) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_models), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + onSelectAllModels() + dismiss() + }, + ) + options.forEach { (modelId, displayName) -> + val checked = selectedModels.isEmpty() || modelId in selectedModels + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = checked, + onCheckedChange = { onToggleModel(modelId) }, + ) + Text( + displayName, + modifier = Modifier.padding(start = 4.dp), + maxLines = 1, + ) + } + }, + onClick = { onToggleModel(modelId) }, + ) + } + } +} + +@Composable +private fun CategoryFilterDropdown( + selected: Set?, + onToggle: (TokenStatCategory) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, +) { + FilterDropdown( + modifier = modifier, + label = if (selected == null) { + stringResource( + R.string.token_stats_filter_category_label, + stringResource(R.string.token_stats_filter_all_categories), + ) + } else { + stringResource( + R.string.token_stats_filter_category_label, + stringResource(R.string.token_stats_filter_categories_count, selected.size), + ) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_categories), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + if (selected != null) onClearAll() + dismiss() + }, + ) + TokenStatCategory.entries.forEach { category -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected?.contains(category) == true, + onCheckedChange = { onToggle(category) }, + ) + Text( + stringResource(category.labelRes()), + modifier = Modifier.padding(start = 4.dp), + ) + } + }, + onClick = { onToggle(category) }, + ) + } + } +} + +@Composable +private fun StatusFilterDropdown( + selected: Set?, + onToggle: (TokenStatStatus) -> Unit, + onClearAll: () -> Unit, + modifier: Modifier = Modifier, +) { + FilterDropdown( + modifier = modifier, + label = if (selected == null) { + stringResource( + R.string.token_stats_filter_status_label, + stringResource(R.string.token_stats_filter_all_statuses), + ) + } else { + stringResource( + R.string.token_stats_filter_status_label, + stringResource(R.string.token_stats_filter_statuses_count, selected.size), + ) + }, + ) { dismiss -> + DropdownMenuItem( + text = { + Text( + stringResource(R.string.token_stats_filter_all_statuses), + fontWeight = FontWeight.Bold, + ) + }, + onClick = { + if (selected != null) onClearAll() + dismiss() + }, + ) + TokenStatStatus.entries.forEach { status -> + DropdownMenuItem( + text = { + Row(verticalAlignment = Alignment.CenterVertically) { + Checkbox( + checked = selected?.contains(status) == true, + onCheckedChange = { onToggle(status) }, + ) + Text( + stringResource(status.labelRes()), + modifier = Modifier.padding(start = 4.dp), + ) + } + }, + onClick = { onToggle(status) }, + ) + } + } +} + +@Composable +private fun FilterDropdown( + label: String, + modifier: Modifier = Modifier, + content: @Composable (dismiss: () -> Unit) -> Unit, +) { + var expanded by remember { mutableStateOf(false) } + Box(modifier = modifier) { + FilterChip( + selected = false, + onClick = { expanded = true }, + label = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + content { expanded = false } + } + } +} + +internal fun TokenStatCategory.labelRes(): Int = + when (this) { + TokenStatCategory.CHAT -> R.string.token_stats_category_chat + TokenStatCategory.SUBAGENT -> R.string.token_stats_category_subagent + TokenStatCategory.SUMMARY -> R.string.token_stats_category_summary + TokenStatCategory.TITLE -> R.string.token_stats_category_title + TokenStatCategory.MEMORY -> R.string.token_stats_category_memory + TokenStatCategory.CHARACTER_GENERATION -> R.string.token_stats_category_character + TokenStatCategory.CONNECTION_TEST -> R.string.token_stats_category_connection_test + TokenStatCategory.OTHER -> R.string.token_stats_category_other + } + +internal fun TokenStatStatus.labelRes(): Int = + when (this) { + TokenStatStatus.COMPLETED -> R.string.token_stats_status_completed + TokenStatStatus.CANCELLED -> R.string.token_stats_status_cancelled + TokenStatStatus.TIMEOUT -> R.string.token_stats_status_timeout + TokenStatStatus.FAILED -> R.string.token_stats_status_failed + } + +// ==== 图表卡片 ==== + +@Composable +internal fun TokenStatsChartCard( + title: String, + summary: String, + modifier: Modifier = Modifier, + onSummaryClick: (() -> Unit)? = null, + headerExtra: @Composable () -> Unit = {}, + content: @Composable () -> Unit, +) { + val colors = LocalTokenStatsColors.current + TokenStatsWhiteCard( + modifier = modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(TokenStatsSpacing.card)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = title, + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + Text( + text = summary, + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = colors.chartAccent, + modifier = if (onSummaryClick != null) { + Modifier.clickable(onClick = onSummaryClick) + } else { + Modifier + }, + ) + } + Spacer(Modifier.height(8.dp)) + headerExtra() + content() + } + } +} + +// ==== 配置详情 ==== + +@Composable +internal fun TokenStatsConfigurationCardsSection( + configurations: List, + currency: PricingCurrency, + configurationNames: Map, + priceSettings: List, + onEditPrice: (TokenStatsPriceSetting?, TokenStatsPriceDraft, String?) -> Unit, +) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column { + configurations + .sortedByDescending { it.totals.totalTokens.knownSum } + .forEachIndexed { index, identity -> + if (index > 0) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + } + val configurationName = + identity.configId?.let { configId -> + configurationNames[configId] + ?: stringResource(R.string.token_stats_config_deleted) + } ?: stringResource(R.string.token_stats_legacy_configuration) + TokenStatsConfigurationRow( + identity = identity, + configurationName = configurationName, + currency = currency, + priceSettings = priceSettings, + onEditPrice = onEditPrice, + ) + } + } + } +} + +@Composable +private fun TokenStatsConfigurationRow( + identity: com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown, + configurationName: String, + currency: PricingCurrency, + priceSettings: List, + onEditPrice: (TokenStatsPriceSetting?, TokenStatsPriceDraft, String?) -> Unit, +) { + val colors = LocalTokenStatsColors.current + var expanded by remember(identity.configId, identity.provider, identity.model) { mutableStateOf(false) } + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(horizontal = TokenStatsSpacing.card, vertical = 10.dp), + ) { + val totals = identity.totals + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = configurationName, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = "${identity.provider} · ${identity.model}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Spacer(Modifier.width(8.dp)) + Column(horizontalAlignment = Alignment.End) { + Text( + text = formatMoney(totals.cost.knownAmount, currency), + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = colors.chartAccent, + maxLines = 1, + ) + Text( + text = formatCompactRequestCountLabel( + totals.requests, + totals.requestCountUnknownContributionCount, + ), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + Icon( + imageVector = if (expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, + contentDescription = stringResource(R.string.token_stats_model_expand), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (expanded) { + FlowRow( + modifier = Modifier.padding(top = 6.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = "${stringResource(R.string.token_stats_token_uncached)} ${formatCompactCount(totals.uncachedInput.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${stringResource(R.string.token_stats_token_cached)} ${formatCompactCount(totals.cachedInput.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = "${stringResource(R.string.token_stats_token_output)} ${formatCompactCount(totals.output.knownSum)}", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + if (totals.uncachedInput.unknownEventCount > 0L || + totals.cachedInput.unknownEventCount > 0L || + totals.output.unknownEventCount > 0L + ) { + Text( + text = stringResource( + R.string.token_stats_unknown_parts, + totals.uncachedInput.unknownEventCount + + totals.cachedInput.unknownEventCount + + totals.output.unknownEventCount, + ), + style = MaterialTheme.typography.labelSmall, + color = colors.unknownHint, + modifier = Modifier.weight(1f), + ) + } else { + Spacer(Modifier.weight(1f)) + } + IconButton( + onClick = { + val scope = + if (identity.configId.isNullOrEmpty()) { + TokenStatsPriceScope.PROVIDER_MODEL + } else { + TokenStatsPriceScope.CONFIG + } + val providerModel = "${identity.provider}:${identity.model}" + val existing = + priceSettings.firstOrNull { + it.scope == scope && + it.providerModel.equals(providerModel, ignoreCase = true) && + (scope == TokenStatsPriceScope.PROVIDER_MODEL || + it.configId == identity.configId) + } + onEditPrice( + existing, + priceDraftForConfiguration(identity, priceSettings), + identity.configId?.let { configurationName }, + ) + }, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Filled.Edit, + contentDescription = stringResource(R.string.token_stats_pricing_edit), + modifier = Modifier.size(18.dp), + ) + } + } + if (totals.cost.unknownContributionCount > 0L) { + Text( + text = stringResource(R.string.token_stats_unknown_cost, totals.cost.unknownContributionCount), + style = MaterialTheme.typography.labelSmall, + color = colors.unknownHint, + ) + } + } + } +} + +// ==== 汇率与币种设置卡 ==== + +@Composable +internal fun TokenStatsRateCard( + manualRate: Double, + rateIsEstimated: Boolean, + onSaveRate: (Double) -> Boolean, +) { + val colors = LocalTokenStatsColors.current + var rateInput by remember { mutableStateOf(formatRateInput(manualRate)) } + // 汇率外部变化(如从 DataStore 重新加载)时同步输入框 + LaunchedEffect(manualRate) { + rateInput = formatRateInput(manualRate) + } + + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier.padding(TokenStatsSpacing.card), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content), + ) { + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = stringResource(R.string.settings_exchange_rate_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.Bold, + modifier = Modifier.weight(1f), + ) + if (rateIsEstimated) { + EstimatedBadge( + text = stringResource(R.string.token_stats_rate_default_badge), + textColor = MaterialTheme.colorScheme.onSurface, + ) + } + } + Text( + text = stringResource(R.string.settings_exchange_rate_subtitle), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + OutlinedTextField( + value = rateInput, + onValueChange = { rateInput = it }, + label = { Text(stringResource(R.string.settings_usd_to_cny_rate_label)) }, + keyboardOptions = androidx.compose.foundation.text.KeyboardOptions( + keyboardType = androidx.compose.ui.text.input.KeyboardType.Decimal, + ), + modifier = Modifier.weight(1f), + ) + TextButton( + onClick = { + val parsed = rateInput.toDoubleOrNull() + if (parsed == null || !onSaveRate(parsed)) { + // 非法输入保持原值并提示(Toast 由调用方统一处理) + rateInput = formatRateInput(manualRate) + } + }, + ) { + Text(stringResource(R.string.settings_save)) + } + } + + if (rateIsEstimated) { + Text( + text = stringResource(R.string.token_stats_rate_default_hint, manualRate), + style = MaterialTheme.typography.bodySmall, + color = colors.unknownHint, + ) + } + } + } +} + +private fun priceDraftForConfiguration( + identity: com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown, + priceSettings: List, +): TokenStatsPriceDraft { + val providerModel = "${identity.provider}:${identity.model}" + val providerSettings = + priceSettings.firstOrNull { + it.scope == TokenStatsPriceScope.PROVIDER_MODEL && + it.providerModel.equals(providerModel, ignoreCase = true) + }?.toModelPriceSettings() + val configurationSettings = + identity.configId?.let { configId -> + priceSettings.firstOrNull { + it.scope == TokenStatsPriceScope.CONFIG && + it.providerModel.equals(providerModel, ignoreCase = true) && + it.configId == configId + } + }?.toModelPriceSettings() + val resolved = + TokenPriceResolver.resolve( + providerModel, + mergePriceSettings(providerSettings, configurationSettings), + ) + return TokenStatsPriceDraft( + scope = + if (identity.configId.isNullOrEmpty()) { + TokenStatsPriceScope.PROVIDER_MODEL + } else { + TokenStatsPriceScope.CONFIG + }, + provider = identity.provider, + model = identity.model, + configId = identity.configId, + billingMode = resolved.billingMode, + currency = resolved.currency, + inputPricePerMillion = resolved.inputPricePerMillion, + cachedInputPricePerMillion = resolved.cachedInputPricePerMillion, + cacheWritePricePerMillion = resolved.cacheWritePricePerMillion, + outputPricePerMillion = resolved.outputPricePerMillion, + pricePerRequest = resolved.pricePerRequest, + ) +} + +private fun mergePriceSettings( + provider: com.ai.assistance.operit.data.stats.ModelPriceSettings?, + configuration: com.ai.assistance.operit.data.stats.ModelPriceSettings?, +) = + com.ai.assistance.operit.data.stats.ModelPriceSettings( + billingMode = configuration?.billingMode ?: provider?.billingMode, + currency = configuration?.currency ?: provider?.currency, + inputPricePerMillion = configuration?.inputPricePerMillion ?: provider?.inputPricePerMillion, + cachedInputPricePerMillion = + configuration?.cachedInputPricePerMillion ?: provider?.cachedInputPricePerMillion, + cacheWritePricePerMillion = + configuration?.cacheWritePricePerMillion ?: provider?.cacheWritePricePerMillion, + outputPricePerMillion = configuration?.outputPricePerMillion ?: provider?.outputPricePerMillion, + pricePerRequest = configuration?.pricePerRequest ?: provider?.pricePerRequest, + ) + +private fun TokenStatsPriceSetting.toModelPriceSettings() = + com.ai.assistance.operit.data.stats.ModelPriceSettings( + billingMode = billingMode, + currency = currency, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = cacheWritePricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = pricePerRequest, + ) + +private fun formatRateInput(rate: Double): String = + String.format(Locale.US, "%.4f", rate).trimEnd('0').trimEnd('.') + +/** 性能聚合的平均值格式化(无有效样本显示“无数据”而非 0)。 */ +@Composable +internal fun durationSummaryText(aggregate: TokenStatsDurationAggregate): String { + if (!aggregate.hasData) return stringResource(R.string.token_stats_perf_no_data) + val avg = formatDuration(aggregate.averageMs) + return stringResource(R.string.token_stats_perf_avg, avg) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt new file mode 100644 index 000000000..424a6fd34 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDialogs.kt @@ -0,0 +1,417 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DateRangePicker +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDateRangePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.model.BillingMode +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsPriceScope +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale + +internal fun datePickerMillisToLocalDate(utcMidnightMs: Long): java.time.LocalDate = + Instant.ofEpochMilli(utcMidnightMs).atZone(java.time.ZoneOffset.UTC).toLocalDate() + +internal fun customRangeInclusiveEnd( + startDate: java.time.LocalDate, + endDate: java.time.LocalDate, + zone: ZoneId, +): com.ai.assistance.operit.data.stats.TokenStatsTimeRange { + require(!endDate.isBefore(startDate)) { "end date must not be before start date" } + val startMs = startDate.atStartOfDay(zone).toInstant().toEpochMilli() + val endMs = endDate.plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli() + return com.ai.assistance.operit.data.stats.TokenStatsTimeRanges.customRange(startMs, endMs) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun TokenStatsDateRangeDialog( + zone: ZoneId, + maxRangeDays: Long, + initialRange: com.ai.assistance.operit.data.stats.TokenStatsTimeRange?, + onConfirm: (startMs: Long, endMs: Long) -> Boolean, + onDismiss: () -> Unit, +) { + var inlineError by remember { mutableStateOf(null) } + val initialStartDateMillis = initialRange + ?.startMs + ?.let { Instant.ofEpochMilli(it).atZone(zone).toLocalDate() } + ?.atStartOfDay(java.time.ZoneOffset.UTC) + ?.toInstant() + ?.toEpochMilli() + val initialEndDateMillis = initialRange + ?.endMs + ?.minus(1L) + ?.let { Instant.ofEpochMilli(it).atZone(zone).toLocalDate() } + ?.atStartOfDay(java.time.ZoneOffset.UTC) + ?.toInstant() + ?.toEpochMilli() + val pickerState = rememberDateRangePickerState( + initialSelectedStartDateMillis = initialStartDateMillis, + initialSelectedEndDateMillis = initialEndDateMillis, + ) + androidx.compose.runtime.LaunchedEffect( + pickerState.selectedStartDateMillis, + pickerState.selectedEndDateMillis, + ) { + inlineError = null + } + + val invalidRangeText = stringResource(R.string.token_stats_custom_range_invalid) + val rangeTooLongText = stringResource(R.string.token_stats_custom_range_too_long) + + DatePickerDialog( + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(max = 360.dp), + confirmButton = { + TextButton( + enabled = + pickerState.selectedStartDateMillis != null && + pickerState.selectedEndDateMillis != null, + onClick = { + val start = pickerState.selectedStartDateMillis ?: return@TextButton + val end = pickerState.selectedEndDateMillis ?: return@TextButton + val range = customRangeInclusiveEnd( + datePickerMillisToLocalDate(start), + datePickerMillisToLocalDate(end), + zone, + ) + inlineError = + when (validateCustomRange(range.startMs, range.endMs, zone, maxRangeDays)) { + CustomRangeValidation.INVALID_BOUNDS -> invalidRangeText + CustomRangeValidation.TOO_LONG -> rangeTooLongText + CustomRangeValidation.VALID -> null + } + if (inlineError == null && onConfirm(range.startMs, range.endMs)) onDismiss() + }, + ) { + Text(stringResource(R.string.token_stats_custom_range_confirm)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) { + Column { + DateRangePicker( + state = pickerState, + title = { + Text( + text = stringResource(R.string.token_stats_date_range), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(start = 24.dp, top = 16.dp), + ) + }, + headline = { + Text( + text = formatDatePickerSelection( + pickerState.selectedStartDateMillis, + pickerState.selectedEndDateMillis, + ), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis, + modifier = + Modifier + .fillMaxWidth() + .padding(start = 24.dp, end = 24.dp, bottom = 12.dp), + ) + }, + showModeToggle = false, + ) + inlineError?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } +} + +private fun formatDatePickerSelection(startMillis: Long?, endMillis: Long?): String { + if (startMillis == null) return "" + val start = datePickerMillisToLocalDate(startMillis).format(datePickerSelectionFormatter) + if (endMillis == null) return start + val end = datePickerMillisToLocalDate(endMillis).format(datePickerSelectionFormatter) + return "$start - $end" +} + +private val datePickerSelectionFormatter = DateTimeFormatter.ofPattern("yyyy/MM/dd", Locale.getDefault()) + +@Composable +internal fun PriceSettingsDialog( + existing: TokenStatsPriceSetting?, + initialDraft: TokenStatsPriceDraft, + configurationName: String?, + onSave: (TokenStatsPriceDraft) -> Unit, + onDelete: (() -> Unit)? = null, + onDismiss: () -> Unit, +) { + val scope = initialDraft.scope + val provider = initialDraft.provider + val model = initialDraft.model + val configId = initialDraft.configId.orEmpty() + var billingMode by remember(existing, initialDraft) { + mutableStateOf(existing?.billingMode ?: initialDraft.billingMode) + } + var currency by remember(existing, initialDraft) { + mutableStateOf(existing?.currency ?: initialDraft.currency) + } + var inputPrice by remember(existing, initialDraft) { + mutableStateOf( + formatEditablePrice(existing?.inputPricePerMillion ?: initialDraft.inputPricePerMillion) + ) + } + var cachedInputPrice by remember(existing, initialDraft) { + mutableStateOf( + formatEditablePrice( + existing?.cachedInputPricePerMillion ?: initialDraft.cachedInputPricePerMillion + ) + ) + } + var cacheWritePrice by remember(existing, initialDraft) { + mutableStateOf( + formatEditablePrice( + existing?.cacheWritePricePerMillion ?: initialDraft.cacheWritePricePerMillion + ) + ) + } + var outputPrice by remember(existing, initialDraft) { + mutableStateOf( + formatEditablePrice(existing?.outputPricePerMillion ?: initialDraft.outputPricePerMillion) + ) + } + var pricePerRequest by remember(existing, initialDraft) { + mutableStateOf( + formatEditablePrice(existing?.pricePerRequest ?: initialDraft.pricePerRequest) + ) + } + val priceFields = + if (billingMode == BillingMode.TOKEN) { + listOf(inputPrice, cachedInputPrice, cacheWritePrice, outputPrice) + } else { + listOf(pricePerRequest) + } + val allPricesValid = + priceFields.all { raw -> + raw.isBlank() || + raw.toDoubleOrNull()?.let { it.isFinite() && it > 0.0 } == true + } + val targetValid = scope != TokenStatsPriceScope.CONFIG || configId.isNotBlank() + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.token_stats_pricing_edit)) }, + text = { + Column( + modifier = Modifier.verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "$provider · $model", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + if (scope == TokenStatsPriceScope.CONFIG) { + Text( + text = configurationName.orEmpty(), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + HorizontalDivider() + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = billingMode == BillingMode.TOKEN, + onClick = { + if (billingMode != BillingMode.TOKEN) { + pricePerRequest = "" + billingMode = BillingMode.TOKEN + } + }, + label = { Text(stringResource(R.string.settings_billing_mode_token)) }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = billingMode == BillingMode.COUNT, + onClick = { + if (billingMode != BillingMode.COUNT) { + inputPrice = "" + cachedInputPrice = "" + cacheWritePrice = "" + outputPrice = "" + billingMode = BillingMode.COUNT + } + }, + label = { Text(stringResource(R.string.settings_billing_mode_count)) }, + modifier = Modifier.weight(1f), + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FilterChip( + selected = currency == PricingCurrency.CNY, + onClick = { currency = PricingCurrency.CNY }, + label = { Text(stringResource(R.string.token_stats_currency_cny)) }, + modifier = Modifier.weight(1f), + ) + FilterChip( + selected = currency == PricingCurrency.USD, + onClick = { currency = PricingCurrency.USD }, + label = { Text(stringResource(R.string.token_stats_currency_usd)) }, + modifier = Modifier.weight(1f), + ) + } + HorizontalDivider() + if (billingMode == BillingMode.TOKEN) { + PriceField( + label = stringResource(R.string.token_stats_pricing_input), + value = inputPrice, + onChange = { inputPrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_cached), + value = cachedInputPrice, + onChange = { cachedInputPrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_cache_write), + value = cacheWritePrice, + onChange = { cacheWritePrice = it }, + ) + PriceField( + label = stringResource(R.string.token_stats_pricing_output), + value = outputPrice, + onChange = { outputPrice = it }, + ) + } else { + PriceField( + label = stringResource(R.string.token_stats_pricing_per_request), + value = pricePerRequest, + onChange = { pricePerRequest = it }, + ) + } + } + }, + confirmButton = { + TextButton( + enabled = targetValid && allPricesValid, + onClick = { + val parse = { raw: String -> raw.trim().toDoubleOrNull() } + onSave( + TokenStatsPriceDraft( + scope = scope, + provider = provider, + model = model, + configId = configId.takeIf { scope == TokenStatsPriceScope.CONFIG }, + billingMode = billingMode, + currency = currency, + inputPricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(inputPrice) else null, + cachedInputPricePerMillion = + if (billingMode == BillingMode.TOKEN) { + parse(cachedInputPrice) + } else { + null + }, + cacheWritePricePerMillion = + if (billingMode == BillingMode.TOKEN) { + parse(cacheWritePrice) + } else { + null + }, + outputPricePerMillion = + if (billingMode == BillingMode.TOKEN) parse(outputPrice) else null, + pricePerRequest = + if (billingMode == BillingMode.COUNT) { + parse(pricePerRequest) + } else { + null + }, + ) + ) + onDismiss() + }, + ) { + Text(stringResource(R.string.settings_save)) + } + }, + dismissButton = { + Row { + if (existing != null && onDelete != null) { + TextButton( + onClick = { + onDelete() + onDismiss() + } + ) { + Text( + stringResource(R.string.token_stats_pricing_delete), + color = MaterialTheme.colorScheme.error, + ) + } + Spacer(Modifier.weight(1f)) + } + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.settings_cancel)) + } + } + }, + ) +} + +@Composable +private fun PriceField(label: String, value: String, onChange: (String) -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onChange, + label = { Text(label) }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) +} + +private fun formatEditablePrice(value: Double?): String = + value?.let { String.format(Locale.US, "%.6f", it).trimEnd('0').trimEnd('.') } ?: "" diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt new file mode 100644 index 000000000..7fd7c5dfe --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsScreen.kt @@ -0,0 +1,771 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.widget.Toast +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Analytics +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsIdentityBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.ui.components.CustomScaffold +import java.time.ZoneId + +/** 性能卡指标切换。 */ +internal enum class PerfMetric { TTFT, GENERATION } +private enum class ChartDetailMetric { COST, REQUESTS, TOKENS } + +/** + * Token 统计完整页面(阶段 4)。 + * 沿用 Operit 设置入口与页面框架(Settings → Token使用统计), + * 升级旧累计页面为账本统计:生命周期总览 + 时间/模型/分类/状态筛选 + + * 四张图表卡 + 配置详情 + 汇率设置。 + */ +@Composable +fun TokenUsageStatisticsScreen( + onBackPressed: () -> Unit, +) { + val context = LocalContext.current + // P1-3:VM 由路由级 ViewModelStore 管理(AppContent 为该 route 提供 + // LocalViewModelStoreOwner,键 = screenKey)——配置变化保留实例, + // 路由出栈/替换/清栈时 store.clear() 触发 onCleared,viewModelScope + // 取消;Factory 只持有 applicationContext。 + val viewModel: TokenUsageStatisticsViewModel = + viewModel(factory = TokenUsageStatisticsViewModel.Factory(context)) + val state by viewModel.state.collectAsState() + val actionMessage by viewModel.actionMessage.collectAsState() + + // 瞬态 UI 状态:可存 rememberSaveable 的在配置变化后保留(P1-3); + // 筛选已在 VM state 中,天然跨配置变化保留。 + var showDateRange by rememberSaveable { mutableStateOf(false) } + var perfMetric by rememberSaveable { mutableStateOf(PerfMetric.TTFT) } + + LaunchedEffect(actionMessage) { + actionMessage?.let { message -> + Toast.makeText(context, message.text, Toast.LENGTH_SHORT).show() + viewModel.consumeActionMessage() + } + } + LaunchedEffect(Unit) { viewModel.loadForEntry() } + + TokenStatsColorsProvider { + CustomScaffold { paddingValues -> + val content: @Composable () -> Unit = { + when { + state.loading && (state.range == null || state.lifetime == null) -> { + LoadingState() + } + state.errorMessage != null && state.range == null -> { + ErrorState( + message = state.errorMessage.orEmpty(), + onRetry = viewModel::load, + ) + } + else -> { + TokenStatsPageContent( + state = state, + viewModel = viewModel, + zone = viewModel.zone, + perfMetric = perfMetric, + onTogglePerfMetric = { perfMetric = it }, + onSelectDateRange = { showDateRange = true }, + ) + } + } + } + + Box( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + content() + } + } + } + + if (showDateRange) { + TokenStatsDateRangeDialog( + zone = viewModel.zone, + maxRangeDays = TokenUsageStatisticsViewModel.MAX_CUSTOM_RANGE_DAYS, + initialRange = state.currentRange, + onConfirm = { start, end -> viewModel.setCustomRange(start, end) }, + onDismiss = { showDateRange = false }, + ) + } + +} + +@Composable +private fun LoadingState() { + Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + androidx.compose.material3.CircularProgressIndicator() + } +} + +@Composable +private fun ErrorState(message: String, onRetry: () -> Unit) { + Box(modifier = Modifier.fillMaxSize().padding(32.dp), contentAlignment = Alignment.Center) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(12.dp)) + TextButton(onClick = onRetry) { + Text(stringResource(R.string.token_stats_retry)) + } + } + } +} + +@Composable +private fun TokenStatsPageContent( + state: TokenStatsUiState, + viewModel: TokenUsageStatisticsViewModel, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, + onSelectDateRange: () -> Unit, +) { + val lifetime = state.lifetime ?: return + val hasAnyData = + lifetime.totals.requests > 0L || lifetime.totals.totalTokens.totalEventCount > 0L + val context = LocalContext.current + val range = state.range + + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(TokenStatsSpacing.page), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), + ) { + item { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader( + title = stringResource(R.string.token_stats_lifetime_total), + ) { + TokenStatsCurrencyDropdown( + selected = state.targetCurrency, + onSelect = viewModel::setTargetCurrency, + modifier = Modifier.width(88.dp), + ) + } + TokenStatsLifetimeCard( + overview = lifetime, + currency = state.targetCurrency, + ) + } + } + + if (lifetime.displayModels.isNotEmpty()) { + item { + TokenStatsLifetimeModelsSection( + models = lifetime.displayModels, + currency = state.targetCurrency, + ) + } + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_range_analysis)) + TokenStatsFilterBar( + selectedModels = state.selectedModels, + availableModels = state.availableDisplayModels, + knownModelNames = state.knownModelNames, + selectedCategories = state.selectedCategories, + selectedStatuses = state.selectedStatuses, + onToggleModel = viewModel::toggleModel, + onSelectAllModels = viewModel::selectAllModels, + onToggleCategory = viewModel::toggleCategory, + onClearAllCategories = viewModel::clearCategories, + onToggleStatus = viewModel::toggleStatus, + onClearAllStatuses = viewModel::clearStatuses, + ) + TokenActivitySection( + state = state.activity, + dateRange = state.currentRange, + zone = zone, + onSelectMode = viewModel::setActivityViewMode, + onSelectDateRange = onSelectDateRange, + ) + when { + range == null -> NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) + !hasAnyData -> EmptyStateCard() + range.eventCount == 0L -> { + NoDataCard(text = stringResource(R.string.token_stats_no_data_in_range)) + } + } + } + } + + if (range != null && hasAnyData && range.eventCount > 0L) { + item { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_trends)) + TokenStatsChartsSection( + range = range, + currency = state.targetCurrency, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + } + + item { + TokenStatsModelDetailsSection( + title = stringResource(R.string.settings_model_details), + models = range.displayModels, + currency = state.targetCurrency, + configurationNames = state.configurationNames, + priceSettings = state.priceSettings, + onSavePrice = viewModel::savePrice, + onDeletePrice = viewModel::deletePrice, + ) + } + } + + item { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = stringResource(R.string.token_stats_settings)) + val rateInvalidText = stringResource(R.string.token_stats_rate_invalid) + TokenStatsRateCard( + manualRate = state.manualRate, + rateIsEstimated = state.rateIsEstimated, + onSaveRate = { rate -> + val ok = viewModel.setManualRate(rate) + if (!ok) { + Toast.makeText(context, rateInvalidText, Toast.LENGTH_SHORT).show() + } + ok + }, + ) + } + } + + item { + Spacer(Modifier.height(96.dp)) + } + } +} + +@Composable +private fun TokenStatsModelDetailsSection( + title: String, + models: List, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + configurationNames: Map, + priceSettings: List, + onSavePrice: (TokenStatsPriceDraft) -> Unit, + onDeletePrice: (com.ai.assistance.operit.data.stats.TokenStatsPriceSetting) -> Unit, + subtitle: String? = null, +) { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + TokenStatsSectionHeader(title = title) { + Text( + text = stringResource( + R.string.token_stats_configuration_count, + models.sumOf { it.identities.size }, + ), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + subtitle?.let { + Text( + text = it, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + var priceEditor by remember { mutableStateOf(null) } + TokenStatsConfigurationCardsSection( + configurations = models.flatMap(TokenStatsDisplayModelBreakdown::identities), + currency = currency, + configurationNames = configurationNames, + priceSettings = priceSettings, + onEditPrice = { existing, draft, configurationName -> + priceEditor = PriceEditorTarget(existing, draft, configurationName) + }, + ) + priceEditor?.let { target -> + PriceSettingsDialog( + existing = target.existing, + initialDraft = target.draft, + configurationName = target.configurationName, + onSave = onSavePrice, + onDelete = target.existing?.let { setting -> { onDeletePrice(setting) } }, + onDismiss = { priceEditor = null }, + ) + } + } +} + +private data class PriceEditorTarget( + val existing: com.ai.assistance.operit.data.stats.TokenStatsPriceSetting?, + val draft: TokenStatsPriceDraft, + val configurationName: String?, +) + +@Composable +private fun EmptyStateCard() { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + imageVector = Icons.Default.Analytics, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(16.dp)) + Text( + text = stringResource(R.string.token_stats_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.token_stats_empty_hint), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.8f), + ) + } + } +} + +@Composable +private fun NoDataCard(text: String) { + TokenStatsWhiteCard( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(24.dp), + ) + } +} + +// ==== 四张图表卡(手机纵向;宽屏 2x2) ==== + +@Composable +private fun TokenStatsChartsSection( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, +) { + var detailMetric by rememberSaveable { mutableStateOf(null) } + BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { + val wide = maxWidth > 700.dp + if (wide) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), + ) { + CostChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.COST + } + TokenChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.TOKENS + } + } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section), + ) { + RequestChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.REQUESTS + } + PerformanceChartCard( + range = range, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + } + } else { + Column(verticalArrangement = Arrangement.spacedBy(TokenStatsSpacing.section)) { + CostChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.COST + } + RequestChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.REQUESTS + } + TokenChartCard(range = range, currency = currency, zone = zone) { + detailMetric = ChartDetailMetric.TOKENS + } + PerformanceChartCard( + range = range, + zone = zone, + perfMetric = perfMetric, + onTogglePerfMetric = onTogglePerfMetric, + ) + } + } + } + + detailMetric?.let { metric -> + TokenStatsChartDetailDialog( + metric = metric, + range = range, + currency = currency, + onDismiss = { detailMetric = null }, + ) + } +} + +@Composable +private fun TokenStatsChartDetailDialog( + metric: ChartDetailMetric, + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + onDismiss: () -> Unit, +) { + val title = stringResource( + when (metric) { + ChartDetailMetric.COST -> R.string.token_stats_detail_cost + ChartDetailMetric.REQUESTS -> R.string.token_stats_detail_requests + ChartDetailMetric.TOKENS -> R.string.token_stats_detail_tokens + } + ) + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title, fontWeight = FontWeight.Bold) }, + text = { + Column { + when (metric) { + ChartDetailMetric.COST -> range.displayModels.forEach { model -> + if (model.totals.cost.knownAmount > 0.0) { + TokenStatsDetailRow(model.displayName, formatMoney(model.totals.cost.knownAmount, currency)) + } + } + ChartDetailMetric.REQUESTS -> range.displayModels.forEach { model -> + if (model.totals.requests > 0L) { + TokenStatsDetailRow( + model.displayName, + formatRequestCount( + model.totals.requests, + model.totals.requestCountUnknownContributionCount, + ), + ) + } + } + ChartDetailMetric.TOKENS -> { + // canonical 总 Token 为权威合计;缓存/非缓存/输出仍是诊断分量 + TokenStatsDetailRow( + stringResource(R.string.token_stats_tokens_total), + formatCount(range.summary.totalTokens.knownSum), + ) + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_cached), + formatCount(range.summary.cachedInput.knownSum), + ) + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_uncached), + formatCount(range.summary.uncachedInput.knownSum), + ) + TokenStatsDetailRow( + stringResource(R.string.token_stats_token_output), + formatCount(range.summary.output.knownSum), + ) + } + } + } + }, + confirmButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.token_stats_detail_close)) + } + }, + ) +} + +@Composable +private fun TokenStatsDetailRow(label: String, value: String) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 3.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text(label, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + Text( + value, + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.chartAccent, + fontWeight = FontWeight.Medium, + ) + } +} + +@Composable +private fun CostChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, + onSummaryClick: () -> Unit, +) { + val colors = LocalTokenStatsColors.current + val models = range.displayModels + val colorFor: (String) -> androidx.compose.ui.graphics.Color = { modelId -> + val index = models.indexOfFirst { it.displayModelId == modelId } + colors.modelPalette[index.coerceAtLeast(0) % colors.modelPalette.size] + } + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val unknownCostTemplate = stringResource(R.string.token_stats_unknown_cost) + val chartTitle = stringResource(R.string.token_stats_chart_cost) + + TokenStatsChartCard( + title = chartTitle, + summary = formatMoney(range.summary.cost.knownAmount, currency), + onSummaryClick = onSummaryClick, + ) { + if (range.summary.cost.unknownContributionCount > 0L) { + RangeUnknownHint( + stringResource(R.string.token_stats_unknown_cost, range.summary.cost.unknownContributionCount) + ) + } + TokenStatsStackedBarChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatMoney(it, currency) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + stackSelector = { bucket -> + models.mapNotNull { model -> + val cost = bucket.byModel[model.displayModelId]?.cost ?: return@mapNotNull null + if (cost.knownAmount <= 0.0) null else cost.knownAmount to colorFor(model.displayModelId) + } + }, + stackLabels = { bucket -> + models.mapNotNull { model -> + val cost = bucket.byModel[model.displayModelId]?.cost ?: return@mapNotNull null + if (cost.knownAmount <= 0.0) null else model.displayName + } + }, + unknownNote = { bucket -> + val unknown = bucket.totals.cost.unknownContributionCount + if (unknown > 0L) String.format(unknownCostTemplate, unknown) else null + }, + legendItems = models.take(8).map { it.displayName to colorFor(it.displayModelId) }, + ) + } +} + +@Composable +private fun RequestChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, + onSummaryClick: () -> Unit, +) { + val chartTitle = stringResource(R.string.token_stats_chart_requests) + val unknownRequestTemplate = stringResource(R.string.token_stats_request_count_unknown) + TokenStatsChartCard( + title = chartTitle, + summary = formatRequestCount( + range.summary.requests, + range.summary.requestCountUnknownContributionCount, + ), + onSummaryClick = onSummaryClick, + ) { + TokenStatsLineChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatCount(it.toLong()) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + valueSelector = { it.totals.requests.toDouble() }, + unknownNote = { bucket -> + val unknown = bucket.totals.requestCountUnknownContributionCount + if (unknown > 0L) String.format(unknownRequestTemplate, unknown) else null + }, + ) + } +} + +@Composable +private fun TokenChartCard( + range: TokenStatsRangeData, + currency: com.ai.assistance.operit.data.collects.PricingCurrency, + zone: ZoneId, + onSummaryClick: () -> Unit, +) { + val colors = LocalTokenStatsColors.current + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val outputLabel = stringResource(R.string.token_stats_token_output) + val cachedLabel = stringResource(R.string.token_stats_token_cached) + val uncachedLabel = stringResource(R.string.token_stats_token_uncached) + val unknownPartsTemplate = stringResource(R.string.token_stats_unknown_parts) + val chartTitle = stringResource(R.string.token_stats_chart_tokens) + + val totalUnknown = range.summary.totalTokens.unknownEventCount + + TokenStatsChartCard( + title = chartTitle, + // Canonical total tokens come from the same SQL records as the headline and details. + summary = formatCompactCount(range.summary.totalTokens.knownSum), + onSummaryClick = onSummaryClick, + ) { + if (totalUnknown > 0L) { + RangeUnknownHint(stringResource(R.string.token_stats_unknown_parts, totalUnknown)) + } + TokenStatsStackedBarChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatCompactCount(it.toLong()) }, + emptyText = stringResource(R.string.token_stats_no_data_in_range), + chartLabel = chartTitle, + stackSelector = { bucket -> + listOf( + bucket.totals.output.knownSum.toDouble() to colors.output, + bucket.totals.uncachedInput.knownSum.toDouble() to colors.uncachedInput, + bucket.totals.cachedInput.knownSum.toDouble() to colors.cachedInput, + ) + }, + stackLabels = { + listOf(outputLabel, uncachedLabel, cachedLabel) + }, + // 堆叠分量是诊断明细(可能因 provider 口径不完全等于总量), + // tooltip/无障碍合计必须用 canonical 总 Token + stackTotalSelector = { bucket -> bucket.totals.totalTokens.knownSum.toDouble() }, + unknownNote = { bucket -> + val unknown = bucket.totals.totalTokens.unknownEventCount + if (unknown > 0L) String.format(unknownPartsTemplate, unknown) else null + }, + legendItems = listOf( + uncachedLabel to colors.uncachedInput, + cachedLabel to colors.cachedInput, + outputLabel to colors.output, + ), + ) + } +} + +@Composable +private fun PerformanceChartCard( + range: TokenStatsRangeData, + zone: ZoneId, + perfMetric: PerfMetric, + onTogglePerfMetric: (PerfMetric) -> Unit, +) { + val colors = LocalTokenStatsColors.current + val aggregate = + if (perfMetric == PerfMetric.TTFT) range.performance.ttft + else range.performance.generationDuration + // 预取模板:chart 回调是非 Composable lambda,不能在回调内解析资源 + val perfNoDataText = stringResource(R.string.token_stats_perf_no_data) + val durationUnknownTemplate = stringResource(R.string.token_stats_duration_unknown) + val chartTitle = stringResource(R.string.token_stats_chart_performance) + + TokenStatsChartCard( + title = chartTitle, + summary = durationSummaryText(aggregate), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(TokenStatsSpacing.content)) { + FilterChip( + selected = perfMetric == PerfMetric.TTFT, + onClick = { onTogglePerfMetric(PerfMetric.TTFT) }, + label = { Text(stringResource(R.string.token_stats_perf_ttft)) }, + ) + FilterChip( + selected = perfMetric == PerfMetric.GENERATION, + onClick = { onTogglePerfMetric(PerfMetric.GENERATION) }, + label = { Text(stringResource(R.string.token_stats_perf_generation)) }, + ) + } + Spacer(Modifier.height(TokenStatsSpacing.content)) + TokenStatsLineChart( + buckets = range.buckets, + granularity = range.granularity, + zone = zone, + formatValue = { formatDuration(it) }, + emptyText = perfNoDataText, + chartLabel = chartTitle, + valueSelector = { bucket -> + val agg = + if (perfMetric == PerfMetric.TTFT) bucket.performance.ttft + else bucket.performance.generationDuration + if (agg.hasData) agg.averageMs else null + }, + unknownNote = { bucket -> + val agg = + if (perfMetric == PerfMetric.TTFT) bucket.performance.ttft + else bucket.performance.generationDuration + when { + !agg.hasData -> perfNoDataText + agg.unknownCount > 0L -> String.format(durationUnknownTemplate, agg.unknownCount) + else -> null + } + }, + ) + } +} + +@Composable +private fun RangeUnknownHint(text: String) { + Text( + text = text, + style = MaterialTheme.typography.bodySmall, + color = LocalTokenStatsColors.current.unknownHint, + modifier = Modifier.padding(bottom = 4.dp), + ) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt new file mode 100644 index 000000000..e2387abde --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/tokenstats/TokenUsageStatisticsViewModel.kt @@ -0,0 +1,384 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import android.content.Context +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import com.ai.assistance.operit.R +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.preferences.ModelConfigManager +import com.ai.assistance.operit.data.stats.TokenStatsPreferencesStore +import com.ai.assistance.operit.data.stats.TokenActivityAggregator +import com.ai.assistance.operit.data.stats.TokenActivityViewMode +import com.ai.assistance.operit.data.stats.TokenActivityRangeData +import com.ai.assistance.operit.data.stats.TokenCostCurrency +import com.ai.assistance.operit.data.stats.TokenStatCategory +import com.ai.assistance.operit.data.stats.TokenStatStatus +import com.ai.assistance.operit.data.stats.TokenStatsDisplayModelBreakdown +import com.ai.assistance.operit.data.stats.TokenStatsPriceDraft +import com.ai.assistance.operit.data.stats.TokenStatsLifetimeOverview +import com.ai.assistance.operit.data.stats.TokenStatsPriceSetting +import com.ai.assistance.operit.data.stats.TokenStatsQueryParams +import com.ai.assistance.operit.data.stats.TokenStatsQueryService +import com.ai.assistance.operit.data.stats.TokenStatsRangeData +import com.ai.assistance.operit.data.stats.TokenStatsSettingsManager +import com.ai.assistance.operit.data.stats.TokenStatsSettingsStore +import com.ai.assistance.operit.data.stats.TokenStatsTimeRange +import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges +import com.ai.assistance.operit.util.AppLogger +import java.time.ZoneId +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +data class TokenActivityUiState( + val loading: Boolean = true, + val viewMode: TokenActivityViewMode = TokenActivityViewMode.DAILY, + val rangeData: TokenActivityRangeData? = null, +) + +data class TokenStatsUiState( + val loading: Boolean = true, + val errorMessage: String? = null, + val refreshVersion: Long = 0L, + val lifetime: TokenStatsLifetimeOverview? = null, + val range: TokenStatsRangeData? = null, + val currentRange: TokenStatsTimeRange? = null, + val targetCurrency: PricingCurrency = PricingCurrency.CNY, + val manualRate: Double = TokenCostCurrency.DEFAULT_USD_TO_CNY_RATE, + val rateIsEstimated: Boolean = true, + val selectedModels: Set = emptySet(), + val selectedCategories: Set? = null, + val selectedStatuses: Set? = null, + val availableDisplayModels: List = emptyList(), + val knownModelNames: Map = emptyMap(), + val configurationNames: Map = emptyMap(), + val priceSettings: List = emptyList(), + val activity: TokenActivityUiState = TokenActivityUiState(), +) + +data class TokenStatsActionMessage( + val text: String, + val isError: Boolean = false, +) + +@android.annotation.SuppressLint("StaticFieldLeak") +class TokenUsageStatisticsViewModel( + context: Context, + private val settings: TokenStatsSettingsStore = TokenStatsPreferencesStore(context), + val zone: ZoneId = ZoneId.systemDefault(), + private val nowMs: () -> Long = { System.currentTimeMillis() }, + private val stringResolver: (Int) -> String = { context.applicationContext.getString(it) }, + private val dispatcher: CoroutineDispatcher = Dispatchers.Main.immediate, +) : ViewModel() { + private val appContext = context.applicationContext + private val manager = TokenStatsSettingsManager(appContext) + private val modelConfigManager = ModelConfigManager(appContext) + private val tag = "TokenUsageStatisticsViewModel" + + private val _state = MutableStateFlow(TokenStatsUiState()) + val state: StateFlow = _state.asStateFlow() + + private val _actionMessage = MutableStateFlow(null) + val actionMessage: StateFlow = _actionMessage.asStateFlow() + + private var loadGeneration = 0 + private var loadJob: Job? = null + private val knownModelNames = linkedMapOf() + + fun consumeActionMessage() { + _actionMessage.value = null + } + + fun load() = loadInternal() + + fun loadForEntry() = loadInternal() + + fun setActivityViewMode(mode: TokenActivityViewMode) { + _state.update { it.copy(activity = it.activity.copy(viewMode = mode)) } + } + + private fun loadInternal() { + loadJob?.cancel() + val generation = ++loadGeneration + val filterSnapshot = _state.value + loadJob = viewModelScope.launch(dispatcher) { + try { + val rateInfo = settings.loadRateWithEstimate() + val currency = settings.loadTargetCurrency() + val range = settings.loadTimeRange() ?: defaultDateRange(nowMs(), zone) + + if (generation != loadGeneration) return@launch + _state.update { it.copy(loading = true, errorMessage = null, activity = it.activity.copy(loading = true)) } + + val result = coroutineScope { + val pricesDeferred = async(Dispatchers.IO) { manager.allPriceSettings() } + val selectedProviderModels = + filterSnapshot.selectedModels + .takeIf { it.isNotEmpty() } + ?.let { selected -> + filterSnapshot.availableDisplayModels + .asSequence() + .filter { it.displayModelId in selected } + .flatMap { it.providerModels.asSequence() } + .toSet() + } + val rangeParams = TokenStatsQueryParams( + targetCurrency = currency, + manualRate = rateInfo.first, + providerModels = selectedProviderModels, + categories = filterSnapshot.selectedCategories, + statuses = filterSnapshot.selectedStatuses, + ) + val availableParams = rangeParams.copy(providerModels = null) + val lifetimeDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.lifetimeOverview( + appContext, + TokenStatsQueryParams( + targetCurrency = currency, + manualRate = rateInfo.first, + ), + ) + } + val rangeDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.rangeData(appContext, range, rangeParams, zone) + } + val availableDeferred = async(Dispatchers.IO) { + if (selectedProviderModels == null) { + null + } else { + TokenStatsQueryService.rangeData( + appContext, + range, + availableParams, + zone, + ) + } + } + val activityDeferred = async(Dispatchers.IO) { + TokenStatsQueryService.activitySnapshot(appContext, range, rangeParams, zone) + } + val rangeData = rangeDeferred.await() + val configurationIds = + rangeData + ?.displayModels + .orEmpty() + .flatMap { it.identities } + .mapNotNull { it.configId } + .distinct() + val configurationNamesDeferred = async(Dispatchers.IO) { + buildMap { + configurationIds.forEach { configId -> + modelConfigManager.getModelConfig(configId)?.let { config -> + put(configId, config.name) + } + } + } + } + QueryLoadResult( + lifetime = lifetimeDeferred.await(), + range = rangeData, + available = availableDeferred.await() ?: rangeData, + prices = pricesDeferred.await(), + configurationNames = configurationNamesDeferred.await(), + activity = TokenActivityAggregator.rangeData(activityDeferred.await(), range), + ) + } + + if (generation != loadGeneration) return@launch + rememberModelNames(result.range?.displayModels.orEmpty()) + rememberModelNames(result.available?.displayModels.orEmpty()) + _state.update { + it.copy( + loading = false, + errorMessage = null, + lifetime = result.lifetime, + range = result.range, + currentRange = range, + targetCurrency = currency, + manualRate = rateInfo.first, + rateIsEstimated = rateInfo.second, + availableDisplayModels = result.available?.displayModels.orEmpty(), + knownModelNames = knownModelNames.toMap(), + configurationNames = result.configurationNames, + priceSettings = result.prices, + activity = it.activity.copy(loading = false, rangeData = result.activity), + refreshVersion = it.refreshVersion + 1L, + ) + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == loadGeneration) { + _state.update { + it.copy( + loading = false, + errorMessage = stringResolver(R.string.token_stats_load_failed), + activity = it.activity.copy(loading = false), + ) + } + } + AppLogger.e(tag, "Token statistics load failed", e) + } + } + } + + private fun rememberModelNames(models: List) { + models.forEach { knownModelNames[it.displayModelId] = it.displayName } + } + + fun setCustomRange(startMs: Long, endMs: Long): Boolean { + when (validateCustomRange(startMs, endMs, zone, MAX_CUSTOM_RANGE_DAYS)) { + CustomRangeValidation.INVALID_BOUNDS -> { + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_custom_range_invalid), + isError = true, + ) + return false + } + CustomRangeValidation.TOO_LONG -> { + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_custom_range_too_long), + isError = true, + ) + return false + } + CustomRangeValidation.VALID -> Unit + } + viewModelScope.launch(dispatcher) { + settings.saveTimeRange(TokenStatsTimeRanges.customRange(startMs, endMs)) + load() + } + return true + } + + fun toggleModel(displayModelId: String) { + _state.update { state -> + val selected = state.selectedModels.toMutableSet() + if (!selected.add(displayModelId)) selected.remove(displayModelId) + state.copy(selectedModels = selected) + } + load() + } + + fun selectAllModels() { + _state.update { it.copy(selectedModels = emptySet()) } + load() + } + + fun toggleCategory(category: TokenStatCategory) { + _state.update { state -> + val selected = state.selectedCategories?.toMutableSet() ?: mutableSetOf() + if (!selected.add(category)) selected.remove(category) + state.copy(selectedCategories = selected.ifEmpty { null }) + } + load() + } + + fun clearCategories() { + _state.update { it.copy(selectedCategories = null) } + load() + } + + fun toggleStatus(status: TokenStatStatus) { + _state.update { state -> + val selected = state.selectedStatuses?.toMutableSet() ?: mutableSetOf() + if (!selected.add(status)) selected.remove(status) + state.copy(selectedStatuses = selected.ifEmpty { null }) + } + load() + } + + fun clearStatuses() { + _state.update { it.copy(selectedStatuses = null) } + load() + } + + fun setTargetCurrency(currency: PricingCurrency) { + viewModelScope.launch(dispatcher) { + settings.saveTargetCurrency(currency) + load() + } + } + + fun setManualRate(rate: Double): Boolean { + if (!rate.isFinite() || rate <= 0.0) return false + viewModelScope.launch(dispatcher) { + settings.saveRate(rate) + load() + } + return true + } + + fun savePrice(draft: TokenStatsPriceDraft) { + viewModelScope.launch(dispatcher) { + try { + manager.savePrice(draft) + load() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_pricing_save_failed), + isError = true, + ) + } + } + } + + fun deletePrice(setting: TokenStatsPriceSetting) { + viewModelScope.launch(dispatcher) { + try { + if (setting.scope == com.ai.assistance.operit.data.stats.TokenStatsPriceScope.CONFIG) { + manager.resetConfigPrice(setting.providerModel, requireNotNull(setting.configId)) + } else { + manager.restoreBuiltInPrice(setting.providerModel) + } + load() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _actionMessage.value = TokenStatsActionMessage( + stringResolver(R.string.token_stats_pricing_delete_failed), + isError = true, + ) + } + } + } + + class Factory(context: Context) : ViewModelProvider.Factory { + private val appContext = context.applicationContext + + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = + TokenUsageStatisticsViewModel(appContext) as T + } + + companion object { + const val MAX_CUSTOM_RANGE_DAYS = 3 * 366L + } +} + +private data class QueryLoadResult( + val lifetime: TokenStatsLifetimeOverview, + val range: TokenStatsRangeData?, + val available: TokenStatsRangeData?, + val prices: List, + val configurationNames: Map, + val activity: TokenActivityRangeData, +) + +private fun defaultDateRange(nowMs: Long, zone: ZoneId): TokenStatsTimeRange { + val today = java.time.Instant.ofEpochMilli(nowMs).atZone(zone).toLocalDate() + val start = today.minusDays(29L).atStartOfDay(zone).toInstant().toEpochMilli() + val end = today.plusDays(1L).atStartOfDay(zone).toInstant().toEpochMilli() + return TokenStatsTimeRanges.customRange(start, end) +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt index 89f5f733e..4c71c9541 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/OperitApp.kt @@ -41,6 +41,7 @@ import com.ai.assistance.operit.ui.main.screens.Screen import com.ai.assistance.operit.ui.main.navigation.AppRouterGateway import com.ai.assistance.operit.ui.main.navigation.AppRouterState import com.ai.assistance.operit.ui.main.navigation.AppRouteDiscoveryGateway +import com.ai.assistance.operit.ui.main.navigation.screenKeysAliveOnStack import com.ai.assistance.operit.ui.main.navigation.NavigationEntrySpec import com.ai.assistance.operit.ui.main.navigation.NavigationSurface import com.ai.assistance.operit.ui.main.navigation.RouteEntrySource @@ -116,6 +117,12 @@ fun OperitApp( val currentRouteEntry = routerState.currentEntry val currentScreen = AppRouteCatalog.resolveScreen(navigationModel, currentRouteEntry) ?: Screen.AiChat val selectedItem = currentScreen.navItem + // 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据: + // AppContent 在转场完成时只保留这些键的 owner) + val aliveScreenKeys: Set = + screenKeysAliveOnStack(routerState.backStack) { entry -> + AppRouteCatalog.resolveScreen(navigationModel, entry) + } val pluginSidebarEntries = remember(navigationModel) { navigationModel.navigationEntries.filter { @@ -532,7 +539,8 @@ fun OperitApp( onGoBack = ::requestGoBack, isNavigatingBack = isNavigatingBack, topBarActions = { topBarActions() }, - topBarTitleContent = topBarTitleContent + topBarTitleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } else { // Phone layout @@ -563,7 +571,8 @@ fun OperitApp( onGoBack = ::requestGoBack, isNavigatingBack = isNavigatingBack, topBarActions = { topBarActions() }, - topBarTitleContent = topBarTitleContent + topBarTitleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt index 2bdbf070d..d71f61d72 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/components/AppContent.kt @@ -53,6 +53,8 @@ import com.ai.assistance.operit.ui.main.NavigationTransitionSource import com.ai.assistance.operit.ui.main.TopBarTitleContent import com.ai.assistance.operit.ui.main.navigation.RouteEntry import com.ai.assistance.operit.ui.main.navigation.LocalRouteInstanceId +import com.ai.assistance.operit.ui.main.navigation.ScreenRouteViewModelStoreOwnerManager +import com.ai.assistance.operit.ui.main.navigation.retainedRouteKeysOnContentAttach import com.ai.assistance.operit.ui.main.screens.Screen import com.ai.assistance.operit.ui.common.composedsl.ToolPkgComposeDslToolScreen import com.ai.assistance.operit.ui.theme.LocalThemePreferenceSnapshot @@ -74,6 +76,8 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.compose.ui.zIndex import androidx.compose.animation.core.tween import androidx.compose.ui.graphics.graphicsLayer @@ -153,7 +157,9 @@ fun AppContent( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, actions: @Composable RowScope.() -> Unit = {}, - titleContent: TopBarTitleContent? = null + titleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { // Get background image state val context = LocalContext.current @@ -214,16 +220,29 @@ fun AppContent( val screenCache = remember { mutableStateMapOf Unit>() } val screenKeepAliveCache = remember { mutableStateMapOf() } val screenStateHolder = rememberSaveableStateHolder() + // 路由级 ViewModelStore 管理器:自身是 Activity 级 ViewModel(跨配置变化 + // 保留 owner/VM);路由出栈/替换/清栈时清理对应 store(P1:离页查询不保留)。 + val routeViewModelStoreManager: ScreenRouteViewModelStoreOwnerManager = viewModel() val currentScreenKey = remember(currentRouteEntry.instanceId, currentScreen) { - if (currentScreen.keepAlive) { - currentScreen.stableScreenKey() ?: currentRouteEntry.instanceId - } else { - currentRouteEntry.instanceId - } + currentScreen.screenKey(currentRouteEntry.instanceId) } var currentScreenSoftInputMode by remember(currentScreenKey) { mutableStateOf(null) } var currentScreenUsesImePadding by remember(currentScreenKey) { mutableStateOf(false) } + + // 全新组合(配置变化/跨 600dp 布局重建):screenCache 已随组合重置, + // 但 Activity 级 manager/routerState 保留的导航栈仍存活,只保留当前页会 + // 误清 backStack 其他路由的 owner(P1)。首次组合(attach)时按 + // aliveScreenKeys + 当前键同步一次。key 必须用 Unit:只在组合进入时执行 + // 一次,不得随 alive 变化重启——pop 后 alive 立即更新,若按它重启会在 + // 退出动画完成前清理仍渲染的离页 owner。pop/replace/clear 的清理只由 + // 转场完成分支 retainOnly(aliveRouteKeys()) 执行(含过渡/keepAlive 时机); + // Phone/Tablet 切换会销毁本组合,新组合 attach 时重新执行并用新传入 alive。 + LaunchedEffect(Unit) { + routeViewModelStoreManager.retainOnly( + retainedRouteKeysOnContentAttach(currentScreenKey, aliveScreenKeys) + ) + } val effectiveSoftInputMode = currentScreenSoftInputMode ?: manifestSoftInputMode @@ -421,16 +440,32 @@ fun AppContent( uiModuleId = screenSnapshot.uiModuleId, fallbackTitle = screenSnapshot.title ) - else -> - screenSnapshot.Content( - navController = navController, - navigateTo = onScreenChange, - onGoBack = onGoBack, - hasBackgroundImage = hasBackgroundImage, - onLoading = onLoading, - onError = onError, - onGestureConsumed = if (screenSnapshot is Screen.AiChat) onGestureConsumed else { _ -> } - ) + else -> { + val content: @Composable () -> Unit = { + screenSnapshot.Content( + navController = navController, + navigateTo = onScreenChange, + onGoBack = onGoBack, + hasBackgroundImage = hasBackgroundImage, + onLoading = onLoading, + onError = onError, + onGestureConsumed = if (screenSnapshot is Screen.AiChat) onGestureConsumed else { _ -> } + ) + } + if (screenSnapshot.usesRouteViewModelStore) { + // 路由级 ViewModelStore(P1):配置变化保留实例, + // 路由出栈/替换/清栈时 store.clear() 触发 onCleared + val routeOwner = + routeViewModelStoreManager.ownerFor(currentScreenKey) + CompositionLocalProvider( + LocalViewModelStoreOwner provides routeOwner + ) { + content() + } + } else { + content() + } + } } } } @@ -461,6 +496,13 @@ fun AppContent( else -> null } + // 计算当前仍应存活的路由键:导航栈 + 仍渲染的 keepAlive 缓存 + + // 当前键(在转场完成时执行,避免清理仍在渲染的过渡页) + fun aliveRouteKeys(): Set = + aliveScreenKeys + + screenKeepAliveCache.filterValues { it }.keys + + currentScreenKey + LaunchedEffect(currentScreenKey) { val fromKey = lastObservedCurrentKey if (currentScreenKey == fromKey) return@LaunchedEffect @@ -483,6 +525,8 @@ fun AppContent( screenKeepAliveCache.remove(removalKey) screenStateHolder.removeState(removalKey) } + // 路由已离开栈:清理其 ViewModelStore(pop/replace/清栈) + routeViewModelStoreManager.retainOnly(aliveRouteKeys()) pendingRemovalKey = null return@LaunchedEffect } @@ -509,6 +553,8 @@ fun AppContent( screenStateHolder.removeState(keyToRemove) } } + // 动画结束、旧路由已离开栈:清理其 ViewModelStore + routeViewModelStoreManager.retainOnly(aliveRouteKeys()) pendingRemovalKey = null } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt index 846953251..51ca2ab43 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/PhoneLayout.kt @@ -78,7 +78,9 @@ fun PhoneLayout( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, topBarActions: @Composable RowScope.() -> Unit = {}, - topBarTitleContent: TopBarTitleContent? = null + topBarTitleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { // 使用 updateTransition 来创建更复杂的动画 val transition = updateTransition(drawerState.targetValue, label = "drawer_transition") @@ -242,7 +244,8 @@ fun PhoneLayout( onGoBack = onGoBack, isNavigatingBack = isNavigatingBack, actions = topBarActions, - titleContent = topBarTitleContent + titleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt index 33878f51a..d748b6c38 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/layout/TabletLayout.kt @@ -64,7 +64,9 @@ fun TabletLayout( onGoBack: () -> Unit, isNavigatingBack: Boolean = false, topBarActions: @Composable RowScope.() -> Unit = {}, - topBarTitleContent: TopBarTitleContent? = null + topBarTitleContent: TopBarTitleContent? = null, + /** 当前导航栈中仍存活的路由 screenKey(路由级 ViewModelStore 清理依据)。 */ + aliveScreenKeys: Set ) { val drawerAppearance = rememberNavigationDrawerAppearance() val sidebarWidthAnimationDurationMillis = 280 @@ -199,7 +201,8 @@ fun TabletLayout( onGoBack = onGoBack, isNavigatingBack = isNavigatingBack, actions = topBarActions, - titleContent = topBarTitleContent + titleContent = topBarTitleContent, + aliveScreenKeys = aliveScreenKeys ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt new file mode 100644 index 000000000..c9036e98b --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwner.kt @@ -0,0 +1,92 @@ +package com.ai.assistance.operit.ui.main.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner +import com.ai.assistance.operit.ui.main.screens.Screen + +/** + * 单个路由(AppContent 的 screenKey)的 [ViewModelStoreOwner]。 + * + * 实例由 [ScreenRouteViewModelStoreOwnerManager] 持有:配置变化期间复用同一 + * 实例(owner 本身不随组合重建),路由真正从导航栈移除时 manager 调用 + * [ViewModelStore.clear],触发该 store 内所有 ViewModel 的 onCleared 并 + * 取消其 viewModelScope。 + * + * 与 ViewModelStore 的约定一致:全部操作必须在主线程执行。 + */ +class ScreenRouteViewModelStoreOwner internal constructor() : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() +} + +/** + * 路由级 ViewModelStore 映射(键 = AppContent 的 screenKey)。 + * + * 本类自身作为 Activity 级 ViewModel 存在(由 AppContent 通过 + * `viewModel()` 获取):跨配置变化保留全部 owner 与已挂载的 ViewModel; + * Activity 销毁时 [onCleared] 全清。导航宿主在路由出栈/替换/清栈的 + * 动画完成后调用 [remove] / [retainOnly],触发对应 owner 的 + * [ViewModelStore.clear]。 + * + * 必须仅在主线程使用(ViewModelStore 语义)。 + */ +class ScreenRouteViewModelStoreOwnerManager : ViewModel() { + + private val owners = mutableMapOf() + + /** 获取(必要时创建)screenKey 的 route owner;配置变化期间复用同一实例。 */ + fun ownerFor(screenKey: String): ScreenRouteViewModelStoreOwner = + owners.getOrPut(screenKey) { ScreenRouteViewModelStoreOwner() } + + /** 移除并清理 screenKey 的 owner:其 store 内所有 ViewModel 收到 onCleared。 */ + fun remove(screenKey: String) { + owners.remove(screenKey)?.let { it.viewModelStore.clear() } + } + + /** + * 仅保留 [aliveScreenKeys] 中的 owner,其余全部移除并清理。 + * 用于 replace / clear stack 等不经过 back 转场动画的栈变化。 + */ + fun retainOnly(aliveScreenKeys: Set) { + owners.keys.filter { it !in aliveScreenKeys }.forEach { remove(it) } + } + + /** 清理全部 owner(Activity 销毁或全新组合时的兜底)。 */ + fun clearAll() { + owners.values.forEach { it.viewModelStore.clear() } + owners.clear() + } + + override fun onCleared() { + clearAll() + } +} + +/** 路由实例在当前导航栈中对应的 screenKey(与 AppContent 的键规则一致)。 */ +fun routeScreenKey(entry: RouteEntry, resolveScreen: (RouteEntry) -> Screen?): String? = + resolveScreen(entry)?.screenKey(entry.instanceId) + +/** 导航栈中仍存活的路由 screenKey 集合(路由级 ViewModelStore 清理依据)。 */ +fun screenKeysAliveOnStack( + stack: List, + resolveScreen: (RouteEntry) -> Screen?, +): Set = + stack.mapNotNull { routeScreenKey(it, resolveScreen) }.toSet() + +/** + * AppContent 全新组合/重建(配置变化、跨 600dp Phone/Tablet 布局切换等)时 + * 应保留的路由键:当前页 + 导航栈中仍存活的路由。 + * + * 仅由 AppContent 首次组合(attach,LaunchedEffect(Unit))调用一次;导航 + * 变化(pop/replace/clear)不得复用本函数,否则会在退出动画完成前清理 + * 仍渲染的离页 owner,其清理由转场完成的 retainOnly(aliveRouteKeys()) 负责。 + * + * 全新组合时 screenCache/keepAlive 缓存已重置,唯一仍在渲染的只有当前页; + * 导航栈(Activity 保留的 routerState/manager)仍存活,栈内的 keepAlive + * 路由键已包含在 [aliveScreenKeys] 中,无需额外补充。离栈的过渡/keepAlive + * 缓存键由转场完成的 retainOnly(aliveRouteKeys()) 负责清理,与本集合无关。 + */ +fun retainedRouteKeysOnContentAttach( + currentScreenKey: String, + aliveScreenKeys: Set, +): Set = aliveScreenKeys + currentScreenKey diff --git a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt index 621553f9f..da8da0177 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/main/screens/OperitScreens.kt @@ -64,8 +64,8 @@ import com.ai.assistance.operit.ui.features.settings.screens.SpeechServicesSetti import com.ai.assistance.operit.ui.features.settings.screens.ThemeSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.ToolPermissionSettingsScreen import com.ai.assistance.operit.ui.features.settings.screens.MnnModelDownloadScreen -import com.ai.assistance.operit.ui.features.settings.screens.TokenUsageStatisticsScreen import com.ai.assistance.operit.ui.features.settings.screens.UserPreferencesSettingsScreen +import com.ai.assistance.operit.ui.features.tokenstats.TokenUsageStatisticsScreen import com.ai.assistance.operit.ui.features.token.TokenConfigWebViewScreen import com.ai.assistance.operit.ui.features.toolbox.screens.AppPermissionsToolScreen import com.ai.assistance.operit.ui.features.toolbox.screens.FileManagerToolScreen @@ -106,10 +106,25 @@ sealed class Screen( // 是否参与 AppContent 的跨页淡入淡出。 // 某些包含实时渲染视图的页面在转场中保留上一页会产生明显残影。 open val participatesInCrossfadeTransition: Boolean = true, - open val keepAlive: Boolean = false + open val keepAlive: Boolean = false, + /** + * 是否使用路由级 ViewModelStore(AppContent 按 screenKey 通过 + * [com.ai.assistance.operit.ui.main.navigation.ScreenRouteViewModelStoreOwnerManager] + * 管理):配置变化保留实例,路由出栈/替换/清栈时 store.clear() 触发 + * onCleared(viewModelScope 取消)。 + * 默认 false 保持 Activity 级语义,防止影响其他页面。 + */ + open val usesRouteViewModelStore: Boolean = false ) { open fun stableScreenKey(): String? = null + /** + * AppContent 使用的屏幕键:keepAlive 屏幕用 [stableScreenKey](同路由 + * 实例复用),否则用路由实例 id(每次进入独立)。 + */ + fun screenKey(routeInstanceId: String): String = + if (keepAlive) stableScreenKey() ?: routeInstanceId else routeInstanceId + // 屏幕内容渲染函数 @Composable open fun Content( @@ -1100,7 +1115,11 @@ sealed class Screen( } data object TokenUsageStatistics : - Screen(navItem = NavItem.Settings, titleRes = R.string.settings_token_usage_stats) { + Screen( + navItem = NavItem.Settings, + titleRes = R.string.settings_token_usage_stats, + usesRouteViewModelStore = true + ) { @Composable override fun Content( navController: NavController, @@ -1111,7 +1130,9 @@ sealed class Screen( onError: (String) -> Unit, onGestureConsumed: (Boolean) -> Unit ) { - TokenUsageStatisticsScreen(onBackPressed = onGoBack) + TokenUsageStatisticsScreen( + onBackPressed = onGoBack, + ) } } diff --git a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt index 08e20dcc0..f5a2b3c19 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt @@ -67,6 +67,13 @@ object AppLogger { @Volatile var enableFileLogging: Boolean = true + /** + * JVM 单元测试开关:关闭对 [android.util.Log] 的调用(返回 0/false),避免 + * 纯 JVM 环境抛 "not mocked" 异常。与 [enableFileLogging] 独立——文件日志照常。 + */ + @Volatile + var enableSystemLog: Boolean = true + @Volatile private var logFile: File? = null @Volatile @@ -158,96 +165,96 @@ object AppLogger { @JvmStatic fun v(tag: String, msg: String): Int { writeToFile(VERBOSE, tag, msg, null) - return Log.v(tag, msg) + return if (enableSystemLog) Log.v(tag, msg) else 0 } @JvmStatic fun v(tag: String, msg: String, tr: Throwable): Int { writeToFile(VERBOSE, tag, msg, tr) - return Log.v(tag, msg, tr) + return if (enableSystemLog) Log.v(tag, msg, tr) else 0 } @JvmStatic fun d(tag: String, msg: String): Int { writeToFile(DEBUG, tag, msg, null) - return Log.d(tag, msg) + return if (enableSystemLog) Log.d(tag, msg) else 0 } @JvmStatic fun d(tag: String, msg: String, tr: Throwable): Int { writeToFile(DEBUG, tag, msg, tr) - return Log.d(tag, msg, tr) + return if (enableSystemLog) Log.d(tag, msg, tr) else 0 } @JvmStatic fun i(tag: String, msg: String): Int { writeToFile(INFO, tag, msg, null) - return Log.i(tag, msg) + return if (enableSystemLog) Log.i(tag, msg) else 0 } @JvmStatic fun i(tag: String, msg: String, tr: Throwable): Int { writeToFile(INFO, tag, msg, tr) - return Log.i(tag, msg, tr) + return if (enableSystemLog) Log.i(tag, msg, tr) else 0 } @JvmStatic fun w(tag: String, msg: String): Int { writeToFile(WARN, tag, msg, null) - return Log.w(tag, msg) + return if (enableSystemLog) Log.w(tag, msg) else 0 } @JvmStatic fun w(tag: String, msg: String, tr: Throwable): Int { writeToFile(WARN, tag, msg, tr) - return Log.w(tag, msg, tr) + return if (enableSystemLog) Log.w(tag, msg, tr) else 0 } @JvmStatic fun w(tag: String, tr: Throwable): Int { writeToFile(WARN, tag, "", tr) - return Log.w(tag, tr) + return if (enableSystemLog) Log.w(tag, tr) else 0 } @JvmStatic fun e(tag: String, msg: String): Int { writeToFile(ERROR, tag, msg, null) - return Log.e(tag, msg) + return if (enableSystemLog) Log.e(tag, msg) else 0 } @JvmStatic fun e(tag: String, msg: String, tr: Throwable): Int { writeToFile(ERROR, tag, msg, tr) - return Log.e(tag, msg, tr) + return if (enableSystemLog) Log.e(tag, msg, tr) else 0 } @JvmStatic fun wtf(tag: String, msg: String): Int { writeToFile(ASSERT, tag, msg, null) - return Log.wtf(tag, msg) + return if (enableSystemLog) Log.wtf(tag, msg) else 0 } @JvmStatic fun wtf(tag: String, msg: String, tr: Throwable): Int { writeToFile(ASSERT, tag, msg, tr) - return Log.wtf(tag, msg, tr) + return if (enableSystemLog) Log.wtf(tag, msg, tr) else 0 } @JvmStatic fun wtf(tag: String, tr: Throwable): Int { writeToFile(ASSERT, tag, "", tr) - return Log.wtf(tag, tr) + return if (enableSystemLog) Log.wtf(tag, tr) else 0 } @JvmStatic fun isLoggable(tag: String, level: Int): Boolean { - return Log.isLoggable(tag, level) + return enableSystemLog && Log.isLoggable(tag, level) } @JvmStatic fun println(priority: Int, tag: String, msg: String): Int { writeToFile(priority, tag, msg, null) - return Log.println(priority, tag, msg) + return if (enableSystemLog) Log.println(priority, tag, msg) else 0 } @JvmStatic diff --git a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt index 1260ea877..06a2b5f96 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/OperitPaths.kt @@ -114,7 +114,7 @@ object OperitPaths { VECTOR_INDEX_DIR_NAME, IMAGE_POOL_DIR_NAME, MEDIA_POOL_DIR_NAME, - SKILL_REPO_ZIP_POOL_DIR_NAME + SKILL_REPO_ZIP_POOL_DIR_NAME, ) } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 99f9c9085..1e5d885c0 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -3613,6 +3613,9 @@ Model Usage Distribution Request Count Requests: %1$d + At least %1$s + Requests: at least %1$s + %1$d historical record(s) have an unknown exact request count Billing Mode Token-based Billing Per-request Billing @@ -3621,7 +3624,7 @@ ¥%1$.4f per request %1$s%2$.4f per request Switch Billing Mode - Model Details + Configuration Details Click to edit pricing Edit Model Pricing - %1$s Set RMB price per million tokens @@ -3642,6 +3645,7 @@ Reset This Model Reset Model Confirmation This will clear token and request count statistics for %1$s. This action cannot be undone. + Failed to reset statistics. Please try again. Reset Edit Pricing Input Tokens @@ -3649,6 +3653,91 @@ Cached Tokens $%.2f/1M Cost + Cumulative Usage + Cumulative Models + %1$s tokens · %2$d%% + Total Tokens + Default estimate + Default estimate: 1 USD = %1$s CNY (not set manually) + Converted at 1 USD = %1$s CNY + Rate must be a positive finite number + %1$d request(s) with unknown cost + %1$d request(s) with partially unknown data + %1$s requests + At least %1$s requests + unknown %1$d + Uncached input + Cache read + Cache write + Output + Reasoning + Date range + Range analysis + Filters + Trends + Statistics settings + OK + End date must be after start date + Custom range must not exceed 3 years + All models + %1$d models + All categories + %1$d categories + All statuses + %1$d statuses + Model: %1$s + Type: %1$s + Result: %1$s + Total currency + CNY + USD + Chat + Subagent + Summary + Title + Memory + Character generation + Connection test + Other + Completed + Cancelled + Timeout + Failed + Cost Trend + Request Trend + Token Trend + Performance Trend + TTFT + Generation time + Avg %1$s + No valid duration samples + %1$d invalid samples + No data in this range + No statistics yet + Model call statistics will appear here + Failed to load statistics. Please retry. + Retry + %1$d models + %1$d configurations + Expand or collapse details + Historical record + Price + Pricing unknown + Deleted configuration + Edit override + Delete + Input price (per million) + Cache read price (per million) + Cache write price (per million) + Output price (per million) + Price per request + Prices must be non-negative finite numbers + Failed to save price override + Failed to delete price override + Previous time bucket + Next time bucket + Bucket %1$d of %2$d + %1$s, %2$s, %3$s, total %4$s Male @@ -5248,7 +5337,6 @@ This will overwrite current app data with the following backup:\n%1$s\n\nThis action cannot be undone. It is recommended to perform a backup before restoring. Confirm restore Cancel - Chat History Cross-format backup, export and recovery Currently %1$d chat record(s). @@ -6799,6 +6887,7 @@ Error: %1$s Error: LLM session not initialized \n\n[Reasoning process error] + Request cancelled by user Error: %1$s Model name not configured Model directory does not exist: %1$s\nPlease download the model first @@ -7069,6 +7158,7 @@ Error: Cannot apply model chat template (llama_model_chat_template/llama_chat_apply_template) llama.cpp inference process error [Inference error occurred] + Request cancelled by user Task execution failed: %1$s @@ -8071,4 +8161,89 @@ Rejected Changes Required Featured + + All + Recent + Favorites + Add to favorites + Remove from favorites + Automatic review policy + Add review rules here to reflect how you like to work; automatic review follows these rules. Policy version: %1$s + Edit managed rules + Collapse policy editor + These rules are sent to the review model as your custom requirements, and review judges risk and authorization according to your habits. Only add rules you truly intend to allow. + Example: Any action publishing data publicly must require manual confirmation. + Recent denials + %1$d denials across %2$d distinct actions + View statistics and audit details + Denial statistics + Total denials + Distinct actions + The 10 most recent distinct denied actions are shown below. Select one to open its review Subagent and inspect the full process. + This chat has no actions denied by automatic review + Open review Subagent details + The corresponding review Subagent is unavailable + Authorize re-review of exact action + Authorized; waiting for Agent retry + Re-review in progress + This authorization was used + Authorization expired; authorize again + Authorization is valid for 5 minutes. Return to the parent chat and ask the Agent to retry the exact action; this button does not execute it directly. + reviewing + approved + denied + timed out + aborted + review failed + Guardian · batch tool %1$d/%2$d · %3$s · %4$s + Later allowed by you or current settings + Later denied by you or current settings + Tap for status, decision, and review conversation + Guardian review details + Status: %1$s + Action: %1$s + Risk level: %1$s + User authorization: %1$s + Decision: %1$s + Review subagent: %1$s + Failure: %1$s + Low + Medium + High + Critical + Unknown + Invalid structured output + Review timed out + Reviewer model error + Open review conversation + Guardian stopped this turn + Automatic review denied several actions in a row. To protect your data, the remaining tools were not run and this turn was stopped safely. + Confirm in %1$d s + I understand + You can now confirm and close this notice + %1$d consecutive safety interruptions combined + What happened + Guardian denied several tool calls in this turn. The denied calls and the remaining tools were not run, so the turn was stopped. + What you should do + Review the audit record and check which operation was denied and why. If it is still needed, return to the main chat and clearly state the exact operation, target, and scope before asking the AI to try again. Do not authorize an operation you do not understand. + Daily + Weekly + Cumulative + Total tokens + Peak tokens + Current streak + Longest streak + %1$d days + %1$s used %2$s tokens + %1$s - %2$s used %3$s tokens + %2$s tokens through %1$s + Tap a cell to view details + Less + More + Spending breakdown + Request breakdown + Token breakdown + Close + Tap to view details + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index e8e89c11f..2a2aa360a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3524,7 +3524,7 @@ 总费用 总请求次数 模型使用分布 - 模型详情 + 配置详情 点击编辑定价和计费方式 编辑模型定价 - %1$s 设置每百万Token的人民币价格 @@ -3540,6 +3540,9 @@ 缓存输入价格(每百万Token) 请求次数 请求次数: %1$d + 至少 %1$s + 请求次数: 至少 %1$s + %1$d 条历史记录的精确请求次数未知 计费方式 按Token计费 按次计费 @@ -3555,6 +3558,7 @@ 重置该模型 重置模型确认 这将清除 %1$s 的Token和请求次数统计,此操作不可恢复。 + 重置统计失败,请重试 重置 编辑定价 输入Token @@ -3562,6 +3566,95 @@ 缓存Token ¥%.2f/1M 费用 + + + 累计消耗 + 模型累计 + %1$s Token · %2$d%% + 总Token + 默认估算 + 默认估算:1 USD = %1$s CNY(未手动设置) + 按 1 USD = %1$s CNY 换算 + 汇率必须为正的有限数值 + 其中 %1$d 个请求费用未知 + %1$d 个请求部分数据未知 + %1$s 次请求 + 至少 %1$s 次请求 + 未知 %1$d + 未缓存输入 + 缓存读取 + 缓存写入 + 输出 + 推理 + 日期范围 + 范围分析 + 筛选条件 + 趋势 + 统计设置 + 确定 + 结束日期需晚于开始日期 + 自定义范围不能超过 3 年 + 全部模型 + %1$d 个模型 + 全部分类 + %1$d 个分类 + 全部状态 + %1$d 个状态 + 模型:%1$s + 调用类型:%1$s + 结果:%1$s + 总计币种 + CNY + USD + 聊天 + 子代理 + 总结 + 标题 + 记忆 + 角色生成 + 连接测试 + 其他 + 完成 + 取消 + 超时 + 失败 + 费用趋势 + 请求趋势 + Token 趋势 + 性能趋势 + 首Token延迟 + 生成时长 + 平均 %1$s + 无有效时长样本 + %1$d 个样本无效 + 该范围暂无数据 + 暂无统计数据 + 模型调用统计将在此显示 + 统计加载失败,请重试 + 重试 + %1$d 个模型 + %1$d 个配置 + 展开或收起详情 + 展开全部(%1$d) + 收起 + 历史记录 + 单价 + 定价未知 + 已删除的配置 + 编辑覆盖 + 删除 + 输入价格(每百万) + 缓存读取价格(每百万) + 缓存写入价格(每百万) + 输出价格(每百万) + 每次请求价格 + 价格必须为非负有限数值 + 价格覆盖保存失败 + 价格覆盖删除失败 + 上一个时间桶 + 下一个时间桶 + 第 %1$d / %2$d 桶 + %1$s,%2$s,%3$s,合计 %4$s @@ -5693,7 +5786,6 @@ 将使用以下备份文件覆盖当前应用数据:\n%1$s\n\n此操作不可撤销。建议先执行一次备份再恢复。 确认恢复 取消 - 聊天记录 跨格式备份、导出与恢复 当前共有 %1$d 条聊天记录。 @@ -7273,6 +7365,7 @@ 错误: %1$s 错误: LLM会话未初始化 \n\n[推理过程出现错误] + 请求已被用户取消 错误: %1$s 未配置模型名称 模型目录不存在: %1$s\n请先下载模型 @@ -7601,6 +7694,7 @@ 错误: 无法应用模型对话模板(llama_model_chat_template/llama_chat_apply_template) llama.cpp 推理过程出现错误 [推理过程出现错误] + 请求已被用户取消 任务执行失败: %1$s @@ -8059,4 +8153,89 @@ 需要修改 入选精选 + + 所有 + 最近 + 收藏 + 收藏对话 + 取消收藏 + 自动审核策略 + 可在此按你的使用习惯追加审核规则,自动审核会遵守这些规则。当前策略版本:%1$s + 编辑追加规则 + 收起策略编辑 + 这些规则会作为你的自定义要求发送给审核模型,审核会按你的习惯判断风险与授权。请只添加你真正愿意放行的规则。 + 例如:所有发布到公网的操作都必须转为人工确认。 + 最近拒绝 + 共 %1$d 次拒绝,涉及 %2$d 个不同操作 + 查看统计和审核详情 + 拒绝统计 + 拒绝总数 + 不同操作 + 下方显示最近 10 个不同的被拒绝操作。点击记录可进入对应的审核 Subagent 查看完整过程。 + 这个对话还没有被自动审核拒绝的操作 + 查看审核 Subagent 详情 + 对应的审核 Subagent 已不可用 + 授权相同操作重新审核 + 已授权,等待 Agent 重试 + 正在重新审核 + 本次授权已使用 + 授权已过期,可重新授权 + 授权有效 5 分钟。请返回主对话,让 Agent 重试完全相同的操作;不会由此按钮直接执行。 + 审核中 + 已允许 + 已拒绝 + 已超时 + 已中止 + 审核失败 + Guardian · 本批工具 %1$d/%2$d · %3$s · %4$s + 随后由你或最新设置允许 + 随后由你或最新设置拒绝 + 点击查看状态、结论和审核对话 + Guardian 审核详情 + 状态:%1$s + 操作:%1$s + 风险等级:%1$s + 用户授权:%1$s + 审核结论:%1$s + 审核子任务:%1$s + 失败原因:%1$s + + + + 严重 + 未知 + 输出格式无效 + 审核超时 + 审核模型错误 + 进入审核对话 + Guardian 已停止本轮操作 + 自动审核连续拒绝了多个操作。为保护你的数据,本轮剩余工具均未执行,对话已安全停止。 + %1$d 秒后可确认 + 我知道了 + 现在可以确认并关闭此提示 + 已合并 %1$d 次连续安全中断 + 发生了什么 + Guardian 连续拒绝了本轮中的多个工具调用。被拒绝的调用和本轮剩余工具都没有执行,因此系统停止了本轮。 + 你应该怎么做 + 先查看审核记录,确认被拒绝的操作和原因。若仍需执行,请回到主对话,明确说明具体操作、目标和范围后再让 AI 重试;不要授权你不理解的操作。 + 每日 + 每周 + 累计 + 累计 Token + 峰值 Token + 当前连续 + 最长连续 + %1$d 天 + %1$s使用了 %2$s Token + %1$s - %2$s 使用了 %3$s Token + 截至 %1$s 累计使用 %2$s Token + 点击方格查看详细数据 + + + 费用明细 + 请求明细 + Token 明细 + 关闭 + 点击查看详细数据 + diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt new file mode 100644 index 000000000..4724abc61 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProviderCancellationTest.kt @@ -0,0 +1,17 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ClaudeProviderCancellationTest { + @Test + fun `normal stream termination is not treated as manual cancellation`() { + assertFalse(shouldPropagateClaudeCancellation(false)) + } + + @Test + fun `cancel streaming marks normal loop exit for cancellation propagation`() { + assertTrue(shouldPropagateClaudeCancellation(true)) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt new file mode 100644 index 000000000..7706e3fef --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/LocalGenerationEndTest.kt @@ -0,0 +1,190 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import com.ai.assistance.operit.data.stats.ProviderUsageNormalizer +import com.ai.assistance.operit.data.stats.ProviderUsageSnapshot +import com.ai.assistance.operit.util.exceptions.UserCancellationException +import java.io.IOException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Test + +/** + * 本地 provider 生成结束顺序契约测试(评审 P2-3): + * 取消必须在工具缓冲转换/emit 之前判定——取消时无工具结果 emit、 + * 已实测 usage 保留、CANCELLED(UserCancellationException)传播; + * 失败路径同样绝不 emit 工具缓冲(先上报 usage 再 failWith)。 + */ +class LocalGenerationEndTest { + + @Test + fun `cancel reports usage then throws without emitting tool result`() = runBlocking { + val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, attempt -> + assertEquals(1, attempt) + usageReports.add(usage) + } + var toolEmitted = false + try { + LocalGenerationEnd.end( + cancelled = true, + success = false, + inputTokens = 300, + outputTokens = 12, + usageReporter = reporter, + cancelMessage = "cancelled by user", + emitToolResult = { toolEmitted = true }, + failWith = { fail("cancel path must not reach failWith") }, + ) + fail("cancellation must propagate") + } catch (e: UserCancellationException) { + assertEquals("cancelled by user", e.message) + } + // 取消时绝不 emit 不完整的工具 XML + assertFalse("tool buffer must not be emitted on cancel", toolEmitted) + // 已实测 usage 先上报 + assertEquals(1, usageReports.size) + assertEquals(300L, usageReports[0].uncachedInputTokens) + assertEquals(12L, usageReports[0].outputTokens) + } + + @Test + fun `success emits tool result and reports usage without throwing`() = runBlocking { + val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> usageReports.add(usage) } + var toolEmitted = false + LocalGenerationEnd.end( + cancelled = false, + success = true, + inputTokens = 100, + outputTokens = 30, + usageReporter = reporter, + cancelMessage = "cancelled", + emitToolResult = { toolEmitted = true }, + failWith = { fail("success path must not fail") }, + ) + assertTrue("tool result must be emitted when not cancelled", toolEmitted) + assertEquals(1, usageReports.size) + assertEquals(100L, usageReports[0].uncachedInputTokens) + assertEquals(30L, usageReports[0].outputTokens) + } + + @Test + fun `failure reports usage then fails without emitting tool result`() = runBlocking { + val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, _ -> usageReports.add(usage) } + var toolEmitted = false + try { + LocalGenerationEnd.end( + cancelled = false, + success = false, + inputTokens = 200, + outputTokens = 5, + usageReporter = reporter, + cancelMessage = "cancelled", + emitToolResult = { toolEmitted = true }, + failWith = { throw IOException("inference failed") }, + ) + fail("failure must propagate") + } catch (e: IOException) { + assertEquals("inference failed", e.message) + } + // 失败路径绝不转换/emit 不完整的工具 XML + assertFalse("tool buffer must not be emitted on failure", toolEmitted) + // 失败前已实测 usage 必须落账 + assertEquals(1, usageReports.size) + assertEquals(200L, usageReports[0].uncachedInputTokens) + assertEquals(5L, usageReports[0].outputTokens) + } + + @Test + fun `failure never emits tool result even if failWith returns normally`() = runBlocking { + val usageReports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> usageReports.add(usage) } + var toolEmitted = false + LocalGenerationEnd.end( + cancelled = false, + success = false, + inputTokens = 80, + outputTokens = 2, + usageReporter = reporter, + cancelMessage = "cancelled", + emitToolResult = { toolEmitted = true }, + failWith = {}, + ) + // failWith 正常返回(未抛异常)时,失败路径也必须就此结束,绝不落入 emit + assertFalse("tool buffer must never be emitted on failure", toolEmitted) + assertEquals(1, usageReports.size) + assertEquals(80L, usageReports[0].uncachedInputTokens) + } + + @Test + fun `coroutine cancellation reports latest usage once and still propagates`() = runBlocking { + val reports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { usage, _ -> reports += usage } + val entered = CompletableDeferred() + val job = launch { + reporter.runReportingFinally({ 42 }, { 7 }) { + entered.complete(Unit) + awaitCancellation() + } + } + entered.await() + job.cancelAndJoin() + + assertTrue(job.isCancelled) + assertEquals(1, reports.size) + assertEquals(42L, reports.single().uncachedInputTokens) + assertEquals(7L, reports.single().outputTokens) + reporter.report(99, 99) + assertEquals("reporter must be once-only", 1, reports.size) + } + + @Test + fun `native exception reports usage once without running success payload`() = runBlocking { + val reports = mutableListOf() + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_MNN) { usage, _ -> reports += usage } + var toolEmitted = false + try { + reporter.runReportingFinally({ 15 }, { 4 }) { + throw IOException("native failure") + } + toolEmitted = true + } catch (e: IOException) { + assertEquals("native failure", e.message) + } + + assertFalse(toolEmitted) + assertEquals(1, reports.size) + assertEquals(15L, reports.single().uncachedInputTokens) + assertEquals(4L, reports.single().outputTokens) + } + + @Test + fun `usage callback failure cannot mask cancellation`() = runBlocking { + val reporter = LocalUsageReporter(ProviderUsageNormalizer.SOURCE_LLAMA) { _, _ -> + throw IOException("ledger unavailable") + } + try { + LocalGenerationEnd.end( + cancelled = true, + success = false, + usageReporter = reporter, + inputTokens = 10, + outputTokens = 2, + cancelMessage = "cancelled", + emitToolResult = { fail("cancel must not emit") }, + failWith = { fail("cancel must not use failure payload") }, + ) + fail("cancellation must propagate") + } catch (e: UserCancellationException) { + assertEquals("cancelled", e.message) + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt new file mode 100644 index 000000000..5e2c400e6 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesPayloadAdapterTest.kt @@ -0,0 +1,66 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * OpenAI Responses/chat 兼容解析的 usage 计数门测试(评审 P1-5/P2-1): + * - 按字段存在判断:显式全零 payload 也是“已观察到的 usage”(返回非 null, + * 字段为 0),不按 “>0” 过滤; + * - 全程 Long 解析,只在旧 UI 计数边界饱和 Int(不回绕为负); + * - usage 对象完全缺失/无任何相关字段 → null(未观察到)。 + */ +class OpenAIResponsesPayloadAdapterTest { + + @Test + fun `explicit zero payload is observed usage with zero fields`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject("""{"prompt_tokens": 0, "completion_tokens": 0}""") + )!! + assertEquals(0, counts.totalInputTokens) + assertEquals(0, counts.outputTokens) + assertEquals(0, counts.cachedInputTokens) + assertEquals(0, counts.actualInputTokens) + } + + @Test + fun `zero cached split with non-zero totals is parsed`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject( + """{"prompt_tokens": 100, "completion_tokens": 50, "prompt_tokens_details": {"cached_tokens": 0}}""" + ) + )!! + assertEquals(100, counts.totalInputTokens) + assertEquals(100, counts.actualInputTokens) + assertEquals(0, counts.cachedInputTokens) + assertEquals(50, counts.outputTokens) + } + + @Test + fun `values beyond int range saturate at the ui boundary instead of wrapping`() { + val counts = + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject( + """{"prompt_tokens": 5000000000, "completion_tokens": 4000000000}""" + ) + )!! + // 旧 UI 计数边界(P2-1):饱和为 Int.MAX,绝不回绕为负 + assertEquals(Int.MAX_VALUE, counts.totalInputTokens) + assertEquals(Int.MAX_VALUE, counts.outputTokens) + } + + @Test + fun `usage absent or without any token fields returns null`() { + assertNull(OpenAIResponsesPayloadAdapter.parseUsageCounts(null)) + assertNull(OpenAIResponsesPayloadAdapter.parseUsageCounts(JSONObject("{}"))) + assertNull( + OpenAIResponsesPayloadAdapter.parseUsageCounts( + JSONObject("""{"other": "x"}""") + ) + ) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt new file mode 100644 index 000000000..b93d7af6e --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/llmprovider/ProviderUsageCancellationTest.kt @@ -0,0 +1,163 @@ +package com.ai.assistance.operit.api.chat.llmprovider + +import android.content.Context +import com.ai.assistance.operit.core.chat.hooks.PromptTurn +import com.ai.assistance.operit.core.chat.hooks.PromptTurnKind +import com.ai.assistance.operit.data.model.ApiProviderType +import com.ai.assistance.operit.util.AppLogger +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import org.junit.Assert.assertSame +import org.junit.Assert.fail +import org.junit.Test +import org.mockito.Mockito +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class ProviderUsageCancellationTest { + + @Test + fun `OpenAI streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = openAiProvider(OPENAI_STREAM_RESPONSE, "text/event-stream"), + stream = true, + ) + } + + @Test + fun `OpenAI non streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = openAiProvider(OPENAI_RESPONSE, "application/json"), + stream = false, + ) + } + + @Test + fun `Gemini streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = geminiProvider(GEMINI_STREAM_RESPONSE, "text/event-stream"), + stream = true, + ) + } + + @Test + fun `Gemini non streaming usage callback cancellation propagates unchanged`() { + assertUsageCancellationPropagates( + provider = geminiProvider(GEMINI_RESPONSE, "application/json"), + stream = false, + ) + } + + private fun assertUsageCancellationPropagates(provider: AIService, stream: Boolean) { + val expected = CancellationException("usage observer cancelled") + val context = mock() + whenever(context.applicationContext).thenReturn(context) + whenever(context.getString(any())).thenReturn("status") + + Mockito.mockStatic(AppLogger::class.java).use { + runBlocking { + val response = + provider.sendMessage( + context = context, + chatHistory = listOf(PromptTurn(PromptTurnKind.USER, "Hi")), + modelParameters = emptyList(), + enableThinking = false, + stream = stream, + availableTools = null, + preserveThinkInHistory = false, + onTokensUpdated = { _, _, _ -> }, + onUsageReported = { _, _ -> throw expected }, + onNonFatalError = {}, + enableRetry = false, + statsCategory = null, + ) + try { + response.collect { } + fail("usage callback cancellation must propagate") + } catch (actual: CancellationException) { + assertSame(expected, actual) + } + } + } + } + + private fun openAiProvider(body: String, contentType: String): OpenAIProvider = + OpenAIProvider( + apiEndpoint = "https://example.test/v1/chat/completions", + apiKeyProvider = SingleApiKeyProvider("test-key"), + modelName = "gpt-test", + client = respondingClient(body, contentType), + providerType = ApiProviderType.OPENAI, + ) + + private fun geminiProvider(body: String, contentType: String): GeminiProvider = + GeminiProvider( + apiEndpoint = "https://example.test", + apiKeyProvider = SingleApiKeyProvider("test-key"), + modelName = "gemini-test", + client = respondingClient(body, contentType), + ) + + private fun respondingClient(body: String, contentType: String): OkHttpClient { + val mediaType = contentType.toMediaType() + return OkHttpClient.Builder() + .addInterceptor( + Interceptor { chain -> + Response.Builder() + .request(chain.request()) + .protocol(Protocol.HTTP_1_1) + .code(200) + .message("OK") + .header("Content-Type", contentType) + .body(body.toResponseBody(mediaType)) + .build() + }, + ) + .build() + } + + companion object { + private val OPENAI_RESPONSE = + """ + { + "choices": [{"message": {"content": "answer"}, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 3, "completion_tokens": 2, "total_tokens": 5} + } + """.trimIndent() + + private val OPENAI_STREAM_RESPONSE = + """ + data: {"choices":[{"delta":{"content":"answer"},"finish_reason":null}]} + + data: {"choices":[],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}} + + data: [DONE] + + """.trimIndent() + + private val GEMINI_RESPONSE = + """ + { + "usageMetadata": { + "promptTokenCount": 3, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 2 + }, + "candidates": [{ + "finishReason": "STOP", + "content": {"parts": [{"text": "answer"}]} + }] + } + """.trimIndent() + + private val GEMINI_STREAM_RESPONSE = + "data: ${GEMINI_RESPONSE.replace("\n", "")}" + "\n\n" + "data: [DONE]\n\n" + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt new file mode 100644 index 000000000..6edd66519 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/JvmSupportSQLiteDatabase.kt @@ -0,0 +1,371 @@ +package com.ai.assistance.operit.data.stats + +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri +import android.os.Bundle +import android.os.CancellationSignal +import android.util.Pair +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteQuery +import androidx.sqlite.db.SupportSQLiteStatement +import java.sql.Connection +import java.sql.DriverManager +import java.sql.ResultSet +import java.sql.ResultSetMetaData +import java.util.Locale + +/** + * 纯 JVM 的最小 [SupportSQLiteDatabase] 测试替身(基于 sqlite-jdbc), + * 用于直接驱动生产 `Migration.migrate(SupportSQLiteDatabase)` 变体。 + * + * 只实现迁移路径实际用到的方法(execSQL / query / close / isOpen), + * 其余方法抛 [UnsupportedOperationException],避免无意义的全量模拟。 + * + * 残余风险:生产环境该变体由 Room 的兼容模式(RoomOpenHelper + + * SupportSQLiteConnection)驱动,包含事务包装与 schema 校验;本替身只覆盖 + * 迁移对象本身与共享 SQL 的真实执行,不覆盖 Room 兼容模式编排(需 Android 框架)。 + */ +class JvmSupportSQLiteDatabase(private val connection: Connection) : SupportSQLiteDatabase { + + override fun execSQL(sql: String) { + connection.createStatement().use { it.execute(sql) } + } + + override fun execSQL(sql: String, bindArgs: Array) { + connection.prepareStatement(sql).use { statement -> + bindArgs.forEachIndexed { index, arg -> + when (arg) { + null -> statement.setNull(index + 1, java.sql.Types.NULL) + is Long -> statement.setLong(index + 1, arg) + is Int -> statement.setLong(index + 1, arg.toLong()) + is Double -> statement.setDouble(index + 1, arg) + is Float -> statement.setDouble(index + 1, arg.toDouble()) + is Boolean -> statement.setInt(index + 1, if (arg) 1 else 0) + is ByteArray -> statement.setBytes(index + 1, arg) + else -> statement.setString(index + 1, arg.toString()) + } + } + statement.execute() + } + } + + override fun query(query: String): Cursor { + val resultSet = connection.createStatement().executeQuery(query) + return JvmCursor(resultSet) + } + + override fun query(query: String, bindArgs: Array): Cursor { + val statement = connection.prepareStatement(query) + bindArgs.forEachIndexed { index, arg -> + when (arg) { + null -> statement.setNull(index + 1, java.sql.Types.NULL) + is Long -> statement.setLong(index + 1, arg) + is Int -> statement.setLong(index + 1, arg.toLong()) + is Double -> statement.setDouble(index + 1, arg) + is Float -> statement.setDouble(index + 1, arg.toDouble()) + is Boolean -> statement.setInt(index + 1, if (arg) 1 else 0) + is ByteArray -> statement.setBytes(index + 1, arg) + else -> statement.setString(index + 1, arg.toString()) + } + } + return JvmCursor(statement.executeQuery(), statement) + } + + override fun query(query: SupportSQLiteQuery): Cursor { + val statement = connection.prepareStatement(query.sql) + query.bindTo(object : androidx.sqlite.db.SupportSQLiteProgram { + override fun bindNull(index: Int) = statement.setNull(index, java.sql.Types.NULL) + override fun bindLong(index: Int, value: Long) = statement.setLong(index, value) + override fun bindDouble(index: Int, value: Double) = statement.setDouble(index, value) + override fun bindString(index: Int, value: String) = statement.setString(index, value) + override fun bindBlob(index: Int, value: ByteArray) = statement.setBytes(index, value) + override fun clearBindings() = statement.clearParameters() + override fun close() = statement.close() + }) + return JvmCursor(statement.executeQuery(), statement) + } + + override fun query(query: SupportSQLiteQuery, cancellationSignal: CancellationSignal?): Cursor = + query(query) + + override fun close() { + connection.close() + } + + override val isOpen: Boolean + get() = !connection.isClosed + + override val path: String? + get() = "jvm-sqlite" + + override val isReadOnly: Boolean + get() = false + + override var version: Int + get() = unsupported("version") + set(value) = unsupported("version") + + override val maximumSize: Long + get() = unsupported("maximumSize") + + override fun setMaximumSize(numBytes: Long): Long = unsupported("setMaximumSize") + + override var pageSize: Long + get() = unsupported("pageSize") + set(value) = unsupported("pageSize") + + override val isDbLockedByCurrentThread: Boolean + get() = unsupported("isDbLockedByCurrentThread") + + override val isWriteAheadLoggingEnabled: Boolean + get() = unsupported("isWriteAheadLoggingEnabled") + + override val attachedDbs: List>? + get() = unsupported("attachedDbs") + + override val isDatabaseIntegrityOk: Boolean + get() = unsupported("isDatabaseIntegrityOk") + + override fun compileStatement(sql: String): SupportSQLiteStatement = + unsupported("compileStatement") + + override fun beginTransaction() = unsupported("beginTransaction") + + override fun beginTransactionNonExclusive() = unsupported("beginTransactionNonExclusive") + + override fun beginTransactionWithListener(listener: android.database.sqlite.SQLiteTransactionListener) = + unsupported("beginTransactionWithListener") + + override fun beginTransactionWithListenerNonExclusive( + listener: android.database.sqlite.SQLiteTransactionListener, + ) = unsupported("beginTransactionWithListenerNonExclusive") + + override fun endTransaction() = unsupported("endTransaction") + + override fun setTransactionSuccessful() = unsupported("setTransactionSuccessful") + + override fun inTransaction(): Boolean = unsupported("inTransaction") + + override fun yieldIfContendedSafely(): Boolean = unsupported("yieldIfContendedSafely") + + override fun yieldIfContendedSafely(sleepAfterYieldDelayMillis: Long): Boolean = + unsupported("yieldIfContendedSafely") + + override fun insert(table: String, conflictAlgorithm: Int, values: ContentValues): Long = + unsupported("insert") + + override fun delete(table: String, whereClause: String?, whereArgs: Array?): Int = + unsupported("delete") + + override fun update( + table: String, + conflictAlgorithm: Int, + values: ContentValues, + whereClause: String?, + whereArgs: Array?, + ): Int = unsupported("update") + + override fun needUpgrade(newVersion: Int): Boolean = unsupported("needUpgrade") + + override fun setLocale(locale: Locale) = unsupported("setLocale") + + override fun setMaxSqlCacheSize(cacheSize: Int) = unsupported("setMaxSqlCacheSize") + + override fun setForeignKeyConstraintsEnabled(enable: Boolean) = + unsupported("setForeignKeyConstraintsEnabled") + + override fun enableWriteAheadLogging(): Boolean = unsupported("enableWriteAheadLogging") + + override fun disableWriteAheadLogging() = unsupported("disableWriteAheadLogging") + + private fun unsupported(method: String): Nothing = + throw UnsupportedOperationException( + "JvmSupportSQLiteDatabase does not support $method (test double)" + ) + + companion object { + fun open(dbPath: String): JvmSupportSQLiteDatabase = + JvmSupportSQLiteDatabase(DriverManager.getConnection("jdbc:sqlite:$dbPath")) + } +} + +/** 最小 android.database.Cursor 实现:迁移路径只用到读取行与列。 */ +private class JvmCursor( + private val resultSet: ResultSet, + private val closeable: AutoCloseable? = null, +) : Cursor { + + private val rows: List> = materialize(resultSet) + private val columnNames: Array = columnNames(resultSet.metaData) + private val columnIndexByName: Map = + columnNames.withIndex().associate { (index, name) -> name.lowercase() to index } + private var position = -1 + private var closed = false + + override fun getCount(): Int = rows.size + + override fun getPosition(): Int = position + + override fun move(position: Int): Boolean = moveToPosition(this.position + position) + + override fun moveToPosition(position: Int): Boolean { + if (position < -1 || position >= rows.size) { + this.position = -1 + return false + } + this.position = position + return true + } + + override fun moveToFirst(): Boolean = moveToPosition(0) + + override fun moveToLast(): Boolean = moveToPosition(rows.size - 1) + + override fun moveToNext(): Boolean = moveToPosition(position + 1) + + override fun moveToPrevious(): Boolean = moveToPosition(position - 1) + + override fun isFirst(): Boolean = position == 0 && rows.isNotEmpty() + + override fun isLast(): Boolean = position == rows.size - 1 && position >= 0 + + override fun isBeforeFirst(): Boolean = position < 0 && rows.isNotEmpty() + + override fun isAfterLast(): Boolean = position >= rows.size + + override fun getColumnCount(): Int = columnNames.size + + override fun getColumnIndex(columnName: String): Int = + columnIndexByName[columnName.lowercase()] ?: -1 + + override fun getColumnIndexOrThrow(columnName: String): Int { + val index = getColumnIndex(columnName) + if (index < 0) throw IllegalArgumentException("column '$columnName' does not exist") + return index + } + + override fun getColumnName(columnIndex: Int): String = columnNames[columnIndex] + + override fun getColumnNames(): Array = columnNames.copyOf() + + override fun getString(columnIndex: Int): String { + val value = row()[columnIndex] + return when (value) { + null -> "" + is ByteArray -> String(value) + else -> value.toString() + } + } + + override fun getLong(columnIndex: Int): Long { + val value = row()[columnIndex] + return when (value) { + null -> 0L + is Number -> value.toLong() + else -> value.toString().toLong() + } + } + + override fun getInt(columnIndex: Int): Int = getLong(columnIndex).toInt() + + override fun getShort(columnIndex: Int): Short = getLong(columnIndex).toShort() + + override fun getFloat(columnIndex: Int): Float { + val value = row()[columnIndex] + return when (value) { + null -> 0f + is Number -> value.toFloat() + else -> value.toString().toFloat() + } + } + + override fun getDouble(columnIndex: Int): Double { + val value = row()[columnIndex] + return when (value) { + null -> 0.0 + is Number -> value.toDouble() + else -> value.toString().toDouble() + } + } + + override fun getBlob(columnIndex: Int): ByteArray = (row()[columnIndex] as? ByteArray) ?: ByteArray(0) + + override fun isNull(columnIndex: Int): Boolean = row()[columnIndex] == null + + override fun getType(columnIndex: Int): Int { + val value = row()[columnIndex] + return when (value) { + null -> android.database.Cursor.FIELD_TYPE_NULL + is ByteArray -> android.database.Cursor.FIELD_TYPE_BLOB + is String -> android.database.Cursor.FIELD_TYPE_STRING + is Number -> android.database.Cursor.FIELD_TYPE_INTEGER + else -> android.database.Cursor.FIELD_TYPE_STRING + } + } + + override fun close() { + if (!closed) { + closed = true + closeable?.close() + resultSet.close() + } + } + + override fun isClosed(): Boolean = closed + + override fun deactivate() = Unit + + override fun requery(): Boolean = false + + override fun copyStringToBuffer(columnIndex: Int, buffer: android.database.CharArrayBuffer) = + unsupported("copyStringToBuffer") + + override fun getWantsAllOnMoveCalls(): Boolean = false + + override fun getExtras(): Bundle? = null + + override fun setExtras(extras: Bundle?) = Unit + + override fun respond(extras: Bundle?): Bundle? = null + + override fun getNotificationUri(): Uri? = null + + override fun setNotificationUri(cr: android.content.ContentResolver, notifyUri: Uri?) = Unit + + override fun registerContentObserver(observer: android.database.ContentObserver) = Unit + + override fun unregisterContentObserver(observer: android.database.ContentObserver) = Unit + + override fun registerDataSetObserver(observer: android.database.DataSetObserver) = Unit + + override fun unregisterDataSetObserver(observer: android.database.DataSetObserver) = Unit + + private fun row(): Array { + if (position < 0 || position >= rows.size) { + throw IllegalStateException("cursor position $position is out of range") + } + return rows[position] + } + + private fun unsupported(method: String): Nothing = + throw UnsupportedOperationException("JvmCursor does not support $method (test double)") + + private companion object { + fun materialize(resultSet: ResultSet): List> { + val rows = mutableListOf>() + val columnCount = resultSet.metaData.columnCount + while (resultSet.next()) { + val row = arrayOfNulls(columnCount) + for (i in 1..columnCount) { + row[i - 1] = resultSet.getObject(i) + } + rows += row + } + return rows + } + + fun columnNames(metaData: ResultSetMetaData): Array = + Array(metaData.columnCount) { index -> metaData.getColumnName(index + 1) } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt new file mode 100644 index 000000000..39801ed34 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ProviderUsageNormalizerTest.kt @@ -0,0 +1,514 @@ +package com.ai.assistance.operit.data.stats + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * provider 原始 usage → 阶段 1 契约归一化测试。 + * + * 语义要点: + * - 未知(缺失字段)→ null;确认 0 → 0(例如无缓存读取、无缓存写入); + * - OpenAI 系 completion_tokens 包含推理 → reasoningIncludedInOutput = true; + * - Anthropic input_tokens 不含缓存分量(文档:总量 = input + cache_read + cache_creation), + * 缓存写入独立保留、独立计费; + * - Gemini candidatesTokenCount 不含 thoughtsTokenCount(官方 API 独立字段, + * 思考 token 按输出计费)→ 推理未包含在输出。 + */ +class ProviderUsageNormalizerTest { + + // ==== OpenAI chat/completions ==== + + @Test + fun `openai chat completions splits cached input and keeps cache write and reasoning`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 1000, + "completion_tokens": 500, + "prompt_tokens_details": {"cached_tokens": 200}, + "output_tokens_details": {"reasoning_tokens": 50} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(800L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull("cache write not provided -> unknown", snapshot.cacheWriteTokens) + assertFalse("OpenAI 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + assertEquals(500L, snapshot.outputTokens) + assertEquals(50L, snapshot.reasoningTokens) + assertEquals(true, snapshot.reasoningIncludedInOutput) + assertEquals(ProviderUsageNormalizer.SOURCE_OPENAI_CHAT_COMPLETIONS, snapshot.source) + } + + @Test + fun `openai chat completions supports cache creation and zero-cached semantics`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 900, + "completion_tokens": 100, + "prompt_tokens_details": {"cached_tokens": 0, "cache_creation_input_tokens": 300} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(900L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(300L, snapshot.cacheWriteTokens) + assertEquals(100L, snapshot.outputTokens) + } + + @Test + fun `openai chat completions returns null when no usage present`() { + assertNull(ProviderUsageNormalizer.openAiChatCompletions(null)) + assertNull(ProviderUsageNormalizer.openAiChatCompletions(JSONObject("{}"))) + } + + @Test + fun `openai without cached details keeps input split unknown not claiming uncached total`() { + // 常规 OpenAI 响应常缺 prompt_tokens_details:cached 拆分未知时, + // 不得把总输入确定为 uncached(分类确定性) + val usage = + JSONObject( + """ + { + "prompt_tokens": 1000, + "completion_tokens": 500 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.cachedInputTokens) + // 拆分未知时仍保留 provider 明确上报的总输入(费用仅在单价相同时可算) + assertEquals(1000L, snapshot.totalInputTokens) + assertEquals(500L, snapshot.outputTokens) + assertNull(snapshot.cacheWriteTokens) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + @Test + fun `openai explicit zero cached split keeps uncached equal to total`() { + val usage = + JSONObject( + """ + { + "prompt_tokens": 100, + "completion_tokens": 40, + "prompt_tokens_details": {"cached_tokens": 0} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.uncachedInputTokens) + } + + @Test + fun `openai chat completions handles input_tokens aliases`() { + val usage = + JSONObject( + """ + { + "input_tokens": 100, + "output_tokens": 40, + "input_tokens_details": {"cached_tokens": 30} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiChatCompletions(usage)!! + assertEquals(70L, snapshot.uncachedInputTokens) + assertEquals(30L, snapshot.cachedInputTokens) + assertEquals(40L, snapshot.outputTokens) + } + + // ==== OpenAI Responses API ==== + + @Test + fun `openai responses keeps reasoning tokens separately and marks included`() { + val usage = + JSONObject( + """ + { + "input_tokens": 1000, + "output_tokens": 500, + "input_tokens_details": {"cached_tokens": 200}, + "output_tokens_details": {"reasoning_tokens": 120} + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.openAiResponses(usage)!! + assertEquals(800L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertEquals(500L, snapshot.outputTokens) + assertEquals(120L, snapshot.reasoningTokens) + assertEquals(true, snapshot.reasoningIncludedInOutput) + assertFalse("OpenAI Responses 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + assertEquals(ProviderUsageNormalizer.SOURCE_OPENAI_RESPONSES, snapshot.source) + } + + // ==== Anthropic ==== + + @Test + fun `anthropic keeps cache read and cache write as independent components`() { + val usage = + JSONObject( + """ + { + "input_tokens": 500, + "cache_read_input_tokens": 200, + "cache_creation_input_tokens": 100, + "output_tokens": 300 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + // 文档语义:input_tokens 不含缓存分量,三个分量各自独立 + assertEquals(500L, snapshot.uncachedInputTokens) + assertEquals(200L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.cacheWriteTokens) + assertEquals(800L, snapshot.totalInputTokens) + assertEquals(300L, snapshot.outputTokens) + assertNull("Anthropic 不提供独立推理 token", snapshot.reasoningTokens) + assertEquals("Anthropic output_tokens 包含 thinking", true, snapshot.reasoningIncludedInOutput) + assertTrue("Anthropic 缓存创建独立计费", snapshot.cacheWriteSeparateBilling) + } + + @Test + fun `anthropic zero cache components are explicit zeros not unknown`() { + val usage = + JSONObject( + """ + { + "input_tokens": 50, + "cache_read_input_tokens": 0, + "cache_creation_input_tokens": 0, + "output_tokens": 10 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.cacheWriteTokens) + } + + @Test + fun `anthropic absent cache fields stay unknown`() { + val usage = JSONObject("""{"input_tokens": 50, "output_tokens": 10}""") + val snapshot = ProviderUsageNormalizer.anthropic(usage)!! + assertNull(snapshot.cachedInputTokens) + assertNull(snapshot.cacheWriteTokens) + // 无任何缓存分量:总输入即 input_tokens + assertEquals(50L, snapshot.totalInputTokens) + } + + // ==== Gemini ==== + + @Test + fun `gemini normalizes usage metadata with cached content and thoughts`() { + val metadata = + JSONObject( + """ + { + "promptTokenCount": 1000, + "cachedContentTokenCount": 300, + "candidatesTokenCount": 400, + "thoughtsTokenCount": 90 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(700L, snapshot.uncachedInputTokens) + assertEquals(300L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull("Gemini 无缓存写入概念", snapshot.cacheWriteTokens) + assertEquals(400L, snapshot.outputTokens) + assertEquals(90L, snapshot.reasoningTokens) + assertEquals("candidatesTokenCount 不含 thought(独立计费)", false, snapshot.reasoningIncludedInOutput) + } + + @Test + fun `gemini thoughts are billed on top of candidates by cost layer`() { + // P1-4:thoughtsTokenCount 独立于 candidatesTokenCount,计费输出 = candidates + thoughts。 + // prompt=100, candidates=20, thoughts=80 → billed output = 100。 + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 20, + "thoughtsTokenCount": 80, + "totalTokenCount": 200 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(100L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(20L, snapshot.outputTokens) + assertEquals(80L, snapshot.reasoningTokens) + assertEquals(false, snapshot.reasoningIncludedInOutput) + } + + @Test + fun `gemini usage metadata without candidates fields stays fully billable`() { + // P1-4:prompt 被拦截时不返回 candidates,但 usageMetadata 仍然存在;provider + // 层必须把该 usage 上报,归一化后应得到完整可计费快照(输入照常计费,输出为真实 0)。 + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "cachedContentTokenCount": 0, + "candidatesTokenCount": 0, + "thoughtsTokenCount": 0 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertEquals(100L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertEquals(0L, snapshot.reasoningTokens) + assertEquals(false, snapshot.reasoningIncludedInOutput) + } + + @Test + fun `gemini without thoughts field keeps reasoning unknown and cached split unknown`() { + val metadata = + JSONObject( + """ + { + "promptTokenCount": 100, + "candidatesTokenCount": 20 + } + """.trimIndent() + ) + val snapshot = ProviderUsageNormalizer.gemini(metadata)!! + assertNull(snapshot.reasoningTokens) + // cachedContentTokenCount 缺失:cached 拆分未知,不得把总输入确定为 uncached + assertNull(snapshot.cachedInputTokens) + assertNull(snapshot.uncachedInputTokens) + // 拆分未知时仍保留 provider 明确上报的总输入 + assertEquals(100L, snapshot.totalInputTokens) + assertEquals(20L, snapshot.outputTokens) + assertFalse("Gemini 无独立缓存写入计费概念", snapshot.cacheWriteSeparateBilling) + } + + // ==== 本地模型 ==== + + @Test + fun `local providers preserve long measured counts with explicit zero cache`() { + val inputTokens = Int.MAX_VALUE.toLong() + 1L + val outputTokens = Int.MAX_VALUE.toLong() + 2L + val snapshot = ProviderUsageNormalizer.local(inputTokens, outputTokens, ProviderUsageNormalizer.SOURCE_LLAMA) + assertEquals(inputTokens, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.cacheWriteTokens) + assertEquals(inputTokens, snapshot.totalInputTokens) + assertEquals(outputTokens, snapshot.outputTokens) + assertNull(snapshot.reasoningTokens) + assertNull(snapshot.reasoningIncludedInOutput) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + // ==== ToolPkg ==== + + @Test + fun `toolpkg derives uncached from total minus cached`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = 1000, + cachedInput = 250, + output = 300, + completeSnapshot = true, + ) + assertEquals(750L, snapshot.uncachedInputTokens) + assertEquals(250L, snapshot.cachedInputTokens) + assertEquals(1000L, snapshot.totalInputTokens) + assertNull(snapshot.cacheWriteTokens) + assertEquals(300L, snapshot.outputTokens) + assertFalse(snapshot.cacheWriteSeparateBilling) + } + + @Test + fun `toolpkg cached greater than input keeps total but rejects split`() { + val snapshot = ProviderUsageNormalizer.toolPkg(100L, 250L, 20L, true) + assertEquals(100L, snapshot.totalInputTokens) + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.cachedInputTokens) + } + + @Test + fun `toolpkg negative input components become unknown independently`() { + val negativeInput = ProviderUsageNormalizer.toolPkg(-1L, 0L, 20L, true) + assertNull(negativeInput.totalInputTokens) + assertNull(negativeInput.uncachedInputTokens) + assertNull(negativeInput.cachedInputTokens) + + val negativeCached = ProviderUsageNormalizer.toolPkg(100L, -1L, 20L, true) + assertEquals(100L, negativeCached.totalInputTokens) + assertNull(negativeCached.uncachedInputTokens) + assertNull(negativeCached.cachedInputTokens) + } + + @Test + fun `toolpkg equal cached and input is a valid zero uncached boundary`() { + val snapshot = ProviderUsageNormalizer.toolPkg(100L, 100L, 20L, true) + assertEquals(0L, snapshot.uncachedInputTokens) + assertEquals(100L, snapshot.cachedInputTokens) + assertEquals(100L, snapshot.totalInputTokens) + } + + // ==== 快照语义 ==== + + @Test + fun `negative provider values are rejected as unknown not recorded`() { + // 评审 P2-5:负值/异常数据必须拒绝为未知,绝不静默落负数 + val negative = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject( + """{"prompt_tokens": -100, "completion_tokens": 500}""" + ) + ) + // 负输入被拒 → uncached/total 未知;output 仍有效 + assertNull(negative!!.uncachedInputTokens) + assertNull(negative.totalInputTokens) + assertEquals(500L, negative.outputTokens) + + val negativeOutput = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 100, "completion_tokens": -50}""") + ) + assertEquals(100L, negativeOutput!!.totalInputTokens) + assertNull("negative output must be unknown", negativeOutput.outputTokens) + } + + @Test + fun `values beyond int range are carried as long without overflow`() { + // 评审 P2-5:JSON 值超过 Int 范围时必须原样以 Long 承载 + val huge = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject( + """{"prompt_tokens": 3000000000, "completion_tokens": 2500000000}""" + ) + ) + // 拆分未知(无 prompt_tokens_details)时 uncached 必须保持未知(设计语义); + // 总量与输出仍以 Long 原样承载,绝不 Int 溢出 + assertNull(huge!!.uncachedInputTokens) + assertEquals(3000000000L, huge.totalInputTokens) + assertEquals(2500000000L, huge.outputTokens) + } + + @Test + fun `snapshot hasKnownFields keeps explicit zero components and drops fully unknown`() { + // 完全无已知字段 → 无有效快照(normalizer 返回 null) + assertFalse(ProviderUsageSnapshot(source = "t").hasKnownFields()) + + // provider 明确全零也是有效快照:0 不得变成未知 + val zeroOnly = + ProviderUsageSnapshot( +uncachedInputTokens = 0L, +cachedInputTokens = 0L, +outputTokens = 0L, + source = "t", + ) + assertTrue(zeroOnly.hasKnownFields()) + + val withValue = + ProviderUsageSnapshot( +uncachedInputTokens = 0L, +cachedInputTokens = 0L, +cacheWriteTokens = 5L, +outputTokens = 0L, + source = "t", + ) + assertTrue(withValue.hasKnownFields()) + } + + // ==== 评审 P1-5:显式全零 payload 按字段存在判断,0L 是真实 0 而非未知 ==== + + @Test + fun `openai chat completions explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.openAiChatCompletions( + JSONObject("""{"prompt_tokens": 0, "completion_tokens": 0}""") + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertNull("cached split absent stays unknown", snapshot.cachedInputTokens) + } + + @Test + fun `openai responses explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.openAiResponses( + JSONObject("""{"input_tokens": 0, "output_tokens": 0}""") + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `anthropic explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.anthropic( + JSONObject("""{"input_tokens": 0, "output_tokens": 0}"""), + completeSnapshot = true, + )!! + assertEquals(0L, snapshot.uncachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `gemini explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.gemini( + JSONObject( + """{"promptTokenCount": 0, "cachedContentTokenCount": 0, "candidatesTokenCount": 0}""" + ) + )!! + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + } + + @Test + fun `toolpkg explicit zero payload is observed usage`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = 0L, + cachedInput = 0L, + output = 0L, + completeSnapshot = true, + ) + assertEquals(0L, snapshot.totalInputTokens) + assertEquals(0L, snapshot.cachedInputTokens) + assertEquals(0L, snapshot.outputTokens) + assertEquals(0L, snapshot.uncachedInputTokens) + } + + @Test + fun `toolpkg missing fields stay unknown and never inherit counters`() { + val snapshot = + ProviderUsageNormalizer.toolPkg( + input = null, + cachedInput = null, + output = 10L, + completeSnapshot = false, + ) + assertNull(snapshot.uncachedInputTokens) + assertNull(snapshot.totalInputTokens) + assertEquals(10L, snapshot.outputTokens) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt new file mode 100644 index 000000000..5421b0f8a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/RecordingSQLiteDriver.kt @@ -0,0 +1,127 @@ +package com.ai.assistance.operit.data.stats + +import androidx.sqlite.SQLiteConnection +import androidx.sqlite.SQLiteDriver +import androidx.sqlite.SQLiteStatement + +/** + * 记录型 SQLite 驱动(仅测试):包装 [JdbcSQLiteDriver],记录每条真实执行的 SQL、 + * 绑定参数与返回行数([RecordedSql]),用于断言: + * - 固定查询次数(防 N+1)、绝不调用全表读取(getAllEvents); + * - IN 分块大小(`?` 占位符个数与绑定值); + * - 生命周期分页的 LIMIT 绑定与每页行数(P2-1 页界)。 + * + * 时机与复用说明:Room 2.8 连接池会**缓存已 prepare 的语句**(同一事务内相同 + * SQL 复用同一 statement,重新绑定并重新 step),且 close 语句发生在 DAO 调用 + * 返回之后。因此本记录器按**执行周期**记录:绑定开始新周期,终止 step(返回 + * false,同步于查询执行)完成当前周期并落记录——行数同时确定,测试在 DAO 调用 + * 返回后读取 [executed] 即完整、确定,不依赖异步 close。 + */ +class RecordingSQLiteDriver : SQLiteDriver { + + private val delegate = JdbcSQLiteDriver() + + /** 已执行完成的语句周期记录(按执行顺序)。 */ + val executed = mutableListOf() + + fun clear() = executed.clear() + + override fun open(fileName: String): SQLiteConnection = + RecordingConnection(delegate.open(fileName), executed) +} + +/** 单次语句执行周期:SQL 文本、绑定参数(index -> 值)、返回行数。 */ +class RecordedSql( + val sql: String, + val binds: Map, + val rows: Int, +) { + /** SQL 中 `?` 占位符个数(Room 动态生成的 IN 列表直接反映参数个数)。 */ + val questionMarkCount: Int + get() = sql.count { it == '?' } + + /** 绑定值(按 index 升序)的文本表示,如 `1=1000;2=...`。 */ + fun bindText(): String = binds.toSortedMap().entries.joinToString(";") { (index, value) -> "$index=$value" } + + override fun toString(): String = "$sql | ${bindText()} | rows=$rows" +} + +private class RecordingConnection( + private val delegate: SQLiteConnection, + private val sink: MutableList, +) : SQLiteConnection by delegate { + override fun prepare(sql: String): SQLiteStatement = + RecordingStatement(delegate.prepare(sql), sql, sink) +} + +private class RecordingStatement( + private val delegate: SQLiteStatement, + private val sql: String, + private val sink: MutableList, +) : SQLiteStatement by delegate { + + private val binds = ArrayList>() + private var rows = 0 + private var cycleComplete = true + + private fun startCycleIfNeeded() { + if (cycleComplete) { + cycleComplete = false + binds.clear() + rows = 0 + } + } + + override fun bindBlob(index: Int, value: ByteArray) { + startCycleIfNeeded() + binds += index to "" + delegate.bindBlob(index, value) + } + + override fun bindDouble(index: Int, value: Double) { + startCycleIfNeeded() + binds += index to value.toString() + delegate.bindDouble(index, value) + } + + override fun bindLong(index: Int, value: Long) { + startCycleIfNeeded() + binds += index to value.toString() + delegate.bindLong(index, value) + } + + override fun bindText(index: Int, value: String) { + startCycleIfNeeded() + binds += index to value + delegate.bindText(index, value) + } + + override fun bindNull(index: Int) { + startCycleIfNeeded() + binds += index to "NULL" + delegate.bindNull(index) + } + + override fun step(): Boolean { + // 无绑定参数的语句(如 SELECT * FROM token_stat_identities)也要开始周期 + startCycleIfNeeded() + val advanced = delegate.step() + if (advanced) { + rows += 1 + } else if (!cycleComplete) { + // 终止 step(同步于查询执行):行数已确定,完成当前执行周期 + cycleComplete = true + sink += RecordedSql(sql, binds.toMap(), rows) + } + return advanced + } + + override fun close() { + // 连接池异步 close:若周期未完成(异常路径),补一条占位记录 + if (!cycleComplete) { + cycleComplete = true + sink += RecordedSql(sql, binds.toMap(), rows) + } + delegate.close() + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt new file mode 100644 index 000000000..e450346ec --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/ReleasedProviderModelKeyDecoderTest.kt @@ -0,0 +1,88 @@ +package com.ai.assistance.operit.data.stats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Test + +class ReleasedProviderModelKeyDecoderTest { + @Test + fun `toolpkg provider id preserves underscores when separating the model`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_example_openai_compatible_provider:deepseek-chat", + provider = "Example OpenAI Compatible Provider", + model = "deepseek-chat", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_example_openai_compatible_provider_deepseek-chat", + mapOf("TOOLPKG_example_openai_compatible_provider" to "Example OpenAI Compatible Provider"), + ), + ) + } + + @Test + fun `future toolpkg providers use the same exact identity rule`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_future_provider_with_underscores:model_with_underscores", + provider = "Future Provider", + model = "model_with_underscores", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_future_provider_with_underscores_model_with_underscores", + mapOf("TOOLPKG_future_provider_with_underscores" to "Future Provider"), + ), + ) + } + + @Test + fun `toolpkg provider id takes precedence over a shorter display name`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "TOOLPKG_future_provider:model", + provider = "Future Provider", + model = "model", + ), + ReleasedProviderModelKeyDecoder.decode( + "TOOLPKG_future_provider_model", + mapOf( + "TOOLPKG" to "ToolPkg", + "TOOLPKG_future_provider" to "Future Provider", + ), + ), + ) + } + + @Test + fun `legacy provider names are decoded when they are no longer registered`() { + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "示例供应商:deepseek-chat", + provider = "示例供应商", + model = "deepseek-chat", + ), + ReleasedProviderModelKeyDecoder.decode("示例供应商_deepseek-chat"), + ) + assertEquals( + ReleasedProviderModelKey( + storedProviderModel = "unknown:provider_model", + provider = "unknown", + model = "provider_model", + ), + ReleasedProviderModelKeyDecoder.decode("unknown_provider_model"), + ) + } + + @Test + fun `malformed released keys still fail with a precise error`() { + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("unknownprovidermodel") + } + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("_model") + } + assertThrows(IllegalArgumentException::class.java) { + ReleasedProviderModelKeyDecoder.decode("provider_") + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt new file mode 100644 index 000000000..f139a86eb --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenActivityAggregatorTest.kt @@ -0,0 +1,64 @@ +package com.ai.assistance.operit.data.stats + +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenActivityAggregatorTest { + private val zone = ZoneId.of("Asia/Shanghai") + + @Test + fun `range data contains every selected calendar day and excludes surrounding activity`() { + val range = dateRange("2026-08-02", "2026-08-04") + val snapshot = TokenActivitySnapshot( + zone = zone, + dayTotals = mapOf( + LocalDate.of(2026, 8, 1) to 40L, + LocalDate.of(2026, 8, 2) to 10L, + LocalDate.of(2026, 8, 4) to 30L, + LocalDate.of(2026, 8, 5) to 50L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals( + listOf( + LocalDate.of(2026, 8, 2), + LocalDate.of(2026, 8, 3), + LocalDate.of(2026, 8, 4), + ), + result.daily.map(TokenActivityDay::date), + ) + assertEquals(listOf(10L, 0L, 30L), result.daily.map(TokenActivityDay::tokens)) + assertEquals(40L, result.stats.totalTokens) + assertEquals(30L, result.stats.peakTokens) + } + + @Test + fun `range data calculates streaks and cumulative totals inside the selected range`() { + val range = dateRange("2026-08-01", "2026-08-05") + val snapshot = TokenActivitySnapshot( + zone = zone, + dayTotals = mapOf( + LocalDate.of(2026, 8, 1) to 10L, + LocalDate.of(2026, 8, 2) to 20L, + LocalDate.of(2026, 8, 4) to 30L, + LocalDate.of(2026, 8, 5) to 40L, + ), + ) + + val result = TokenActivityAggregator.rangeData(snapshot, range) + + assertEquals(2, result.stats.currentStreak) + assertEquals(2, result.stats.longestStreak) + assertEquals(listOf(10L, 30L, 30L, 60L, 100L), result.cumulative.map(TokenActivityDay::tokens)) + } + + private fun dateRange(start: String, inclusiveEnd: String): TokenStatsTimeRange = + TokenStatsTimeRanges.customRange( + LocalDate.parse(start).atStartOfDay(zone).toInstant().toEpochMilli(), + LocalDate.parse(inclusiveEnd).plusDays(1).atStartOfDay(zone).toInstant().toEpochMilli(), + ) +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt new file mode 100644 index 000000000..0bfb444e9 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCanonicalTotalsTest.kt @@ -0,0 +1,116 @@ +package com.ai.assistance.operit.data.stats + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * canonical 总 Token 推导(聚合器/活动/UI 共用同一纯 helper): + * - 权威 totalInputTokens 已知则用它(拆分未知也可表达输入量); + * - fallback 按 cacheWriteSeparateBilling 决定输入口径:OpenAI 非独立计费不重复 + * cacheWrite,Anthropic 独立计费不漏 cacheWrite; + * - reasoning 按 reasoningIncludedInOutput 决定是否补加(已包含/未声明不加); + * - 未知组件保持 unknown(返回 null),绝不把 null 当作 0; + * - 饱和加法,Long 溢出钳制不回绕。 + */ +class TokenCanonicalTotalsTest { + + private fun total( + totalInput: Long? = null, + uncached: Long? = null, + cached: Long? = null, + cacheWrite: Long? = null, + separate: Boolean? = null, + output: Long? = null, + reasoning: Long? = null, + reasoningIncluded: Boolean? = null, + ): Long? = + canonicalTotalTokens( + totalInputTokens = totalInput, + uncachedInputTokens = uncached, + cachedInputTokens = cached, + cacheWriteTokens = cacheWrite, + cacheWriteSeparateBilling = separate, + outputTokens = output, + reasoningTokens = reasoning, + reasoningIncludedInOutput = reasoningIncluded, + ) + + @Test + fun `authoritative total input wins even when split is unknown`() { + // OpenAI 兼容端点缺 prompt_tokens_details:拆分未知但总输入权威已知 + assertEquals(1500L, total(totalInput = 1000L, uncached = null, cached = null, output = 500L)) + // Gemini:cachedContentTokenCount 缺失同理 + assertEquals(1200L, total(totalInput = 700L, cached = null, output = 500L)) + } + + @Test + fun `non separate billing never double counts cache write`() { + // OpenAI:无 totalInput 时输入 = uncached + cached,cacheWrite 已含在输入内 + assertEquals( + 1000L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = false, output = 400L), + ) + // 有权威 totalInput 时同样不再追加 cacheWrite + assertEquals( + 1000L, + total(totalInput = 600L, uncached = 500L, cached = 100L, cacheWrite = 50L, separate = false, output = 400L), + ) + // cacheWrite 未知也不阻碍(非独立计费概念下该分量不影响总量) + assertEquals( + 1000L, + total(uncached = 500L, cached = 100L, cacheWrite = null, separate = false, output = 400L), + ) + } + + @Test + fun `separate billing counts cache write exactly once`() { + // Anthropic:无 totalInput 时输入 = uncached + cached + cacheWrite + assertEquals( + 1050L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = true, output = 400L), + ) + // 权威 totalInput(= 三分量之和)直接使用,不得再加 cacheWrite(只计一次) + assertEquals( + 1050L, + total(totalInput = 650L, uncached = 500L, cached = 100L, cacheWrite = 50L, separate = true, output = 400L), + ) + // 旧行未声明独立计费 → 保守默认 true,cacheWrite 计入(与费用重估同一边界) + assertEquals( + 1050L, + total(uncached = 500L, cached = 100L, cacheWrite = 50L, separate = null, output = 400L), + ) + } + + @Test + fun `reasoning added only when excluded from output`() { + assertEquals(1000L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = true)) + assertEquals(1050L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = false)) + // null = 未声明 → 按“已包含”处理,避免重复收费 + assertEquals(1000L, total(totalInput = 600L, output = 400L, reasoning = 50L, reasoningIncluded = null)) + } + + @Test + fun `unknown required component keeps total unknown`() { + // fallback 输入拆分缺失 → 整体 unknown(不把 null 当 0) + assertNull(total(uncached = 100L, cached = null, separate = false, output = 50L)) + // 独立计费下 cacheWrite 缺失 → unknown + assertNull(total(uncached = 100L, cached = 20L, cacheWrite = null, separate = true, output = 50L)) + // 独立推理但 reasoning 未知 → 输出 unknown → 整体 unknown + assertNull(total(totalInput = 100L, output = 50L, reasoning = null, reasoningIncluded = false)) + // 输出未知 → 整体 unknown + assertNull(total(totalInput = 100L, output = null)) + } + + @Test + fun `saturated addition never wraps negative`() { + val saturated = + total( + totalInput = Long.MAX_VALUE, + uncached = Long.MAX_VALUE, + cached = Long.MAX_VALUE, + output = Long.MAX_VALUE, + ) + assertEquals(Long.MAX_VALUE, saturated) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt new file mode 100644 index 000000000..20b347253 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenCostCalculatorTest.kt @@ -0,0 +1,193 @@ +package com.ai.assistance.operit.data.stats + +import com.ai.assistance.operit.data.collects.PricingCurrency +import com.ai.assistance.operit.data.dao.TokenUsageModelAggregateRow +import com.ai.assistance.operit.data.model.BillingMode +import org.junit.Assert.assertEquals +import org.junit.Test + +class TokenCostCalculatorTest { + @Test + fun `token cost uses current split prices`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow( + uncachedInputTokens = 800L, + cachedInputTokens = 200L, + totalInputTokens = 1_000L, + outputTokens = 500L, + ), + pricing = tokenPricing(), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, + ) + + assertEquals(0.0019, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) + assertEquals( + 0.0019, + result.originalCurrencyAmounts.getValue(PricingCurrency.USD), + 1e-12, + ) + } + + @Test + fun `equal input prices use total input when split is unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow( + uncachedInputKnown = 0L, + cachedInputKnown = 0L, + totalInputTokens = 1_000L, + outputTokens = 500L, + ), + pricing = tokenPricing(cachedInputPricePerMillion = 1.0), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, + ) + + assertEquals(0.002, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) + } + + @Test + fun `missing priced token field counts request as unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(outputKnown = 0L), + pricing = tokenPricing(), + targetCurrency = PricingCurrency.USD, + usdToCnyRate = 7.0, + ) + + assertEquals(1L, result.unknownContributionCount) + assertEquals(1L, result.totalContributionCount) + } + + @Test + fun `unknown zero pricing counts every request as unknown`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(requests = 3L), + pricing = + tokenPricing( + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + cacheWritePricePerMillion = 0.0, + outputPricePerMillion = 0.0, + source = PricingSource.UNKNOWN, + ), + targetCurrency = PricingCurrency.CNY, + usdToCnyRate = 7.0, + ) + + assertEquals(0.0, result.knownAmount, 0.0) + assertEquals(3L, result.unknownContributionCount) + } + + @Test + fun `count billing uses current per request price`() { + val result = + TokenCostCalculator.currentCost( + row = aggregateRow(requests = 4L), + pricing = + ResolvedTokenPricing( + billingMode = BillingMode.COUNT, + currency = PricingCurrency.CNY, + inputPricePerMillion = 0.0, + cachedInputPricePerMillion = 0.0, + outputPricePerMillion = 0.0, + pricePerRequest = 0.02, + source = PricingSource.USER, + ), + targetCurrency = PricingCurrency.CNY, + usdToCnyRate = 7.0, + ) + + assertEquals(0.08, result.knownAmount, 1e-12) + assertEquals(0L, result.unknownContributionCount) + } + + @Test + fun `currency conversion uses configured rate`() { + assertEquals( + 70.0, + TokenCostCurrency.convertTo( + amount = 10.0, + source = PricingCurrency.USD, + target = PricingCurrency.CNY, + usdToCnyRate = 7.0, + ), + 1e-12, + ) + assertEquals( + 10.0, + TokenCostCurrency.convertTo( + amount = 70.0, + source = PricingCurrency.CNY, + target = PricingCurrency.USD, + usdToCnyRate = 7.0, + ), + 1e-12, + ) + } + + @Test + fun `saturated add clamps overflow`() { + assertEquals(Long.MAX_VALUE, TokenCostCalculator.saturatedAdd(Long.MAX_VALUE, 1L)) + assertEquals(7L, TokenCostCalculator.saturatedAdd(3L, 4L)) + } + + private fun tokenPricing( + inputPricePerMillion: Double = 1.0, + cachedInputPricePerMillion: Double = 0.5, + outputPricePerMillion: Double = 2.0, + source: PricingSource = PricingSource.BUILT_IN, + ) = ResolvedTokenPricing( + billingMode = BillingMode.TOKEN, + currency = PricingCurrency.USD, + inputPricePerMillion = inputPricePerMillion, + cachedInputPricePerMillion = cachedInputPricePerMillion, + cacheWritePricePerMillion = inputPricePerMillion, + outputPricePerMillion = outputPricePerMillion, + pricePerRequest = 0.0, + source = source, + ) + + private fun aggregateRow( + requests: Long = 1L, + uncachedInputTokens: Long = 1_000L, + uncachedInputKnown: Long = requests, + cachedInputTokens: Long = 0L, + cachedInputKnown: Long = requests, + cacheWriteTokens: Long = 0L, + cacheWriteKnown: Long = requests, + totalInputTokens: Long = uncachedInputTokens + cachedInputTokens, + totalInputKnown: Long = requests, + outputTokens: Long = 500L, + outputKnown: Long = requests, + ) = TokenUsageModelAggregateRow( + provider = "OPENAI", + model = "gpt-test", + configId = "test-config", + requests = requests, + requestCountKnown = requests, + usageRows = requests, + uncachedInputTokens = uncachedInputTokens, + uncachedInputKnown = uncachedInputKnown, + cachedInputTokens = cachedInputTokens, + cachedInputKnown = cachedInputKnown, + cacheWriteTokens = cacheWriteTokens, + cacheWriteKnown = cacheWriteKnown, + totalInputTokens = totalInputTokens, + totalInputKnown = totalInputKnown, + outputTokens = outputTokens, + outputKnown = outputKnown, + reasoningTokens = 0L, + reasoningKnown = requests, + ttftTotalMs = 0L, + ttftSamples = 0L, + durationTotalMs = 0L, + durationSamples = 0L, + ) +} diff --git a/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt new file mode 100644 index 000000000..454c38d38 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/data/stats/TokenStatsTimeRangeTest.kt @@ -0,0 +1,155 @@ +package com.ai.assistance.operit.data.stats + +import java.time.LocalDateTime +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** 日历范围及桶边界测试,覆盖 DST 和半开区间语义。 */ +class TokenStatsTimeRangeTest { + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val newYork = ZoneId.of("America/New_York") + + private fun localMs(dateTime: String, zone: ZoneId): Long = + LocalDateTime.parse(dateTime).atZone(zone).toInstant().toEpochMilli() + + private fun local(epochMs: Long, zone: ZoneId): LocalDateTime = + LocalDateTime.ofInstant(java.time.Instant.ofEpochMilli(epochMs), zone) + + // ==== 日历范围与校验 ==== + + @Test + fun `custom range requires end after start`() { + val range = TokenStatsTimeRanges.customRange(1000L, 2000L) + assertEquals(1000L, range.startMs) + assertEquals(2000L, range.endMs) + try { + TokenStatsTimeRanges.customRange(2000L, 2000L) + throw AssertionError("expected IllegalArgumentException") + } catch (expected: IllegalArgumentException) { + // ok + } + } + + // ==== 粒度选择 ==== + + @Test + fun `granularity is chosen by range duration`() { + fun granularityOf(hours: Long) = + TokenStatsTimeRanges.granularityFor(TokenStatsTimeRanges.customRange(0L, hours * TokenStatsTimeRanges.HOUR_MS)) + assertEquals(TokenStatsGranularity.TEN_MINUTES, granularityOf(5)) + assertEquals(TokenStatsGranularity.TEN_MINUTES, granularityOf(12)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(13)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(24)) + assertEquals(TokenStatsGranularity.HOURLY, granularityOf(48)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(49)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(7 * 24)) + assertEquals(TokenStatsGranularity.DAILY, granularityOf(31 * 24)) + } + + // ==== 桶对齐与归属 ==== + + @Test + fun `ten minute buckets align to local clock boundaries`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-08-07T13:07:00", shanghai), + localMs("2026-08-07T18:07:00", shanghai), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.TEN_MINUTES, shanghai) + // 首个桶边界为本地 13:00(早于范围起点,属正常:桶是日历对齐的) + assertEquals(localMs("2026-08-07T13:00:00", shanghai), starts.first()) + assertEquals(31, starts.size) + assertTrue(starts.zipWithNext().all { (a, b) -> b - a == TokenStatsTimeRanges.TEN_MINUTES_MS }) + } + + @Test + fun `hourly buckets across spring forward skip the missing hour`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-03-08T00:00:00", newYork), + localMs("2026-03-09T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, newYork) + assertEquals(23, starts.size) + // 单调递增且没有 02:00 本地小时的桶 + assertTrue(starts.zipWithNext().all { (a, b) -> b > a }) + assertTrue(starts.none { local(it, newYork).hour == 2 }) + // 事件归属:01:30 EST -> 01:00 桶;03:30 EDT -> 03:00 桶 + val early = localMs("2026-03-08T01:30:00", newYork) + val late = localMs("2026-03-08T03:30:00", newYork) + val earlyIndex = TokenStatsTimeRanges.bucketIndexOf(early, starts, TokenStatsGranularity.HOURLY, newYork)!! + val lateIndex = TokenStatsTimeRanges.bucketIndexOf(late, starts, TokenStatsGranularity.HOURLY, newYork)!! + assertEquals(localMs("2026-03-08T01:00:00", newYork), starts[earlyIndex]) + assertEquals(localMs("2026-03-08T03:00:00", newYork), starts[lateIndex]) + // 02:00 不存在:03:00 桶紧跟在 01:00 桶之后(无空洞) + assertEquals(earlyIndex + 1, lateIndex) + } + + @Test + fun `hourly buckets across fall back produce both repeated hour buckets`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-11-01T00:00:00", newYork), + localMs("2026-11-02T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, newYork) + assertEquals(25, starts.size) + assertTrue(starts.zipWithNext().all { (a, b) -> b > a }) + // 重复的本地 01:00 出现两次:01:00 EDT 与 01:00 EST(不同 epoch) + val hourOneBuckets = starts.filter { local(it, newYork).hour == 1 } + assertEquals(2, hourOneBuckets.size) + val first = localMs("2026-11-01T01:30:00", newYork) // 第一次 01:30(EDT) + // 第二次 01:30 是 EST(epoch 多 1 小时) + val secondEpoch = first + TokenStatsTimeRanges.HOUR_MS + val firstIndex = TokenStatsTimeRanges.bucketIndexOf(first, starts, TokenStatsGranularity.HOURLY, newYork)!! + val secondIndex = TokenStatsTimeRanges.bucketIndexOf(secondEpoch, starts, TokenStatsGranularity.HOURLY, newYork)!! + assertEquals(hourOneBuckets[0], starts[firstIndex]) + assertEquals(hourOneBuckets[1], starts[secondIndex]) + } + + @Test + fun `daily buckets across dst have exact 23 and 24 hour spans`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-03-08T00:00:00", newYork), + localMs("2026-03-10T00:00:00", newYork), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.DAILY, newYork) + assertEquals(2, starts.size) + assertEquals(localMs("2026-03-08T00:00:00", newYork), starts[0]) + assertEquals(localMs("2026-03-09T00:00:00", newYork), starts[1]) + assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, + TokenStatsTimeRanges.bucketEndMs(starts, 0, TokenStatsGranularity.DAILY, newYork) - starts[0]) + assertEquals(24L * TokenStatsTimeRanges.HOUR_MS, + TokenStatsTimeRanges.bucketEndMs(starts, 1, TokenStatsGranularity.DAILY, newYork) - starts[1]) + // 23:30 EDT 属于 03-08 的桶 + val lateEvent = localMs("2026-03-08T23:30:00", newYork) + assertEquals(0, TokenStatsTimeRanges.bucketIndexOf(lateEvent, starts, TokenStatsGranularity.DAILY, newYork)) + } + + @Test + fun `bucket boundaries partition events exactly once`() { + val range = TokenStatsTimeRanges.customRange( + localMs("2026-08-07T00:00:00", shanghai), + localMs("2026-08-09T00:00:00", shanghai), + ) + val starts = TokenStatsTimeRanges.bucketStarts(range, TokenStatsGranularity.HOURLY, shanghai) + // 逐小时采样:范围内每个整点恰好属于一个桶,桶序号随事件时间单调递增 + var previousIndex = -1 + for (hour in 0 until 48) { + val ts = range.startMs + hour * TokenStatsTimeRanges.HOUR_MS + val index = TokenStatsTimeRanges.bucketIndexOf(ts, starts, TokenStatsGranularity.HOURLY, shanghai) + assertTrue("ts=$ts must belong to a bucket", index != null) + assertTrue("bucket index must be monotonic", index!! >= previousIndex) + previousIndex = index + } + // 范围终点本身不属于任何桶(半开语义) + assertNull( + TokenStatsTimeRanges.bucketIndexOf(range.endMs, starts, TokenStatsGranularity.HOURLY, shanghai) + ) + // 范围起点之前的事件不属于任何桶 + assertNull( + TokenStatsTimeRanges.bucketIndexOf(range.startMs - 1, starts, TokenStatsGranularity.HOURLY, shanghai) + ) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt new file mode 100644 index 000000000..027286c0a --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/tokenstats/TokenStatsDatePickerTest.kt @@ -0,0 +1,92 @@ +package com.ai.assistance.operit.ui.features.tokenstats + +import com.ai.assistance.operit.data.stats.TokenStatsTimeRanges +import java.time.LocalDate +import java.time.ZoneId +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 自定义范围日期选择纯逻辑测试(P1-6): + * - DatePicker 毫秒按 **UTC 日历**解析日期(西时区不再回退一天); + * - 结束日期**包含当天**(+1 天 0 点作为半开区间终点),同日合法; + * - DST 自然日跨度为 23/25 小时由 java.time 日历运算保证。 + */ +class TokenStatsDatePickerTest { + + private val shanghai = ZoneId.of("Asia/Shanghai") + private val newYork = ZoneId.of("America/New_York") + + private fun utcMidnightMs(date: String): Long = + LocalDate.parse(date).atStartOfDay(java.time.ZoneOffset.UTC).toInstant().toEpochMilli() + + @Test + fun `date picker millis parse as UTC calendar in east and west zones`() { + // 回归:西时区(New York)若用设备时区解析,UTC 8/7 0 点会被看成 + // 8/6 20:00 而回退一天;按 UTC 日历解析必须是 8/7 + assertEquals(LocalDate.of(2026, 8, 7), datePickerMillisToLocalDate(utcMidnightMs("2026-08-07"))) + // 东时区(Shanghai)同样按 UTC 日历解析 + assertEquals(LocalDate.of(2026, 8, 7), datePickerMillisToLocalDate(utcMidnightMs("2026-08-07"))) + } + + @Test + fun `inclusive end date makes same day selection a valid one day range`() { + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 8, 7), LocalDate.of(2026, 8, 7), shanghai) + assertEquals( + LocalDate.of(2026, 8, 7).atStartOfDay(shanghai).toInstant().toEpochMilli(), + range.startMs, + ) + assertEquals( + LocalDate.of(2026, 8, 8).atStartOfDay(shanghai).toInstant().toEpochMilli(), + range.endMs, + ) + assertEquals(TokenStatsTimeRanges.DAY_MS, range.durationMs) + } + + @Test + fun `cross day selection spans all selected days`() { + // 8/7 → 8/9(含结束日)= 3 个自然日:终点为 8/10 0 点 + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 8, 7), LocalDate.of(2026, 8, 9), newYork) + assertEquals(3L * TokenStatsTimeRanges.DAY_MS, range.durationMs) + assertEquals( + LocalDate.of(2026, 8, 10).atStartOfDay(newYork).toInstant().toEpochMilli(), + range.endMs, + ) + } + + @Test + fun `dst spring forward day is 23 hours`() { + // 美东 2026-03-08 春季拨快 1 小时:单日范围正好 23 小时 + val range = + customRangeInclusiveEnd(LocalDate.of(2026, 3, 8), LocalDate.of(2026, 3, 8), newYork) + assertEquals(23L * TokenStatsTimeRanges.HOUR_MS, range.durationMs) + } + + @Test + fun `end before start is rejected`() { + val failure = runCatching { + customRangeInclusiveEnd(LocalDate.of(2026, 8, 9), LocalDate.of(2026, 8, 7), shanghai) + } + assertTrue("end before start must be rejected", failure.isFailure) + } + + @Test + fun `fall back range at maximum natural days is accepted despite extra elapsed hour`() { + val maxDays = 10L + val start = LocalDate.of(2026, 10, 25).atStartOfDay(newYork).toInstant().toEpochMilli() + val end = LocalDate.of(2026, 11, 4).atStartOfDay(newYork).toInstant().toEpochMilli() + assertEquals(10L * TokenStatsTimeRanges.DAY_MS + TokenStatsTimeRanges.HOUR_MS, end - start) + assertEquals(CustomRangeValidation.VALID, validateCustomRange(start, end, newYork, maxDays)) + } + + @Test + fun `range one natural day over maximum is rejected across fall back`() { + val maxDays = 10L + val start = LocalDate.of(2026, 10, 25).atStartOfDay(newYork).toInstant().toEpochMilli() + val end = LocalDate.of(2026, 11, 5).atStartOfDay(newYork).toInstant().toEpochMilli() + assertEquals(CustomRangeValidation.TOO_LONG, validateCustomRange(start, end, newYork, maxDays)) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt new file mode 100644 index 000000000..a7eeb11cd --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/ui/main/navigation/ScreenRouteViewModelStoreOwnerManagerTest.kt @@ -0,0 +1,417 @@ +package com.ai.assistance.operit.ui.main.navigation + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStoreOwner +import androidx.lifecycle.viewModelScope +import com.ai.assistance.operit.ui.main.screens.Screen +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Before +import org.junit.Test + +/** + * 路由级 ViewModelStore 管理测试(阶段 4 P1,纯 JVM,无仪器): + * - 配置变化不 pop:同一 manager(Activity VM 保留)复用同一 owner/VM; + * - pop(路由出栈):remove 触发 onCleared 与 viewModelScope 取消; + * - 两个 screenKey 互不影响; + * - replace/clear stack:retainOnly 只保留存活键; + * - Activity 销毁:clearAll 全清; + * - 真实导航栈(AppRouterState push/pop/resetTo)驱动的 alive 键与清理; + * - AppContent 首次组合(attach)只同步一次(LaunchedEffect(Unit)):pop 后 + * 转场完成前不得清理仍渲染的离页 owner,只在转场完成分支 retainOnly 后清理。 + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ScreenRouteViewModelStoreOwnerManagerTest { + + @Before + fun setUp() { + // viewModelScope 使用 Main.immediate;Unconfined 保证取消回调同步执行 + Dispatchers.setMain(UnconfinedTestDispatcher()) + } + + @After + fun tearDown() { + Dispatchers.resetMain() + } + + /** 记录 onCleared 与 viewModelScope 取消的跟踪 VM(须可被 ViewModelProvider 反射实例化)。 */ + class TrackableViewModel : ViewModel() { + var onClearedCalled = false + var scopeCancelled = false + + init { + viewModelScope.launch { + try { + awaitCancellation() + } finally { + scopeCancelled = true + } + } + } + + override fun onCleared() { + onClearedCalled = true + } + } + + private fun trackableVM(owner: ViewModelStoreOwner): TrackableViewModel = + ViewModelProvider(owner)[TrackableViewModel::class.java] + + private fun awaitScopeCancelled(vm: TrackableViewModel) { + val deadline = System.currentTimeMillis() + 5_000 + while (!vm.scopeCancelled) { + if (System.currentTimeMillis() > deadline) { + fail("timed out waiting for viewModelScope cancellation") + } + Thread.sleep(10) + } + } + + // ==== 配置变化:不 pop 复用同 owner/VM ==== + + @Test + fun configurationChange_withoutPop_reusesSameOwnerAndViewModel() { + val manager = ScreenRouteViewModelStoreOwnerManager() + + val ownerFirst = manager.ownerFor("stats-key") + val vmFirst = trackableVM(ownerFirst) + + // 模拟配置变化:同一 manager 实例(Activity VM 保留),同 screenKey → 同 owner + val ownerSecond = manager.ownerFor("stats-key") + assertSame(ownerFirst, ownerSecond) + // 同 owner → 同 store → 同 VM 实例 + assertSame(vmFirst, trackableVM(ownerSecond)) + + assertFalse(vmFirst.onClearedCalled) + assertFalse(vmFirst.scopeCancelled) + } + + // ==== pop:remove → onCleared + viewModelScope 取消 ==== + + @Test + fun pop_removeOwner_clearsViewModelAndCancelsScope() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val owner = manager.ownerFor("stats-key") + val vm = trackableVM(owner) + + manager.remove("stats-key") + + assertTrue(vm.onClearedCalled) + awaitScopeCancelled(vm) + + // 再次进入同路由:新 owner + 新 VM(旧 store 已清理) + val newOwner = manager.ownerFor("stats-key") + assertNotSame(owner, newOwner) + assertNotSame(vm, trackableVM(newOwner)) + } + + @Test + fun remove_unknownKey_isNoOp() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vm = trackableVM(manager.ownerFor("stats-key")) + + manager.remove("no-such-key") + + assertFalse(vm.onClearedCalled) + assertFalse(vm.scopeCancelled) + } + + // ==== 两个 screenKey 互不影响 ==== + + @Test + fun twoKeys_areIndependent() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmStats = trackableVM(manager.ownerFor("stats-key")) + val vmOther = trackableVM(manager.ownerFor("other-key")) + + manager.remove("stats-key") + + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmOther.onClearedCalled) + assertFalse(vmOther.scopeCancelled) + } + + // ==== replace / clear stack:retainOnly ==== + + @Test + fun retainOnly_removesNonAliveKeys_keepsAliveKeys() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmStats = trackableVM(manager.ownerFor("stats-key")) + val vmHome = trackableVM(manager.ownerFor("home-key")) + + // 抽屉导航 resetTo:栈只剩 home + manager.retainOnly(setOf("home-key")) + + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } + + // ==== Activity 销毁:clearAll 全清 ==== + + @Test + fun clearAll_clearsEveryOwner() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val vmA = trackableVM(manager.ownerFor("key-a")) + val vmB = trackableVM(manager.ownerFor("key-b")) + + manager.clearAll() + + assertTrue(vmA.onClearedCalled) + assertTrue(vmB.onClearedCalled) + awaitScopeCancelled(vmA) + awaitScopeCancelled(vmB) + + // 全清后同键再进:新 owner(旧实例不再复用) + assertNotSame(vmA, trackableVM(manager.ownerFor("key-a"))) + } + + // ==== 真实导航栈驱动(AppRouterState push/pop/resetTo)==== + + private val resolveScreen: (RouteEntry) -> Screen? = { entry -> + when (entry.routeId) { + "home" -> Screen.AiChat + "settings" -> Screen.AiChat + "stats" -> Screen.AiChat + "other" -> Screen.AiChat + else -> null + } + } + + @Test + fun realNavigationStack_drivesRouteViewModelCleanup() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + // 模拟 AppContent 转场完成时的同步调用 + fun syncAlive() = manager.retainOnly(screenKeysAliveOnStack(router.backStack, resolveScreen)) + + val homeKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val homeVm = trackableVM(manager.ownerFor(homeKey)) + syncAlive() + assertFalse(homeVm.onClearedCalled) + + // 推入 stats(Settings → TokenUsageStatistics) + router.navigate(routeId = "stats") + val statsKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val statsVm = trackableVM(manager.ownerFor(statsKey)) + syncAlive() + assertFalse(homeVm.onClearedCalled) + assertFalse(statsVm.onClearedCalled) + + // pop(返回):stats 离开栈 → 清理(离页查询不再保留) + router.pop() + syncAlive() + assertTrue(statsVm.onClearedCalled) + awaitScopeCancelled(statsVm) + assertFalse(homeVm.onClearedCalled) + + // 再次进入 stats:新路由实例 → 新 screenKey → 新 owner/VM + router.navigate(routeId = "stats") + val statsKey2 = routeScreenKey(router.currentEntry, resolveScreen)!! + assertNotEquals(statsKey, statsKey2) + val statsVm2 = trackableVM(manager.ownerFor(statsKey2)) + assertNotSame(statsVm, statsVm2) + + // resetTo(replace/clear stack,如抽屉导航):旧栈全部清理 + router.resetTo(RouteEntry(routeId = "home")) + syncAlive() + assertTrue(statsVm2.onClearedCalled) + awaitScopeCancelled(statsVm2) + assertTrue(homeVm.onClearedCalled) + // 新 home 实例存活 + val newHomeKey = routeScreenKey(router.currentEntry, resolveScreen)!! + val newHomeVm = trackableVM(manager.ownerFor(newHomeKey)) + assertFalse(newHomeVm.onClearedCalled) + } + + // ==== keepAlive 路由:stableScreenKey 复用 ==== + + @Test + fun keepAliveRoute_usesStableScreenKeyAndReusesOwner() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val resolveKeepAlive: (RouteEntry) -> Screen? = { + Screen.ToolPkgComposeDsl( + containerPackageName = "pkg", + uiModuleId = "mod", + title = "t", + keepAlive = true, + ) + } + + val entry1 = RouteEntry(routeId = "toolpkg") + val key1 = routeScreenKey(entry1, resolveKeepAlive)!! + assertEquals("toolpkg_keepalive:pkg:mod", key1) + val vm = trackableVM(manager.ownerFor(key1)) + + // 同 routeId 再次进入:stableScreenKey 相同 → 复用同一 owner/VM + val entry2 = RouteEntry(routeId = "toolpkg") + val key2 = routeScreenKey(entry2, resolveKeepAlive)!! + assertEquals(key1, key2) + assertSame(vm, trackableVM(manager.ownerFor(key2))) + assertFalse(vm.onClearedCalled) + } + + // ==== AppContent 重建(配置变化/跨 600dp):attach 同步 = alive + current ==== + // attach 同步只在首次组合执行一次(LaunchedEffect(Unit)):pop 后 alive + // 立即更新,但退出动画未完成、离页仍在渲染,不得触发 retainOnly; + // 清理只发生在模拟的转场完成分支 retainOnly(aliveRouteKeys()) 之后。 + + @Test + fun retainedRouteKeysOnContentAttach_unionsCurrentWithAliveAndDeduplicates() { + // 当前页不在 alive 中:并入 alive + assertEquals( + setOf("stats-a", "other", "stats-b", "current"), + retainedRouteKeysOnContentAttach( + currentScreenKey = "current", + aliveScreenKeys = setOf("stats-a", "other", "stats-b") + ) + ) + // 当前页已在 alive 中:不重复,结果不变 + assertEquals( + setOf("stats-a", "other"), + retainedRouteKeysOnContentAttach( + currentScreenKey = "stats-a", + aliveScreenKeys = setOf("stats-a", "other") + ) + ) + // alive 为空(栈解析未就绪)时仍保留当前页 + assertEquals( + setOf("current"), + retainedRouteKeysOnContentAttach(currentScreenKey = "current", aliveScreenKeys = emptySet()) + ) + } + + @Test + fun appContentRecreation_attachSyncOnce_preservesFullStack_popClearsOnlyAfterTransitionCompleted() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + // 模拟 AppContent 首次组合(attach)时的单次同步:alive + 当前键 + // (对应 LaunchedEffect(Unit):只在组合进入时执行,不随导航变化重启) + fun attachSync() { + manager.retainOnly( + retainedRouteKeysOnContentAttach( + currentScreenKey = routeScreenKey(router.currentEntry, resolveScreen)!!, + aliveScreenKeys = screenKeysAliveOnStack(router.backStack, resolveScreen) + ) + ) + } + // 模拟 AppContent 转场完成分支的清理:alive = 导航栈 + keepAlive 缓存 + + // 当前键;本测试路由均非 keepAlive,缓存部分为空 + fun transitionCleanup() { + manager.retainOnly( + screenKeysAliveOnStack(router.backStack, resolveScreen) + + routeScreenKey(router.currentEntry, resolveScreen)!! + ) + } + + // 两个不同 TokenStats 路由实例(中间夹 other)同栈: + // 跨 600dp 重建前旧组合已访问过 backStack [home, statsA, other, statsB] + router.navigate(routeId = "stats") + router.navigate(routeId = "other") + router.navigate(routeId = "stats") + val homeKey = routeScreenKey(router.backStack[0], resolveScreen)!! + val statsAKey = routeScreenKey(router.backStack[1], resolveScreen)!! + val otherKey = routeScreenKey(router.backStack[2], resolveScreen)!! + val statsBKey = routeScreenKey(router.backStack[3], resolveScreen)!! + + // 重建前旧组合已为这些路由创建 owner/VM(manager 是 Activity 级,跨重建保留) + val vmHome = trackableVM(manager.ownerFor(homeKey)) + val vmStatsA = trackableVM(manager.ownerFor(statsAKey)) + val vmOther = trackableVM(manager.ownerFor(otherKey)) + val vmStatsB = trackableVM(manager.ownerFor(statsBKey)) + + // 首次组合 attach 只同步一次:保留 alive + 当前键 → 全部存活 + // (回归:旧逻辑只保留当前键会误清 backStack 其他 opt-in owner) + attachSync() + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + assertFalse(vmStatsB.onClearedCalled) + + // pop statsB:alive 已更新但退出动画未完成,不得调用 retainOnly → + // statsB 仍在渲染,owner/VM 必须存活(P1 回归:attach 若按 alive + // 变化重启会在此立即清理 statsB) + router.pop() + assertFalse(vmStatsB.onClearedCalled) + assertFalse(vmStatsB.scopeCancelled) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + + // 调用转场完成清理后:仅 statsB 清理,其余存活 + transitionCleanup() + assertTrue(vmStatsB.onClearedCalled) + awaitScopeCancelled(vmStatsB) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmOther.onClearedCalled) + + // pop other:转场完成前不清理,转场完成后才清理 + router.pop() + assertFalse(vmOther.onClearedCalled) + assertFalse(vmOther.scopeCancelled) + transitionCleanup() + assertTrue(vmOther.onClearedCalled) + awaitScopeCancelled(vmOther) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStatsA.onClearedCalled) + + // pop statsA:逐次同前,home 始终存活 + router.pop() + assertFalse(vmStatsA.onClearedCalled) + assertFalse(vmStatsA.scopeCancelled) + transitionCleanup() + assertTrue(vmStatsA.onClearedCalled) + awaitScopeCancelled(vmStatsA) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } + + @Test + fun layoutSwitch_newAppContentAttach_usesNewAliveKeys() { + val manager = ScreenRouteViewModelStoreOwnerManager() + val router = AppRouterState(RouteEntry(routeId = "home")) + router.navigate(routeId = "stats") + val homeKey = routeScreenKey(router.backStack[0], resolveScreen)!! + val statsKey = routeScreenKey(router.backStack[1], resolveScreen)!! + val vmHome = trackableVM(manager.ownerFor(homeKey)) + val vmStats = trackableVM(manager.ownerFor(statsKey)) + + // 旧组合(如 Phone 布局)attach:保留 [home, stats] + 当前键 + manager.retainOnly(retainedRouteKeysOnContentAttach(statsKey, setOf(homeKey, statsKey))) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmStats.onClearedCalled) + + // pop stats 后转场未完成时发生 Phone → Tablet 切换:旧 AppContent + // 销毁,新 AppContent attach 重新执行(LaunchedEffect(Unit))并使用 + // 新传入的 alive(pop 后的栈 [home] + 当前键)。stats 在新组合中不再 + // 渲染(screenCache/keepAlive 缓存已重置),attach 同步即清理其 owner。 + router.pop() + manager.retainOnly( + retainedRouteKeysOnContentAttach( + currentScreenKey = routeScreenKey(router.currentEntry, resolveScreen)!!, + aliveScreenKeys = screenKeysAliveOnStack(router.backStack, resolveScreen) + ) + ) + assertTrue(vmStats.onClearedCalled) + awaitScopeCancelled(vmStats) + assertFalse(vmHome.onClearedCalled) + assertFalse(vmHome.scopeCancelled) + } +} diff --git a/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md b/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md new file mode 100644 index 000000000..4aa7b05b1 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/1_merge_baseline_and_reproduction.md @@ -0,0 +1,18 @@ +# 1. Merge Baseline And Reproduction [DONE] + +## Previous State + +`main` at `f83e69cb` contains the MNN schema-generation repair from #926. #922 was +nine commits behind it and its two candidate runs failed while compiling MNN with +stale generated schema headers. + +## Change + +Created `fix/token-stats-922-review` from `main` and normally merged +`origin/review-pr-922` into it. The resulting merge commit is `663a3a59`. + +## Expected State + +The branch contains both the #922 feature work and the current MNN build repair, +so subsequent checks test the actual candidate intended for review rather than the +obsolete PR head. diff --git a/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md new file mode 100644 index 000000000..e21cc4c1e --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/2_restore_integrity.md @@ -0,0 +1,35 @@ +# 2. Restore Integrity + +## Previous State + +`RoomDatabaseRestoreManager` deletes the active database, WAL, and SHM files before +calling `replaceFile`. `replaceFile` can still fail while renaming or copying the +validated temporary database. The exception path then removes temporary files, +leaving no recoverable active database. + +## Intended Change + +Validate WAL/SHM compatibility before committing the restore marker. Replace each +staged database file using only same-filesystem atomic move with replacement; do not +delete the active target first or copy after a failed move. Preserve the restore +barrier semantics and the replacing marker. + +## Expected State + +A failed atomic replacement reports failure without deleting the user's previously +active database. A focused regression test injects the final move failure and +verifies that the existing database remains intact. [DONE] + +## Statistics Repository Lifecycle + +`TokenUsageRepository` survives for the process lifetime, while both Room-only +restore and raw snapshot restore close and replace `AppDatabase`. Retaining a Room +DAO in that repository would leave token queries and request recording attached to +the closed database when a user chooses to restart later. + +The repository now uses one process-wide mutex for every Room-backed statistics +operation and for both restore entry points. A restore clears the initialization +state while holding that mutex, then keeps it until the database files have been +replaced. Each operation obtains the current DAO only after that barrier and the +one-time import have completed, so it cannot use a Room instance that a restore is +closing or has closed. [DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/3_verification.md b/docs/TODO/token_stats_922_review_20260811/3_verification.md new file mode 100644 index 000000000..9e7ffafbb --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/3_verification.md @@ -0,0 +1,94 @@ +# 3. Verification + +## Required Evidence + +- Confirm removed unpublished mechanisms have no production references. +- Confirm existing chat and message token columns are unchanged. +- Inspect the Room schema migration, one-time DataStore import ordering, SQL queries, + and request write path statically. +- Review the final diff and working tree without running compilation, builds, or + tests unless the user explicitly requests them. +- Confirm the final branch remains based on the current `main` before proposing any + merge. + +## 2026-08-12 ToolPkg Released-Key Fix + +- Added focused JVM tests for current and future `TOOLPKG_` identities. +- Kept unknown-provider rejection explicit; no underscore-based fallback was added. +- Compilation and tests were not run because the repository execution rules require + an explicit user request. + +## 2026-08-12 Statistics UI Follow-up + +- [DONE] Statically verified that imported timestamp-free counters join the normal + lifetime aggregate while time-range charts remain timestamp-bound. +- [DONE] Removed the Token Activity profile implementation, its test, and its + dedicated localized strings; no production references remain. +- The targeted token-statistics diff passed `git diff --check`. Compilation and tests + were not run because the user did not request them. + +## 2026-08-12 Lifetime And Theme Follow-up + +- [DONE] Lifecycle totals now always include the imported timestamp-free totals; the + time-range, trend, and activity queries remain timestamp-bound. +- [DONE] Token Activity heatmap uses the active primary color at increasing alpha + levels only. +- No compilation, build, or test was run for this follow-up, per user request. + +## 2026-08-12 Activity And Lifetime Model Layout Follow-up + +- [DONE] Statically confirmed that the removed activity-insights UI no longer has + production references; its hourly Room query and aggregation fields are removed. +- [DONE] Reviewed the lifetime model section: pie slices and list percentages use + the same lifecycle total-token value, including imported timestamp-free totals. +- No compilation, build, or test was run, per user request. + +## 2026-08-12 Date Range And Currency Follow-up + +- [DONE] Statically verified that no production or test code references the removed + preset-selection types, preference APIs, automatic range probing, or localized + preset labels. +- [DONE] Checked the date-range filter call chain: display converts the stored + half-open timestamp range to inclusive local dates, and confirmation converts it + back at local midnight boundaries before persistence and SQL querying. +- [DONE] `git diff --check` completed without whitespace errors. No compilation, + build, or test was run because the user explicitly requested that compilation not + be run. + +## 2026-08-13 Unified Statistics Scope + +- [DONE] Statically traced the shared `TokenStatsQueryParams` from selected model + groups, call types, and results into both range aggregation and activity-day SQL. + The activity query projects the complete identity and filters it after SQL so + grouped models retain configuration-level precision. +- [DONE] Removed the independent activity recent/year state, query, UI controls, + and tests. Activity aggregation now accepts only an explicit selected range. +- No compilation, build, or test was run, per user instruction. + +## 2026-08-13 Read-only History Follow-up + +- [DONE] Statically confirmed that the token-statistics screen exposes no record + deletion actions. Removed the associated ViewModel, repository, DAO, query-helper, + and localized-string code; group and price-rule deletion remain separate settings + operations. +- `git diff --check` completed without whitespace errors. No compilation, build, or + test was run, per user instruction. + +## 2026-08-13 Information Hierarchy Follow-up + +- [DONE] Statically confirmed that lifetime totals, range analysis, trends, range + model details, and statistics settings use one page-section heading component. + Range controls, activity summaries, and the selected visualization share one card; + the card-level labels do not compete with page-section headings. +- No compilation, build, or test was run, per user instruction. + +## 2026-08-13 Mainline Merge And Restore Lifecycle + +- [DONE] Merged current `main`; resolved `MemoryLibrary` by retaining windowed + analysis semantics while keeping memory requests categorized for token statistics. +- [DONE] Retained the current snapshot package-prefix validation. +- [DONE] Statically traced both database restore entry points and all Room-backed + token-statistics reads and writes. They share one mutex which holds from + initialization through DAO use, or from clearing initialization through database + file replacement, so no statistics operation can retain or use a closed DAO. +- No compilation, build, or test was run, per user instruction. diff --git a/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md b/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md new file mode 100644 index 000000000..a6150892d --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/4_provider_capabilities_and_token_types.md @@ -0,0 +1,21 @@ +# 4. Provider Capabilities And Token Types + +## Previous State + +`OpenAIProvider` inspected `ApiProviderType` to decide whether a request should +include `stream_options.include_usage`. This couples the generic compatibility +base to concrete provider identities. The statistics integration also narrowed +the local MNN and llama.cpp token counters from `Long` to `Int`, then converted +the values back only at API boundaries. + +## Intended Change + +Append the stream usage request field directly while constructing requests for +native OpenAI, DeepSeek, and Kimi. Generic OpenAI-compatible providers do not add +the field. Keep local token counts as `Long` through generation finalization and +statistics normalization. + +## Expected State + +Adding a provider no longer requires editing a central provider-type condition. +Local usage statistics preserve values larger than `Int.MAX_VALUE`. [DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md b/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md new file mode 100644 index 000000000..934ce4d1c --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/5_final_storage_design.md @@ -0,0 +1,31 @@ +# 5. Final Storage Design + +## Previous State + +The unpublished implementation treats token statistics as a durable billing ledger +and adds operational recovery systems around it. Released `main` already stores +conversation token state in Room and lifetime provider/model counters in DataStore. + +## Intended Change + +- Use `token_usage_records` for completed requests, copied conversation history, and + imported cumulative counters. The `source` column distinguishes `REQUEST` and + `CONVERSATION`; imported counters are timestamp-free `REQUEST` rows. +- Use `token_stats_models` for complete model identities, group membership, group + names, and model-level or configuration-level price overrides. +- Use a dedicated `token_stats_preferences` Preferences DataStore for currency, + exchange rate, time selection, and `importedAtMs`. These are scalar key/value + settings and do not justify another SQL table. +- Copy AI messages and all message variants once during the Room migration. Do not + query the chat tables at runtime after the schema migration. +- Keep new request identities as configuration, provider, and model columns. +- Key model settings by `configId + provider + model`. An empty `configId` represents + provider/model-wide pricing and an identity without configuration ownership. +- Calculate costs from current settings only. +- Use direct SQL aggregation instead of cached daily or lifetime rollups. + +## Expected State + +The entire statistics feature uses two tables plus one small preferences file, with +no ledger, spool, backup, or recovery state. Lifetime totals do not double count +copied conversation rows already included in the released counters. diff --git a/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md b/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md new file mode 100644 index 000000000..d0e2b89b0 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/6_data_layer_and_request_integration.md @@ -0,0 +1,28 @@ +# 6. Data Layer And Request Integration + +## Previous State + +Provider usage is normalized into a large ledger pipeline with identities, stable +event UUIDs, spool fencing, recovery, price snapshots, and cleanup operations. + +## Intended Change + +- Retain provider usage normalization and `Long` token counts. +- Insert a compact event directly through a repository when a provider request ends. +- Add only the two final Room entities, a dedicated statistics preferences store, + focused DAO methods, and the required schema migration. Do not create intermediate + version-21 tables. +- Store `provider` and `model` separately. Use `configId` only for new + requests where the application actually knows the configuration. +- Import existing DataStore counters, model prices, and exchange rate once, + then remove every token-statistics DataStore key. +- Give each imported cumulative total a stable nullable `importKey`; repeated initialization + replaces that row instead of duplicating it if the process stops between Room and + Preferences commits. +- Record only `importedAtMs` in the dedicated statistics preferences. +- Restore normal application backup and restore behavior. + +## Expected State + +The request path has one understandable statistics write and no filesystem spool or +cross-component lifecycle coordinator. diff --git a/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md b/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md new file mode 100644 index 000000000..5fa407e21 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/7_sql_queries_and_ui.md @@ -0,0 +1,121 @@ +# 7. SQL Queries And UI Adaptation + +## Previous State + +The UI depends on an in-memory ledger aggregator, historical versus revalued cost +modes, baseline rows, cutoff rows, quarantine management, and event pagination. + +## Intended Change + +- Provide SQL projections for lifetime totals, range totals, trend buckets, models, + categories, statuses, TTFT, duration, and activity data. +- Treat imported cumulative counters as timestamp-free `REQUEST` rows. Lifetime + aggregation includes them; time-range SQL excludes them through its timestamp bounds. +- Use copied `CONVERSATION` rows from `messages` and every `message_variants` row as + the available historical distribution. Treat their exact request count as unknown + because one saved response can contain multiple provider requests. +- Keep legacy conversation rows out of lifetime addition because the DataStore total + already includes them. +- Aggregate models by normalized model name and expose individual call configurations + only when the user expands a model row. +- Keep the statistics, activity, and inline configuration-price editing UI. +- Remove historical-price mode, quarantine, migration, and token-backup controls. +- Present imported cumulative counters in the normal lifetime totals and model + aggregation. They have no timestamp, so they do not appear in a selected time range + or trend chart. +- Remove the Token Activity title, its unrelated profile card, and activity-insights + card while retaining the activity selector, summary metrics, and visualizations. +- Derive cards, typography, charts, and heatmap colors from the + application `MaterialTheme.colorScheme`; no statistics-specific visual palette remains. + +## Expected State + +The UI presents the useful #922 statistics against a compact SQL-backed repository, +while released conversation data remains available through the existing chat domain. + +## 2026-08-12 Follow-up + +[DONE] The imported cumulative counters use the same lifecycle aggregate and model +identity path as all other requests. The activity section no longer owns profile +state, avatar files, or profile controls. + +[DONE] The token statistics UI now follows the active application theme for surfaces, +content, accents, charts, heatmap, and model management. It no longer overrides the +local `MaterialTheme` or retains a white/pink statistics-only palette. + +## 2026-08-12 Activity And Lifetime Model Layout Follow-up + +[DONE] The cumulative-usage card is now the first page section. The activity-insights +card and its hour-based SQL query are removed; the recent/year selector now shares +the activity-mode row. + +[DONE] Lifetime model totals use a total-token distribution pie and compact rows. + +[DONE] Page-level statistics headings and activity controls start at the shared page +edge. Their data cards use the same edge, with a shared 16dp internal card inset. + +## 2026-08-12 Date Range And Currency Follow-up + +[DONE] The range filter is a Material date-range calendar. Its calendar icon sits at +the right edge of the daily, weekly, and cumulative activity-mode row; it retains +the existing nearby recent/year activity selector. The query continues to use its +explicit half-open timestamp interval. + +[DONE] The visible preset menu, rolling-window selection, automatic preset probing, +and associated preference state are removed. The first view uses the most recent +30 natural days; thereafter the selected calendar range is persisted directly. + +[DONE] Display currency is a single CNY/USD dropdown rather than parallel chips. + +[DONE] The first cumulative-usage card uses the active theme's primary-container +surface and matching on-primary-container content color. Remaining statistics cards +continue to use the ordinary application surface. + +[DONE] The three cumulative metrics use compact single-line values. Cumulative cost +is displayed to two decimal places; detailed prices and other cost views retain +their existing precision. + +## 2026-08-13 Unified Statistics Scope + +[DONE] The page now has two explicit scopes: cumulative usage and cumulative model +totals always cover all history; the date-range activity, charts, and range model +details share one date range plus model, call-type, and result conditions. + +[DONE] Removed the unrelated recent/year activity selector. The activity SQL query +receives the same range and query conditions as range statistics, then applies the +selected models before building daily, weekly, and cumulative views. + +[DONE] The visible conditions use labelled values rather than ambiguous standalone +phrases. Currency is shown next to cumulative usage because it changes only money +display, not the records included in a query. The destructive action explicitly +states that it deletes all records in the selected date range. + +## 2026-08-13 Compact Controls Follow-up + +[DONE] The cumulative-usage heading reserves a fixed-width currency control, so a +narrow screen keeps the heading on one line. The date range calendar uses a compact +in-app heading and single-line selected-range summary instead of Material's oversized +default range headline. Cumulative model rows show the five largest models initially; +the complete list remains available through an explicit expand control, while the pie +continues to represent every model. + +[DONE] Token statistics is a read-only history surface. Removed model, date-range, +and all-history usage-record deletion from the UI and the supporting statistics data +APIs. A custom configuration price can be removed directly from its editor because +that action does not discard recorded usage. + +## 2026-08-13 Information Hierarchy Follow-up + +[DONE] The statistics page now separates lifetime totals, range analysis, trends, +configuration details, and settings with one shared page-section heading style. The range +filters and activity visualization are grouped into one range-analysis section; cards +use only compact internal labels. The lifetime card no longer repeats the applied-rate +hint, which belongs to the dedicated statistics-settings section. + +## 2026-08-13 Configuration Details Follow-up + +[DONE] Removed model grouping and the separate model/pricing management screens. +The configuration-details list has no model grouping layer: each compact row is one +configuration, identified by its configured name and provider/model. Expanding a row +reveals its token components and the inline price editor for that configuration. A +custom price can be removed from the same editor. diff --git a/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md b/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md new file mode 100644 index 000000000..2c42eed04 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/8_legacy_history_and_identity.md @@ -0,0 +1,44 @@ +# 8. Legacy History And Identity + +## Previous State + +The compact rewrite imports released counters into a table keyed only by the +combined `providerModel` string. Imported rows are projected with an empty +configuration ID, and model-group changes reduce complete identity IDs back to the +same combined string. Existing messages and message variants are not visible to the +statistics queries. + +## Intended Change + +- Represent a new request identity as configuration, provider, and model. +- Represent an imported cumulative-counter identity as configuration-unscoped provider and model. +- Store group assignments and price overrides in one model row keyed by the complete + identity. +- Copy assistant messages and all generated variants during migration for token + trends and model distribution. +- Keep historical conversation request counts out of time buckets because a saved response can + aggregate multiple provider calls. +- Include imported DataStore totals in the normal lifetime aggregate. + +## Expected State + +The model-management UI retains its hierarchy and configuration-level operations. +The statistics UI can show recoverable historical conversation usage without +inventing configuration ownership, request events, or duplicate lifetime totals. + +## ToolPkg Released-Key Identity Fix + +The released DataStore decoder now accepts each registered ToolPkg `providerId`, its +legacy `TOOLPKG_` form, and display name as exact prefixes. This preserves +provider IDs containing underscores without changing runtime statistics identities, +database structure, or UI behavior. It reads with the original prefix and imports with +the registered display identity so the historical total and new requests remain in the +same model entry. + +It also preserves released custom-provider totals after a provider is removed or renamed. +Those keys have no registry metadata, so migration decodes the historical provider name +from the first encoded separator instead of failing the entire import. Known ToolPkg IDs +continue to use the longest registered prefix, which keeps underscores in provider IDs +unambiguous. + +[DONE] diff --git a/docs/TODO/token_stats_922_review_20260811/index.md b/docs/TODO/token_stats_922_review_20260811/index.md new file mode 100644 index 000000000..f49c73ff1 --- /dev/null +++ b/docs/TODO/token_stats_922_review_20260811/index.md @@ -0,0 +1,54 @@ +--- +fork_repository: https://github.com/AAswordman/Operit.git +source_pr: https://github.com/AAswordman/Operit/pull/922 +working_branch: fix/token-stats-922-review +--- + +# Token Statistics PR 922 Redesign + +## Background + +PR #922 introduces useful statistics and model-management UI, but its unpublished +storage design adds a request ledger, spool, recovery generations, quarantine, +cleanup outbox, baseline migration, and token-specific backup coordination. The +implementation is much larger than the product requirement and duplicates behavior +already owned by the application database and normal backup system. + +## Intent + +Keep the useful UI and provider usage extraction while replacing the unpublished +storage design completely. Use two Room tables for structured token statistics data, +keep scalar UI state in a dedicated Preferences DataStore, and perform aggregation +with SQL. Copy existing messages and every message variant once during migration so +the statistics domain remains self-contained after the schema migration. + +## Scope + +- Preserve the #922 commit topology through merge commit `663a3a59`. +- Delete every unpublished spool, baseline, quarantine, cleanup, cutoff, generation, + token-specific restore, and historical-price mechanism. +- Add `token_usage_records` and `token_stats_models`. +- Add a dedicated `token_stats_preferences` file for currency, exchange rate, time + selection, and the completed-import timestamp. +- Preserve provider, model, and configuration ownership as separate identity + dimensions instead of flattening them into a `providerModel` assignment. +- Import released DataStore counters once as authoritative upgrade-time lifetime + totals. Copy messages and all message variants as recoverable historical + conversation history without adding them to lifetime totals again. +- Keep existing `chats` and `messages` token columns unchanged. +- Query totals, trends, categories, statuses, performance, and activity with SQL. +- Store billing mode and price overrides in structured Room rows. +- Store currency, exchange rate, time selection, and `importedAtMs` in the dedicated + Preferences DataStore. +- Do not run compilation, builds, or tests without an explicit user request. + +## Steps + +1. [DONE] [Merge baseline](1_merge_baseline_and_reproduction.md) +2. [DONE] [Restore integrity investigation](2_restore_integrity.md) +3. [Provider capabilities and token types](4_provider_capabilities_and_token_types.md) +4. [Final storage design](5_final_storage_design.md) +5. [Data layer and request integration](6_data_layer_and_request_integration.md) +6. [SQL queries and UI adaptation](7_sql_queries_and_ui.md) +7. [Legacy history and identity](8_legacy_history_and_identity.md) +8. [Verification](3_verification.md)