From 4faa7ef3a36e5d55fa6cf4d34ae727c33ea7e25f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=A5=E9=81=97?= Date: Fri, 5 Sep 2025 14:29:38 +0800 Subject: [PATCH] optimize api sercice --- apps/Android/MnnLlmChat/README.md | 3 + apps/Android/MnnLlmChat/README_CN.md | 3 + apps/Android/MnnLlmChat/app/build.gradle | 4 +- .../app/src/main/AndroidManifest.xml | 12 + .../app/src/main/assets/test_page.html | 538 ++++++++++++++++++ .../manager/ApiServiceActionReceiver.kt | 132 +++++ .../api/openai/manager/NotificationManager.kt | 114 +++- .../api/openai/network/application/Routing.kt | 30 +- .../models/OpenAiChatResponseModels.kt | 36 ++ .../api/openai/network/routes/ChatRoutes.kt | 6 - .../api/openai/network/routes/ModelsRoutes.kt | 26 + .../network/services/MNNModelsService.kt | 53 ++ .../openai/service/ApiServiceCoordinator.kt | 12 +- .../api/openai/service/OpenAIService.kt | 25 +- .../src/main/res/drawable/ic_action_copy.xml | 10 + .../src/main/res/drawable/ic_action_stop.xml | 10 + .../app/src/main/res/values-zh/strings.xml | 5 + .../app/src/main/res/values/strings.xml | 5 + apps/Android/MnnLlmChat/test_api.py | 176 ++++++ 19 files changed, 1172 insertions(+), 28 deletions(-) create mode 100644 apps/Android/MnnLlmChat/app/src/main/assets/test_page.html create mode 100644 apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/ApiServiceActionReceiver.kt create mode 100644 apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ModelsRoutes.kt create mode 100644 apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/services/MNNModelsService.kt create mode 100644 apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_copy.xml create mode 100644 apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_stop.xml create mode 100755 apps/Android/MnnLlmChat/test_api.py diff --git a/apps/Android/MnnLlmChat/README.md b/apps/Android/MnnLlmChat/README.md index 68827b16..8d9e475a 100644 --- a/apps/Android/MnnLlmChat/README.md +++ b/apps/Android/MnnLlmChat/README.md @@ -61,6 +61,9 @@ This is our full multimodal language model (LLM) Android app ``` # Releases +## Version 0.7.3 ++ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_3.apk) ++ Optimize ApiService ## Version 0.7.2 + Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk) + Bugfix: diff --git a/apps/Android/MnnLlmChat/README_CN.md b/apps/Android/MnnLlmChat/README_CN.md index 45834be8..e9aa41a5 100644 --- a/apps/Android/MnnLlmChat/README_CN.md +++ b/apps/Android/MnnLlmChat/README_CN.md @@ -53,6 +53,9 @@ ``` # Releases +## Version 0.7.3 ++ 点击这里 [下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_3.apk) ++ 优化 API 服务 ## 版本 0.7.2 + 点击这里 [下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk) diff --git a/apps/Android/MnnLlmChat/app/build.gradle b/apps/Android/MnnLlmChat/app/build.gradle index 5e2c2206..79ffc3ac 100644 --- a/apps/Android/MnnLlmChat/app/build.gradle +++ b/apps/Android/MnnLlmChat/app/build.gradle @@ -59,8 +59,8 @@ android { applicationId "com.alibaba.mnnllm.android" minSdk 26 targetSdk 35 - versionCode 702 - versionName "0.7.2" + versionCode 703 + versionName "0.7.3" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" externalNativeBuild { diff --git a/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml b/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml index 39412015..9a17edc5 100644 --- a/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml +++ b/apps/Android/MnnLlmChat/app/src/main/AndroidManifest.xml @@ -112,6 +112,18 @@ android:exported="true" android:foregroundServiceType="dataSync" > + + + + + + + + + + + + + + MNN Frontend + + + +
+
+

Chat with MNN

+

MNN-LLM's server API is OpenAI API compatible. You can use other frameworks like OpenWebUI or LobeChat.

+
+ +
+
+ + + +
+
+ +
+ +
+
+ +
+
+ + +
+
Ready to chat
+
+
+ + + + \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/ApiServiceActionReceiver.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/ApiServiceActionReceiver.kt new file mode 100644 index 00000000..df6da3e7 --- /dev/null +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/ApiServiceActionReceiver.kt @@ -0,0 +1,132 @@ +package com.alibaba.mnnllm.api.openai.manager + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.ClipboardManager +import android.content.ClipData +import android.widget.Toast +import android.net.Uri +import com.alibaba.mnnllm.api.openai.service.OpenAIService +import com.alibaba.mnnllm.api.openai.service.ApiServerConfig +import timber.log.Timber + +/** + * 处理通知栏操作按钮点击事件的广播接收器 + */ +class ApiServiceActionReceiver : BroadcastReceiver() { + + companion object { + const val ACTION_STOP_SERVICE = "com.alibaba.mnnllm.api.openai.STOP_SERVICE" + const val ACTION_COPY_URL = "com.alibaba.mnnllm.api.openai.COPY_URL" + const val ACTION_TEST_PAGE = "com.alibaba.mnnllm.api.openai.TEST_PAGE" + const val EXTRA_URL = "extra_url" + } + + override fun onReceive(context: Context, intent: Intent) { + Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST RECEIVED ===") + Timber.tag("ApiServiceActionReceiver").i("Action: ${intent.action}") + Timber.tag("ApiServiceActionReceiver").i("Package: ${intent.`package`}") + Timber.tag("ApiServiceActionReceiver").i("Component: ${intent.component}") + Timber.tag("ApiServiceActionReceiver").i("Intent extras: ${intent.extras}") + Timber.tag("ApiServiceActionReceiver").i("Intent data: ${intent.data}") + Timber.tag("ApiServiceActionReceiver").i("Intent flags: ${intent.flags}") + + when (intent.action) { + ACTION_STOP_SERVICE -> { + Timber.tag("ApiServiceActionReceiver").i("Processing STOP_SERVICE action") + stopApiService(context) + } + ACTION_COPY_URL -> { + Timber.tag("ApiServiceActionReceiver").i("Processing COPY_URL action") + val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080" + Timber.tag("ApiServiceActionReceiver").i("URL to copy: $url") + copyUrlToClipboard(context, url) + } + ACTION_TEST_PAGE -> { + Timber.tag("ApiServiceActionReceiver").i("Processing TEST_PAGE action") + val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080" + Timber.tag("ApiServiceActionReceiver").i("URL to open: $url") + openTestPageInBrowser(context, url) + } + else -> { + Timber.tag("ApiServiceActionReceiver").w("Unknown action: ${intent.action}") + Timber.tag("ApiServiceActionReceiver").w("Expected actions: $ACTION_STOP_SERVICE, $ACTION_COPY_URL, $ACTION_TEST_PAGE") + } + } + + Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST PROCESSING COMPLETE ===") + } + + /** + * 停止 API 服务 + */ + private fun stopApiService(context: Context) { + Timber.tag("ApiServiceActionReceiver").i("Starting to stop API service...") + try { + Timber.tag("ApiServiceActionReceiver").i("Calling OpenAIService.releaseService()") + OpenAIService.releaseService(context) + Timber.tag("ApiServiceActionReceiver").i("API service stopped successfully") + Toast.makeText(context, "API Service stopped", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Timber.tag("ApiServiceActionReceiver").e(e, "Failed to stop API service: ${e.message}") + Toast.makeText(context, "Failed to stop service", Toast.LENGTH_SHORT).show() + } + } + + /** + * 复制 URL 到剪贴板 + */ + private fun copyUrlToClipboard(context: Context, url: String) { + Timber.tag("ApiServiceActionReceiver").i("Starting to copy URL to clipboard: $url") + try { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + Timber.tag("ApiServiceActionReceiver").i("Clipboard service obtained: $clipboard") + + val clip = ClipData.newPlainText("API URL", url) + Timber.tag("ApiServiceActionReceiver").i("ClipData created: $clip") + + clipboard.setPrimaryClip(clip) + Timber.tag("ApiServiceActionReceiver").i("URL copied to clipboard successfully") + + val successMessage = context.getString(com.alibaba.mnnllm.android.R.string.api_url_copied) + Timber.tag("ApiServiceActionReceiver").i("Showing toast: $successMessage") + Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Timber.tag("ApiServiceActionReceiver").e(e, "Failed to copy URL to clipboard: ${e.message}") + Toast.makeText(context, "Failed to copy URL", Toast.LENGTH_SHORT).show() + } + } + + /** + * 在浏览器中打开测试页面 + */ + private fun openTestPageInBrowser(context: Context, url: String) { + Timber.tag("ApiServiceActionReceiver").i("Starting to open test page in browser: $url") + try { + // 检查是否启用了认证,如果启用则添加token参数 + val finalUrl = if (ApiServerConfig.isAuthEnabled(context)) { + val apiKey = ApiServerConfig.getApiKey(context) + val separator = if (url.contains("?")) "&" else "?" + val urlWithToken = "$url${separator}token=$apiKey" + Timber.tag("ApiServiceActionReceiver").i("Auth enabled, adding token to URL: $urlWithToken") + urlWithToken + } else { + Timber.tag("ApiServiceActionReceiver").i("Auth disabled, using original URL") + url + } + + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(finalUrl)) + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + Timber.tag("ApiServiceActionReceiver").i("Intent created: $intent") + + context.startActivity(intent) + Timber.tag("ApiServiceActionReceiver").i("Test page opened in browser successfully") + + Toast.makeText(context, "Opening test page in browser", Toast.LENGTH_SHORT).show() + } catch (e: Exception) { + Timber.tag("ApiServiceActionReceiver").e(e, "Failed to open test page in browser: ${e.message}") + Toast.makeText(context, "Failed to open browser", Toast.LENGTH_SHORT).show() + } + } +} \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/NotificationManager.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/NotificationManager.kt index d59c628f..0f6af3ce 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/NotificationManager.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/manager/NotificationManager.kt @@ -4,8 +4,11 @@ import android.R import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager +import android.app.PendingIntent import android.content.Context +import android.content.Intent import androidx.core.app.NotificationCompat +import com.alibaba.mnnllm.api.openai.service.ApiServerConfig import timber.log.Timber /** @@ -46,20 +49,86 @@ class ApiNotificationManager(private val context: Context) { } /** - * Build API service notification + * Build API service notification with IP address and action buttons * * @param contentTitle Notification title, uses default from string resources if not provided * @param contentText Notification content, uses default from string resources if not provided + * @param port Server port number * @return Built Notification object */ fun buildNotification( contentTitle: String? = null, - contentText: String? = null + contentText: String? = null, + port: Int = 8080 ): Notification { val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running) - val text = contentText ?: context.getString(com.alibaba.mnnllm.android.R.string.server_running_message) + val ipAddress = ApiServerConfig.getIpAddress(context) + val url = "http://$ipAddress:$port" + val text = if (contentText.isNullOrBlank()) { + context.getString(com.alibaba.mnnllm.android.R.string.api_service_running_on, ipAddress, port) + } else { + contentText + } - return NotificationCompat.Builder(context, CHANNEL_ID) + Timber.tag("ApiNotificationManager").i("Building notification - Config IP: $ipAddress, Port: $port, Text: $text") + + // 创建停止服务的 PendingIntent + val stopIntent = Intent(ApiServiceActionReceiver.ACTION_STOP_SERVICE).apply { + `package` = context.packageName + } + Timber.tag("ApiNotificationManager").i("Creating stop intent with action: ${ApiServiceActionReceiver.ACTION_STOP_SERVICE}") + Timber.tag("ApiNotificationManager").i("Stop intent package: ${context.packageName}") + val stopPendingIntent = PendingIntent.getBroadcast( + context, + 1001, + stopIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + Timber.tag("ApiNotificationManager").i("Stop PendingIntent created: $stopPendingIntent") + + // 创建复制 URL 的 PendingIntent + val copyIntent = Intent(ApiServiceActionReceiver.ACTION_COPY_URL).apply { + `package` = context.packageName + putExtra(ApiServiceActionReceiver.EXTRA_URL, url) + } + Timber.tag("ApiNotificationManager").i("Creating copy intent with action: ${ApiServiceActionReceiver.ACTION_COPY_URL}, URL: $url") + Timber.tag("ApiNotificationManager").i("Copy intent package: ${context.packageName}") + val copyPendingIntent = PendingIntent.getBroadcast( + context, + System.currentTimeMillis().toInt(), + copyIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + Timber.tag("ApiNotificationManager").i("Copy PendingIntent created: $copyPendingIntent") + + // 创建打开测试页面的 PendingIntent + val testIntent = Intent(ApiServiceActionReceiver.ACTION_TEST_PAGE).apply { + `package` = context.packageName + putExtra(ApiServiceActionReceiver.EXTRA_URL, url) + } + Timber.tag("ApiNotificationManager").i("Creating test intent with action: ${ApiServiceActionReceiver.ACTION_TEST_PAGE}, URL: $url") + Timber.tag("ApiNotificationManager").i("Test intent package: ${context.packageName}") + val testPendingIntent = PendingIntent.getBroadcast( + context, + (System.currentTimeMillis() + 1).toInt(), + testIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + Timber.tag("ApiNotificationManager").i("Test PendingIntent created: $testPendingIntent") + + // 创建点击通知时打开主Activity的PendingIntent + val mainActivityIntent = Intent(context, com.alibaba.mnnllm.android.main.MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP + } + val mainActivityPendingIntent = PendingIntent.getActivity( + context, + (System.currentTimeMillis() + 2).toInt(), + mainActivityIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + Timber.tag("ApiNotificationManager").i("MainActivity PendingIntent created: $mainActivityPendingIntent") + + val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle(title) .setContentText(text) .setSmallIcon(R.drawable.ic_dialog_info) @@ -67,7 +136,30 @@ class ApiNotificationManager(private val context: Context) { .setOngoing(true) .setAutoCancel(false) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setContentIntent(mainActivityPendingIntent) + // .addAction( + // R.drawable.ic_dialog_alert, + // context.getString(com.alibaba.mnnllm.android.R.string.api_service_stop), + // stopPendingIntent + // ) + .addAction( + R.drawable.ic_dialog_info, + context.getString(com.alibaba.mnnllm.android.R.string.api_service_copy_url), + copyPendingIntent + ) + .addAction( + R.drawable.ic_dialog_info, + context.getString(com.alibaba.mnnllm.android.R.string.api_service_test_page), + testPendingIntent + ) .build() + + Timber.tag("ApiNotificationManager").i("Notification built successfully with ${notification.actions?.size ?: 0} actions") + notification.actions?.forEachIndexed { index, action -> + Timber.tag("ApiNotificationManager").i("Action $index: ${action.title}, Intent: ${action.actionIntent}") + } + + return notification } /** @@ -75,11 +167,14 @@ class ApiNotificationManager(private val context: Context) { * * @param contentTitle New notification title * @param contentText New notification content + * @param port Server port number */ - fun updateNotification(contentTitle: String, contentText: String) { - val notification = buildNotification(contentTitle, contentText) - Timber.tag("NotificationManager").i("Updating notification: $contentTitle - $contentText") + fun updateNotification(contentTitle: String, contentText: String, port: Int = 8080) { + Timber.tag("ApiNotificationManager").i("updateNotification called - Title: $contentTitle, Text: $contentText, Port: $port") + val notification = buildNotification(contentTitle, contentText, port) + Timber.tag("ApiNotificationManager").i("Updating notification: $contentTitle - $contentText") notificationManager.notify(NOTIFICATION_ID, notification) + Timber.tag("ApiNotificationManager").i("Notification updated with ID: $NOTIFICATION_ID") } /** @@ -90,9 +185,10 @@ class ApiNotificationManager(private val context: Context) { fun cancelNotification() { try { notificationManager.cancel(NOTIFICATION_ID) - Timber.tag("NotificationManager").i("Notification cancelled") + Timber.tag("ApiNotificationManager").i("Notification cancelled") } catch (e: Exception) { - Timber.tag("NotificationManager").w("Failed to cancel notification: ${e.message}") + Timber.tag("ApiNotificationManager").w("Failed to cancel notification: ${e.message}") } } + } \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/application/Routing.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/application/Routing.kt index 9b2e57d9..2c0795bb 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/application/Routing.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/application/Routing.kt @@ -5,6 +5,7 @@ import com.alibaba.mnnllm.android.chat.ChatActivity import com.alibaba.mnnllm.android.llm.LlmSession import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes import com.alibaba.mnnllm.api.openai.network.routes.queueRoutes +import com.alibaba.mnnllm.api.openai.network.routes.modelsRoutes import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* import io.ktor.server.application.* @@ -19,6 +20,8 @@ import io.ktor.server.routing.* import io.ktor.server.sse.* import io.ktor.sse.* import org.slf4j.event.* +import android.content.Context +import java.io.InputStream fun Application.configureRouting() { @@ -26,8 +29,12 @@ fun Application.configureRouting() { routing { get("/") { - val response = "Hello, World!" - call.respondText(response, contentType = ContentType.Text.Plain) + try { + val htmlContent = loadHtmlFromAssets() + call.respondText(htmlContent, contentType = ContentType.Text.Html) + } catch (e: Exception) { + call.respondText("Error loading test page: ${e.message}", contentType = ContentType.Text.Plain) + } } sse("/hello") { send(ServerSentEvent("world")) @@ -40,6 +47,8 @@ fun Application.configureRouting() { // 在这里定义需要认证的路由 // /v1/chat/completions chatRoutes() + // /v1/models + modelsRoutes() } } @@ -59,4 +68,19 @@ fun Application.configureRouting() { } - +/** + * 从assets目录加载HTML文件 + */ +private fun loadHtmlFromAssets(): String { + return try { + // 使用Application Context来访问assets + val context = com.alibaba.mnnllm.android.MnnLlmApplication.getAppContext() + val inputStream: InputStream = context.assets.open("test_page.html") + + inputStream.bufferedReader().use { reader -> + reader.readText() + } + } catch (e: Exception) { + throw Exception("Failed to load HTML from assets: ${e.message}") + } +} \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/models/OpenAiChatResponseModels.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/models/OpenAiChatResponseModels.kt index 8649e4fa..94ea062c 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/models/OpenAiChatResponseModels.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/models/OpenAiChatResponseModels.kt @@ -64,4 +64,40 @@ data class CompletionChoice( data class Message( val role: String, val content: String +) + +/** + * Models API 响应数据模型 + */ +@Serializable +data class ModelsResponse( + val `object`: String = "list", + val data: List +) + +@Serializable +data class ModelData( + val id: String, + val `object`: String = "model", + val created: Long, + val owned_by: String = "mnn", + val permission: List = emptyList(), + val root: String? = null, + val parent: String? = null +) + +@Serializable +data class ModelPermission( + val id: String, + val `object`: String = "model_permission", + val created: Long, + val allow_create_engine: Boolean = false, + val allow_sampling: Boolean = true, + val allow_logprobs: Boolean = true, + val allow_search_indices: Boolean = true, + val allow_view: Boolean = true, + val allow_fine_tuning: Boolean = false, + val organization: String = "*", + val group: String? = null, + val is_blocking: Boolean = false ) \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ChatRoutes.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ChatRoutes.kt index 8067e035..f8b7b020 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ChatRoutes.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ChatRoutes.kt @@ -23,14 +23,8 @@ fun Route.chatRoutes() { post("/v1/chat/completions") { val traceId = UUID.randomUUID().toString() - - // 记录请求开始 logger.logRequestStart(traceId, call) - - // 接收请求体 val chatRequest = call.receive() - - // 委托给服务层处理 MNNChatService.processChatCompletion(call, chatRequest, traceId) } } \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ModelsRoutes.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ModelsRoutes.kt new file mode 100644 index 00000000..b99d9752 --- /dev/null +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/routes/ModelsRoutes.kt @@ -0,0 +1,26 @@ +package com.alibaba.mnnllm.api.openai.network.routes + +import com.alibaba.mnnllm.api.openai.network.models.ModelData +import com.alibaba.mnnllm.api.openai.network.models.ModelPermission +import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse +import com.alibaba.mnnllm.api.openai.network.services.MNNModelsService +import io.ktor.server.routing.Route +import io.ktor.server.routing.get +import java.util.UUID + +/** + * 模型路由定义 + * 负责定义 /v1/models API路由 + */ + +/** + * 注册模型相关的路由 + */ +fun Route.modelsRoutes() { + val modelsService = MNNModelsService() + + get("/v1/models") { + val traceId = UUID.randomUUID().toString() + modelsService.getAvailableModels(call, traceId) + } +} diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/services/MNNModelsService.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/services/MNNModelsService.kt new file mode 100644 index 00000000..e31213ab --- /dev/null +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/network/services/MNNModelsService.kt @@ -0,0 +1,53 @@ +package com.alibaba.mnnllm.api.openai.network.services + +import com.alibaba.mnnllm.api.openai.network.logging.ChatLogger +import com.alibaba.mnnllm.api.openai.network.models.ModelData +import com.alibaba.mnnllm.api.openai.network.models.ModelPermission +import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse +import com.alibaba.mnnllm.android.modelist.ModelListManager +import com.alibaba.mnnllm.android.MnnLlmApplication +import io.ktor.http.HttpStatusCode +import io.ktor.server.application.call +import io.ktor.server.response.respond +import kotlinx.coroutines.runBlocking +import timber.log.Timber + +/** + * MNN 模型服务 + * 负责处理模型相关的业务逻辑 + */ +class MNNModelsService { + private val logger = ChatLogger() + + suspend fun getAvailableModels(call: io.ktor.server.application.ApplicationCall, traceId: String) { + try { + logger.logRequestStart(traceId, call) + + val context = MnnLlmApplication.getAppContext() + val availableModels = runBlocking { + ModelListManager.loadAvailableModels(context) + } + val modelDataList = availableModels.map { modelWrapper -> + ModelData( + id = modelWrapper.modelItem.modelId ?: "unknown", + created = System.currentTimeMillis() / 1000, // Unix timestamp + permission = listOf( + ModelPermission( + id = "modelperm-${modelWrapper.modelItem.modelId}", + created = System.currentTimeMillis() / 1000 + ) + ) + ) + } + val response = ModelsResponse(data = modelDataList) + + call.respond(response) + logger.logInfo(traceId, "Models list returned successfully") + + } catch (e: Exception) { + Timber.tag("MNNModelsService").e(e, "Error getting available models") + logger.logError(traceId, e, "Failed to get available models") + call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Failed to get available models")) + } + } +} diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/ApiServiceCoordinator.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/ApiServiceCoordinator.kt index 40aa4258..6b478133 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/ApiServiceCoordinator.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/ApiServiceCoordinator.kt @@ -82,7 +82,8 @@ class ApiServiceCoordinator(private val context: Context) { // 更新通知 notificationManager?.updateNotification( context.getString(R.string.api_service_running), - context.getString(R.string.api_service_port, app.getPort()) + "", // 让 NotificationManager 使用默认的 IP 地址显示 + app.getPort() ) _isServerRunning = true @@ -123,8 +124,8 @@ class ApiServiceCoordinator(private val context: Context) { /** * 更新通知内容 */ - fun updateNotification(title: String, content: String) { - notificationManager?.updateNotification(title, content) + fun updateNotification(title: String, content: String, port: Int = 8080) { + notificationManager?.updateNotification(title, content, port) } /** @@ -132,8 +133,9 @@ class ApiServiceCoordinator(private val context: Context) { */ fun getNotification( title: String = context.getString(R.string.api_service_running), - content: String = context.getString(R.string.api_service_port, 8080) - ) = notificationManager?.buildNotification(title, content) + content: String = context.getString(R.string.api_service_port, 8080), + port: Int = 8080 + ) = notificationManager?.buildNotification(title, content, port) /** * 获取服务器端口 diff --git a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/OpenAIService.kt b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/OpenAIService.kt index 275181cf..f258d3b4 100644 --- a/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/OpenAIService.kt +++ b/apps/Android/MnnLlmChat/app/src/main/java/com/alibaba/mnnllm/api/openai/service/OpenAIService.kt @@ -1,15 +1,18 @@ package com.alibaba.mnnllm.api.openai.service +import android.Manifest import android.app.Service import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.os.Binder import android.os.Build import android.os.IBinder import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat import com.alibaba.mnnllm.android.chat.ChatActivity import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager @@ -29,10 +32,27 @@ class OpenAIService : Service() { return } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + Timber.tag("ServiceStartCondition").w("Notification permission not granted, cannot start foreground service") + return + } + } + val serviceIntent = Intent(context, OpenAIService::class.java) - // 在启动服务前设置标志,避免onStartCommand中的检查失败 isServiceRunning = true - context.startForegroundService(serviceIntent) + try { + context.startForegroundService(serviceIntent) + Timber.tag("ServiceStartCondition").i("Foreground service started successfully") + } catch (e: Exception) { + Timber.tag("ServiceStartCondition").e(e, "Failed to start foreground service") + isServiceRunning = false + return + } val connection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { @@ -43,7 +63,6 @@ class OpenAIService : Service() { override fun onServiceDisconnected(name: ComponentName?) { serviceConnection = null - // 服务断开连接时重置标志 isServiceRunning = false } } diff --git a/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_copy.xml b/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_copy.xml new file mode 100644 index 00000000..894f1cf3 --- /dev/null +++ b/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_copy.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_stop.xml b/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_stop.xml new file mode 100644 index 00000000..a807dd98 --- /dev/null +++ b/apps/Android/MnnLlmChat/app/src/main/res/drawable/ic_action_stop.xml @@ -0,0 +1,10 @@ + + + diff --git a/apps/Android/MnnLlmChat/app/src/main/res/values-zh/strings.xml b/apps/Android/MnnLlmChat/app/src/main/res/values-zh/strings.xml index c5064eb7..11eee246 100644 --- a/apps/Android/MnnLlmChat/app/src/main/res/values-zh/strings.xml +++ b/apps/Android/MnnLlmChat/app/src/main/res/values-zh/strings.xml @@ -106,9 +106,14 @@ API 服务未启动 未找到活跃会话 API 服务运行中 + API 服务运行在 %1$s:%2$d 端口:%1$d API 服务启动失败 错误:%1$s + 停止 + 复制URL + 测试页面 + API URL已复制到剪贴板 魔搭 魔乐 HuggingFace diff --git a/apps/Android/MnnLlmChat/app/src/main/res/values/strings.xml b/apps/Android/MnnLlmChat/app/src/main/res/values/strings.xml index dce25a43..5929e6b1 100644 --- a/apps/Android/MnnLlmChat/app/src/main/res/values/strings.xml +++ b/apps/Android/MnnLlmChat/app/src/main/res/values/strings.xml @@ -113,9 +113,14 @@ API Service Not Started No active session found API Service Running + API Service running on %1$s:%2$d Port: %1$d API Service Start Failed Error: %1$s + Stop + Copy URL + Test Page + API URL copied to clipboard Modelscope Modelers HuggingFace diff --git a/apps/Android/MnnLlmChat/test_api.py b/apps/Android/MnnLlmChat/test_api.py new file mode 100755 index 00000000..1f217e9f --- /dev/null +++ b/apps/Android/MnnLlmChat/test_api.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +MNN LLM Chat API Test Script +Supports multiple test scenarios +""" + +import requests +import json +import sys +import time +import argparse +from typing import Dict, Any, List + +class MnnApiTester: + def __init__(self, host: str = "localhost", port: int = 8080, token: str = "mnn-llm-chat"): + self.host = host + self.port = port + self.token = token + self.base_url = f"http://{host}:{port}" + self.headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + def test_models(self) -> Dict[str, Any]: + """Test the /v1/models endpoint""" + print("Testing /v1/models endpoint...") + url = f"{self.base_url}/v1/models" + + try: + response = requests.get(url, headers=self.headers, timeout=10) + print(f"Status code: {response.status_code}") + + if response.status_code == 200: + data = response.json() + print(f"Response: {json.dumps(data, indent=2, ensure_ascii=False)}") + return {"success": True, "data": data} + else: + print(f"Error: {response.text}") + return {"success": False, "error": response.text} + + except Exception as e: + print(f"Exception: {e}") + return {"success": False, "error": str(e)} + + def test_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]: + """Test the /v1/chat/completions endpoint""" + print(f"Testing /v1/chat/completions endpoint (Model: {model})...") + url = f"{self.base_url}/v1/chat/completions" + + data = { + "model": model, + "messages": [{"role": "user", "content": message}], + "max_tokens": 100, + "temperature": 0.7 + } + + try: + response = requests.post(url, headers=self.headers, json=data, timeout=30) + print(f"Status code: {response.status_code}") + + if response.status_code == 200: + result = response.json() + print(f"Response: {json.dumps(result, indent=2, ensure_ascii=False)}") + return {"success": True, "data": result} + else: + print(f"Error: {response.text}") + return {"success": False, "error": response.text} + + except Exception as e: + print(f"Exception: {e}") + return {"success": False, "error": str(e)} + + def test_stream_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]: + """Test the streaming chat endpoint""" + print(f"Testing streaming chat endpoint (Model: {model})...") + url = f"{self.base_url}/v1/chat/completions" + + data = { + "model": model, + "messages": [{"role": "user", "content": message}], + "max_tokens": 100, + "temperature": 0.7, + "stream": True + } + + try: + response = requests.post(url, headers=self.headers, json=data, timeout=30, stream=True) + print(f"Status code: {response.status_code}") + + if response.status_code == 200: + print("Streaming response:") + for line in response.iter_lines(): + if line: + line_str = line.decode('utf-8') + if line_str.startswith('data: '): + data_str = line_str[6:] + if data_str.strip() == '[DONE]': + break + try: + chunk = json.loads(data_str) + if 'choices' in chunk and len(chunk['choices']) > 0: + delta = chunk['choices'][0].get('delta', {}) + if 'content' in delta: + print(delta['content'], end='', flush=True) + except json.JSONDecodeError: + pass + print("\n") + return {"success": True} + else: + print(f"Error: {response.text}") + return {"success": False, "error": response.text} + + except Exception as e: + print(f"Exception: {e}") + return {"success": False, "error": str(e)} + +def main(): + parser = argparse.ArgumentParser(description='MNN LLM Chat API Test Tool') + parser.add_argument('--host', default='localhost', help='Server address') + parser.add_argument('--port', type=int, default=8080, help='Server port') + parser.add_argument('--token', default='mnn-llm-chat', help='Authentication token') + parser.add_argument('--test', choices=['models', 'chat', 'stream', 'all'], default='all', help='Test type') + parser.add_argument('--model', default='qwen2.5-7b-instruct', help='Test model') + parser.add_argument('--message', default='Hello, please briefly introduce yourself', help='Test message') + + args = parser.parse_args() + + print("MNN LLM Chat API Test Tool") + print("=" * 50) + print(f"Server: {args.host}:{args.port}") + print(f"Token: {args.token}") + print(f"Test type: {args.test}") + print() + + tester = MnnApiTester(args.host, args.port, args.token) + + results = [] + + if args.test in ['models', 'all']: + print("1. Test models endpoint") + print("-" * 30) + result = tester.test_models() + results.append(('models', result)) + print() + + if args.test in ['chat', 'all']: + print("2. Test chat endpoint") + print("-" * 30) + result = tester.test_chat(args.model, args.message) + results.append(('chat', result)) + print() + + if args.test in ['stream', 'all']: + print("3. Test streaming chat endpoint") + print("-" * 30) + result = tester.test_stream_chat(args.model, args.message) + results.append(('stream', result)) + print() + + # Summary + print("Test Summary:") + for test_name, result in results: + status = "Success" if result['success'] else "Failure" + print(f" {test_name}: {status}") + + all_success = all(result['success'] for _, result in results) + if all_success: + print("\n✅ All tests passed!") + return 0 + else: + print("\n❌ Some tests failed!") + return 1 + +if __name__ == "__main__": + sys.exit(main())