optimize api sercice

This commit is contained in:
若遗 2025-09-05 14:29:38 +08:00
parent 20defb5b90
commit 4faa7ef3a3
19 changed files with 1172 additions and 28 deletions

View File

@ -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:

View File

@ -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)

View File

@ -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 {

View File

@ -113,6 +113,18 @@
android:foregroundServiceType="dataSync" >
</service>
<!-- 注册通知操作按钮的广播接收器 -->
<receiver
android:name="com.alibaba.mnnllm.api.openai.manager.ApiServiceActionReceiver"
android:enabled="true"
android:exported="false">
<intent-filter>
<action android:name="com.alibaba.mnnllm.api.openai.STOP_SERVICE" />
<action android:name="com.alibaba.mnnllm.api.openai.COPY_URL" />
<action android:name="com.alibaba.mnnllm.api.openai.TEST_PAGE" />
</intent-filter>
</receiver>
<activity android:name=".debug.WidgetTestActivity"
android:enabled="true"
android:exported="true">

View File

@ -0,0 +1,538 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>MNN Frontend</title>
<style>
* {
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
margin: 0;
padding: 1rem;
background-color: #f5f5f5;
min-height: 100vh;
}
.container {
max-width: 100%;
margin: 0 auto;
background: white;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1.5rem 1rem;
text-align: center;
}
.header h1 {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
font-weight: 600;
}
.header p {
margin: 0;
font-size: 0.9rem;
opacity: 0.9;
}
.model-selector {
padding: 1rem;
background-color: #f8f9fa;
border-bottom: 1px solid #e9ecef;
}
.model-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.model-row label {
font-weight: 500;
color: #495057;
font-size: 0.9rem;
}
#model-select {
flex: 1;
min-width: 200px;
padding: 0.5rem;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 0.9rem;
background: white;
}
.button {
padding: 0.5rem 1rem;
font-size: 0.9rem;
border: none;
border-radius: 6px;
cursor: pointer;
font-weight: 500;
transition: all 0.2s ease;
}
.button-primary {
background-color: #007bff;
color: white;
}
.button-primary:hover {
background-color: #0056b3;
}
.button-secondary {
background-color: #6c757d;
color: white;
}
.button-secondary:hover {
background-color: #545b62;
}
#chat-container {
height: 400px;
overflow-y: auto;
padding: 1rem;
background: white;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 8px;
max-width: 85%;
word-wrap: break-word;
}
.user {
background-color: #e3f2fd;
margin-left: auto;
text-align: right;
}
.assistant {
background-color: #f1f3f4;
margin-right: auto;
}
.message strong {
display: block;
margin-bottom: 0.25rem;
font-size: 0.8rem;
opacity: 0.7;
}
.input-area {
padding: 1rem;
background-color: #f8f9fa;
border-top: 1px solid #e9ecef;
}
.input-row {
display: flex;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
#user-input {
flex: 1;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 6px;
font-size: 1rem;
background: white;
}
.button-group {
display: flex;
gap: 0.5rem;
}
.status {
padding: 0.5rem;
background-color: #e8f4fd;
border-radius: 6px;
font-size: 0.85rem;
color: #495057;
text-align: center;
}
/* 移动端优化 */
@media (max-width: 768px) {
body {
padding: 0.5rem;
}
.header {
padding: 1rem;
}
.header h1 {
font-size: 1.25rem;
}
.header p {
font-size: 0.8rem;
}
.model-row {
flex-direction: column;
align-items: stretch;
}
#model-select {
min-width: auto;
width: 100%;
}
.button-group {
width: 100%;
}
.button-group .button {
flex: 1;
}
#chat-container {
height: 300px;
padding: 0.75rem;
}
.message {
max-width: 95%;
font-size: 0.9rem;
}
.input-row {
flex-direction: column;
}
#user-input {
margin-bottom: 0.5rem;
}
}
/* 滚动条样式 */
#chat-container::-webkit-scrollbar {
width: 6px;
}
#chat-container::-webkit-scrollbar-track {
background: #f1f1f1;
}
#chat-container::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 3px;
}
#chat-container::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Chat with MNN</h1>
<p>MNN-LLM's server API is OpenAI API compatible. You can use other frameworks like OpenWebUI or LobeChat.</p>
</div>
<div class="model-selector">
<div class="model-row">
<label for="model-select">Current Model:</label>
<select id="model-select">
<option value="unknown">Loading models...</option>
</select>
<button class="button button-secondary" onclick="refreshModels()">Refresh Models</button>
</div>
</div>
<div id="chat-container"></div>
<div class="input-area">
<div class="input-row">
<input
type="text"
id="user-input"
placeholder="Type your message here..."
onkeydown="if(event.key==='Enter'){ sendMessage(); }"
/>
</div>
<div class="button-group">
<button id="send-btn" class="button button-primary" onclick="sendMessage()">Send</button>
<button id="reset-btn" class="button button-secondary" onclick="resetChat()">Reset</button>
</div>
<div class="status" id="status">Ready to chat</div>
</div>
</div>
<script>
// 从URL参数中获取token
function getTokenFromUrl() {
const urlParams = new URLSearchParams(window.location.search);
return urlParams.get('token') || "no";
}
const OPENAI_API_KEY = getTokenFromUrl(); // 从URL参数获取token如果没有则使用"no"
let currentModel = "unknown";
let availableModels = [];
let messages = [
{ role: "system", content: "You are a helpful assistant." },
];
// Load available models on page load
window.onload = function() {
refreshModels();
};
async function refreshModels() {
try {
updateStatus("Loading models...");
const response = await fetch("/v1/models", {
method: "GET",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
});
if (!response.ok) {
throw new Error(`Error: ${response.status} - ${response.statusText}`);
}
const data = await response.json();
availableModels = data.data || [];
const modelSelect = document.getElementById("model-select");
modelSelect.innerHTML = "";
if (availableModels.length === 0) {
modelSelect.innerHTML = '<option value="unknown">No models available</option>';
updateStatus("No models available");
} else {
availableModels.forEach(model => {
const option = document.createElement("option");
option.value = model.id;
// 只显示模型名称的后缀部分
const modelName = extractModelSuffix(model.id);
option.textContent = modelName;
modelSelect.appendChild(option);
});
// Set first model as default
if (availableModels.length > 0) {
currentModel = availableModels[0].id;
modelSelect.value = currentModel;
updateStatus(`Model loaded: ${extractModelSuffix(currentModel)}`);
}
}
} catch (error) {
console.error("Failed to load models:", error);
updateStatus(`Error loading models: ${error.message}`);
document.getElementById("model-select").innerHTML = '<option value="unknown">Error loading models</option>';
}
}
// 提取模型名称后缀的函数
function extractModelSuffix(modelId) {
if (!modelId) return "unknown";
// 移除常见的前缀
let suffix = modelId;
// 移除 ModelScope/MNN/ 前缀
if (suffix.startsWith("ModelScope/MNN/")) {
suffix = suffix.substring("ModelScope/MNN/".length);
}
// 移除其他常见前缀
const prefixes = [
"ModelScope/",
"HuggingFace/",
"MNN/",
"modelscope/",
"huggingface/"
];
for (const prefix of prefixes) {
if (suffix.startsWith(prefix)) {
suffix = suffix.substring(prefix.length);
break;
}
}
return suffix || modelId;
}
// Handle model selection change
document.getElementById("model-select").addEventListener("change", function() {
currentModel = this.value;
updateStatus(`Model switched to: ${extractModelSuffix(currentModel)}`);
});
function updateStatus(message) {
document.getElementById("status").textContent = message;
}
async function resetChat() {
document.getElementById("user-input").value = "";
document.getElementById("chat-container").innerHTML = "";
messages = [
{ role: "system", content: "You are a helpful assistant." },
];
updateStatus("Chat reset");
try {
await fetch("/reset", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
},
body: JSON.stringify({ reset: true }),
});
} catch (error) {
console.error("Reset failed:", error);
}
}
async function sendMessage() {
const userInput = document.getElementById("user-input").value.trim();
if (!userInput) return;
if (currentModel === "unknown") {
updateStatus("Please select a model first");
return;
}
// Display user message
displayMessage(userInput, "user");
document.getElementById("user-input").value = "";
updateStatus("Sending message...");
messages.push({ role: "user", content: userInput });
try {
// We set "stream": true to indicate we want SSE streaming from our server
const payload = {
model: currentModel,
messages: messages,
max_tokens: 100,
temperature: 0.7,
stream: true,
};
const response = await fetch("/v1/chat/completions", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${OPENAI_API_KEY}`,
Accept: "text/event-stream",
},
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error(`Error: ${response.status} - ${response.statusText}`);
}
// Prepare to stream the response
await handleStream(response);
} catch (error) {
displayMessage(`Error: ${error.message}`, "assistant");
updateStatus(`Error: ${error.message}`);
}
}
async function handleStream(response) {
// We'll accumulate tokens into this variable
let assistantMessage = "";
// Create a DOM element for the assistant's streaming message
const chatContainer = document.getElementById("chat-container");
const messageElem = document.createElement("div");
messageElem.classList.add("message", "assistant");
messageElem.innerHTML = `<strong class="assistant">Assistant:</strong> <span></span>`;
chatContainer.appendChild(messageElem);
chatContainer.scrollTop = chatContainer.scrollHeight;
const messageTextSpan = messageElem.querySelector("span");
updateStatus("Receiving response...");
// Read the response body as a stream
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
try {
while (true) {
const { value, done } = await reader.read();
if (done) {
break;
}
const chunk = decoder.decode(value, { stream: true });
const lines = chunk.split("\n");
for (let line of lines) {
if (!line || !line.startsWith("data: ")) {
continue;
}
const jsonStr = line.substring("data: ".length).trim();
if (jsonStr === "[DONE]") {
messages.push({ role: "assistant", content: assistantMessage });
updateStatus(`Response complete (${extractModelSuffix(currentModel)})`);
return;
}
try {
const parsed = JSON.parse(jsonStr);
if (parsed.choices && parsed.choices.length > 0) {
const deltaContent = parsed.choices[0].delta.content;
if (deltaContent) {
assistantMessage += deltaContent;
// Update the DOM text
messageTextSpan.textContent = assistantMessage;
chatContainer.scrollTop = chatContainer.scrollHeight;
}
}
} catch (e) {
console.error("Could not parse SSE line:", e, line);
}
}
}
} finally {
reader.releaseLock();
}
}
function displayMessage(text, sender) {
const chatContainer = document.getElementById("chat-container");
const messageElem = document.createElement("div");
messageElem.classList.add("message", sender);
if (sender === "user") {
messageElem.innerHTML = `<strong class="user">User:</strong> ${text}`;
} else {
messageElem.innerHTML = `<strong class="assistant">Assistant:</strong> ${text}`;
}
chatContainer.appendChild(messageElem);
chatContainer.scrollTop = chatContainer.scrollHeight;
}
</script>
</body>
</html>

View File

@ -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()
}
}
}

View File

@ -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}")
}
}
}

View File

@ -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}")
}
}

View File

@ -65,3 +65,39 @@ data class Message(
val role: String,
val content: String
)
/**
* Models API 响应数据模型
*/
@Serializable
data class ModelsResponse(
val `object`: String = "list",
val data: List<ModelData>
)
@Serializable
data class ModelData(
val id: String,
val `object`: String = "model",
val created: Long,
val owned_by: String = "mnn",
val permission: List<ModelPermission> = 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
)

View File

@ -23,14 +23,8 @@ fun Route.chatRoutes() {
post("/v1/chat/completions") {
val traceId = UUID.randomUUID().toString()
// 记录请求开始
logger.logRequestStart(traceId, call)
// 接收请求体
val chatRequest = call.receive<OpenAIChatRequest>()
// 委托给服务层处理
MNNChatService.processChatCompletion(call, chatRequest, traceId)
}
}

View File

@ -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)
}
}

View File

@ -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"))
}
}
}

View File

@ -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)
/**
* 获取服务器端口

View File

@ -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
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
}
}

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5v12h11L19,5zM19,19L8,19v2h11c1.1,0 2,-0.9 2,-2v-2h-2z"/>
</vector>

View File

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorOnSurface">
<path
android:fillColor="@android:color/white"
android:pathData="M6,6h12v12H6z"/>
</vector>

View File

@ -106,9 +106,14 @@
<string name="api_service_not_started">API 服务未启动</string>
<string name="no_active_session">未找到活跃会话</string>
<string name="api_service_running">API 服务运行中</string>
<string name="api_service_running_on">API 服务运行在 %1$s:%2$d</string>
<string name="api_service_port">端口:%1$d</string>
<string name="api_service_start_failed">API 服务启动失败</string>
<string name="api_service_error">错误:%1$s</string>
<string name="api_service_stop">停止</string>
<string name="api_service_copy_url">复制URL</string>
<string name="api_service_test_page">测试页面</string>
<string name="api_url_copied">API URL已复制到剪贴板</string>
<string name="modelscope">魔搭</string>
<string name="modelers">魔乐</string>
<string name="huggingface">HuggingFace</string>

View File

@ -113,9 +113,14 @@
<string name="api_service_not_started">API Service Not Started</string>
<string name="no_active_session">No active session found</string>
<string name="api_service_running">API Service Running</string>
<string name="api_service_running_on">API Service running on %1$s:%2$d</string>
<string name="api_service_port">Port: %1$d</string>
<string name="api_service_start_failed">API Service Start Failed</string>
<string name="api_service_error">Error: %1$s</string>
<string name="api_service_stop">Stop</string>
<string name="api_service_copy_url">Copy URL</string>
<string name="api_service_test_page">Test Page</string>
<string name="api_url_copied">API URL copied to clipboard</string>
<string name="modelscope">Modelscope</string>
<string name="modelers">Modelers</string>
<string name="huggingface">HuggingFace</string>

View File

@ -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())