From 6f367e7dbd784c3bef1f2b3836c8b9a9bc33ca74 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Thu, 13 Nov 2025 21:22:11 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E6=94=AF=E4=BB=98=E9=80=BB?= =?UTF-8?q?=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../agent/controllers/agent_controller.dart | 5 + .../agent_service/AgentService.kt | 89 ++++++++++------ .../agent_service/BleMediaService.kt | 7 +- .../agent_service/lib/agent_service.dart | 5 + .../chat_api/ChatApiService.kt | 100 +++++++++++++++--- .../com/yunqiinnovation/chat_api/Utils.kt | 48 +-------- 6 files changed, 159 insertions(+), 95 deletions(-) diff --git a/lib/modules/agent/controllers/agent_controller.dart b/lib/modules/agent/controllers/agent_controller.dart index e18abbbc7..959115f54 100644 --- a/lib/modules/agent/controllers/agent_controller.dart +++ b/lib/modules/agent/controllers/agent_controller.dart @@ -1019,6 +1019,11 @@ class AgentController extends GetxController with WidgetsBindingObserver { // } } break; + case AgentServiceEventType.assistantUsages: + final sessionid = event.data['sessionid'] ?? ''; + final integral = event.data['integral'] ?? 0; + User.instance.aiIntegral = integral; + break; default: break; } diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt index 140a73b01..abda6adc4 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/AgentService.kt @@ -205,7 +205,8 @@ object AgentService : CoroutineScope { serverurl = config["serverurl"]?.toString() ?: ""; usertoken = config["usertoken"]?.toString() ?: ""; callingTool = config["callingTool"]?.toString() ?: ""; - aiintegral = (config["aiintegral"] as? Number)?.toInt() ?: 0 + aiintegral = config["aiintegral"]?.toString()?.toIntOrNull() ?: 0; + // 初始化上下文和服务 this.context = appContext.applicationContext @@ -1007,7 +1008,6 @@ object AgentService : CoroutineScope { // 兜底处理,直接添加原消息 addToHistoryMessages(userMessage) } -// Log.e("ChatApiService", "liwei---------sendMessageStream messages:${messagesWithSystemPrompt}") chatApiService.sendMessageStream( sessionid, messagesWithSystemPrompt, @@ -1017,16 +1017,19 @@ object AgentService : CoroutineScope { sessionid: String, prompt_tokens: Int?, completion_tokens: Int?, - total_tokens: Int? + callfunctions: List? ) { try { - sendStatisticsEvent( - mapOf( - "CHAT_PROMPT_TOKENS" to (prompt_tokens ?: 0), - "CHAT_COMPLETION_TOKENS" to (completion_tokens ?: 0), - "CHAT_TOTAL_TOKENS" to (total_tokens ?: 0) - ) - ) + // 显式构造 Map,避免 mapOf 推断为 Map + val actions = mutableMapOf() + actions["AICHAT_PROMPT_TOKENS"] = (prompt_tokens ?: 0) + actions["AICHAT_COMPLETION_TOKENS"] = (completion_tokens ?: 0) + // 累计函数调用次数:按函数名分别累加 value += 1 + callfunctions?.forEach { funcName -> + val key = "AICHAT_FUNCTION_" + funcName + actions[key] = (actions[key] ?: 0) + 1 + } + sendStatisticsEvent(actions) }catch (e: Exception) { Log.e(TAG, "liwei--------------- AI Call onUsage 异常", e) } @@ -1035,12 +1038,10 @@ object AgentService : CoroutineScope { override fun onToken(sessionid: String, token: String) { try { if (sessionid != currsessionId) { -// Log.d(TAG, "liwei--------------- AI Call onToken 会话过期 $sessionid-------$currsessionId") - return + return } responseBuilder.append(token) -// Log.d(TAG, "liwei--------------- AI Call onToken:$token speakResponse:$speakResponse reply:$reply broadcast:$broadcast") - if (speakResponse && reply && broadcast && token.isNotEmpty()) { + if (speakResponse && reply && broadcast && token.isNotEmpty()) { ttsService?.speakStream(token) } if (reply && token.isNotEmpty()) { @@ -1308,26 +1309,18 @@ object AgentService : CoroutineScope { } language = languages[0] return try { - Log.i(TAG, "设置支持的语言: ${languages.joinToString(", ")}") - - - - val asrSuccess = azureAsrHelper?.initialize( + Log.i(TAG, "设置支持的语言: ${languages.joinToString(", ")}") + val asrSuccess = azureAsrHelper?.initialize( subscriptionKey = this.azureSpeechKey, region = this.azureSpeechRegion, supportedLanguages = languages.toTypedArray(), audioSourceType = audioSourceType ) ?: false - if (asrSuccess) { - - recognizeCallback() - + recognizeCallback() } else { Log.w(TAG, "Azure ASR初始化失败") } - - // 同时更新TTS语言设置 val ttsSuccess = updateTtsLanguage(languages.first()) // 使用第一个语言作为TTS语言 @@ -2549,16 +2542,18 @@ object AgentService : CoroutineScope { for ((key, value) in actions) { actionsObject.put(key, value) } - - jsonObject.put("actions", actionsObject) + + jsonObject.put("usagetype", 3) // 3 表示AICHAT + jsonObject.put("usages", actionsObject) val json = jsonObject.toString() + Log.d(TAG, "统计事件发送成功,请求: $json") val mediaType = "application/json".toMediaType() val body = json.toRequestBody(mediaType) // 构建请求 val request = Request.Builder() - .url("$serverurl/api/home/user_pushaction") + .url("$serverurl/api/home/user_usages") .addHeader("Authorization", usertoken) .addHeader("Content-Type", "application/json") .post(body) @@ -2573,8 +2568,42 @@ object AgentService : CoroutineScope { override fun onResponse(call: Call, response: Response) { response.use { + // 仅读取一次响应体,避免重复读取导致异常,同时统一后续处理使用 + val bodyStr = response.body?.string() ?: "" if (response.isSuccessful) { - Log.d(TAG, "统计事件发送成功") + Log.d(TAG, "统计事件发送成功,响应: $bodyStr") + try { + val json = JSONObject(bodyStr) + val code = json.optInt("code", -1) + val msg = json.optString("msg", "") + val dataObj = json.optJSONObject("data") + + // 解析 data.usages 为 Map + val dataMap = mutableMapOf() + val usagesObj = dataObj?.optJSONObject("usages") + if (usagesObj != null) { + val keys = usagesObj.keys() + while (keys.hasNext()) { + val k = keys.next() + dataMap[k] = usagesObj.optInt(k, 0) + } + for ((k, v) in dataMap) { + Log.d(TAG, "统计事件发送成功,响应: $k -> $v") + if(k == "3"){ //AI积分更新 + aiintegral = v + // 广播一个细化事件,便于前端单独处理 AI 积分更新 + sendEvent( + "assistant_usages", + mapOf( + "integral" to aiintegral + ) + ) + } + } + } + } catch (e: Exception) { + Log.w(TAG, "解析统计响应失败: ${e.message}") + } } else { Log.w(TAG, "统计事件发送失败,HTTP状态码: ${response.code}") } @@ -2619,4 +2648,4 @@ object AgentService : CoroutineScope { Log.e(TAG, "保存 _isInterrupt 缓存状态时发生异常: ${e.message}", e) } } -} \ No newline at end of file +} diff --git a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index f8cea9728..db9462134 100644 --- a/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt +++ b/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt @@ -70,6 +70,7 @@ class BleMediaService : Service(), CoroutineScope { private const val APP_NAVIGATION_MODE = "navigationMode" private const val APP_CALLING_TOOL = "callingTool" private const val KEY_AI_INTAGRAL = "aiintegral" + // 用于跟踪服务是否正在运行 private val isRunning = AtomicBoolean(false) @@ -663,11 +664,13 @@ private var companionManager: BleCompanionManager? = null private fun getConfigString(prefs: SharedPreferences, key: String, defaultValue: String): String { return prefs.getString(key, defaultValue) ?: defaultValue } - + + /** + * 从SharedPreferences获取字符串配置 + */ private fun getConfigInt(prefs: SharedPreferences, key: String, defaultValue: Int): Int { return prefs.getInt(key, defaultValue) } - override fun onBind(intent: Intent?): IBinder? = null override fun onDestroy() { diff --git a/local_plugins/agent_service/lib/agent_service.dart b/local_plugins/agent_service/lib/agent_service.dart index 689a17c6c..bf54a27b7 100644 --- a/local_plugins/agent_service/lib/agent_service.dart +++ b/local_plugins/agent_service/lib/agent_service.dart @@ -59,6 +59,9 @@ enum AgentServiceEventType { /// 函数调用结果 functionCallResult, + /// AI助手使用统计 + assistantUsages, + /// 图片处理中 imageProcessing, @@ -167,6 +170,8 @@ class AgentService { return AgentServiceEventType.functionCall; case 'function_call_result': return AgentServiceEventType.functionCallResult; + case 'assistant_usages': + return AgentServiceEventType.assistantUsages; case 'image_processing': return AgentServiceEventType.imageProcessing; case 'image_ready': diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt index e0374ae37..5c0e12951 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/ChatApiService.kt @@ -38,7 +38,7 @@ interface StreamCallback { /** * AI使用量 * */ - fun onUsage(sessionid: String,prompt_tokens: Int?,completion_tokens: Int?,total_tokens: Int?) + fun onUsage(sessionid: String,prompt_tokens: Int?,completion_tokens: Int?,callfunctions: List?) /** * 接收到新的token */ @@ -106,6 +106,13 @@ private data class ToolCallInfo( } } +// 会话统计信息结构 +private data class SessionStats( + var promptTokens: Int = 0, + var completionTokens: Int = 0, + val calledFunctions: MutableList = mutableListOf() +) + /** * ChatAPI服务的Android实现 * 基于 openai-kotlin 4.0.1,与 iOS 版本接口完全一致 @@ -113,7 +120,7 @@ private data class ToolCallInfo( class ChatApiService(private val context: android.content.Context? = null) : CoroutineScope { override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() - + companion object { private const val TAG = "ChatApiService" } @@ -133,7 +140,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // MCP 客户端 private var _mcpClient: MCPClient? = null - + // 公开的MCP客户端访问器 val mcpClient: MCPClient? get() = _mcpClient @@ -147,6 +154,8 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor private var currSessionId = "" // 为每个会话维护独立的回调函数 private val sessionCallbacks: MutableMap = Collections.synchronizedMap(mutableMapOf()) + // 会话统计信息 + private val sessionStats: MutableMap = Collections.synchronizedMap(mutableMapOf()) @@ -165,8 +174,17 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 1. 取消当前的协程任务 currentStreamJob?.cancel(CancellationException("New session started")) currentStreamJob = null - - // 2. 通知旧会话被中止 + if (oldSessionId != ""){ + val stats = sessionStats[oldSessionId] + getSessionCallback(oldSessionId)?.onUsage( + oldSessionId, + stats?.promptTokens, + stats?.completionTokens, + stats?.calledFunctions?.toList() + ) + sessionStats.remove(oldSessionId) + } + getSessionCallback(oldSessionId)?.onComplete(oldSessionId) // 直接完成当前会话 // 3. 清理状态 @@ -174,6 +192,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor sessionCallbacks.remove(oldSessionId) toolCalls.clear() + Log.d(TAG, "会话 $oldSessionId 已被中止") } } @@ -187,6 +206,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor sessionCallbacks.remove(oldSessionId) currentStreamJob = null toolCalls.clear() + sessionStats.remove(oldSessionId) } /** @@ -335,6 +355,8 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // val newSessionId = UUID.randomUUID().toString() currSessionId = sessionid sessionCallbacks[sessionid] = callback + // 初始化会话统计 + sessionStats[sessionid] = SessionStats() // 3. 转换消息并开始处理 val messageList = convertJsonArrayToList(messages) @@ -414,7 +436,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor throw IllegalStateException("OpenAI客户端未初始化") } // Log.e("ChatApiService", "liwei---------,发送AI请求 model:${currentModel} messages:${chatMessages}") - // 添加超时控制,10秒超时 + // 添加超时控制,10秒超时 val flow = withTimeout(10.seconds) { openAI!!.chatCompletions(chatCompletionRequest) } @@ -496,7 +518,6 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } toolCall.function?.let { function -> - // 安全处理函数名称 - 使用 try-catch 避免 null 属性访问 try { val functionName = function.name if (!functionName.isNullOrEmpty()) { @@ -532,9 +553,22 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } } } - val promptTokens = estimatePromptTokens(chatMessages); //计算上传token - val completionTokens = roughTokenCount(replyContext); //计算上传token - getSessionCallback(sessionid)?.onUsage(sessionid,promptTokens,completionTokens,promptTokens+completionTokens) + + // 仅统计数据字节数,服务端负责计算token + val promptBytes = try { + val jsonArray = org.json.JSONArray() + messages.forEach { msg -> + jsonArray.put(convertMapToJsonObject(msg)) + } + jsonArray.toString().toByteArray(Charsets.UTF_8).size + } catch (_: Exception) { 0 } + val completionBytes = replyContext.toByteArray(Charsets.UTF_8).size + // 累计到会话统计 + sessionStats.getOrPut(sessionid) { SessionStats() }.apply { + this.promptTokens += promptBytes + this.completionTokens += completionBytes + } + // Log.d(TAG, "liwei-------------------------开始AI 对话 8") // 完成处理 if (sessionid == currSessionId) { @@ -543,12 +577,33 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor val hasToolCalls = processToolCalls(sessionid) if (!hasToolCalls) { try { + val stats = sessionStats[sessionid] + getSessionCallback(sessionid)?.onUsage( + sessionid, + stats?.promptTokens, + stats?.completionTokens, + stats?.calledFunctions?.toList() + ) getSessionCallback(sessionid)?.onComplete(sessionid) } catch (e: Exception) { Log.e(TAG, "onComplete回调异常: ${e.message}", e) } clearCurrentSession() } + }else{ + // 会话结束,上报使用统计,再回调完成 + try { + val stats = sessionStats[sessionid] + getSessionCallback(sessionid)?.onUsage( + sessionid, + stats?.promptTokens, + stats?.completionTokens, + stats?.calledFunctions?.toList() + ) + getSessionCallback(sessionid)?.onComplete(sessionid) + } catch (e: Exception) { + Log.e(TAG, "onUsage回调异常: ${e.message}", e) + } } // Log.d(TAG, "liwei-------------------------开始AI 对话 10") } catch (e: CancellationException) { @@ -557,6 +612,13 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } catch (e: Exception) { if (sessionid == currSessionId) { try { + val stats = sessionStats[sessionid] + getSessionCallback(sessionid)?.onUsage( + sessionid, + stats?.promptTokens, + stats?.completionTokens, + stats?.calledFunctions?.toList() + ) Log.d(TAG, "AI聊天异常 Session $sessionid 错误类型:${e::class.simpleName} 错误完整类型:${e::class.qualifiedName}") getSessionCallback(sessionid)?.onError(sessionid,1000,"流式请求失败: ${e.message}") } catch (ex: Exception) { @@ -586,7 +648,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 只处理有效的工具调用 val validToolCalls = toolCalls.values.filter { it.isValid() } - if (validToolCalls.isEmpty()) { + if (validToolCalls.isEmpty()) { Log.w("ChatApiService", "[Session: $sessionid] 没有有效的工具调用(参数可能不完整)") return false } @@ -601,6 +663,12 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor "id" to firstToolCall.id ) + // 记录方法名称以返回给调用方 + val functionName: String = firstToolCall.name + if (functionName.isNotEmpty()) { + sessionStats.getOrPut(sessionid) { SessionStats() }.calledFunctions.add(functionName) + } + // 通知上层工具调用事件 try { getSessionCallback(sessionid)?.onFunctionCall(sessionid,convertMapToJsonObject(functionCall)) @@ -609,7 +677,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor } // 在当前协程作用域内处理工具调用,使用async确保生命周期管理 - val toolCallDeferred = async { + val toolCallDeferred = async { try { // 检查会话是否仍然有效 if (sessionid != currSessionId) { @@ -750,7 +818,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor // 不重新抛出异常,让流程正常结束 } - return true + return true } /** @@ -983,7 +1051,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor * 关闭MCP客户端 */ fun closeMcpClient() { - runBlocking { + runBlocking { _mcpClient?.disconnectAll() _mcpClient = null } @@ -1230,7 +1298,7 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor ChatMessage.System(displayText) } Role.User -> ChatMessage.User(contentParts) - Role.Assistant -> { + Role.Assistant -> { // 将多模态内容转换为ListContent val listContent = ListContent(contentParts) // 提取文本内容作为Assistant消息的content @@ -1468,4 +1536,4 @@ class ChatApiService(private val context: android.content.Context? = null) : Cor return "Function '$functionName' called with arguments: $arguments" } } -} \ No newline at end of file +} diff --git a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt index d303ae3b4..49e3b8408 100644 --- a/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt +++ b/local_plugins/chat_api/android/src/main/kotlin/com/yunqiinnovation/chat_api/Utils.kt @@ -8,9 +8,6 @@ import android.net.Uri import android.provider.Settings import android.util.Log import androidx.core.content.ContextCompat -import com.aallam.openai.api.chat.ChatMessage -import com.aallam.openai.api.chat.TextContent -import com.aallam.openai.api.chat.ListContent import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.plugins.sse.* @@ -166,47 +163,4 @@ fun roughTokenCount(text: String): Int { val englishCharCount = trimmed.length - chineseCharCount return chineseCharCount + (englishCharCount / 4) + 1 -} - - -/** - * 估算消息列表的总 token 数量(近似) - * @param messages List of message map: role: user/assistant, content: string or array - * @return 估算 token 数 - */ -fun estimatePromptTokens(messages: List): Int { - var totalText = "" - for (message in messages) { - when (val content = message.messageContent) { - is TextContent? -> { - totalText += content?.content + " " - } - is ListContent -> { - // 处理多模态内容数组 - content.content.forEach { item -> - if (item is Map<*, *>) { - val type = item["type"] as? String - if (type == "text") { - val text = item["text"] as? String ?: "" - totalText += text + " " - }else if(type == "image_url"){ - val text = item["image_url"] as? String ?: "" - totalText += text + " " - } - } - } - } - } - } - return roughTokenCount(totalText) -} -//fun estimatePromptTokens(messages: List): Int { -// var tokens = 0 -// for (msg in messages) { -// tokens += 4 // 每条消息结构开销 -// tokens += roughTokenCount(msg.role.role.lowercase()) // ChatRole 枚举转小写字符串 -// tokens += roughTokenCount(msg.content ?: "") -// } -// tokens += 2 // 结尾 assistant 回复结构开销 -// return tokens -//} \ No newline at end of file +} \ No newline at end of file