mirror of https://github.com/alibaba/MNN.git
optimize api sercice
This commit is contained in:
parent
20defb5b90
commit
4faa7ef3a3
|
@ -61,6 +61,9 @@ This is our full multimodal language model (LLM) Android app
|
||||||
```
|
```
|
||||||
|
|
||||||
# Releases
|
# 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
|
## Version 0.7.2
|
||||||
+ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk)
|
+ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk)
|
||||||
+ Bugfix:
|
+ Bugfix:
|
||||||
|
|
|
@ -53,6 +53,9 @@
|
||||||
```
|
```
|
||||||
|
|
||||||
# Releases
|
# Releases
|
||||||
|
## Version 0.7.3
|
||||||
|
+ 点击这里 [下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_3.apk)
|
||||||
|
+ 优化 API 服务
|
||||||
|
|
||||||
## 版本 0.7.2
|
## 版本 0.7.2
|
||||||
+ 点击这里 [下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk)
|
+ 点击这里 [下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_2.apk)
|
||||||
|
|
|
@ -59,8 +59,8 @@ android {
|
||||||
applicationId "com.alibaba.mnnllm.android"
|
applicationId "com.alibaba.mnnllm.android"
|
||||||
minSdk 26
|
minSdk 26
|
||||||
targetSdk 35
|
targetSdk 35
|
||||||
versionCode 702
|
versionCode 703
|
||||||
versionName "0.7.2"
|
versionName "0.7.3"
|
||||||
|
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
externalNativeBuild {
|
externalNativeBuild {
|
||||||
|
|
|
@ -112,6 +112,18 @@
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:foregroundServiceType="dataSync" >
|
android:foregroundServiceType="dataSync" >
|
||||||
</service>
|
</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"
|
<activity android:name=".debug.WidgetTestActivity"
|
||||||
android:enabled="true"
|
android:enabled="true"
|
||||||
|
|
|
@ -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>
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,11 @@ import android.R
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.alibaba.mnnllm.api.openai.service.ApiServerConfig
|
||||||
import timber.log.Timber
|
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 contentTitle Notification title, uses default from string resources if not provided
|
||||||
* @param contentText Notification content, 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
|
* @return Built Notification object
|
||||||
*/
|
*/
|
||||||
fun buildNotification(
|
fun buildNotification(
|
||||||
contentTitle: String? = null,
|
contentTitle: String? = null,
|
||||||
contentText: String? = null
|
contentText: String? = null,
|
||||||
|
port: Int = 8080
|
||||||
): Notification {
|
): Notification {
|
||||||
val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running)
|
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)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setSmallIcon(R.drawable.ic_dialog_info)
|
.setSmallIcon(R.drawable.ic_dialog_info)
|
||||||
|
@ -67,7 +136,30 @@ class ApiNotificationManager(private val context: Context) {
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.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()
|
.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 contentTitle New notification title
|
||||||
* @param contentText New notification content
|
* @param contentText New notification content
|
||||||
|
* @param port Server port number
|
||||||
*/
|
*/
|
||||||
fun updateNotification(contentTitle: String, contentText: String) {
|
fun updateNotification(contentTitle: String, contentText: String, port: Int = 8080) {
|
||||||
val notification = buildNotification(contentTitle, contentText)
|
Timber.tag("ApiNotificationManager").i("updateNotification called - Title: $contentTitle, Text: $contentText, Port: $port")
|
||||||
Timber.tag("NotificationManager").i("Updating notification: $contentTitle - $contentText")
|
val notification = buildNotification(contentTitle, contentText, port)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Updating notification: $contentTitle - $contentText")
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
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() {
|
fun cancelNotification() {
|
||||||
try {
|
try {
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
Timber.tag("NotificationManager").i("Notification cancelled")
|
Timber.tag("ApiNotificationManager").i("Notification cancelled")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("NotificationManager").w("Failed to cancel notification: ${e.message}")
|
Timber.tag("ApiNotificationManager").w("Failed to cancel notification: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -5,6 +5,7 @@ import com.alibaba.mnnllm.android.chat.ChatActivity
|
||||||
import com.alibaba.mnnllm.android.llm.LlmSession
|
import com.alibaba.mnnllm.android.llm.LlmSession
|
||||||
import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes
|
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.queueRoutes
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.routes.modelsRoutes
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
@ -19,6 +20,8 @@ import io.ktor.server.routing.*
|
||||||
import io.ktor.server.sse.*
|
import io.ktor.server.sse.*
|
||||||
import io.ktor.sse.*
|
import io.ktor.sse.*
|
||||||
import org.slf4j.event.*
|
import org.slf4j.event.*
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
|
||||||
fun Application.configureRouting() {
|
fun Application.configureRouting() {
|
||||||
|
@ -26,8 +29,12 @@ fun Application.configureRouting() {
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
get("/") {
|
get("/") {
|
||||||
val response = "Hello, World!"
|
try {
|
||||||
call.respondText(response, contentType = ContentType.Text.Plain)
|
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") {
|
sse("/hello") {
|
||||||
send(ServerSentEvent("world"))
|
send(ServerSentEvent("world"))
|
||||||
|
@ -40,6 +47,8 @@ fun Application.configureRouting() {
|
||||||
// 在这里定义需要认证的路由
|
// 在这里定义需要认证的路由
|
||||||
// /v1/chat/completions
|
// /v1/chat/completions
|
||||||
chatRoutes()
|
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}")
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,4 +64,40 @@ data class CompletionChoice(
|
||||||
data class Message(
|
data class Message(
|
||||||
val role: String,
|
val role: String,
|
||||||
val content: 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
|
||||||
)
|
)
|
|
@ -23,14 +23,8 @@ fun Route.chatRoutes() {
|
||||||
|
|
||||||
post("/v1/chat/completions") {
|
post("/v1/chat/completions") {
|
||||||
val traceId = UUID.randomUUID().toString()
|
val traceId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
// 记录请求开始
|
|
||||||
logger.logRequestStart(traceId, call)
|
logger.logRequestStart(traceId, call)
|
||||||
|
|
||||||
// 接收请求体
|
|
||||||
val chatRequest = call.receive<OpenAIChatRequest>()
|
val chatRequest = call.receive<OpenAIChatRequest>()
|
||||||
|
|
||||||
// 委托给服务层处理
|
|
||||||
MNNChatService.processChatCompletion(call, chatRequest, traceId)
|
MNNChatService.processChatCompletion(call, chatRequest, traceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -82,7 +82,8 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
// 更新通知
|
// 更新通知
|
||||||
notificationManager?.updateNotification(
|
notificationManager?.updateNotification(
|
||||||
context.getString(R.string.api_service_running),
|
context.getString(R.string.api_service_running),
|
||||||
context.getString(R.string.api_service_port, app.getPort())
|
"", // 让 NotificationManager 使用默认的 IP 地址显示
|
||||||
|
app.getPort()
|
||||||
)
|
)
|
||||||
|
|
||||||
_isServerRunning = true
|
_isServerRunning = true
|
||||||
|
@ -123,8 +124,8 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
/**
|
/**
|
||||||
* 更新通知内容
|
* 更新通知内容
|
||||||
*/
|
*/
|
||||||
fun updateNotification(title: String, content: String) {
|
fun updateNotification(title: String, content: String, port: Int = 8080) {
|
||||||
notificationManager?.updateNotification(title, content)
|
notificationManager?.updateNotification(title, content, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,8 +133,9 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
*/
|
*/
|
||||||
fun getNotification(
|
fun getNotification(
|
||||||
title: String = context.getString(R.string.api_service_running),
|
title: String = context.getString(R.string.api_service_running),
|
||||||
content: String = context.getString(R.string.api_service_port, 8080)
|
content: String = context.getString(R.string.api_service_port, 8080),
|
||||||
) = notificationManager?.buildNotification(title, content)
|
port: Int = 8080
|
||||||
|
) = notificationManager?.buildNotification(title, content, port)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取服务器端口
|
* 获取服务器端口
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
package com.alibaba.mnnllm.api.openai.service
|
package com.alibaba.mnnllm.api.openai.service
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.alibaba.mnnllm.android.chat.ChatActivity
|
import com.alibaba.mnnllm.android.chat.ChatActivity
|
||||||
import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator
|
import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator
|
||||||
import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager
|
import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager
|
||||||
|
@ -29,10 +32,27 @@ class OpenAIService : Service() {
|
||||||
return
|
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)
|
val serviceIntent = Intent(context, OpenAIService::class.java)
|
||||||
// 在启动服务前设置标志,避免onStartCommand中的检查失败
|
|
||||||
isServiceRunning = true
|
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 {
|
val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
|
@ -43,7 +63,6 @@ class OpenAIService : Service() {
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
serviceConnection = null
|
serviceConnection = null
|
||||||
// 服务断开连接时重置标志
|
|
||||||
isServiceRunning = false
|
isServiceRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -106,9 +106,14 @@
|
||||||
<string name="api_service_not_started">API 服务未启动</string>
|
<string name="api_service_not_started">API 服务未启动</string>
|
||||||
<string name="no_active_session">未找到活跃会话</string>
|
<string name="no_active_session">未找到活跃会话</string>
|
||||||
<string name="api_service_running">API 服务运行中</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_port">端口:%1$d</string>
|
||||||
<string name="api_service_start_failed">API 服务启动失败</string>
|
<string name="api_service_start_failed">API 服务启动失败</string>
|
||||||
<string name="api_service_error">错误:%1$s</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="modelscope">魔搭</string>
|
||||||
<string name="modelers">魔乐</string>
|
<string name="modelers">魔乐</string>
|
||||||
<string name="huggingface">HuggingFace</string>
|
<string name="huggingface">HuggingFace</string>
|
||||||
|
|
|
@ -113,9 +113,14 @@
|
||||||
<string name="api_service_not_started">API Service Not Started</string>
|
<string name="api_service_not_started">API Service Not Started</string>
|
||||||
<string name="no_active_session">No active session found</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">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_port">Port: %1$d</string>
|
||||||
<string name="api_service_start_failed">API Service Start Failed</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_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="modelscope">Modelscope</string>
|
||||||
<string name="modelers">Modelers</string>
|
<string name="modelers">Modelers</string>
|
||||||
<string name="huggingface">HuggingFace</string>
|
<string name="huggingface">HuggingFace</string>
|
||||||
|
|
|
@ -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())
|
Loading…
Reference in New Issue