mirror of https://github.com/alibaba/MNN.git
Merge pull request #3861 from Juude/master
fix qwen think/nothink switch and some ui update
This commit is contained in:
commit
333b3c4b5d
|
@ -61,6 +61,15 @@ 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:
|
||||
+ qwen think/no_think switch sometimes not work.
|
||||
+ UI Update:
|
||||
+ update ui for history and benchmark test screen.
|
||||
## Version 0.7.1
|
||||
+ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk)
|
||||
+ add new models:
|
||||
|
|
|
@ -53,6 +53,16 @@
|
|||
```
|
||||
|
||||
# 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)
|
||||
+ 问题修复:
|
||||
+ 修复通义千问思考/不思考开关有时不生效的问题。
|
||||
+ 界面更新:
|
||||
+ 更新历史记录和性能测试界面。
|
||||
|
||||
## 版本 0.7.1
|
||||
+ [点击此处下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk)
|
||||
|
|
|
@ -59,8 +59,8 @@ android {
|
|||
applicationId "com.alibaba.mnnllm.android"
|
||||
minSdk 26
|
||||
targetSdk 35
|
||||
versionCode 701
|
||||
versionName "0.7.1"
|
||||
versionCode 703
|
||||
versionName "0.7.3"
|
||||
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
externalNativeBuild {
|
||||
|
@ -114,7 +114,6 @@ android {
|
|||
signingConfig signingConfigs.release
|
||||
}
|
||||
applicationIdSuffix ".release"
|
||||
versionNameSuffix ".gp"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -127,6 +126,7 @@ android {
|
|||
googleplay {
|
||||
dimension "store"
|
||||
buildConfigField "boolean", "IS_GOOGLE_PLAY_BUILD", "true"
|
||||
versionNameSuffix ".gp"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"version": "5",
|
||||
"version": "6",
|
||||
"tagTranslations": {
|
||||
"Vision": "图像理解",
|
||||
"Video": "视频理解",
|
||||
|
@ -95,7 +95,6 @@
|
|||
{
|
||||
"modelName": "MiniCPM4-0.5B-MNN",
|
||||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"categories": [
|
||||
"recommended",
|
||||
|
@ -113,7 +112,6 @@
|
|||
{
|
||||
"modelName": "MiniCPM4-8B-MNN",
|
||||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"categories": [
|
||||
"recommended",
|
||||
|
@ -359,6 +357,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -377,6 +376,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -415,6 +415,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -433,6 +434,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -469,6 +471,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -507,6 +510,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
@ -525,6 +529,7 @@
|
|||
"tags": [
|
||||
"Think"
|
||||
],
|
||||
"extra_tags": ["ThinkingSwitch"],
|
||||
"categories": [
|
||||
"recommended",
|
||||
"qwen"
|
||||
|
|
|
@ -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>
|
|
@ -398,7 +398,21 @@ Java_com_alibaba_mnnllm_android_llm_LlmSession_updateAssistantPromptNative(JNIEn
|
|||
}
|
||||
env->ReleaseStringUTFChars(assistant_prompt_j, assistant_prompt_cstr);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_alibaba_mnnllm_android_llm_LlmSession_updateConfigNative(JNIEnv *env,
|
||||
jobject thiz,
|
||||
jlong llm_ptr,
|
||||
jstring config_json_j) {
|
||||
auto *llm = reinterpret_cast<mls::LlmSession *>(llm_ptr);
|
||||
const char *config_json_cstr = env->GetStringUTFChars(config_json_j, nullptr);
|
||||
if (llm) {
|
||||
llm->updateConfig(config_json_cstr);
|
||||
}
|
||||
env->ReleaseStringUTFChars(config_json_j, config_json_cstr);
|
||||
}
|
||||
|
||||
extern "C"
|
||||
JNIEXPORT void JNICALL
|
||||
Java_com_alibaba_mnnllm_android_llm_LlmSession_updateEnableAudioOutputNative(JNIEnv *env,jobject thiz, jlong llm_ptr, jboolean enable) {
|
||||
|
@ -629,3 +643,4 @@ Java_com_alibaba_mnnllm_android_llm_LlmSession_runBenchmarkNative(
|
|||
return env->NewObject(resultClass, resultCtor, testInstance, (jboolean)result.success, errorMessage);
|
||||
}
|
||||
|
||||
} // extern "C"
|
||||
|
|
|
@ -236,6 +236,23 @@ void LlmSession::SetAssistantPrompt(const std::string& assistant_prompt) {
|
|||
MNN_DEBUG("dumped config: %s", llm_->dump_config().c_str());
|
||||
}
|
||||
|
||||
void LlmSession::updateConfig(const std::string& config_json) {
|
||||
try {
|
||||
json new_config = json::parse(config_json);
|
||||
for (auto& [key, value] : new_config.items()) {
|
||||
current_config_[key] = value;
|
||||
}
|
||||
if (llm_) {
|
||||
llm_->set_config(current_config_.dump());
|
||||
MNN_DEBUG("Updated config applied: %s", current_config_.dump().c_str());
|
||||
} else {
|
||||
MNN_DEBUG("LLM not initialized yet, config saved for later: %s", current_config_.dump().c_str());
|
||||
}
|
||||
} catch (const std::exception& e) {
|
||||
MNN_ERROR("Failed to parse config JSON: %s", e.what());
|
||||
}
|
||||
}
|
||||
|
||||
void LlmSession::enableAudioOutput(bool enable) {
|
||||
enable_audio_output_ = enable;
|
||||
}
|
||||
|
|
|
@ -40,6 +40,8 @@ public:
|
|||
|
||||
void SetAssistantPrompt(const std::string& assistant_prompt);
|
||||
|
||||
void updateConfig(const std::string& config_json);
|
||||
|
||||
void enableAudioOutput(bool b);
|
||||
|
||||
// 新增:API服务历史消息推理方法
|
||||
|
|
|
@ -32,6 +32,10 @@ class ModelItem {
|
|||
}
|
||||
}
|
||||
|
||||
fun getExtraTags(): List<String> {
|
||||
return modelMarketItem?.extraTags ?: emptyList()
|
||||
}
|
||||
|
||||
fun addTag(tag: String) {
|
||||
tags.add(tag)
|
||||
}
|
||||
|
|
|
@ -29,8 +29,9 @@ class DownloadForegroundService : Service() {
|
|||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(AppR.string.download_service_title),
|
||||
NotificationManager.IMPORTANCE_LOW
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
)
|
||||
channel.description = "Shows download progress for model files"
|
||||
notificationManager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
|
@ -41,7 +42,7 @@ class DownloadForegroundService : Service() {
|
|||
|
||||
val notification = createNotification()
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(SERVICE_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(SERVICE_ID, notification)
|
||||
|
@ -90,8 +91,10 @@ class DownloadForegroundService : Service() {
|
|||
fun updateNotification(downloadCount: Int, modelName: String? = null) {
|
||||
currentDownloadCount = downloadCount
|
||||
currentModelName = modelName
|
||||
android.util.Log.d("DownloadForegroundService", "updateNotification: count=$downloadCount, modelName=$modelName")
|
||||
val notification = createNotification()
|
||||
notificationManager.notify(SERVICE_ID, notification)
|
||||
android.util.Log.d("DownloadForegroundService", "Notification updated successfully")
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
|
|
@ -354,7 +354,7 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
}
|
||||
|
||||
if (count == 1) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
if (ContextCompat.checkSelfPermission(
|
||||
appContext,
|
||||
Manifest.permission.POST_NOTIFICATIONS
|
||||
|
@ -375,6 +375,9 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
} else {
|
||||
startForegroundService()
|
||||
}
|
||||
} else {
|
||||
// For Android 13 and below, start foreground service directly
|
||||
startForegroundService()
|
||||
}
|
||||
}
|
||||
updateNotification()
|
||||
|
@ -383,6 +386,7 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
private fun startForegroundService() {
|
||||
// Do not start foreground service in Google Play build
|
||||
if (disableForegroundService) {
|
||||
Log.d(TAG, "startForegroundService: skipped - disableForegroundService is true")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -393,8 +397,10 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_DOWNLOAD_COUNT, count)
|
||||
foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_MODEL_NAME, modelName)
|
||||
|
||||
Log.d(TAG, "startForegroundService: starting service with count=$count, modelName=$modelName")
|
||||
ApplicationProvider.get().startForegroundService(foregroundServiceIntent)
|
||||
foregroundServiceStarted = true
|
||||
Log.d(TAG, "startForegroundService: service started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "start foreground service failed", e)
|
||||
foregroundServiceStarted = false
|
||||
|
@ -417,6 +423,7 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
|
||||
private fun updateNotification() {
|
||||
if (!foregroundServiceStarted || disableForegroundService) {
|
||||
Log.d(TAG, "updateNotification: skipped - foregroundServiceStarted: $foregroundServiceStarted, disableForegroundService: $disableForegroundService")
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -424,6 +431,7 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
val count = activeDownloadCount.get()
|
||||
val modelName = getActiveDownloadModelName()
|
||||
|
||||
Log.d(TAG, "updateNotification: count=$count, modelName=$modelName")
|
||||
// Use the static method to update notification
|
||||
DownloadForegroundService.updateNotification(count, modelName)
|
||||
} catch (e: Exception) {
|
||||
|
@ -525,6 +533,9 @@ class ModelDownloadManager private constructor(context: Context) {
|
|||
}
|
||||
Log.v(TAG, "[updateDownloadingProgress] Notifying ${listeners.size} listeners for $modelId stage: $stage")
|
||||
listeners.forEach { it.onDownloadProgress(modelId, downloadInfo) }
|
||||
|
||||
// Update notification with progress information
|
||||
updateNotification()
|
||||
}
|
||||
|
||||
suspend fun deleteModel(item: ModelMarketItem) {
|
||||
|
|
|
@ -27,6 +27,24 @@ class BenchmarkContract {
|
|||
fun updateStatus(message: String)
|
||||
fun hideStatus()
|
||||
|
||||
// Progress and Status Cards
|
||||
fun showProgressCard(show: Boolean)
|
||||
fun showStatusCard(show: Boolean)
|
||||
fun updateStatusMessage(message: String)
|
||||
fun updateTestDetails(
|
||||
currentIteration: Int,
|
||||
totalIterations: Int,
|
||||
nPrompt: Int,
|
||||
nGenerate: Int
|
||||
)
|
||||
fun updateProgressMetrics(
|
||||
runtime: Float,
|
||||
prefillTime: Float,
|
||||
decodeTime: Float,
|
||||
prefillSpeed: Float,
|
||||
decodeSpeed: Float
|
||||
)
|
||||
|
||||
// UI state
|
||||
fun setStartButtonText(text: String)
|
||||
fun setStartButtonEnabled(enabled: Boolean)
|
||||
|
|
|
@ -10,6 +10,7 @@ import android.view.View
|
|||
import android.view.ViewGroup
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.Toast
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
|
@ -60,15 +61,15 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
}
|
||||
|
||||
private fun setupClickListeners() {
|
||||
binding.startTestButton.setOnClickListener {
|
||||
Log.d(TAG, "Start test button clicked, current text: ${binding.startTestButton.text}")
|
||||
binding.startTestButtonContainer.setOnClickListener {
|
||||
Log.d(TAG, "Start test button clicked, current text: ${binding.startTestText.text}")
|
||||
presenter?.onStartBenchmarkClicked()
|
||||
}
|
||||
|
||||
// Back button click handler
|
||||
binding.backButton.setOnClickListener {
|
||||
Log.d(TAG, "Back button clicked")
|
||||
presenter?.onBackClicked()
|
||||
// Share button click handler
|
||||
binding.shareButton.setOnClickListener {
|
||||
Log.d(TAG, "Share button clicked")
|
||||
shareResultCard()
|
||||
}
|
||||
|
||||
// Model selector click handler - now clicking the entire layout
|
||||
|
@ -79,7 +80,7 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
showModelSelectionDialog()
|
||||
} else {
|
||||
Log.d(TAG, "Model selection disabled in state: $currentState")
|
||||
showToast("Cannot change model during benchmark")
|
||||
showToast(getString(R.string.cannot_change_model_during_benchmark))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -138,20 +139,20 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
|
||||
// Update the new UI elements
|
||||
if (models.isEmpty()) {
|
||||
binding.modelSelectorTitle.text = requireContext().getString(R.string.no_models_available)
|
||||
binding.modelSelectorStatus.text = requireContext().getString(R.string.please_download_model)
|
||||
binding.modelAvatar.setModelName("")
|
||||
binding.modelTagsLayout.setTags(emptyList())
|
||||
_binding?.modelSelectorTitle?.text = requireContext().getString(R.string.no_models_available)
|
||||
_binding?.modelSelectorStatus?.text = requireContext().getString(R.string.please_download_model)
|
||||
_binding?.modelAvatar?.setModelName("")
|
||||
_binding?.modelTagsLayout?.setTags(emptyList())
|
||||
} else {
|
||||
binding.modelSelectorTitle.text = "Select Model"
|
||||
binding.modelSelectorStatus.text = "Click to select model"
|
||||
binding.modelAvatar.setModelName("")
|
||||
binding.modelTagsLayout.setTags(emptyList())
|
||||
_binding?.modelSelectorTitle?.text = requireContext().getString(R.string.select_model_title)
|
||||
_binding?.modelSelectorStatus?.text = requireContext().getString(R.string.click_to_select_model)
|
||||
_binding?.modelAvatar?.setModelName("")
|
||||
_binding?.modelTagsLayout?.setTags(emptyList())
|
||||
}
|
||||
|
||||
// Keep the autocomplete for compatibility
|
||||
binding.modelSelectorAutocomplete.apply {
|
||||
setText("Select Model")
|
||||
_binding?.modelSelectorAutocomplete?.apply {
|
||||
setText(getString(R.string.select_model_title))
|
||||
isFocusable = false
|
||||
isClickable = true
|
||||
}
|
||||
|
@ -162,65 +163,151 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
|
||||
// Update the new UI elements with model information
|
||||
val modelItem = modelWrapper.modelItem
|
||||
val modelName = modelItem.modelName ?: modelItem.modelId ?: "Unknown Model"
|
||||
val modelName = modelItem.modelName ?: modelItem.modelId ?: getString(R.string.unknown_model)
|
||||
|
||||
// Set model title and avatar
|
||||
binding.modelSelectorTitle.text = modelName
|
||||
binding.modelAvatar.setModelName(modelName)
|
||||
_binding?.modelSelectorTitle?.text = modelName
|
||||
_binding?.modelAvatar?.setModelName(modelName)
|
||||
|
||||
// Set tags similar to ModelItemHolder
|
||||
val tags = getDisplayTags(modelItem)
|
||||
binding.modelTagsLayout.setTags(tags)
|
||||
_binding?.modelTagsLayout?.setTags(tags)
|
||||
|
||||
// Set status with file size
|
||||
val formattedSize = getFormattedFileSize(modelWrapper)
|
||||
binding.modelSelectorStatus.text = if (formattedSize.isNotEmpty()) {
|
||||
_binding?.modelSelectorStatus?.text = if (formattedSize.isNotEmpty()) {
|
||||
getString(R.string.downloaded_click_to_chat, formattedSize)
|
||||
} else {
|
||||
"Ready for benchmark"
|
||||
getString(R.string.ready_for_benchmark)
|
||||
}
|
||||
|
||||
// Keep the autocomplete updated for compatibility
|
||||
binding.modelSelectorAutocomplete.setText(modelWrapper.displayName)
|
||||
_binding?.modelSelectorAutocomplete?.setText(modelWrapper.displayName)
|
||||
Log.d(TAG, "Selected model: ${modelWrapper.displayName}")
|
||||
}
|
||||
|
||||
override fun enableStartButton(enabled: Boolean) {
|
||||
binding.startTestButton.isEnabled = enabled
|
||||
_binding?.startTestButtonContainer?.isEnabled = enabled
|
||||
_binding?.startTestButtonContainer?.alpha = if (enabled) 1.0f else 0.5f
|
||||
}
|
||||
|
||||
override fun updateProgress(progress: BenchmarkProgress) {
|
||||
binding.textStatus.text = progress.statusMessage
|
||||
binding.textStatus.visibility = View.VISIBLE
|
||||
binding.resultCard.visibility = View.INVISIBLE
|
||||
Log.d(TAG, "updateProgress: $progress")
|
||||
|
||||
// Note: Do NOT update progress bar here as it's handled by UI state with realProgress
|
||||
// updateBenchmarkProgress(progress.progress) - REMOVED to avoid overriding realProgress
|
||||
|
||||
// Update status message
|
||||
if (progress.statusMessage.isNotEmpty()) {
|
||||
updateStatusMessage(progress.statusMessage)
|
||||
}
|
||||
|
||||
// Update test details if available
|
||||
if (progress.totalIterations > 0) {
|
||||
updateTestDetails(
|
||||
progress.currentIteration,
|
||||
progress.totalIterations,
|
||||
progress.nPrompt,
|
||||
progress.nGenerate
|
||||
)
|
||||
}
|
||||
|
||||
// Update performance metrics if available
|
||||
if (progress.runTimeSeconds > 0) {
|
||||
updateProgressMetrics(
|
||||
progress.runTimeSeconds,
|
||||
progress.prefillTimeSeconds,
|
||||
progress.decodeTimeSeconds,
|
||||
progress.prefillSpeed,
|
||||
progress.decodeSpeed
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun showResults(results: BenchmarkContract.BenchmarkResults) {
|
||||
populateResultsUI(results)
|
||||
binding.resultCard.visibility = View.VISIBLE
|
||||
_binding?.resultCard?.visibility = View.VISIBLE
|
||||
|
||||
// Scroll to result_layout after showing results
|
||||
_binding?.resultLayout?.let { resultLayout ->
|
||||
resultLayout.postDelayed({
|
||||
// Get the ScrollView parent and scroll to result_layout
|
||||
val scrollView = binding.root as? android.widget.ScrollView
|
||||
scrollView?.let { sv ->
|
||||
// Calculate the scroll position to show result_layout at the top
|
||||
val scrollToY = resultLayout.top - sv.paddingTop
|
||||
sv.smoothScrollTo(0, scrollToY)
|
||||
}
|
||||
}, 100) // Small delay to ensure results are fully rendered
|
||||
}
|
||||
}
|
||||
|
||||
override fun hideResults() {
|
||||
binding.testResultsTitle.visibility = View.INVISIBLE
|
||||
binding.resultCard.visibility = View.INVISIBLE
|
||||
_binding?.resultCard?.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun updateStatus(message: String) {
|
||||
binding.textStatus.text = message
|
||||
binding.textStatus.visibility = View.VISIBLE
|
||||
_binding?.statusMessage?.text = message
|
||||
_binding?.statusCard?.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
override fun hideStatus() {
|
||||
binding.textStatus.visibility = View.GONE
|
||||
_binding?.statusCard?.visibility = View.GONE
|
||||
}
|
||||
|
||||
override fun setStartButtonText(text: String) {
|
||||
Log.d(TAG, "Setting start button text to: $text")
|
||||
binding.startTestButton.text = text
|
||||
if (isFragmentValid()) {
|
||||
binding.startTestText.text = text
|
||||
|
||||
// Update button icon and background based on text
|
||||
when (text) {
|
||||
getString(R.string.start_test) -> {
|
||||
binding.startTestIcon.setImageResource(R.drawable.ic_play_fill)
|
||||
binding.startTestIcon.visibility = View.VISIBLE
|
||||
binding.startTestProgress.visibility = View.GONE
|
||||
binding.startTestArrow.visibility = View.VISIBLE
|
||||
binding.startTestButtonContainer.background = ContextCompat.getDrawable(requireContext(), R.drawable.benchmark_button_background_selector)
|
||||
}
|
||||
getString(R.string.restart_test) -> {
|
||||
binding.startTestIcon.setImageResource(R.drawable.ic_play_fill)
|
||||
binding.startTestIcon.visibility = View.VISIBLE
|
||||
binding.startTestProgress.visibility = View.GONE
|
||||
binding.startTestArrow.visibility = View.VISIBLE
|
||||
binding.startTestButtonContainer.background = ContextCompat.getDrawable(requireContext(), R.drawable.benchmark_button_background_selector)
|
||||
}
|
||||
getString(R.string.stop_test) -> {
|
||||
// Check if we're in a running state (progress card is visible)
|
||||
if (binding.progressCard.visibility == View.VISIBLE) {
|
||||
// Show progress indicator when actively running (like iOS)
|
||||
binding.startTestIcon.visibility = View.GONE
|
||||
binding.startTestProgress.visibility = View.VISIBLE
|
||||
binding.startTestArrow.visibility = View.GONE
|
||||
} else {
|
||||
// Show stop icon when just stopping/initializing
|
||||
binding.startTestIcon.setImageResource(R.drawable.ic_stop_fill)
|
||||
binding.startTestIcon.visibility = View.VISIBLE
|
||||
binding.startTestProgress.visibility = View.GONE
|
||||
binding.startTestArrow.visibility = View.GONE
|
||||
}
|
||||
binding.startTestButtonContainer.background = ContextCompat.getDrawable(requireContext(), R.drawable.benchmark_button_stop_background_selector)
|
||||
}
|
||||
else -> {
|
||||
// For other text (like "Share", "Upload to Leaderboard"), hide icon and arrow
|
||||
binding.startTestIcon.visibility = View.GONE
|
||||
binding.startTestProgress.visibility = View.GONE
|
||||
binding.startTestArrow.visibility = View.VISIBLE
|
||||
binding.startTestButtonContainer.background = ContextCompat.getDrawable(requireContext(), R.drawable.benchmark_button_background_selector)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun setStartButtonEnabled(enabled: Boolean) {
|
||||
binding.startTestButton.isEnabled = enabled
|
||||
if (isFragmentValid()) {
|
||||
binding.startTestButtonContainer.isEnabled = enabled
|
||||
binding.startTestButtonContainer.alpha = if (enabled) 1.0f else 0.5f
|
||||
}
|
||||
}
|
||||
|
||||
override fun showProgressBar() {
|
||||
|
@ -229,64 +316,132 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
|
||||
override fun hideProgressBar() {
|
||||
// binding.progressBar.visibility = View.GONE
|
||||
// Hide textStatus if results are visible
|
||||
if (binding.resultCard.visibility == View.VISIBLE) {
|
||||
binding.textStatus.visibility = View.INVISIBLE
|
||||
// Hide status card if results are visible
|
||||
if (_binding?.resultCard?.visibility == View.VISIBLE) {
|
||||
_binding?.statusCard?.visibility = View.INVISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
override fun showBenchmarkIcon(show: Boolean) {
|
||||
binding.iconBenchmark.visibility = if (show) View.VISIBLE else View.INVISIBLE
|
||||
binding.iconBenchmarkParent.visibility = if (show) View.VISIBLE else View.INVISIBLE
|
||||
Log.d(TAG, "showBenchmarkIcon: $show")
|
||||
// Benchmark icon removed to match iOS - no large icon display
|
||||
Log.d(TAG, "showBenchmarkIcon: $show (removed to match iOS)")
|
||||
}
|
||||
|
||||
override fun showBenchmarkProgressBar(show: Boolean) {
|
||||
binding.benchmarkProgressBar.visibility = if (show) View.VISIBLE else View.INVISIBLE
|
||||
Log.d(TAG, "showBenchmarkProgressBar: $show")
|
||||
// Progress bar removed with benchmark icon - using progress card instead
|
||||
// Update button state for consistency
|
||||
if (isFragmentValid()) {
|
||||
updateButtonIconState()
|
||||
}
|
||||
Log.d(TAG, "showBenchmarkProgressBar: $show (using progress card instead)")
|
||||
}
|
||||
|
||||
override fun updateBenchmarkProgress(progress: Int) {
|
||||
binding.benchmarkProgressBar.progress = progress
|
||||
Log.d(TAG, "updateBenchmarkProgress: $progress%")
|
||||
if (isFragmentValid()) {
|
||||
// Update progress percentage text
|
||||
binding.progressPercentage.text = "$progress%"
|
||||
// Update actual progress bar
|
||||
binding.progressBar.progress = progress
|
||||
}
|
||||
Log.d(TAG, "updateBenchmarkProgress: $progress% (updated progress bar)")
|
||||
}
|
||||
|
||||
private fun updateButtonIconState() {
|
||||
if (isFragmentValid()) {
|
||||
val currentText = binding.startTestText.text.toString()
|
||||
if (currentText == getString(R.string.stop_test)) {
|
||||
// Re-evaluate the stop button state based on progress card visibility
|
||||
if (binding.progressCard.visibility == View.VISIBLE) {
|
||||
// Show progress indicator when actively running (like iOS)
|
||||
binding.startTestIcon.visibility = View.GONE
|
||||
binding.startTestProgress.visibility = View.VISIBLE
|
||||
binding.startTestArrow.visibility = View.GONE
|
||||
} else {
|
||||
// Show stop icon when just stopping/initializing
|
||||
binding.startTestIcon.setImageResource(R.drawable.ic_stop_fill)
|
||||
binding.startTestIcon.visibility = View.VISIBLE
|
||||
binding.startTestProgress.visibility = View.GONE
|
||||
binding.startTestArrow.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun showProgressCard(show: Boolean) {
|
||||
if (isFragmentValid()) {
|
||||
binding.progressCard.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
Log.d(TAG, "showProgressCard: $show")
|
||||
}
|
||||
|
||||
override fun showStatusCard(show: Boolean) {
|
||||
if (isFragmentValid()) {
|
||||
binding.statusCard.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
Log.d(TAG, "showStatusCard: $show")
|
||||
}
|
||||
|
||||
override fun updateStatusMessage(message: String) {
|
||||
if (isFragmentValid()) {
|
||||
binding.statusMessage.text = message
|
||||
}
|
||||
Log.d(TAG, "updateStatusMessage: $message")
|
||||
}
|
||||
|
||||
override fun updateTestDetails(
|
||||
currentIteration: Int,
|
||||
totalIterations: Int,
|
||||
nPrompt: Int,
|
||||
nGenerate: Int
|
||||
) {
|
||||
if (isFragmentValid()) {
|
||||
binding.testIterationInfo.text = "Test $currentIteration of $totalIterations"
|
||||
binding.testConfigInfo.text = "PP: $nPrompt • TG: $nGenerate"
|
||||
binding.testDetailsContainer.visibility = View.VISIBLE
|
||||
}
|
||||
Log.d(TAG, "updateTestDetails: $currentIteration/$totalIterations, PP: $nPrompt, TG: $nGenerate")
|
||||
}
|
||||
|
||||
override fun updateProgressMetrics(
|
||||
runtime: Float,
|
||||
prefillTime: Float,
|
||||
decodeTime: Float,
|
||||
prefillSpeed: Float,
|
||||
decodeSpeed: Float
|
||||
) {
|
||||
if (isFragmentValid()) {
|
||||
binding.runtimeMetric.updateMetric("Runtime", String.format("%.3fs", runtime), "ic_clock")
|
||||
binding.prefillTimeMetric.updateMetric("Prefill", String.format("%.3fs", prefillTime), "ic_arrow_up_circle")
|
||||
binding.decodeTimeMetric.updateMetric("Decode", String.format("%.3fs", decodeTime), "ic_arrow_down_circle")
|
||||
binding.prefillSpeedMetricProgress.updateMetric("Prefill Speed", String.format("%.2f t/s", prefillSpeed), "ic_speedometer")
|
||||
binding.decodeSpeedMetricProgress.updateMetric("Decode Speed", String.format("%.2f t/s", decodeSpeed), "ic_gauge")
|
||||
}
|
||||
Log.d(TAG, "updateProgressMetrics: runtime=$runtime, prefill=$prefillTime, decode=$decodeTime")
|
||||
}
|
||||
|
||||
override fun enableModelSelector(enabled: Boolean) {
|
||||
if (isFragmentValid()) {
|
||||
binding.modelSelectorLayout.isEnabled = enabled
|
||||
binding.modelSelectorLayout.alpha = if (enabled) 1.0f else 0.6f
|
||||
}
|
||||
Log.d(TAG, "enableModelSelector: $enabled")
|
||||
}
|
||||
|
||||
override fun showBackButton(show: Boolean) {
|
||||
binding.backButton.visibility = if (show) View.VISIBLE else View.GONE
|
||||
Log.d(TAG, "showBackButton: $show")
|
||||
// Back button removed, no longer needed
|
||||
Log.d(TAG, "showBackButton: $show (back button removed)")
|
||||
}
|
||||
|
||||
override fun showModelSelectorCard(show: Boolean) {
|
||||
if (isFragmentValid()) {
|
||||
binding.modelSelectorCard.visibility = if (show) View.VISIBLE else View.GONE
|
||||
}
|
||||
Log.d(TAG, "showModelSelectorCard: $show")
|
||||
}
|
||||
|
||||
override fun updateButtonLayout(showBackButton: Boolean) {
|
||||
if (showBackButton) {
|
||||
// Show back button and adjust main button layout
|
||||
binding.backButton.visibility = View.VISIBLE
|
||||
binding.startTestButton.layoutParams = LinearLayout.LayoutParams(
|
||||
0,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
).apply {
|
||||
weight = 1f
|
||||
marginStart = 8
|
||||
}
|
||||
} else {
|
||||
// Hide back button and make main button full width
|
||||
binding.backButton.visibility = View.GONE
|
||||
binding.startTestButton.layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT
|
||||
)
|
||||
}
|
||||
Log.d(TAG, "updateButtonLayout: showBackButton=$showBackButton")
|
||||
// Back button removed, main button always full width
|
||||
Log.d(TAG, "updateButtonLayout: showBackButton=$showBackButton (back button removed)")
|
||||
}
|
||||
|
||||
override fun shareResultCard() {
|
||||
|
@ -323,7 +478,7 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Error sharing result card", e)
|
||||
showToast("Failed to share result: ${e.message}")
|
||||
showToast(getString(R.string.failed_to_share_result, e.message ?: getString(R.string.unknown_error)))
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -332,17 +487,19 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
}
|
||||
|
||||
override fun showUploadProgress(message: String) {
|
||||
binding.textStatus.text = message
|
||||
binding.textStatus.visibility = View.VISIBLE
|
||||
_binding?.statusMessage?.text = message
|
||||
_binding?.statusCard?.visibility = View.VISIBLE
|
||||
// Disable the upload button while uploading
|
||||
binding.startTestButton.isEnabled = false
|
||||
_binding?.startTestButtonContainer?.isEnabled = false
|
||||
_binding?.startTestButtonContainer?.alpha = 0.5f
|
||||
Log.d(TAG, "Showing upload progress: $message")
|
||||
}
|
||||
|
||||
override fun hideUploadProgress() {
|
||||
binding.textStatus.visibility = View.GONE
|
||||
_binding?.statusCard?.visibility = View.GONE
|
||||
// Re-enable the upload button
|
||||
binding.startTestButton.isEnabled = true
|
||||
_binding?.startTestButtonContainer?.isEnabled = true
|
||||
_binding?.startTestButtonContainer?.alpha = 1.0f
|
||||
Log.d(TAG, "Hiding upload progress")
|
||||
}
|
||||
|
||||
|
@ -376,58 +533,63 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
|
|||
// ===== UI Helpers =====
|
||||
|
||||
private fun populateResultsUI(results: BenchmarkContract.BenchmarkResults) {
|
||||
binding.resultCard.visibility = View.VISIBLE
|
||||
binding.testResultsTitle.visibility = View.VISIBLE
|
||||
binding.modelName.text = results.modelDisplayName
|
||||
_binding?.resultCard?.visibility = View.VISIBLE
|
||||
_binding?.modelName?.text = results.modelDisplayName
|
||||
DeviceName.with(requireContext()).request { info, error ->
|
||||
val deviceName = info?.marketName ?: info?.name ?: android.os.Build.MODEL
|
||||
binding.deviceInfo.text = getString(R.string.benchmark_device_info, deviceName)
|
||||
_binding?.deviceInfo?.text = getString(R.string.benchmark_device_info, deviceName)
|
||||
}
|
||||
|
||||
// Use BenchmarkResultsHelper to process test results
|
||||
val statistics = BenchmarkResultsHelper.processTestResults(requireContext(), results.testResults)
|
||||
|
||||
// Display configuration
|
||||
// binding.benchmarkConfig.text = statistics.configText
|
||||
_binding?.benchmarkConfigText?.text = statistics.configText
|
||||
|
||||
// Show prompt processing results (prefill)
|
||||
statistics.prefillStats?.let { stats ->
|
||||
// Display average value
|
||||
val averageText = "%.2f".format(stats.average)
|
||||
// Display standard deviation in label
|
||||
val labelText = "tokens/s ±%.2f".format(stats.stdev)
|
||||
Log.d(TAG, "Setting prefill - Average: '$averageText', Label: '$labelText'")
|
||||
binding.promptProcessingValue.text = averageText
|
||||
binding.promptProcessingLabel.text = labelText
|
||||
} ?: run {
|
||||
Log.d(TAG, "No prefill stats available")
|
||||
}
|
||||
// Set up performance metrics using new PerformanceMetricView components
|
||||
_binding?.prefillSpeedMetric?.setSpeedMetric(
|
||||
R.drawable.ic_speed,
|
||||
R.string.prefill_speed_title,
|
||||
statistics.prefillStats,
|
||||
R.color.benchmark_gradient_start
|
||||
)
|
||||
|
||||
// Show token generation results (decode)
|
||||
statistics.decodeStats?.let { stats ->
|
||||
// Display average value
|
||||
val averageText = "%.2f".format(stats.average)
|
||||
// Display standard deviation in label
|
||||
val labelText = "tokens/s ±%.2f".format(stats.stdev)
|
||||
Log.d(TAG, "Setting decode - Average: '$averageText', Label: '$labelText'")
|
||||
binding.tokenGenerationValue.text = averageText
|
||||
binding.tokenGenerationLabel.text = labelText
|
||||
} ?: run {
|
||||
Log.d(TAG, "No decode stats available")
|
||||
}
|
||||
_binding?.decodeSpeedMetric?.setSpeedMetric(
|
||||
R.drawable.ic_gauge,
|
||||
R.string.decode_speed_title,
|
||||
statistics.decodeStats,
|
||||
R.color.benchmark_gradient_end
|
||||
)
|
||||
|
||||
// Set up total time metric
|
||||
_binding?.totalTokensMetric?.setTotalTimeMetric(
|
||||
statistics.totalTimeSeconds,
|
||||
R.color.benchmark_success
|
||||
)
|
||||
|
||||
// Set up peak memory metric
|
||||
val totalMemoryKb = BenchmarkResultsHelper.getTotalMemoryKb()
|
||||
_binding?.peakMemoryMetric?.setMemoryMetric(
|
||||
results.maxMemoryKb,
|
||||
totalMemoryKb,
|
||||
R.color.benchmark_warning
|
||||
)
|
||||
|
||||
// Display peak memory usage
|
||||
val (peakValue, maxValue) = BenchmarkResultsHelper.formatMemoryUsageDetailed(requireContext(), results.maxMemoryKb)
|
||||
binding.peakMemoryValue.text = peakValue
|
||||
binding.maxMemoryValue.text = maxValue
|
||||
// Timestamp
|
||||
binding.timestamp.text = results.timestamp
|
||||
_binding?.timestamp?.text = results.timestamp
|
||||
|
||||
Log.d(TAG, "Results populated - Memory: ${results.maxMemoryKb} KB, Model: ${results.modelDisplayName}")
|
||||
}
|
||||
|
||||
// ===== Helper Methods =====
|
||||
|
||||
/**
|
||||
* Check if fragment is in valid state for UI updates
|
||||
*/
|
||||
private fun isFragmentValid(): Boolean {
|
||||
return isAdded && !isDetached && _binding != null
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current benchmark state from presenter
|
||||
*/
|
||||
|
|
|
@ -54,7 +54,10 @@ class BenchmarkPresenter(
|
|||
statusMessage = "Loading models...",
|
||||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = false
|
||||
showBenchmarkProgressBar = false,
|
||||
showModelSelectorCard = true, // Show model selector card (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = true // Show status card for loading message
|
||||
)
|
||||
BenchmarkState.LOADING_MODELS -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.start_test),
|
||||
|
@ -65,7 +68,10 @@ class BenchmarkPresenter(
|
|||
statusMessage = "Loading models...",
|
||||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = false
|
||||
showBenchmarkProgressBar = false,
|
||||
showModelSelectorCard = true, // Show model selector card (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = false // Show status card for loading message
|
||||
)
|
||||
BenchmarkState.READY -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.start_test),
|
||||
|
@ -76,7 +82,10 @@ class BenchmarkPresenter(
|
|||
statusMessage = context.getString(R.string.select_a_model_to_start),
|
||||
enableModelSelector = true,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = false
|
||||
showBenchmarkProgressBar = false,
|
||||
showModelSelectorCard = true, // Show model selector card (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = false // Show status card for instructions
|
||||
)
|
||||
BenchmarkState.INITIALIZING -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.stop_test),
|
||||
|
@ -88,7 +97,10 @@ class BenchmarkPresenter(
|
|||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = true,
|
||||
benchmarkProgress = 5 // Initial 5% for starting
|
||||
benchmarkProgress = 0, // Initial 0% before initialization
|
||||
showProgressCard = true,
|
||||
showStatusCard = true,
|
||||
showModelSelectorCard = true // Show model selector card (like iOS)
|
||||
)
|
||||
BenchmarkState.RUNNING -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.stop_test),
|
||||
|
@ -99,7 +111,10 @@ class BenchmarkPresenter(
|
|||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = true,
|
||||
benchmarkProgress = 10 // Initial 10% (5% start + 5% initialization)
|
||||
benchmarkProgress = 10, // Initial 10% for entering running state
|
||||
showProgressCard = true,
|
||||
showStatusCard = true,
|
||||
showModelSelectorCard = true // Show model selector card (like iOS)
|
||||
)
|
||||
BenchmarkState.STOPPING -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.stop_test),
|
||||
|
@ -110,22 +125,24 @@ class BenchmarkPresenter(
|
|||
statusMessage = context.getString(R.string.benchmark_stopping),
|
||||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = true
|
||||
showBenchmarkProgressBar = true,
|
||||
showProgressCard = true,
|
||||
showStatusCard = true,
|
||||
showModelSelectorCard = true // Show model selector card (like iOS)
|
||||
)
|
||||
BenchmarkState.COMPLETED -> BenchmarkUIState(
|
||||
startButtonText = if (useLeaderboardUpload)
|
||||
context.getString(R.string.upload_to_leaderboard)
|
||||
else
|
||||
context.getString(R.string.share),
|
||||
startButtonText = context.getString(R.string.restart_test), // Changed to "重新评测"
|
||||
startButtonEnabled = true,
|
||||
showProgressBar = false,
|
||||
showResults = true,
|
||||
showStatus = false,
|
||||
enableModelSelector = false, // Disable model selector in results view
|
||||
enableModelSelector = true, // Enable model selector in results view (like iOS)
|
||||
showBenchmarkIcon = false, // Hide icon when showing results
|
||||
showBenchmarkProgressBar = false,
|
||||
showBackButton = true, // Show back button in results view
|
||||
showModelSelectorCard = false // Hide model selector card in results view
|
||||
showBackButton = false, // Back button removed, share button in result card instead
|
||||
showModelSelectorCard = true, // Show model selector card in results view (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = false // Hide status card when showing results
|
||||
)
|
||||
BenchmarkState.ERROR -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.start_test),
|
||||
|
@ -135,7 +152,10 @@ class BenchmarkPresenter(
|
|||
showStatus = false,
|
||||
enableModelSelector = true,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = false
|
||||
showBenchmarkProgressBar = false,
|
||||
showModelSelectorCard = true, // Show model selector card (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = false // Hide status card for error state
|
||||
)
|
||||
BenchmarkState.ERROR_MODEL_NOT_FOUND -> BenchmarkUIState(
|
||||
startButtonText = context.getString(R.string.start_test),
|
||||
|
@ -147,6 +167,9 @@ class BenchmarkPresenter(
|
|||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = false,
|
||||
statusMessage = context.getString(R.string.no_models_found),
|
||||
showModelSelectorCard = true, // Show model selector card (like iOS)
|
||||
showProgressCard = false,
|
||||
showStatusCard = true // Show status card for error message
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -157,7 +180,7 @@ class BenchmarkPresenter(
|
|||
* Apply UI state to view
|
||||
*/
|
||||
private fun applyUIState(uiState: BenchmarkUIState) {
|
||||
Log.d(TAG, "Applying UI state: buttonText='${uiState.startButtonText}', buttonEnabled=${uiState.startButtonEnabled}, showProgressBar=${uiState.showProgressBar}, showResults=${uiState.showResults}, showStatus=${uiState.showStatus}, showBenchmarkIcon=${uiState.showBenchmarkIcon}, showBenchmarkProgressBar=${uiState.showBenchmarkProgressBar}, benchmarkProgress=${uiState.benchmarkProgress}, showBackButton=${uiState.showBackButton}, showModelSelectorCard=${uiState.showModelSelectorCard}")
|
||||
Log.d(TAG, "Applying UI state: buttonText='${uiState.startButtonText}', buttonEnabled=${uiState.startButtonEnabled}, showProgressBar=${uiState.showProgressBar}, showResults=${uiState.showResults}, showStatus=${uiState.showStatus}, showBenchmarkIcon=${uiState.showBenchmarkIcon}, showBenchmarkProgressBar=${uiState.showBenchmarkProgressBar}, benchmarkProgress=${uiState.benchmarkProgress}, showBackButton=${uiState.showBackButton}, showModelSelectorCard=${uiState.showModelSelectorCard}, showProgressCard=${uiState.showProgressCard}, showStatusCard=${uiState.showStatusCard}")
|
||||
|
||||
view.setStartButtonText(uiState.startButtonText)
|
||||
view.setStartButtonEnabled(uiState.startButtonEnabled)
|
||||
|
@ -194,6 +217,10 @@ class BenchmarkPresenter(
|
|||
// Apply new button layout controls
|
||||
view.updateButtonLayout(uiState.showBackButton)
|
||||
view.showModelSelectorCard(uiState.showModelSelectorCard)
|
||||
|
||||
// Apply progress and status cards
|
||||
view.showProgressCard(uiState.showProgressCard)
|
||||
view.showStatusCard(uiState.showStatusCard)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
|
@ -217,12 +244,13 @@ class BenchmarkPresenter(
|
|||
}
|
||||
}
|
||||
BenchmarkState.COMPLETED -> {
|
||||
if (useLeaderboardUpload) {
|
||||
Log.d(TAG, "In COMPLETED state, uploading to leaderboard")
|
||||
view.uploadToLeaderboard()
|
||||
Log.d(TAG, "In COMPLETED state, restarting benchmark")
|
||||
// Restart benchmark instead of sharing
|
||||
if (stateMachine.canStart()) {
|
||||
Log.d(TAG, "Restarting benchmark...")
|
||||
startBenchmark()
|
||||
} else {
|
||||
Log.d(TAG, "In COMPLETED state, sharing result card")
|
||||
view.shareResultCard()
|
||||
Log.w(TAG, "Cannot restart benchmark in state: $currentState")
|
||||
}
|
||||
}
|
||||
BenchmarkState.RUNNING, BenchmarkState.INITIALIZING -> {
|
||||
|
@ -471,7 +499,10 @@ class BenchmarkPresenter(
|
|||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = true,
|
||||
benchmarkProgress = 10 // 5% start + 5% initialization
|
||||
benchmarkProgress = 10, // 10% for entering running state
|
||||
showModelSelectorCard = true,
|
||||
showProgressCard = true, // 关键修复:显示进度卡片
|
||||
showStatusCard = true // 关键修复:显示状态卡片
|
||||
)
|
||||
applyUIState(runningUIState)
|
||||
|
||||
|
@ -492,6 +523,7 @@ class BenchmarkPresenter(
|
|||
|
||||
// Calculate real progress based on token processing
|
||||
val realProgress = calculateRealProgress(progress)
|
||||
Log.d(TAG, "onProgress: calculated realProgress=$realProgress for progressType=${progress.progressType}, nativeProgress=${progress.progress}")
|
||||
|
||||
// Update UI with real progress
|
||||
val uiState = when (currentState) {
|
||||
|
@ -504,7 +536,10 @@ class BenchmarkPresenter(
|
|||
enableModelSelector = false,
|
||||
showBenchmarkIcon = true,
|
||||
showBenchmarkProgressBar = true,
|
||||
benchmarkProgress = realProgress
|
||||
benchmarkProgress = realProgress,
|
||||
showModelSelectorCard = true,
|
||||
showProgressCard = true, // 关键修复:显示进度卡片
|
||||
showStatusCard = true // 关键修复:显示状态卡片
|
||||
)
|
||||
else -> return
|
||||
}
|
||||
|
@ -513,6 +548,34 @@ class BenchmarkPresenter(
|
|||
// Format progress message based on structured data
|
||||
val formattedProgress = formatProgressMessage(progress)
|
||||
view.updateProgress(formattedProgress)
|
||||
|
||||
// Update progress card with detailed information - use realProgress instead of native progress
|
||||
// Note: realProgress is already applied via UI state, no need to call again here
|
||||
if (formattedProgress.statusMessage.isNotEmpty()) {
|
||||
view.updateStatusMessage(formattedProgress.statusMessage)
|
||||
}
|
||||
|
||||
// Update test details if available
|
||||
if (progress.totalIterations > 0) {
|
||||
view.updateTestDetails(
|
||||
progress.currentIteration,
|
||||
progress.totalIterations,
|
||||
progress.nPrompt,
|
||||
progress.nGenerate
|
||||
)
|
||||
}
|
||||
|
||||
// Update performance metrics if available
|
||||
if (progress.runTimeSeconds > 0) {
|
||||
view.updateProgressMetrics(
|
||||
progress.runTimeSeconds,
|
||||
progress.prefillTimeSeconds,
|
||||
progress.decodeTimeSeconds,
|
||||
progress.prefillSpeed,
|
||||
progress.decodeSpeed
|
||||
)
|
||||
}
|
||||
|
||||
Log.d(TAG, "Benchmark Progress (${progress.progress}% -> ${realProgress}% real): ${formattedProgress.statusMessage}")
|
||||
}
|
||||
|
||||
|
@ -641,38 +704,67 @@ class BenchmarkPresenter(
|
|||
}
|
||||
|
||||
/**
|
||||
* Calculate real progress based on token processing
|
||||
* - Start: 5%
|
||||
* - Initialization: 5% (total 10%)
|
||||
* - Running: 90% based on token progress
|
||||
* Calculate real progress based on benchmark state
|
||||
* - Running state start: 10%
|
||||
* - After warming up: at least 20%
|
||||
* - Remaining realProgress distributed over remaining 80%
|
||||
*/
|
||||
private fun calculateRealProgress(progress: BenchmarkProgress): Int {
|
||||
// Base progress: 5% for start + 5% for initialization = 10%
|
||||
val baseProgress = 10
|
||||
Log.d(TAG, "calculateRealProgress: progressType=${progress.progressType}, nativeProgress=${progress.progress}, currentIteration=${progress.currentIteration}, totalIterations=${progress.totalIterations}")
|
||||
|
||||
// Base progress for entering RUNNING state: 10%
|
||||
val runningStateStart = 10
|
||||
|
||||
// After warming up: at least 20%
|
||||
val afterWarmupMin = 20
|
||||
|
||||
// Remaining 80% for actual progress distribution
|
||||
val remainingProgressRange = 80
|
||||
|
||||
// Calculate progress based on progressType
|
||||
val finalProgress = when (progress.progressType) {
|
||||
ProgressType.INITIALIZING -> {
|
||||
// During initialization: 0-10%
|
||||
val initProgress = (progress.progress.coerceIn(0, 100) / 100.0f * runningStateStart).toInt()
|
||||
initProgress.coerceIn(0, runningStateStart)
|
||||
}
|
||||
ProgressType.WARMING_UP -> {
|
||||
// During warming up: 10-20%
|
||||
val warmupProgress = runningStateStart + (progress.progress.coerceIn(0, 100) / 100.0f * (afterWarmupMin - runningStateStart)).toInt()
|
||||
warmupProgress.coerceIn(runningStateStart, afterWarmupMin)
|
||||
}
|
||||
ProgressType.RUNNING_TEST -> {
|
||||
// After warming up: 20-100%, distributed over remaining 80%
|
||||
|
||||
// If we have iteration information, calculate based on that
|
||||
if (progress.totalIterations > 0 && progress.currentIteration >= 0) {
|
||||
val iterationProgress = (progress.currentIteration.toFloat() / progress.totalIterations.toFloat() * 90).toInt()
|
||||
return (baseProgress + iterationProgress).coerceIn(10, 100)
|
||||
val iterationProgress = (progress.currentIteration.toFloat() / progress.totalIterations.toFloat() * remainingProgressRange).toInt()
|
||||
(afterWarmupMin + iterationProgress).coerceIn(afterWarmupMin, 100)
|
||||
}
|
||||
|
||||
// If we have token information, calculate based on tokens
|
||||
if (progress.nPrompt > 0 && progress.nGenerate > 0) {
|
||||
// Total expected tokens per iteration
|
||||
val totalTokensPerIteration = progress.nPrompt + progress.nGenerate
|
||||
|
||||
// Use the native progress percentage if available, but scale it to our 90% range
|
||||
else if (progress.nPrompt > 0 && progress.nGenerate > 0) {
|
||||
// Use the native progress percentage if available, but scale it to our remaining 80% range
|
||||
val nativeProgress = progress.progress.coerceIn(0, 100)
|
||||
val scaledProgress = (nativeProgress / 100.0f * 90).toInt()
|
||||
val scaledProgress = (nativeProgress / 100.0f * remainingProgressRange).toInt()
|
||||
|
||||
return (baseProgress + scaledProgress).coerceIn(10, 100)
|
||||
(afterWarmupMin + scaledProgress).coerceIn(afterWarmupMin, 100)
|
||||
}
|
||||
// Fallback: use native progress but scale to remaining 80%
|
||||
else {
|
||||
val fallbackProgress = progress.progress.coerceIn(0, 100)
|
||||
val scaledFallback = (fallbackProgress / 100.0f * remainingProgressRange).toInt()
|
||||
|
||||
(afterWarmupMin + scaledFallback).coerceIn(afterWarmupMin, 100)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
// Fallback for unknown states
|
||||
runningStateStart
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: use native progress but ensure minimum 10%
|
||||
val fallbackProgress = progress.progress.coerceIn(0, 100)
|
||||
val scaledFallback = (fallbackProgress / 100.0f * 90).toInt()
|
||||
|
||||
return (baseProgress + scaledFallback).coerceIn(10, 100)
|
||||
Log.d(TAG, "calculateRealProgress result: $finalProgress")
|
||||
return finalProgress
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -24,6 +24,8 @@ object BenchmarkResultsHelper {
|
|||
var totalTokensProcessed = 0
|
||||
var configText = context.getString(R.string.benchmark_config) + "\n"
|
||||
|
||||
var totalTimeSeconds = 0.0
|
||||
|
||||
testResults.forEach { testInstance ->
|
||||
// Calculate speeds for this test instance
|
||||
if (testInstance.prefillUs.isNotEmpty()) {
|
||||
|
@ -38,6 +40,11 @@ object BenchmarkResultsHelper {
|
|||
|
||||
totalTokensProcessed += testInstance.nPrompt + testInstance.nGenerate
|
||||
configText += "PP: ${testInstance.nPrompt} • TG: ${testInstance.nGenerate}\n"
|
||||
|
||||
// Calculate total time for this test instance
|
||||
val prefillTimeSeconds = testInstance.prefillUs.sum() / 1_000_000.0
|
||||
val decodeTimeSeconds = testInstance.decodeUs.sum() / 1_000_000.0
|
||||
totalTimeSeconds += prefillTimeSeconds + decodeTimeSeconds
|
||||
}
|
||||
|
||||
android.util.Log.d("BenchmarkResultsHelper", "Processing results: prefillSpeeds=${allPrefillSpeeds.size}, decodeSpeeds=${allDecodeSpeeds.size}")
|
||||
|
@ -75,7 +82,8 @@ object BenchmarkResultsHelper {
|
|||
prefillStats = prefillStats,
|
||||
decodeStats = decodeStats,
|
||||
totalTokensProcessed = totalTokensProcessed,
|
||||
totalTests = testResults.size
|
||||
totalTests = testResults.size,
|
||||
totalTimeSeconds = totalTimeSeconds
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -93,14 +101,14 @@ object BenchmarkResultsHelper {
|
|||
* Format speed statistics for display
|
||||
*/
|
||||
fun formatSpeedStatistics(stats: SpeedStatistics): String {
|
||||
return "%.2f ± %.2f t/s".format(stats.average, stats.stdev)
|
||||
return "%.1f ± %.1f tok/s".format(stats.average, stats.stdev)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format speed value (average and stdev) for display in a single line like "avg ± stdev t/s".
|
||||
* Format speed value (average and stdev) for display in a single line like "avg ± stdev tok/s".
|
||||
*/
|
||||
fun formatSpeedStatisticsLine(stats: SpeedStatistics): String {
|
||||
return "%.2f ± %.2f t/s".format(stats.average, stats.stdev)
|
||||
return "%.1f ± %.1f tok/s".format(stats.average, stats.stdev)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -114,7 +122,7 @@ object BenchmarkResultsHelper {
|
|||
* Format speed value (average only) for display
|
||||
*/
|
||||
fun formatSpeedValue(stats: SpeedStatistics): String {
|
||||
return "%.2f t/s".format(stats.average)
|
||||
return "%.1f tok/s".format(stats.average)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -148,6 +156,40 @@ object BenchmarkResultsHelper {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format memory usage with percentage and absolute values
|
||||
* Returns Pair<value,label> where value is like "12.3%" and label is "Peak Memory\n3 GB / 24 GB"
|
||||
*/
|
||||
fun formatMemoryUsage(maxMemoryKb: Long, totalKb: Long): Pair<String, String> {
|
||||
val percentage = if (totalKb > 0) {
|
||||
(maxMemoryKb.toDouble() / totalKb.toDouble()) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
|
||||
// Format memory values with appropriate units (MB or GB)
|
||||
val maxMemoryFormatted = formatMemorySize(maxMemoryKb)
|
||||
val totalMemoryFormatted = formatMemorySize(totalKb)
|
||||
|
||||
val valueText = maxMemoryFormatted
|
||||
val labelText = "%.1f%% of %s".format(percentage, totalMemoryFormatted)
|
||||
|
||||
return Pair(valueText, labelText)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format memory size with appropriate unit (MB or GB)
|
||||
*/
|
||||
private fun formatMemorySize(memoryKb: Long): String {
|
||||
val memoryMB = memoryKb / 1024.0
|
||||
return if (memoryMB >= 1024.0) {
|
||||
val memoryGB = memoryMB / 1024.0
|
||||
"%.1f GB".format(memoryGB)
|
||||
} else {
|
||||
"%.0f MB".format(memoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format peak memory usage value and label.
|
||||
* Returns Pair<value,label> where value is like "12.0%" and label is "Peak Memory\n3 GB / 24 GB".
|
||||
|
@ -177,7 +219,8 @@ data class BenchmarkStatistics(
|
|||
val prefillStats: SpeedStatistics?,
|
||||
val decodeStats: SpeedStatistics?,
|
||||
val totalTokensProcessed: Int,
|
||||
val totalTests: Int
|
||||
val totalTests: Int,
|
||||
val totalTimeSeconds: Double
|
||||
) {
|
||||
companion object {
|
||||
fun empty() = BenchmarkStatistics(
|
||||
|
@ -185,7 +228,8 @@ data class BenchmarkStatistics(
|
|||
prefillStats = null,
|
||||
decodeStats = null,
|
||||
totalTokensProcessed = 0,
|
||||
totalTests = 0
|
||||
totalTests = 0,
|
||||
totalTimeSeconds = 0.0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -150,5 +150,7 @@ data class BenchmarkUIState(
|
|||
val showBenchmarkProgressBar: Boolean = false,
|
||||
val benchmarkProgress: Int = 0,
|
||||
val showBackButton: Boolean = false,
|
||||
val showModelSelectorCard: Boolean = true
|
||||
val showModelSelectorCard: Boolean = true,
|
||||
val showProgressCard: Boolean = false,
|
||||
val showStatusCard: Boolean = false
|
||||
)
|
|
@ -0,0 +1,223 @@
|
|||
package com.alibaba.mnnllm.android.benchmark
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.drawable.GradientDrawable
|
||||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.widget.LinearLayout
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.alibaba.mnnllm.android.R
|
||||
import com.alibaba.mnnllm.android.databinding.ViewPerformanceMetricBinding
|
||||
|
||||
/**
|
||||
* Custom view component for displaying performance metrics
|
||||
* Similar to iOS PerformanceMetricView with icon, title, value, and subtitle
|
||||
*/
|
||||
class PerformanceMetricView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = 0
|
||||
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||
|
||||
private val binding: ViewPerformanceMetricBinding
|
||||
|
||||
init {
|
||||
binding = ViewPerformanceMetricBinding.inflate(LayoutInflater.from(context), this, true)
|
||||
orientation = VERTICAL
|
||||
|
||||
// Set default styling
|
||||
val padding = resources.getDimensionPixelSize(R.dimen.performance_metric_padding)
|
||||
setPadding(padding, padding, padding, padding)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set performance metric data
|
||||
* @param icon Resource ID for the icon
|
||||
* @param title Main title text
|
||||
* @param value Primary value to display (e.g., "121.13 t/s")
|
||||
* @param subtitle Secondary text (e.g., "Prompt Processing")
|
||||
* @param colorResId Color resource ID for theming
|
||||
* @param stdDev Standard deviation value to display next to the value (e.g., "±3.88")
|
||||
*/
|
||||
fun setMetricData(
|
||||
icon: Int,
|
||||
title: String,
|
||||
value: String,
|
||||
subtitle: String,
|
||||
colorResId: Int,
|
||||
stdDev: String? = null
|
||||
) {
|
||||
val color = ContextCompat.getColor(context, colorResId)
|
||||
|
||||
// Set icon with circular background
|
||||
binding.metricIcon.setImageResource(icon)
|
||||
setIconBackground(color)
|
||||
|
||||
// Set texts
|
||||
binding.metricTitle.text = title
|
||||
binding.metricValue.text = value
|
||||
binding.metricSubtitle.text = subtitle
|
||||
|
||||
// Handle standard deviation display
|
||||
if (stdDev != null && stdDev.isNotEmpty()) {
|
||||
binding.metricStdDev.text = stdDev
|
||||
binding.metricStdDev.setTextColor(color)
|
||||
binding.metricStdDev.visibility = android.view.View.VISIBLE
|
||||
} else {
|
||||
binding.metricStdDev.visibility = android.view.View.GONE
|
||||
}
|
||||
|
||||
// Apply color theming
|
||||
binding.metricValue.setTextColor(color)
|
||||
binding.metricIcon.imageTintList = ColorStateList.valueOf(color)
|
||||
}
|
||||
|
||||
/**
|
||||
* Create circular gradient background for icon
|
||||
*/
|
||||
private fun setIconBackground(color: Int) {
|
||||
val gradientDrawable = GradientDrawable().apply {
|
||||
shape = GradientDrawable.OVAL
|
||||
|
||||
// Create gradient colors with transparency
|
||||
val startColor = (color and 0x00FFFFFF) or 0x33000000 // 20% opacity
|
||||
val endColor = (color and 0x00FFFFFF) or 0x1A000000 // 10% opacity
|
||||
|
||||
colors = intArrayOf(startColor, endColor)
|
||||
gradientType = GradientDrawable.RADIAL_GRADIENT
|
||||
gradientRadius = 50f
|
||||
}
|
||||
|
||||
binding.iconBackground.background = gradientDrawable
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for speed statistics with string resource support
|
||||
*/
|
||||
fun setSpeedMetric(
|
||||
icon: Int,
|
||||
titleResId: Int,
|
||||
stats: SpeedStatistics?,
|
||||
colorResId: Int
|
||||
) {
|
||||
val title = context.getString(titleResId)
|
||||
if (stats != null) {
|
||||
val value = "%.1f t/s".format(stats.average)
|
||||
val stdDev = "±%.1f".format(stats.stdev)
|
||||
setMetricData(icon, title, value, context.getString(R.string.prefill_speed_subtitle), colorResId, stdDev)
|
||||
} else {
|
||||
setMetricData(icon, title, context.getString(R.string.not_available), context.getString(R.string.prefill_speed_subtitle), colorResId)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for memory metric
|
||||
*/
|
||||
fun setMemoryMetric(
|
||||
maxMemoryKb: Long,
|
||||
totalMemoryKb: Long,
|
||||
colorResId: Int
|
||||
) {
|
||||
// Format memory values with appropriate units (MB or GB)
|
||||
val maxMemoryFormatted = formatMemorySize(maxMemoryKb)
|
||||
val totalMemoryFormatted = formatMemorySize(totalMemoryKb)
|
||||
|
||||
setMetricData(
|
||||
R.drawable.ic_memorychip,
|
||||
context.getString(R.string.memory_usage_title),
|
||||
maxMemoryFormatted,
|
||||
context.getString(R.string.memory_usage_subtitle),
|
||||
colorResId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format memory size with appropriate unit (MB or GB)
|
||||
*/
|
||||
private fun formatMemorySize(memoryKb: Long): String {
|
||||
val memoryMB = memoryKb / 1024.0
|
||||
return if (memoryMB >= 1024.0) {
|
||||
val memoryGB = memoryMB / 1024.0
|
||||
"%.1f GB".format(memoryGB)
|
||||
} else {
|
||||
"%.0f MB".format(memoryMB)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update metric with simple title, value, and icon name
|
||||
* @param title Metric title
|
||||
* @param value Metric value
|
||||
* @param iconName Icon resource name (e.g., "ic_clock")
|
||||
*/
|
||||
fun updateMetric(title: String, value: String, iconName: String) {
|
||||
val iconResId = getIconResourceId(iconName)
|
||||
val colorResId = R.color.benchmark_accent
|
||||
|
||||
setMetricData(
|
||||
icon = iconResId,
|
||||
title = title,
|
||||
value = value,
|
||||
subtitle = title,
|
||||
colorResId = colorResId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get icon resource ID from resource name
|
||||
*/
|
||||
private fun getIconResourceId(iconName: String): Int {
|
||||
return try {
|
||||
val resourceName = if (iconName.startsWith("ic_")) iconName else "ic_$iconName"
|
||||
val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName)
|
||||
if (resourceId != 0) resourceId else R.drawable.ic_clock
|
||||
} catch (e: Exception) {
|
||||
R.drawable.ic_clock
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for total time metric
|
||||
*/
|
||||
fun setTotalTimeMetric(
|
||||
totalTimeSeconds: Double,
|
||||
colorResId: Int
|
||||
) {
|
||||
val formattedTime = formatTime(totalTimeSeconds)
|
||||
setMetricData(
|
||||
R.drawable.ic_clock,
|
||||
context.getString(R.string.total_tokens_title),
|
||||
formattedTime,
|
||||
context.getString(R.string.total_tokens_subtitle),
|
||||
colorResId
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time in seconds to appropriate unit (ms, s, or min)
|
||||
*/
|
||||
private fun formatTime(seconds: Double): String {
|
||||
return when {
|
||||
seconds < 1.0 -> "%.0f ms".format(seconds * 1000)
|
||||
seconds < 60.0 -> "%.3f s".format(seconds)
|
||||
else -> "%.1f min".format(seconds / 60.0)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience method for total tokens metric (deprecated, use setTotalTimeMetric instead)
|
||||
*/
|
||||
fun setTotalTokensMetric(
|
||||
totalTokens: Int,
|
||||
colorResId: Int
|
||||
) {
|
||||
setMetricData(
|
||||
R.drawable.ic_clock,
|
||||
context.getString(R.string.total_tokens_title),
|
||||
"$totalTokens",
|
||||
context.getString(R.string.total_tokens_subtitle),
|
||||
colorResId
|
||||
)
|
||||
}
|
||||
}
|
|
@ -166,11 +166,8 @@ class ChatActivity : AppCompatActivity() {
|
|||
private fun setupInputModule() {
|
||||
this.chatInputModule!!.apply {
|
||||
setOnThinkingModeChanged {isThinking ->
|
||||
(chatSession as LlmSession).updateAssistantPrompt(if (isThinking) {
|
||||
"<|im_start|>assistant\n%s<|im_end|>\n"
|
||||
} else {
|
||||
"<|im_start|>assistant\n<think>\n</think>%s<|im_end|>\n"
|
||||
})
|
||||
Log.d(TAG, "isThinking: $isThinking")
|
||||
(chatSession as LlmSession).updateThinking(isThinking)
|
||||
}
|
||||
setOnAudioOutputModeChanged {
|
||||
chatPresenter.setEnableAudioOutput(it)
|
||||
|
@ -326,6 +323,7 @@ class ChatActivity : AppCompatActivity() {
|
|||
}
|
||||
|
||||
fun onLoadingChanged(loading: Boolean) {
|
||||
isLoading = loading
|
||||
this.chatInputModule!!.onLoadingStatesChanged(loading)
|
||||
layoutModelLoading!!.visibility =
|
||||
if (loading) View.VISIBLE else View.GONE
|
||||
|
@ -588,6 +586,27 @@ class ChatActivity : AppCompatActivity() {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle generation stop request from voice chat or other components
|
||||
* This method triggers the same stop logic as the UI stop button
|
||||
*/
|
||||
fun onStopGenerationRequested() {
|
||||
Log.d(TAG, "Stop generation requested from external component")
|
||||
if (isGenerating) {
|
||||
// Trigger the same stop logic as the UI stop button
|
||||
chatPresenter.stopGenerate()
|
||||
|
||||
// Update UI state immediately
|
||||
setIsGenerating(false)
|
||||
val recentItem = chatListComponent.recentItem
|
||||
recentItem?.loading = false
|
||||
|
||||
Log.d(TAG, "Generation stopped by external request")
|
||||
} else {
|
||||
Log.d(TAG, "No active generation to stop")
|
||||
}
|
||||
}
|
||||
|
||||
val sessionDebugInfo: String
|
||||
get() = chatSession!!.debugInfo
|
||||
|
||||
|
|
|
@ -178,9 +178,7 @@ class GenerateResultProcessor {
|
|||
|
||||
private fun formatAndSetGptOssThinkingContent(content: String) {
|
||||
if (content.isNotBlank()) {
|
||||
thinkingStringBuilder.append("\n> ")
|
||||
thinkingStringBuilder.append(content.replace("\n", "\n> "))
|
||||
thinkingStringBuilder.append("\n")
|
||||
thinkingStringBuilder.append(content)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -223,7 +221,7 @@ class GenerateResultProcessor {
|
|||
val text = buffer.substring(0, effectiveEndIndex)
|
||||
|
||||
if (text.isNotEmpty()) {
|
||||
thinkingStringBuilder.append(text.replace("\n", "\n> "))
|
||||
thinkingStringBuilder.append(text)
|
||||
thinkHasContent = true
|
||||
}
|
||||
|
||||
|
@ -268,7 +266,7 @@ class GenerateResultProcessor {
|
|||
// 3. Add the pending text and the current text to the thinking block.
|
||||
val textToThink = pendingTextBuffer.toString() + textBefore
|
||||
if (textToThink.isNotEmpty()) {
|
||||
thinkingStringBuilder.append(textToThink.replace("\n", "\n> "))
|
||||
thinkingStringBuilder.append(textToThink)
|
||||
thinkHasContent = true
|
||||
}
|
||||
|
||||
|
@ -295,9 +293,6 @@ class GenerateResultProcessor {
|
|||
isThinking = true
|
||||
if (!hasThought) {
|
||||
hasThought = true
|
||||
thinkingStringBuilder.append("\n> ")
|
||||
} else {
|
||||
thinkingStringBuilder.append("\n> ") // Separator for subsequent thoughts
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -320,11 +315,12 @@ class GenerateResultProcessor {
|
|||
fun getRawResult(): String = rawStringBuilder.toString()
|
||||
|
||||
fun getThinkingContent(): String {
|
||||
return if (currentFormat == StreamFormat.THINK_TAGS) {
|
||||
val thinkingContent = if (currentFormat == StreamFormat.THINK_TAGS) {
|
||||
if (thinkHasContent) thinkingStringBuilder.toString() else ""
|
||||
} else {
|
||||
thinkingStringBuilder.toString()
|
||||
}
|
||||
return if (thinkingContent.isNotBlank()) thinkingContent else ""
|
||||
}
|
||||
|
||||
fun getNormalOutput(): String = normalStringBuilder.toString()
|
||||
|
|
|
@ -142,6 +142,8 @@ object ChatViewHolders {
|
|||
RecyclerView.ViewHolder(view), View.OnClickListener, OnLongClickListener {
|
||||
private val viewText: TextView = view.findViewById(R.id.tv_chat_text)
|
||||
private val viewThinking: TextView = view.findViewById(R.id.tv_chat_thinking)
|
||||
private val thinkingContainer: View = view.findViewById(R.id.ll_thinking_container)
|
||||
private val thinkingMarker: View = view.findViewById(R.id.view_thinking_marker)
|
||||
private val benchmarkInfo: TextView = view.findViewById(R.id.tv_chat_benchmark)
|
||||
private val thinkingToggle: LinearLayout = view.findViewById(R.id.ll_thinking_toggle)
|
||||
private val textThinkingHeader:TextView = view.findViewById(R.id.tv_thinking_header)
|
||||
|
@ -299,12 +301,21 @@ object ChatViewHolders {
|
|||
textThinkingHeader.resources.getString(R.string.r1_think_complete_template, (data.thinkingFinishedTime / 1000).toString())
|
||||
else textThinkingHeader.resources.getString(R.string.r1_thinking_message)
|
||||
if (showThinking && !TextUtils.isEmpty(data.thinkingText)) {
|
||||
val thinkingText = data.thinkingText!!
|
||||
thinkingContainer.visibility = View.VISIBLE
|
||||
viewThinking.visibility = View.VISIBLE
|
||||
markdown.setMarkdown(viewThinking, data.thinkingText!!)
|
||||
// Legacy compatibility: if content starts with '>' assume preformatted blockquote
|
||||
val isLegacyBlockQuote = thinkingText.trimStart().startsWith(">")
|
||||
// Hide left marker if legacy content already has its own marker style
|
||||
thinkingMarker.visibility = if (isLegacyBlockQuote) View.GONE else View.VISIBLE
|
||||
markdown.setMarkdown(viewThinking, thinkingText)
|
||||
ivThinkingHeader.setImageResource(R.drawable.ic_arrow_up)
|
||||
} else {
|
||||
ivThinkingHeader.setImageResource(R.drawable.ic_arrow_down)
|
||||
viewThinking.visibility = View.GONE
|
||||
thinkingContainer.visibility = View.GONE
|
||||
// Reset marker visible for next binds by default
|
||||
thinkingMarker.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -22,10 +22,13 @@ import com.alibaba.mnnllm.android.chat.input.VoiceRecordingModule.VoiceRecording
|
|||
import com.alibaba.mnnllm.android.chat.chatlist.ChatViewHolders
|
||||
import com.alibaba.mnnllm.android.chat.model.ChatDataItem
|
||||
import com.alibaba.mnnllm.android.databinding.ActivityChatBinding
|
||||
import com.alibaba.mnnllm.android.llm.LlmSession
|
||||
import com.alibaba.mnnllm.android.utils.KeyboardUtils
|
||||
import com.alibaba.mnnllm.android.model.ModelUtils
|
||||
import com.alibaba.mnnllm.android.utils.Permissions.REQUEST_RECORD_AUDIO_PERMISSION
|
||||
import java.util.Date
|
||||
import com.alibaba.mnnllm.android.modelist.ModelListManager
|
||||
import com.alibaba.mnnllm.android.modelsettings.ModelConfig
|
||||
|
||||
class ChatInputComponent(
|
||||
private val chatActivity: ChatActivity,
|
||||
|
@ -87,6 +90,9 @@ class ChatInputComponent(
|
|||
|
||||
private fun setupToggleAudioOutput() {
|
||||
binding.btnToggleAudioOutput.setOnClickListener {
|
||||
if (chatActivity.isLoading) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
if (!binding.btnToggleAudioOutput.isSelected) {
|
||||
android.app.AlertDialog.Builder(chatActivity)
|
||||
.setMessage(R.string.audio_output_confirm)
|
||||
|
@ -117,13 +123,18 @@ class ChatInputComponent(
|
|||
}
|
||||
|
||||
private fun setupThinkingMode() {
|
||||
binding.btnToggleThinking.visibility = if (ModelUtils.isSupportThinkingSwitch(currentModelName)) {
|
||||
binding.btnToggleThinking.isSelected = true
|
||||
val extraTags = ModelListManager.getExtraTags(currentModelId)
|
||||
binding.btnToggleThinking.visibility = if (ModelUtils.isSupportThinkingSwitchByTags(extraTags)) {
|
||||
binding.btnToggleThinking.isSelected = ModelConfig.loadConfig(currentModelId)?.jinja?.context?.enableThinking != false
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
binding.btnToggleThinking.setOnClickListener {
|
||||
Log.d(TAG, "handleSendClick isGenerating : ${chatActivity.isLoading}")
|
||||
if (chatActivity.isLoading) {
|
||||
return@setOnClickListener
|
||||
}
|
||||
binding.btnToggleThinking.isSelected = !binding.btnToggleThinking.isSelected
|
||||
onThinkingModeChanged?.apply {
|
||||
this(binding.btnToggleThinking.isSelected)
|
||||
|
@ -268,7 +279,8 @@ class ChatInputComponent(
|
|||
}
|
||||
|
||||
override fun onLeaveRecordingMode() {
|
||||
if (ModelUtils.isSupportThinkingSwitch(currentModelName)) {
|
||||
val extraTags = ModelListManager.getExtraTags(currentModelId)
|
||||
if (ModelUtils.isSupportThinkingSwitchByTags(extraTags)) {
|
||||
binding.btnToggleThinking.visibility = View.VISIBLE
|
||||
}
|
||||
updateAudioOutput()
|
||||
|
@ -322,6 +334,7 @@ class ChatInputComponent(
|
|||
if (!loading && ModelUtils.isAudioModel(currentModelName)) {
|
||||
voiceRecordingModule.onEnabled()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fun onRequestPermissionsResult(
|
||||
|
|
|
@ -243,7 +243,12 @@ class ChatDataManager private constructor(context: Context) {
|
|||
cursor.getString(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MODEL_ID))
|
||||
val name =
|
||||
cursor.getString(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_NAME))
|
||||
list.add(SessionItem(sid, mid, name))
|
||||
val lastChatTime = try {
|
||||
cursor.getLong(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_LAST_CHAT_TIME))
|
||||
} catch (e: Exception) {
|
||||
0L // Fallback for when column doesn't exist
|
||||
}
|
||||
list.add(SessionItem(sid, mid, name, lastChatTime))
|
||||
}
|
||||
cursor.close()
|
||||
}
|
||||
|
|
|
@ -4,4 +4,5 @@ package com.alibaba.mnnllm.android.chat.model
|
|||
|
||||
class SessionItem(@JvmField val sessionId: String,
|
||||
@JvmField val modelId: String,
|
||||
@JvmField var title: String)
|
||||
@JvmField var title: String,
|
||||
@JvmField val lastChatTime: Long = 0L)
|
|
@ -453,6 +453,14 @@ class VoiceChatPresenter(
|
|||
// Reset generation state
|
||||
isGenerationFinished = false
|
||||
|
||||
// Stop any ongoing generation and trigger ChatActivity's stop logic
|
||||
if (isProcessingLlm || isSpeaking) {
|
||||
chatPresenter.stopGenerate()
|
||||
if (activity is com.alibaba.mnnllm.android.chat.ChatActivity) {
|
||||
activity.onStopGenerationRequested()
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister from ChatPresenter
|
||||
chatPresenter.removeGenerateListener(this)
|
||||
|
||||
|
@ -495,7 +503,15 @@ class VoiceChatPresenter(
|
|||
if (isProcessingLlm || isSpeaking) {
|
||||
isStoppingGeneration = true
|
||||
isGenerationFinished = false
|
||||
chatPresenter.stopGenerate() // Stop generation in ChatPresenter
|
||||
|
||||
// Stop generation in ChatPresenter
|
||||
chatPresenter.stopGenerate()
|
||||
|
||||
// Trigger ChatActivity's stop logic
|
||||
if (activity is com.alibaba.mnnllm.android.chat.ChatActivity) {
|
||||
activity.onStopGenerationRequested()
|
||||
}
|
||||
|
||||
audioPlayer?.stop()
|
||||
isProcessingLlm = false
|
||||
isSpeaking = false
|
||||
|
|
|
@ -5,10 +5,16 @@ package com.alibaba.mnnllm.android.history
|
|||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.alibaba.mnnllm.android.R
|
||||
import com.alibaba.mnnllm.android.chat.model.SessionItem
|
||||
import com.alibaba.mnnllm.android.model.ModelUtils
|
||||
import com.alibaba.mnnllm.android.modelist.ModelListManager
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.*
|
||||
|
||||
class HistoryListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
||||
private var historySessionList: MutableList<SessionItem>? = null
|
||||
|
@ -57,6 +63,9 @@ class HistoryListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||
|
||||
class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
|
||||
var textHistory: TextView
|
||||
var textTimestamp: TextView
|
||||
var textModelName: TextView
|
||||
var modelAvatarView: ImageView
|
||||
var viewDelete: View
|
||||
|
||||
private var onHistoryCallback: OnHistoryCallback? = null
|
||||
|
@ -66,27 +75,103 @@ class HistoryListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
|
|||
this.viewDelete = itemView.findViewById(R.id.iv_delete_history)
|
||||
viewDelete.setOnClickListener(this)
|
||||
textHistory = itemView.findViewById(R.id.text_history)
|
||||
textTimestamp = itemView.findViewById(R.id.text_timestamp)
|
||||
textModelName = itemView.findViewById(R.id.text_model_name)
|
||||
modelAvatarView = itemView.findViewById(R.id.model_avatar_view)
|
||||
}
|
||||
|
||||
fun bind(sessionItem: SessionItem) {
|
||||
textHistory.text = sessionItem.title
|
||||
textTimestamp.text = formatTimestamp(sessionItem.lastChatTime)
|
||||
setModelAvatar(sessionItem.modelId)
|
||||
textModelName.text = getModelDisplayName(sessionItem.modelId)
|
||||
|
||||
itemView.tag = sessionItem
|
||||
viewDelete.tag = sessionItem
|
||||
}
|
||||
|
||||
private fun setModelAvatar(modelId: String) {
|
||||
val drawableId = ModelUtils.getDrawableId(modelId)
|
||||
if (drawableId != 0) {
|
||||
modelAvatarView.visibility = View.VISIBLE
|
||||
modelAvatarView.setImageResource(drawableId)
|
||||
} else {
|
||||
modelAvatarView.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
private fun getModelDisplayName(modelId: String): String {
|
||||
return ModelUtils.getVendor(modelId)
|
||||
}
|
||||
|
||||
private fun formatTimestamp(timestamp: Long): String {
|
||||
if (timestamp == 0L) {
|
||||
return ""
|
||||
}
|
||||
|
||||
val now = System.currentTimeMillis()
|
||||
val chatDate = Date(timestamp)
|
||||
val today = Date(now)
|
||||
|
||||
// Determine whether it's the same day
|
||||
val isSameDay = isSameDay(chatDate, today)
|
||||
|
||||
val formattedTime = if (isSameDay) {
|
||||
// Chat occurred today, display hours and minutes, e.g., 8:30
|
||||
val timeFormat = SimpleDateFormat("H:mm", Locale.getDefault())
|
||||
timeFormat.format(chatDate)
|
||||
} else {
|
||||
// Chat did not occur today, display date, supports both Chinese and English
|
||||
val locale = Locale.getDefault()
|
||||
val dateFormat = if (locale.language == "zh") {
|
||||
SimpleDateFormat("M月d日", locale)
|
||||
} else {
|
||||
SimpleDateFormat("MMM d", locale) // For example: Jun 20, Dec 15
|
||||
}
|
||||
dateFormat.format(chatDate)
|
||||
}
|
||||
|
||||
return formattedTime
|
||||
}
|
||||
|
||||
private fun isSameDay(date1: Date, date2: Date): Boolean {
|
||||
val cal1 = java.util.Calendar.getInstance()
|
||||
val cal2 = java.util.Calendar.getInstance()
|
||||
cal1.time = date1
|
||||
cal2.time = date2
|
||||
return cal1.get(java.util.Calendar.YEAR) == cal2.get(java.util.Calendar.YEAR) &&
|
||||
cal1.get(java.util.Calendar.DAY_OF_YEAR) == cal2.get(java.util.Calendar.DAY_OF_YEAR)
|
||||
}
|
||||
|
||||
override fun onClick(v: View) {
|
||||
val sessionItem = v.tag as SessionItem
|
||||
if (v.id == R.id.iv_delete_history) {
|
||||
if (onHistoryCallback != null) {
|
||||
onHistoryCallback!!.onSessionHistoryDelete(sessionItem)
|
||||
}
|
||||
} else { //itemView
|
||||
showDeleteConfirmDialog(sessionItem)
|
||||
} else {
|
||||
if (onHistoryCallback != null) {
|
||||
onHistoryCallback!!.onSessionHistoryClick(sessionItem)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showDeleteConfirmDialog(sessionItem: SessionItem) {
|
||||
val context = itemView.context
|
||||
MaterialAlertDialogBuilder(context)
|
||||
.setTitle(R.string.delete_history_title)
|
||||
.setMessage(R.string.delete_history_message)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
if (onHistoryCallback != null) {
|
||||
onHistoryCallback!!.onSessionHistoryDelete(sessionItem)
|
||||
}
|
||||
val index = (itemView.parent as RecyclerView).getChildAdapterPosition(itemView)
|
||||
if (index != RecyclerView.NO_POSITION) {
|
||||
(itemView.parent as RecyclerView).adapter?.notifyItemRemoved(index)
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
fun setOnHistoryClick(onHistoryCallback: OnHistoryCallback?) {
|
||||
this.onHistoryCallback = onHistoryCallback
|
||||
}
|
||||
|
|
|
@ -20,4 +20,5 @@ interface ChatSession {
|
|||
fun setEnableAudioOutput(enable: Boolean)
|
||||
fun getHistory(): List<ChatDataItem>?
|
||||
fun setHistory(history:List<ChatDataItem>?)
|
||||
fun updateThinking(thinking: Boolean)
|
||||
}
|
||||
|
|
|
@ -122,6 +122,9 @@ class DiffusionSession(
|
|||
savedHistory = history
|
||||
}
|
||||
|
||||
override fun updateThinking(thinking: Boolean) {
|
||||
}
|
||||
|
||||
|
||||
private external fun initNative(
|
||||
configPath: String,
|
||||
|
|
|
@ -18,6 +18,10 @@ import android.util.Pair
|
|||
import com.alibaba.mnnllm.android.utils.MmapUtils
|
||||
import android.content.Context
|
||||
import android.app.ActivityManager
|
||||
import com.alibaba.mnnllm.android.modelsettings.Jinja
|
||||
import com.alibaba.mnnllm.android.modelsettings.JinjaContext
|
||||
import com.alibaba.mnnllm.android.modelsettings.ModelConfig.Companion.defaultConfig
|
||||
import com.alibaba.mnnllm.android.modelsettings.ModelConfig.Companion.loadConfig
|
||||
|
||||
class LlmSession (
|
||||
private val modelId: String,
|
||||
|
@ -25,7 +29,6 @@ class LlmSession (
|
|||
private val configPath: String,
|
||||
var savedHistory: List<ChatDataItem>?,
|
||||
): ChatSession{
|
||||
private var extraAssistantPrompt: String? = null
|
||||
override var supportOmni: Boolean = false
|
||||
private var nativePtr: Long = 0
|
||||
|
||||
|
@ -66,16 +69,12 @@ class LlmSession (
|
|||
rootCacheDir = MmapUtils.getMmapDir(modelId)
|
||||
File(rootCacheDir).mkdirs()
|
||||
}
|
||||
val backend = config.backendType
|
||||
val configMap = HashMap<String, Any>().apply {
|
||||
put("is_r1", ModelUtils.isR1Model(modelId))
|
||||
put("mmap_dir", rootCacheDir ?: "")
|
||||
put("keep_history", keepHistory)
|
||||
}
|
||||
val extraConfig = ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))?.apply {
|
||||
this.assistantPromptTemplate = extraAssistantPrompt
|
||||
this.backendType = backend
|
||||
}
|
||||
val extraConfig = ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))
|
||||
Log.d(TAG, "MNN_DEBUG load initNative")
|
||||
nativePtr = initNative(
|
||||
configPath,
|
||||
|
@ -94,6 +93,10 @@ class LlmSession (
|
|||
}
|
||||
}
|
||||
|
||||
fun getConfig(): ModelConfig? {
|
||||
return ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))
|
||||
}
|
||||
|
||||
private fun generateNewSessionId(): String {
|
||||
this.sessionId = System.currentTimeMillis().toString()
|
||||
return this.sessionId
|
||||
|
@ -209,9 +212,18 @@ class LlmSession (
|
|||
updateSystemPromptNative(nativePtr, systemPrompt)
|
||||
}
|
||||
|
||||
fun updateAssistantPrompt(assistantPrompt: String) {
|
||||
extraAssistantPrompt = assistantPrompt
|
||||
updateAssistantPromptNative(nativePtr, assistantPrompt)
|
||||
override fun updateThinking(thinking: Boolean) {
|
||||
val loadedConfig = loadConfig(modelId)
|
||||
loadedConfig?.let {
|
||||
loadedConfig.jinja = Jinja(context = JinjaContext(enableThinking = thinking))
|
||||
ModelConfig.saveConfig(getExtraConfigFile(modelId), loadedConfig)
|
||||
updateConfig(Gson().toJson(loadedConfig))
|
||||
}
|
||||
}
|
||||
|
||||
fun updateConfig(configJson: String) {
|
||||
Log.d(TAG, "updateConfig: $configJson")
|
||||
updateConfigNative(nativePtr, configJson)
|
||||
}
|
||||
|
||||
private external fun updateEnableAudioOutputNative(llmPtr: Long, enable: Boolean)
|
||||
|
@ -223,6 +235,8 @@ class LlmSession (
|
|||
|
||||
private external fun updateAssistantPromptNative(llmPtr: Long, assistantPrompt: String)
|
||||
|
||||
private external fun updateConfigNative(llmPtr: Long, configJson: String)
|
||||
|
||||
|
||||
companion object {
|
||||
const val TAG: String = "LlmSession"
|
||||
|
|
|
@ -19,8 +19,15 @@ import com.alibaba.mnnllm.android.modelist.ModelListManager
|
|||
import com.alibaba.mnnllm.android.modelsettings.ModelConfig
|
||||
|
||||
object ModelUtils {
|
||||
@Deprecated("Use ModelMarketItem.vendor field instead for market models")
|
||||
|
||||
fun getVendor(modelName: String):String {
|
||||
// First try to get vendor from ModelMarketItem
|
||||
val modelItem = ModelListManager.getModelIdModelMap()[modelName]
|
||||
if (modelItem?.modelMarketItem?.vendor != null) {
|
||||
return modelItem.modelMarketItem!!.vendor
|
||||
}
|
||||
|
||||
// If not available from market item, use the existing logic
|
||||
val modelLower = modelName.lowercase(Locale.getDefault())
|
||||
if (modelLower.contains("deepseek")) {
|
||||
return ModelVendors.DeepSeek
|
||||
|
@ -51,6 +58,21 @@ object ModelUtils {
|
|||
} else if (modelLower.contains("openelm")) {
|
||||
return ModelVendors.OpenElm
|
||||
} else {
|
||||
// If still not found, try to extract vendor from modelName by splitting on - or _
|
||||
// First split by "/" and take last part
|
||||
val lastPart = modelName.split("/").last()
|
||||
|
||||
// Then split that by "-" or "_"
|
||||
val parts = lastPart.split("-", "_")
|
||||
for (part in parts) {
|
||||
val trimmedPart = part.trim()
|
||||
if (trimmedPart.isNotEmpty()) {
|
||||
// Capitalize first letter to match vendor naming convention
|
||||
return trimmedPart.replaceFirstChar {
|
||||
if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString()
|
||||
}
|
||||
}
|
||||
}
|
||||
return ModelVendors.Others
|
||||
}
|
||||
}
|
||||
|
@ -188,8 +210,8 @@ object ModelUtils {
|
|||
return modelName.lowercase(Locale.getDefault()).contains("omni")
|
||||
}
|
||||
|
||||
fun isSupportThinkingSwitch(modelName: String): Boolean {
|
||||
return isQwen3(modelName)
|
||||
fun isSupportThinkingSwitchByTags(extraTags: List<String>): Boolean {
|
||||
return extraTags.any { it.equals("ThinkingSwitch", ignoreCase = true) }
|
||||
}
|
||||
|
||||
fun supportAudioOutput(modelName: String): Boolean {
|
||||
|
|
|
@ -37,6 +37,14 @@ object ModelListManager {
|
|||
return modelItem?.getTags() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get extra tags for a specific model by modelId (not shown to users)
|
||||
*/
|
||||
fun getExtraTags(modelId: String): List<String> {
|
||||
val modelItem = modelIdModelMap[modelId]
|
||||
return modelItem?.getExtraTags() ?: emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a model is a thinking model by examining its tags
|
||||
*/
|
||||
|
@ -90,7 +98,6 @@ object ModelListManager {
|
|||
val modelItem = ModelItem.fromDownloadModel(context, downloadedModel.modelId, downloadedModel.modelPath)
|
||||
// Set market item data if available
|
||||
modelItem.modelMarketItem = ModelMarketUtils.readMarketConfig(downloadedModel.modelId)
|
||||
|
||||
// Calculate download size
|
||||
val downloadSize = try {
|
||||
val file = File(downloadedModel.modelPath)
|
||||
|
@ -126,6 +133,9 @@ object ModelListManager {
|
|||
// Load market tags for local model
|
||||
localModel.loadMarketTags(context)
|
||||
|
||||
// Set market item data if available (same as downloaded models)
|
||||
localModel.modelMarketItem = ModelMarketUtils.readMarketConfig(localModel.modelId!!)
|
||||
|
||||
// Calculate local model size
|
||||
val localSize = try {
|
||||
val file = File(localModel.localPath!!)
|
||||
|
@ -166,6 +176,8 @@ object ModelListManager {
|
|||
// Clear and cache modelId model to a map
|
||||
modelIdModelMap.clear()
|
||||
sortedModels.forEach {
|
||||
//add log for each it.modelItem.modelId
|
||||
Log.d(TAG, "loadAvailableModels modelIdModelMap: ${it.modelItem.modelId} ${it.modelItem.modelMarketItem?.vendor} ${it.modelItem.modelMarketItem?.modelName}")
|
||||
modelIdModelMap[it.modelItem.modelId!!] = it.modelItem
|
||||
}
|
||||
return@withContext sortedModels
|
||||
|
|
|
@ -12,6 +12,7 @@ data class ModelMarketItem(
|
|||
val sources: Map<String, String>,
|
||||
val description: String? = null,
|
||||
@SerializedName("file_size") val fileSize: Long = 0L, // File size in bytes from model_market.json
|
||||
@SerializedName("extra_tags") val extraTags: List<String> = emptyList(), // Extra tags not shown to users
|
||||
var currentSource: String = "", // e.g. "modelscope", "huggingface"
|
||||
var currentRepoPath: String = "", // e.g. "MNN/Qwen-1.8B-Chat-Int4"
|
||||
var modelId: String = "" // e.g. "ModelScope/MNN/Qwen-1.8B-Chat-Int4"
|
||||
|
|
|
@ -8,6 +8,7 @@ import com.alibaba.mls.api.ApplicationProvider
|
|||
import com.alibaba.mnnllm.android.modelsettings.ModelConfig
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.GsonBuilder
|
||||
import com.google.gson.JsonParser
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
@ -16,16 +17,19 @@ object ModelMarketUtils {
|
|||
|
||||
const val TAG = "ModelMarketUtils"
|
||||
|
||||
fun writeMarketConfig(modelItem: ModelMarketItem) {
|
||||
fun writeMarketConfig(modelItem: ModelMarketItem, marketVersion: String? = null) {
|
||||
// Create a map with all fields except 'sources', and add 'modelId'
|
||||
val configMap = mutableMapOf<String, Any?>()
|
||||
configMap["modelName"] = modelItem.modelName
|
||||
configMap["vendor"] = modelItem.vendor
|
||||
configMap["size_gb"] = modelItem.sizeB
|
||||
configMap["tags"] = modelItem.tags
|
||||
configMap["extra_tags"] = modelItem.extraTags
|
||||
configMap["categories"] = modelItem.categories
|
||||
configMap["description"] = modelItem.description
|
||||
configMap["modelId"] = modelItem.modelId
|
||||
// Record current market data version
|
||||
configMap["market_version"] = marketVersion
|
||||
// Pretty-print JSON
|
||||
val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
val jsonString = gson.toJson(configMap)
|
||||
|
@ -43,26 +47,57 @@ object ModelMarketUtils {
|
|||
|
||||
|
||||
suspend fun readMarketConfig(modelId:String):ModelMarketItem? {
|
||||
Log.d(TAG, "readMarketConfig for $modelId")
|
||||
return withContext(Dispatchers.IO) {
|
||||
var marketItem: ModelMarketItem? = readMarketConfigFromLocal(modelId)
|
||||
if (marketItem == null) {
|
||||
//read from ModelRepository
|
||||
var marketItem: ModelMarketItem? = null
|
||||
try {
|
||||
val context = ApplicationProvider.get()
|
||||
val repository = ModelRepository(context)
|
||||
// Get all models from different categories and find the one with matching modelId
|
||||
|
||||
// Get current market data version from repository (network/cache/assets)
|
||||
val currentMarketVersion: String? = try {
|
||||
repository.getModelMarketData()?.version
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to get current market version from repository", e)
|
||||
null
|
||||
}
|
||||
|
||||
val localMarketVersion: String? = readLocalMarketVersion(modelId)
|
||||
val shouldRefreshFromRepository = (currentMarketVersion == null ||
|
||||
localMarketVersion == null ||
|
||||
localMarketVersion != currentMarketVersion)
|
||||
|
||||
if (!shouldRefreshFromRepository) {
|
||||
marketItem = readMarketConfigFromLocal(modelId)
|
||||
if (marketItem != null) {
|
||||
return@withContext marketItem
|
||||
}
|
||||
} else {
|
||||
Log.d(TAG, "Local market version ($localMarketVersion) != current market version ($currentMarketVersion), refreshing from repository")
|
||||
}
|
||||
try {
|
||||
val allModels = mutableListOf<ModelMarketItem>()
|
||||
allModels.addAll(repository.getModels())
|
||||
allModels.addAll(repository.getTtsModels())
|
||||
allModels.addAll(repository.getAsrModels())
|
||||
marketItem = allModels.find { it.modelId == modelId }
|
||||
if (marketItem != null) {
|
||||
writeMarketConfig(marketItem)
|
||||
writeMarketConfig(marketItem, currentMarketVersion)
|
||||
} else {
|
||||
Log.e(TAG, "Failed to find model $modelId in ModelRepository ${allModels.joinToString { it.modelId }}")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to read from ModelRepository for $modelId", e)
|
||||
// If repository fails, attempt to return local config as last resort
|
||||
if (marketItem == null) {
|
||||
marketItem = readMarketConfigFromLocal(modelId)
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "readMarketConfig unexpected error for $modelId", e)
|
||||
// Attempt local as last resort
|
||||
marketItem = readMarketConfigFromLocal(modelId)
|
||||
}
|
||||
marketItem
|
||||
}
|
||||
}
|
||||
|
@ -84,4 +119,20 @@ object ModelMarketUtils {
|
|||
marketItem
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readLocalMarketVersion(modelId: String): String? {
|
||||
return withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val marketConfigFile = ModelConfig.getMarketConfigFile(modelId)
|
||||
val configFile = File(marketConfigFile)
|
||||
if (!configFile.exists()) return@withContext null
|
||||
val configJson = configFile.readText()
|
||||
val jsonObj = JsonParser.parseString(configJson).asJsonObject
|
||||
if (jsonObj.has("market_version")) jsonObj.get("market_version").asString else null
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to read local market version for $modelId", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -94,6 +94,16 @@ class ModelRepository(private val context: Context) {
|
|||
val cacheData = loadFromCache()
|
||||
if (cacheData != null) {
|
||||
Log.d(TAG, "Successfully loaded data from local cache")
|
||||
if (assetsData != null) {
|
||||
val cacheVersion = cacheData.version
|
||||
val assetsVersion = assetsData.version
|
||||
Log.d(TAG, "Cache version: $cacheVersion, Assets version: $assetsVersion")
|
||||
if (isVersionLower(cacheVersion, assetsVersion)) {
|
||||
Log.d(TAG, "Cache version is lower than assets version, using assets data")
|
||||
cachedModelMarketData = assetsData
|
||||
return@withContext assetsData
|
||||
}
|
||||
}
|
||||
cachedModelMarketData = cacheData
|
||||
return@withContext cacheData
|
||||
}
|
||||
|
@ -151,6 +161,37 @@ class ModelRepository(private val context: Context) {
|
|||
return@withContext null
|
||||
}
|
||||
|
||||
/**
|
||||
* Force refresh market data from network and update cache.
|
||||
* Will still prefer assets if network version is older than assets.
|
||||
*/
|
||||
suspend fun refreshFromNetwork(): ModelMarketData? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val assetsData = loadFromAssets()
|
||||
val networkData = fetchFromNetwork()
|
||||
if (networkData != null) {
|
||||
if (assetsData != null) {
|
||||
val networkVersion = networkData.version ?: "0"
|
||||
val assetsVersion = assetsData.version
|
||||
if (isVersionLower(networkVersion, assetsVersion)) {
|
||||
Log.d(TAG, "[refreshFromNetwork] Network version lower than assets, using assets data")
|
||||
cachedModelMarketData = assetsData
|
||||
isNetworkRequestAttempted = true
|
||||
return@withContext assetsData
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "[refreshFromNetwork] Using network data and updating cache")
|
||||
cachedModelMarketData = networkData
|
||||
saveCacheToFile(networkData)
|
||||
isNetworkRequestAttempted = true
|
||||
return@withContext networkData
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "refreshFromNetwork failed", e)
|
||||
}
|
||||
return@withContext null
|
||||
}
|
||||
|
||||
private suspend fun loadFromCache(): ModelMarketData? = withContext(Dispatchers.IO) {
|
||||
// If not allowed to use network, skip cache and use assets
|
||||
if (!isAllowNetwork(context)) {
|
||||
|
|
|
@ -15,6 +15,14 @@ import com.google.gson.JsonParser
|
|||
import java.io.File
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class JinjaContext(
|
||||
@SerializedName("enable_thinking") var enableThinking: Boolean = false
|
||||
)
|
||||
|
||||
data class Jinja(
|
||||
@SerializedName("context") var context: JinjaContext? = null
|
||||
)
|
||||
|
||||
data class ModelConfig(
|
||||
@SerializedName("llm_model") var llmModel: String?,
|
||||
@SerializedName("llm_weight") var llmWeight: String?,
|
||||
|
@ -37,7 +45,8 @@ data class ModelConfig(
|
|||
@SerializedName("ngram_factor")var nGramFactor:Float?,
|
||||
@SerializedName("max_new_tokens")var maxNewTokens:Int?,
|
||||
@SerializedName("assistant_prompt_template")var assistantPromptTemplate:String?,
|
||||
@SerializedName("penalty_sampler")var penaltySampler:String?
|
||||
@SerializedName("penalty_sampler")var penaltySampler:String?,
|
||||
@SerializedName("jinja") var jinja: Jinja?
|
||||
) {
|
||||
fun deepCopy(): ModelConfig {
|
||||
return ModelConfig(
|
||||
|
@ -62,7 +71,10 @@ data class ModelConfig(
|
|||
maxNewTokens = this.maxNewTokens,
|
||||
assistantPromptTemplate = this.assistantPromptTemplate,
|
||||
penaltySampler = this.penaltySampler,
|
||||
useMmap = this.useMmap
|
||||
useMmap = this.useMmap,
|
||||
jinja = this.jinja?.let {
|
||||
Jinja(context = JinjaContext(enableThinking = it.context?.enableThinking == true))
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
@ -145,7 +157,10 @@ data class ModelConfig(
|
|||
}
|
||||
|
||||
fun toJson(): String {
|
||||
return Gson().toJson(this)
|
||||
return GsonBuilder()
|
||||
.disableHtmlEscaping()
|
||||
.create()
|
||||
.toJson(this)
|
||||
}
|
||||
|
||||
fun saveConfig(filePath: String, config: ModelConfig): Boolean {
|
||||
|
@ -153,7 +168,10 @@ data class ModelConfig(
|
|||
Log.d(TAG, "file is : $filePath")
|
||||
val file = File(filePath)
|
||||
FileUtils.ensureParentDirectoriesExist(file)
|
||||
val gson = GsonBuilder().setPrettyPrinting().create()
|
||||
val gson = GsonBuilder()
|
||||
.setPrettyPrinting()
|
||||
.disableHtmlEscaping()
|
||||
.create()
|
||||
val jsonString = gson.toJson(config)
|
||||
file.writeText(jsonString)
|
||||
true
|
||||
|
@ -201,7 +219,8 @@ data class ModelConfig(
|
|||
maxNewTokens = 2048,
|
||||
assistantPromptTemplate = "",
|
||||
penaltySampler = "greedy",
|
||||
useMmap = false
|
||||
useMmap = false,
|
||||
jinja = null
|
||||
)
|
||||
|
||||
}
|
||||
|
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.util.AttributeSet
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import com.alibaba.mnnllm.android.R
|
||||
|
@ -18,6 +19,7 @@ class ModelAvatarView @JvmOverloads constructor(
|
|||
|
||||
private val tvModelName: TextView
|
||||
private val headerIcon: ImageView
|
||||
private var isCompactMode: Boolean = false
|
||||
|
||||
init {
|
||||
LayoutInflater.from(context).inflate(R.layout.view_model_avatar, this, true)
|
||||
|
@ -35,9 +37,11 @@ class ModelAvatarView @JvmOverloads constructor(
|
|||
|
||||
try {
|
||||
val modelName = typedArray.getString(R.styleable.ModelAvatarView_modelName)
|
||||
val compactMode = typedArray.getBoolean(R.styleable.ModelAvatarView_compactMode, false)
|
||||
if (!modelName.isNullOrEmpty()) {
|
||||
setModelName(modelName)
|
||||
}
|
||||
setCompactMode(compactMode)
|
||||
} finally {
|
||||
typedArray.recycle()
|
||||
}
|
||||
|
@ -67,15 +71,20 @@ class ModelAvatarView @JvmOverloads constructor(
|
|||
) else headerText
|
||||
tvModelName.visibility = View.VISIBLE
|
||||
}
|
||||
//
|
||||
// if (name.contains("qwen", ignoreCase = true)) {
|
||||
// tvModelName.visibility = View.GONE
|
||||
// headerIcon.visibility = View.VISIBLE
|
||||
// headerIcon.setImageResource(R.drawable.qwen_icon)
|
||||
// } else {
|
||||
// tvModelName.visibility = View.VISIBLE
|
||||
// headerIcon.visibility = View.GONE
|
||||
// tvModelName.text = name.split("-").joinToString("") { it.firstOrNull()?.toString() ?: "" }.take(2).uppercase()
|
||||
// }
|
||||
}
|
||||
|
||||
fun setCompactMode(compactMode: Boolean) {
|
||||
isCompactMode = compactMode
|
||||
if (compactMode) {
|
||||
// 在紧凑模式下移除 ImageView 的 margin 和 CardView 的背景
|
||||
val layoutParams = headerIcon.layoutParams as? ViewGroup.MarginLayoutParams
|
||||
layoutParams?.setMargins(0, 0, 0, 0)
|
||||
headerIcon.layoutParams = layoutParams
|
||||
|
||||
// 移除 CardView 的背景
|
||||
setCardBackgroundColor(android.graphics.Color.TRANSPARENT)
|
||||
cardElevation = 0f
|
||||
strokeWidth = 0
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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.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}")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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}")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
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)
|
||||
|
||||
/**
|
||||
* 获取服务器端口
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -110,7 +129,6 @@ class OpenAIService : Service() {
|
|||
|
||||
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (!isServiceRunning) {
|
||||
Timber.tag("ServiceLifecycle").w("Service started illegally and will be stopped immediately.")
|
||||
|
@ -119,24 +137,25 @@ class OpenAIService : Service() {
|
|||
}
|
||||
val notification = coordinator.getNotification()
|
||||
if (notification != null) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(ApiNotificationManager.NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC)
|
||||
} else {
|
||||
startForeground(ApiNotificationManager.NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
coordinator = ApiServiceCoordinator(this)
|
||||
coordinator.initialize()
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
val notification = coordinator.getNotification()
|
||||
if (notification != null) {
|
||||
startForeground(ApiNotificationManager.NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
Timber.tag(TAG).i("Service is being destroyed")
|
||||
|
|
|
@ -34,6 +34,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
private var _binding: FragmentApiConsoleSheetBinding? = null
|
||||
private val binding get() = _binding!!
|
||||
private var chatActivity: ChatActivity? = null
|
||||
private var bottomSheetBehavior: BottomSheetBehavior<FrameLayout>? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "ApiConsoleBottomSheetFragment"
|
||||
|
@ -49,7 +50,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
private val serverEventManager = ServerEventManager.getInstance()
|
||||
private val logCollector = LogCollector.getInstance()
|
||||
|
||||
// 管理协程订阅
|
||||
// Manage coroutine subscriptions
|
||||
private var serverStateJob: Job? = null
|
||||
private var serverInfoJob: Job? = null
|
||||
private var logCollectorJob: Job? = null
|
||||
|
@ -69,10 +70,18 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
val bottomSheet: FrameLayout? = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)
|
||||
if (bottomSheet != null) {
|
||||
val behavior = BottomSheetBehavior.from(bottomSheet)
|
||||
this.bottomSheetBehavior = behavior // Store the behavior instance
|
||||
bottomSheet.post {
|
||||
behavior.state = BottomSheetBehavior.STATE_EXPANDED
|
||||
}
|
||||
behavior.skipCollapsed = false
|
||||
|
||||
// Optimize touch event handling to reduce conflicts with ScrollView
|
||||
behavior.isDraggable = true
|
||||
behavior.isHideable = false
|
||||
|
||||
// Set up touch event listener to optimize scrolling experience
|
||||
// setupBottomSheetTouchHandling(bottomSheet, behavior) // Temporarily disabled for debugging
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -85,18 +94,40 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
setupLogArea()
|
||||
setupActionButtons()
|
||||
observeServerEvents()
|
||||
|
||||
// Resolve scrolling conflicts between ScrollView and BottomSheetDialog
|
||||
setupScrollViewTouchHandling()
|
||||
|
||||
// Resolve scrolling conflicts for the log RecyclerView using the isDraggable property
|
||||
binding.recyclerLogContent.setOnTouchListener { _, event ->
|
||||
when (event.action) {
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
// When moving, dynamically enable/disable dragging on the BottomSheet
|
||||
val canScroll = binding.recyclerLogContent.canScrollVertically(1) || binding.recyclerLogContent.canScrollVertically(-1)
|
||||
bottomSheetBehavior?.isDraggable = !canScroll
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP,
|
||||
android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
// When the gesture ends, always re-enable dragging.
|
||||
bottomSheetBehavior?.isDraggable = true
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// 重新订阅事件,确保状态监听正常工作
|
||||
// Re-subscribe to events to ensure status monitoring works correctly
|
||||
//observeServerEvents()
|
||||
|
||||
|
||||
// 每次Fragment可见时刷新状态,确保显示最新的服务器状态
|
||||
// Refresh status every time the fragment is visible to ensure the latest server status is displayed
|
||||
// updateServiceStatus()
|
||||
|
||||
// 延迟再次检查状态,确保服务重启后能正确获取状态
|
||||
// Check status again after a delay to ensure correct status is obtained after service restart
|
||||
//binding.root.postDelayed({
|
||||
// if (isAdded && !isDetached) {
|
||||
// updateServiceStatus()
|
||||
|
@ -115,7 +146,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
val serverState = serverEventManager.getCurrentState()
|
||||
val serverInfo = serverEventManager.getCurrentInfo()
|
||||
|
||||
// 获取配置的IP和端口,用于显示API端点
|
||||
// Get configured IP and port to display the API endpoint
|
||||
val configuredHost = ApiServerConfig.getIpAddress(context)
|
||||
val configuredPort = ApiServerConfig.getPort(context)
|
||||
|
||||
|
@ -137,7 +168,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.textServiceStatus.setTextColor(resources.getColor(android.R.color.holo_green_dark, null))
|
||||
binding.textListenAddress.visibility = View.GONE
|
||||
binding.labelListenAddress.visibility = View.GONE
|
||||
// 使用实际运行的服务器信息,如果为空则使用配置信息
|
||||
// Use actual running server info, otherwise use configured info
|
||||
val displayHost = if (serverInfo.host.isNotEmpty()) serverInfo.host else configuredHost
|
||||
val displayPort = if (serverInfo.port > 0) serverInfo.port else configuredPort
|
||||
val endpointUrl = "http://${displayHost}:${displayPort}/v1/chat/completions"
|
||||
|
@ -187,7 +218,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
binding.textCorsStatus.text = if (corsEnabled) getString(R.string.cors_enabled) else getString(R.string.cors_disabled_status)
|
||||
binding.textAuthStatus.text = if (authEnabled) getString(R.string.api_key_enabled) else getString(R.string.no_authentication)
|
||||
|
||||
// 设置折叠/展开功能
|
||||
// Set up collapse/expand functionality
|
||||
binding.layoutConfigHeader.setOnClickListener {
|
||||
val isVisible = binding.layoutConfigDetails.isVisible
|
||||
binding.layoutConfigDetails.isVisible = !isVisible
|
||||
|
@ -197,20 +228,21 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
private fun setupLogArea() {
|
||||
// 设置RecyclerView
|
||||
// Set up RecyclerView
|
||||
binding.recyclerLogContent.apply {
|
||||
layoutManager = LinearLayoutManager(context)
|
||||
adapter = logAdapter
|
||||
// 设置触摸事件拦截,防止滑动冲突
|
||||
setOnTouchListener { view, event ->
|
||||
// 请求父容器不要拦截触摸事件
|
||||
// Intercept touch events to prevent scrolling conflicts
|
||||
setOnTouchListener {
|
||||
view, event ->
|
||||
// Request parent container not to intercept touch events
|
||||
view.parent.requestDisallowInterceptTouchEvent(true)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
Timber.tag("ApiConsoleUI").d("[Log] Initializing log area")
|
||||
// 添加初始日志
|
||||
// Add initial log message
|
||||
addLogMessage(getString(R.string.console_started))
|
||||
|
||||
val serverState = serverEventManager.getCurrentState()
|
||||
|
@ -227,9 +259,10 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
}
|
||||
|
||||
// 订阅实时日志
|
||||
// Subscribe to real-time logs
|
||||
logCollector.logFlow
|
||||
.onEach { logEntry ->
|
||||
.onEach {
|
||||
logEntry ->
|
||||
if (isAdded && !isDetached) {
|
||||
val (formattedLog, clickableInfo) = logCollector.formatLogEntryWithClickableInfo(logEntry)
|
||||
addRawLogEntryWithClickInfo(formattedLog, clickableInfo)
|
||||
|
@ -237,7 +270,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
// 设置折叠/展开功能
|
||||
// Set up collapse/expand functionality
|
||||
binding.layoutLogHeader.setOnClickListener {
|
||||
val isVisible = binding.layoutLogContent.isVisible
|
||||
binding.layoutLogContent.isVisible = !isVisible
|
||||
|
@ -251,14 +284,14 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
val logEntry = "[$timestamp] $message"
|
||||
logAdapter.addLogMessage(logEntry)
|
||||
|
||||
// 自动滚动到底部
|
||||
// Auto-scroll to the bottom
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
private fun addRawLogMessage(message: String) {
|
||||
logAdapter.addLogMessage(message)
|
||||
|
||||
// 自动滚动到底部
|
||||
// Auto-scroll to the bottom
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
|
@ -266,7 +299,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
val logEntry = LogAdapter.LogEntryData(message, clickableInfo)
|
||||
logAdapter.addLogEntry(logEntry)
|
||||
|
||||
// 自动滚动到底部
|
||||
// Auto-scroll to the bottom
|
||||
scrollToBottom()
|
||||
}
|
||||
|
||||
|
@ -280,6 +313,57 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
|
||||
|
||||
|
||||
private fun setupScrollViewTouchHandling() {
|
||||
// Set up touch event handling for the ScrollView to resolve scrolling conflicts with the BottomSheetDialog
|
||||
binding.settingsScrollView.setOnTouchListener {
|
||||
view, event ->
|
||||
when (event.action) {
|
||||
android.view.MotionEvent.ACTION_DOWN -> {
|
||||
// When touch starts, request parent container not to intercept touch events
|
||||
view.parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
// Check if scrolling is needed
|
||||
val scrollView = view as androidx.core.widget.NestedScrollView
|
||||
if (scrollView.canScrollVertically(-1) || scrollView.canScrollVertically(1)) {
|
||||
// If it can scroll, continue to request no interception
|
||||
view.parent.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_UP, android.view.MotionEvent.ACTION_CANCEL -> {
|
||||
// When touch ends, allow parent container to intercept touch events
|
||||
view.parent.requestDisallowInterceptTouchEvent(false)
|
||||
}
|
||||
}
|
||||
false // Return false to let the ScrollView continue handling the touch event
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupBottomSheetTouchHandling(bottomSheet: FrameLayout, behavior: BottomSheetBehavior<FrameLayout>) {
|
||||
// Set up touch event handling for the BottomSheet to optimize interaction with the ScrollView
|
||||
bottomSheet.setOnTouchListener {
|
||||
view, event ->
|
||||
when (event.action) {
|
||||
android.view.MotionEvent.ACTION_DOWN -> {
|
||||
// When touch starts, check the touch position
|
||||
val scrollView = binding.settingsScrollView
|
||||
if (scrollView.canScrollVertically(-1) || scrollView.canScrollVertically(1)) {
|
||||
// If the ScrollView can scroll, let the ScrollView handle the touch event
|
||||
scrollView.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
android.view.MotionEvent.ACTION_MOVE -> {
|
||||
// When moving, if the ScrollView is scrolling, let it continue to handle it
|
||||
val scrollView = binding.settingsScrollView
|
||||
if (scrollView.canScrollVertically(-1) || scrollView.canScrollVertically(1)) {
|
||||
scrollView.requestDisallowInterceptTouchEvent(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
false // Return false to let the BottomSheet continue handling the touch event
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupActionButtons() {
|
||||
binding.buttonClose.setOnClickListener {
|
||||
dismiss()
|
||||
|
@ -293,7 +377,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
copyLogToClipboard()
|
||||
}
|
||||
|
||||
// 添加测试按钮(长按清空日志按钮)
|
||||
// Add test button (long press to clear log)
|
||||
binding.buttonClearLog.setOnLongClickListener {
|
||||
addTestLogWithCodeLocation()
|
||||
true
|
||||
|
@ -301,7 +385,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
/**
|
||||
* 添加测试日志,包含代码行号信息
|
||||
* Add test log, including code line number information
|
||||
*/
|
||||
private fun addTestLogWithCodeLocation() {
|
||||
Timber.tag("TestLog").i(getString(R.string.test_log_message1))
|
||||
|
@ -326,21 +410,22 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
|
||||
private fun observeServerEvents() {
|
||||
// 取消之前的订阅
|
||||
// Cancel previous subscriptions
|
||||
cancelObservations()
|
||||
Timber.tag("ApiConsoleUI").d("[ServerEvent] Subscribing to server events")
|
||||
|
||||
// 立即获取当前状态并更新UI
|
||||
// Immediately get the current status and update the UI
|
||||
updateServiceStatus()
|
||||
|
||||
// 观察服务器状态变化
|
||||
// Observe server status changes
|
||||
serverStateJob = serverEventManager.serverState
|
||||
.onEach { state ->
|
||||
.onEach {
|
||||
state ->
|
||||
if (isAdded && !isDetached) {
|
||||
// 强制更新UI状态
|
||||
// Force update UI status
|
||||
updateServiceStatus()
|
||||
|
||||
// 根据状态变化添加日志
|
||||
// Add log based on status change
|
||||
when (state) {
|
||||
ServerEventManager.ServerState.STARTING -> {
|
||||
addLogMessage(getString(R.string.server_starting_message))
|
||||
|
@ -365,11 +450,12 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
|
|||
}
|
||||
.launchIn(lifecycleScope)
|
||||
|
||||
// 观察服务器信息变化
|
||||
// Observe server info changes
|
||||
serverInfoJob = serverEventManager.serverInfo
|
||||
.onEach { info ->
|
||||
.onEach {
|
||||
info ->
|
||||
if (isAdded && !isDetached) {
|
||||
// 当服务器信息变化时强制更新状态
|
||||
// Force update status when server info changes
|
||||
updateServiceStatus()
|
||||
|
||||
if (info.isRunning) {
|
||||
|
|
|
@ -0,0 +1,21 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:id="@android:id/background">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#374151" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:id="@android:id/progress">
|
||||
<clip>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_gradient_start"
|
||||
android:endColor="@color/benchmark_gradient_end"
|
||||
android:angle="0" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
</layer-list>
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/benchmark_button_disabled_gradient" android:state_enabled="false" />
|
||||
<item android:drawable="@drawable/benchmark_button_gradient" />
|
||||
</selector>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_secondary"
|
||||
android:endColor="@color/benchmark_secondary"
|
||||
android:angle="0" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_gradient_start"
|
||||
android:endColor="@color/benchmark_gradient_end"
|
||||
android:angle="0" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#33FFFFFF" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@drawable/benchmark_button_disabled_gradient" android:state_enabled="false" />
|
||||
<item android:drawable="@drawable/benchmark_button_stop_gradient" />
|
||||
</selector>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_error"
|
||||
android:endColor="@color/benchmark_error"
|
||||
android:angle="0" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#0D6366f1" />
|
||||
<corners android:radius="6dp" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#1A6366f1" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#1Af59e0b" />
|
||||
</shape>
|
|
@ -0,0 +1,20 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:id="@android:id/background">
|
||||
<shape android:shape="rectangle">
|
||||
<solid android:color="#E5E7EB" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</item>
|
||||
<item android:id="@android:id/progress">
|
||||
<clip>
|
||||
<shape android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_gradient_start"
|
||||
android:endColor="@color/benchmark_gradient_end"
|
||||
android:angle="0" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
</clip>
|
||||
</item>
|
||||
</layer-list>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<gradient
|
||||
android:startColor="@color/benchmark_gradient_start"
|
||||
android:endColor="@color/benchmark_gradient_end"
|
||||
android:angle="0" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/benchmark_card_bg" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#4D10b981" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#3310b981" />
|
||||
</shape>
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="@color/benchmark_card_bg" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#4Df59e0b" />
|
||||
<corners android:radius="16dp" />
|
||||
</shape>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="?colorPrimaryContainer" />
|
||||
</shape>
|
|
@ -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>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,17l1,-1 1,1 1.41,-1.41L12,13.17l-2.41,2.42L11,17z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M8.59,16.59L13.17,12L8.59,7.41L10,6l6,6l-6,6L8.59,16.59z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,7l-1,1 -1,-1 -1.41,1.41L12,10.83l2.41,-2.42L13,7z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M3,13h2v8h-2v-8zM7,9h2v12h-2v-12zM11,5h2v16h-2v-16zM15,8h2v13h-2v-13zM19,11h2v10h-2v-10z"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M3.5,18.49l6,-6.01 4,4L22,6.92l-1.41,-1.41 -7.09,7.97 -4,-4L2,16.99z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"
|
||||
android:fillColor="@color/benchmark_warning"/>
|
||||
</vector>
|
|
@ -0,0 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17,7H7A2,2 0 0,0 5,9V15A2,2 0 0,0 7,17H17A2,2 0 0,0 19,15V9A2,2 0 0,0 17,7M17,15H7V9H17V15M8,10H9V14H8V10M10.5,10H11.5V14H10.5V10M13,10H14V14H13V10M15.5,10H16.5V14H15.5V10Z" />
|
||||
|
||||
</vector>
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- iOS SF Symbol memorychip style -->
|
||||
<!-- Main chip body -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M7,6C5.9,6 5,6.9 5,8V16C5,17.1 5.9,18 7,18H17C18.1,18 19,17.1 19,16V8C19,6.9 18.1,6 17,6H7ZM17,16H7V8H17V16Z" />
|
||||
|
||||
<!-- Memory slots -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M8,10H9V14H8V10Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M10.5,10H11.5V14H10.5V10Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M13,10H14V14H13V10Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M15.5,10H16V14H15.5V10Z" />
|
||||
|
||||
<!-- Connection pins (left side) -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,9H5V10H3V9Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,11H5V12H3V11Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M3,13H5V14H3V13Z" />
|
||||
|
||||
<!-- Connection pins (right side) -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,9H21V10H19V9Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,11H21V12H19V11Z" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,13H21V14H19V13Z" />
|
||||
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M8,5v14l11,-7z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="12dp"
|
||||
android:height="12dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zm10,10H7v-3l-4,4 4,4v-3h12v-6h-2v4z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#000000"
|
||||
android:pathData="M15,5l4,0l0,4l-1.5,-1.5l-2.5,2.5l-1,-1l2.5,-2.5l-1.5,-1.5zM11,7l-6,0c-1.1,0 -2,0.9 -2,2l0,10c0,1.1 0.9,2 2,2l10,0c1.1,0 2,-0.9 2,-2l0,-6l-2,0l0,6l-10,0l0,-10l6,0l0,-2z"/>
|
||||
</vector>
|
|
@ -0,0 +1,50 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
|
||||
<!-- iOS SF Symbol speedometer style -->
|
||||
<!-- Outer circle -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20Z" />
|
||||
|
||||
<!-- Speed scale marks -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6.34,8.34L7.76,9.76" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M5,12L7,12" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M6.34,15.66L7.76,14.24" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,19L12,17" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.66,15.66L16.24,14.24" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M19,12L17,12" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M17.66,8.34L16.24,9.76" />
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,5L12,7" />
|
||||
|
||||
<!-- Needle pointing to speed -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,8L13.5,12.5L12,12L10.5,12.5L12,8Z" />
|
||||
|
||||
<!-- Center circle -->
|
||||
<path
|
||||
android:fillColor="#FF000000"
|
||||
android:pathData="M12,10.5C12.8,10.5 13.5,11.2 13.5,12C13.5,12.8 12.8,13.5 12,13.5C11.2,13.5 10.5,12.8 10.5,12C10.5,11.2 11.2,10.5 12,10.5Z" />
|
||||
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="18dp"
|
||||
android:height="18dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"
|
||||
android:fillColor="@color/benchmark_accent"/>
|
||||
</vector>
|
|
@ -0,0 +1,9 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="16dp"
|
||||
android:height="16dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M6,6h12v12H6z"
|
||||
android:fillColor="#FFFFFF"/>
|
||||
</vector>
|
|
@ -0,0 +1,13 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
|
||||
<solid android:color="@color/benchmark_card_bg" />
|
||||
|
||||
<corners android:radius="12dp" />
|
||||
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="@color/benchmark_card_border" />
|
||||
|
||||
</shape>
|
File diff suppressed because it is too large
Load Diff
|
@ -19,7 +19,6 @@
|
|||
/>
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/chat_history_recycler_view"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_below="@id/history_title"
|
||||
|
|
|
@ -41,25 +41,41 @@
|
|||
app:tint="?colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_chat_thinking"
|
||||
android:layout_toEndOf="@id/ic_header"
|
||||
android:layout_below="@id/ll_thinking_toggle"
|
||||
<LinearLayout
|
||||
android:id="@+id/ll_thinking_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_toEndOf="@id/ic_header"
|
||||
android:layout_below="@id/ll_thinking_toggle"
|
||||
android:orientation="horizontal"
|
||||
tools:visibility="visible"
|
||||
android:visibility="gone"
|
||||
android:padding="@dimen/space10">
|
||||
|
||||
<View
|
||||
android:id="@+id/view_thinking_marker"
|
||||
android:layout_width="1dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_marginTop="2dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:background="?colorOutlineVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_chat_thinking"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="@dimen/space10"
|
||||
android:textAppearance="@style/Light"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:textSize="@dimen/h4"
|
||||
android:visibility="gone"
|
||||
tools:text="This is the thinking process..."
|
||||
tools:visibility="visible" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_chat_text"
|
||||
tools:text="this is the generated text"
|
||||
android:layout_toEndOf="@id/ic_header"
|
||||
android:layout_below="@id/tv_chat_thinking"
|
||||
android:layout_below="@id/ll_thinking_container"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/space10"
|
||||
|
|
|
@ -1,50 +1,90 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:paddingStart="5dp"
|
||||
android:paddingEnd="20dp"
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
<ImageView
|
||||
app:srcCompat="@drawable/ic_chat"
|
||||
android:id="@+id/iv_header"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_centerVertical="true"
|
||||
app:tint="?colorOnSurfaceVariant"
|
||||
tools:ignore="ContentDescription" />
|
||||
|
||||
<View
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1px"
|
||||
android:layout_alignParentBottom="true"
|
||||
android:background="?colorOutlineVariant"/>
|
||||
<FrameLayout
|
||||
android:id="@+id/iv_delete_history"
|
||||
android:layout_width="50dp"
|
||||
android:layout_height="50dp"
|
||||
android:layout_alignParentEnd="true">
|
||||
<ImageView
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_gravity="center_vertical|end"
|
||||
app:srcCompat="@drawable/ic_delete_6"
|
||||
app:tint="?colorOnSurfaceVariant"
|
||||
tools:ignore="ContentDescription" />
|
||||
</FrameLayout>
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_marginEnd="12dp"
|
||||
android:layout_marginTop="3dp"
|
||||
android:layout_marginBottom="3dp"
|
||||
android:orientation="vertical"
|
||||
android:minHeight="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:focusable="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_history"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?colorOnSurface"
|
||||
android:textSize="15sp"
|
||||
android:fontFamily="sans-serif"
|
||||
android:lineSpacingExtra="2dp"
|
||||
android:maxLines="3"
|
||||
android:ellipsize="end"
|
||||
tools:text="AI聊天记录演示文本内容,这里应该显示最后一条消息的前100个字符" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="6dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/model_avatar_view"
|
||||
android:layout_width="20dp"
|
||||
android:layout_height="20dp"
|
||||
android:layout_marginEnd="6dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/text_model_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="sans-serif-medium"
|
||||
android:alpha="0.5"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:layout_toEndOf="@id/iv_header"
|
||||
android:layout_toStartOf="@id/iv_delete_history"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_centerVertical="true"
|
||||
tools:text="聊天记录 demo"
|
||||
tools:ignore="RelativeOverlap" />
|
||||
</RelativeLayout>
|
||||
tools:text="Qwen" />
|
||||
|
||||
<View
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<TextView
|
||||
android:id="@+id/text_timestamp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="?colorOnSurfaceVariant"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="sans-serif"
|
||||
android:alpha="0.5"
|
||||
tools:text="2小时前" />
|
||||
|
||||
<!-- 删除按钮 -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/iv_delete_history"
|
||||
style="@style/Widget.Material3.Button.IconButton"
|
||||
android:layout_width="36dp"
|
||||
android:layout_height="36dp"
|
||||
android:layout_marginStart="6dp"
|
||||
android:contentDescription="删除聊天记录"
|
||||
app:icon="@drawable/ic_u_delete"
|
||||
app:iconSize="14dp"
|
||||
app:iconTint="?colorOnSurfaceVariant"
|
||||
app:backgroundTint="@android:color/transparent"
|
||||
app:rippleColor="?colorSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingTop="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:background="@drawable/performance_metric_background">
|
||||
|
||||
<!-- Icon with circular background -->
|
||||
<FrameLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="50dp"
|
||||
android:layout_marginBottom="12dp">
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/icon_background"
|
||||
android:layout_width="50dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_height="50dp" >
|
||||
<ImageView
|
||||
android:id="@+id/metric_icon"
|
||||
android:layout_width="25dp"
|
||||
android:layout_height="25dp"
|
||||
android:scaleType="fitCenter"
|
||||
tools:src="@drawable/ic_speed"
|
||||
android:layout_gravity="center"
|
||||
tools:tint="@color/benchmark_gradient_start" />
|
||||
</FrameLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<!-- Title and Subtitle -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:layout_marginBottom="8dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/metric_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:textColor="?android:textColorPrimary"
|
||||
android:gravity="center"
|
||||
tools:text="Prefill Speed" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/metric_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="10sp"
|
||||
android:textColor="?android:textColorSecondary"
|
||||
android:gravity="center"
|
||||
android:layout_marginTop="2dp"
|
||||
tools:text="Prompt Processing" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Value and Standard Deviation -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:layout_gravity="center">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/metric_value"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="17sp"
|
||||
android:textStyle="bold"
|
||||
android:gravity="center"
|
||||
android:maxLines="2"
|
||||
android:lineSpacingMultiplier="1.1"
|
||||
tools:text="121.3 t/s"
|
||||
tools:textColor="@color/benchmark_gradient_start" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/metric_std_dev"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="normal"
|
||||
android:gravity="center"
|
||||
android:layout_marginStart="1dp"
|
||||
android:visibility="gone"
|
||||
tools:text="±3.88"
|
||||
tools:textColor="@color/benchmark_gradient_start"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
|
@ -46,4 +46,16 @@
|
|||
|
||||
<color name="chip_background">#2c2c2e</color>
|
||||
|
||||
<!-- Benchmark Performance Metric Colors (夜间模式) -->
|
||||
<color name="benchmark_gradient_start">#667eea</color>
|
||||
<color name="benchmark_gradient_end">#764ba2</color>
|
||||
<color name="benchmark_warning">#f59e0b</color>
|
||||
<color name="benchmark_success">#10b981</color>
|
||||
<color name="benchmark_error">#ef4444</color>
|
||||
<color name="benchmark_secondary">#9ca3af</color>
|
||||
<color name="benchmark_light">#1f2937</color>
|
||||
<color name="benchmark_card_bg">#1c1b1f</color>
|
||||
<color name="benchmark_card_border">#3f3f46</color>
|
||||
<color name="benchmark_accent">#6366f1</color>
|
||||
|
||||
</resources>
|
|
@ -47,7 +47,7 @@
|
|||
<string name="nav_close">Close navigation drawer</string>
|
||||
<string name="models">模型列表</string>
|
||||
<string name="models_market">模型市场</string>
|
||||
<string name="history">历史会话</string>
|
||||
<string name="history">对话历史</string>
|
||||
<string name="history_title">Chat History</string>
|
||||
<string name="history_delete_success">历史被删除</string>
|
||||
<string name="diffusion_generated_message">已生成图片:</string>
|
||||
|
@ -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>
|
||||
|
@ -236,9 +241,16 @@
|
|||
<string name="audio_output_confirm">当前音频输出可能较慢。您是否仍要打开?</string>
|
||||
<string name="confirm_delete_model_title">确认删除</string>
|
||||
<string name="confirm_delete_model_message">您确定要删除这个模型吗?此操作无法恢复。</string>
|
||||
<string name="delete_history_title">删除聊天记录</string>
|
||||
<string name="delete_history_message">您确定要删除这条聊天记录吗?此操作无法恢复。</string>
|
||||
<string name="vendor_menu_title">厂商</string>
|
||||
<string name="modality_menu_title">模态</string>
|
||||
<string name="downloaded">已下载</string>
|
||||
<string name="unknown_time">未知时间</string>
|
||||
<string name="just_now">刚刚</string>
|
||||
<string name="minutes_ago">%1$d分钟前</string>
|
||||
<string name="hours_ago">%1$d小时前</string>
|
||||
<string name="days_ago">%1$d天前</string>
|
||||
<string name="last_chat">最后聊天</string>
|
||||
<string name="show_model_info">显示模型信息</string>
|
||||
<string name="model_info_title">模型信息</string>
|
||||
|
@ -327,7 +339,7 @@
|
|||
<string name="voice_chat_stopping">正在停止...</string>
|
||||
<string name="voice_chat_ready_greeting">有什么可以帮助您的?</string>
|
||||
<string name="voice_chat_usage_notice">使用语音聊天需要下载TTS和ASR模型,请注意多语言支持。</string>
|
||||
<string name="nav_name_chats">会话</string>
|
||||
<string name="nav_name_chats">我的模型</string>
|
||||
<string name="benchmark">性能评测</string>
|
||||
<string name="download_source">选择下载源</string>
|
||||
<string name="filter">筛选</string>
|
||||
|
@ -375,10 +387,15 @@
|
|||
<string name="no">否</string>
|
||||
<string name="select_a_model_to_start">选择好模型后,就可以开始评测了</string>
|
||||
<string name="start_test">开始测试</string>
|
||||
<string name="restart_test">重新评测</string>
|
||||
<string name="prefill_speed">"预填充速度: "</string>
|
||||
<string name="memory_title">峰值内存:</string>
|
||||
<string name="decode_speed">解码速度:</string>
|
||||
<string name="test_result">评测结果</string>
|
||||
<string name="test_progress">测试进度</string>
|
||||
<string name="running_performance_tests">正在运行性能测试</string>
|
||||
<string name="complete">完成</string>
|
||||
<string name="status_update">状态更新</string>
|
||||
<string name="share">分享</string>
|
||||
|
||||
<!-- Benchmark Strings -->
|
||||
|
@ -497,4 +514,28 @@
|
|||
<string name="download_service_title">MNN Chat 下载服务</string>
|
||||
<string name="downloading_single_model">正在下载 %1$s</string>
|
||||
<string name="downloading_multiple_models">正在下载 %1$d 个模型</string>
|
||||
|
||||
<!-- Benchmark Performance Metric Strings -->
|
||||
<string name="prefill_speed_title">预填充速度</string>
|
||||
<string name="prefill_speed_subtitle">每秒令牌数</string>
|
||||
<string name="decode_speed_title">解码速度</string>
|
||||
<string name="decode_speed_subtitle">生成速率</string>
|
||||
<string name="memory_usage_title">内存使用</string>
|
||||
<string name="memory_usage_subtitle">峰值内存</string>
|
||||
<string name="total_tokens_title">总时间</string>
|
||||
<string name="total_tokens_subtitle">完成时长</string>
|
||||
<string name="benchmark_config_title">基准测试配置</string>
|
||||
<string name="powered_by_mnn">Powered By MNN</string>
|
||||
<string name="benchmark_results_title">基准测试结果</string>
|
||||
<string name="performance_analysis_complete">性能分析完成</string>
|
||||
<string name="completed">完成时间</string>
|
||||
<string name="completion_time">完成时间</string>
|
||||
<string name="select_model_title">选择模型</string>
|
||||
<string name="click_to_select_model">点击选择模型</string>
|
||||
<string name="ready_for_benchmark">准备进行基准测试</string>
|
||||
<string name="cannot_change_model_during_benchmark">基准测试期间无法更改模型</string>
|
||||
<string name="failed_to_share_result">分享结果失败:%1$s</string>
|
||||
<string name="unknown_error">未知错误</string>
|
||||
<string name="not_available">不可用</string>
|
||||
<string name="unknown_model">未知模型</string>
|
||||
</resources>
|
||||
|
|
|
@ -38,6 +38,7 @@
|
|||
|
||||
<declare-styleable name="ModelAvatarView">
|
||||
<attr name="modelName" />
|
||||
<attr name="compactMode" format="boolean" />
|
||||
</declare-styleable>
|
||||
|
||||
<declare-styleable name="ProgressPieView">
|
||||
|
|
|
@ -57,4 +57,16 @@
|
|||
|
||||
<!-- Chip -->
|
||||
<color name="chip_background">#e8f1ff</color>
|
||||
|
||||
<!-- Benchmark Performance Metric Colors -->
|
||||
<color name="benchmark_gradient_start">#667eea</color>
|
||||
<color name="benchmark_gradient_end">#764ba2</color>
|
||||
<color name="benchmark_warning">#f59e0b</color>
|
||||
<color name="benchmark_success">#10b981</color>
|
||||
<color name="benchmark_error">#ef4444</color>
|
||||
<color name="benchmark_secondary">#6b7280</color>
|
||||
<color name="benchmark_light">#f8fafc</color>
|
||||
<color name="benchmark_card_bg">#FFFFFF</color>
|
||||
<color name="benchmark_card_border">#E0E0E0</color>
|
||||
<color name="benchmark_accent">#6366f1</color>
|
||||
</resources>
|
|
@ -37,4 +37,7 @@
|
|||
<dimen name="filter_chip_height">30dp</dimen>
|
||||
<dimen name="filter_chip_margin_end">8dp</dimen>
|
||||
<dimen name="bottom_tab_icon_size">30dp</dimen>
|
||||
|
||||
<!-- Performance Metric View -->
|
||||
<dimen name="performance_metric_padding">0dp</dimen>
|
||||
</resources>
|
|
@ -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>
|
||||
|
@ -246,9 +251,16 @@
|
|||
<string name="audio_output_confirm">Audio Output is very slow for now, Would you like to continue anyway?</string>
|
||||
<string name="confirm_delete_model_title">Confirm Deletion</string>
|
||||
<string name="confirm_delete_model_message">Are you sure you want to delete this model? This action cannot be undone.</string>
|
||||
<string name="delete_history_title">Delete Chat History</string>
|
||||
<string name="delete_history_message">Are you sure you want to delete this chat history? This action cannot be undone.</string>
|
||||
<string name="vendor_menu_title">Vendor</string>
|
||||
<string name="modality_menu_title">Modality</string>
|
||||
<string name="downloaded">Downloaded</string>
|
||||
<string name="unknown_time">Unknown time</string>
|
||||
<string name="just_now">Just now</string>
|
||||
<string name="minutes_ago">%1$d minutes ago</string>
|
||||
<string name="hours_ago">%1$d hours ago</string>
|
||||
<string name="days_ago">%1$d days ago</string>
|
||||
<string name="last_chat">Last chat</string>
|
||||
<string name="show_model_info">Show model info</string>
|
||||
<string name="model_info_title">Model Information</string>
|
||||
|
@ -332,7 +344,7 @@
|
|||
<string name="voice_chat_stopping">Stopping...</string>
|
||||
<string name="voice_chat_ready_greeting">What can I help you with?</string>
|
||||
<string name="voice_chat_usage_notice">To use voice chat, TTS and ASR models need to be downloaded. Please note multi-language support.</string>
|
||||
<string name="nav_name_chats">Chats</string>
|
||||
<string name="nav_name_chats">My Models</string>
|
||||
<string name="benchmark">Benchmark</string>
|
||||
<string name="download_source">Select Download Source</string>
|
||||
<string name="filter">Filter</string>
|
||||
|
@ -384,10 +396,15 @@
|
|||
<string name="no">No</string>
|
||||
<string name="select_a_model_to_start">Start benchmark after selected your model</string>
|
||||
<string name="start_test">Start Test</string>
|
||||
<string name="restart_test">Restart Test</string>
|
||||
<string name="prefill_speed">"Prefill Speed: "</string>
|
||||
<string name="memory_title">Peak Memory: </string>
|
||||
<string name="decode_speed">Decode Speed: </string>
|
||||
<string name="test_result">Test Result</string>
|
||||
<string name="test_progress">Test Progress</string>
|
||||
<string name="running_performance_tests">Running performance tests</string>
|
||||
<string name="complete">Complete</string>
|
||||
<string name="status_update">Status Update</string>
|
||||
<string name="share">Share</string>
|
||||
|
||||
<!-- Benchmark Strings -->
|
||||
|
@ -500,4 +517,28 @@
|
|||
<string name="download_service_title">MNN Chat Download Service</string>
|
||||
<string name="downloading_single_model">Downloading %1$s</string>
|
||||
<string name="downloading_multiple_models">Downloading %1$d models</string>
|
||||
|
||||
<!-- Benchmark Performance Metric Strings -->
|
||||
<string name="prefill_speed_title">Prefill Speed</string>
|
||||
<string name="prefill_speed_subtitle">Tokens per second</string>
|
||||
<string name="decode_speed_title">Decode Speed</string>
|
||||
<string name="decode_speed_subtitle">Generation rate</string>
|
||||
<string name="memory_usage_title">Memory Usage</string>
|
||||
<string name="memory_usage_subtitle">Peak memory</string>
|
||||
<string name="total_tokens_title">Total Time</string>
|
||||
<string name="total_tokens_subtitle">Completion duration</string>
|
||||
<string name="benchmark_config_title">Benchmark Configuration</string>
|
||||
<string name="powered_by_mnn">Powered By MNN</string>
|
||||
<string name="completion_time">Completion Time</string>
|
||||
<string name="benchmark_results_title">基准测试结果</string>
|
||||
<string name="performance_analysis_complete">Performance analysis complete</string>
|
||||
<string name="completed">Completed</string>
|
||||
<string name="select_model_title">Select Model</string>
|
||||
<string name="click_to_select_model">Click to select model</string>
|
||||
<string name="ready_for_benchmark">Ready for benchmark</string>
|
||||
<string name="cannot_change_model_during_benchmark">Cannot change model during benchmark</string>
|
||||
<string name="failed_to_share_result">Failed to share result: %1$s</string>
|
||||
<string name="unknown_error">Unknown error</string>
|
||||
<string name="not_available">N/A</string>
|
||||
<string name="unknown_model">Unknown Model</string>
|
||||
</resources>
|
|
@ -1,3 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- <style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar"> <item name="colorPrimary">@color/...</item>-->
|
||||
|
@ -151,4 +152,14 @@
|
|||
<style name="BottomSheetStyle" parent="Widget.Material3.BottomSheet">
|
||||
<item name="android:background">@drawable/bottom_sheet_background</item>
|
||||
</style>
|
||||
|
||||
<!-- Benchmark Button Style -->
|
||||
<style name="Widget.Material3.Button.Benchmark" parent="Widget.Material3.Button">
|
||||
<item name="android:background">@drawable/benchmark_button_background_selector</item>
|
||||
<item name="android:textColor">@color/white</item>
|
||||
<item name="android:textSize">18sp</item>
|
||||
<item name="android:textStyle">bold</item>
|
||||
<item name="cornerRadius">16dp</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -0,0 +1,98 @@
|
|||
package com.alibaba.mnnllm.android.chat
|
||||
|
||||
import org.junit.Assert.*
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class GenerateResultProcessorTest {
|
||||
|
||||
@Test
|
||||
fun noSlashThink_removesLeadingEndTag() {
|
||||
assertEquals("abc", GenerateResultProcessor.noSlashThink("</think>abc"))
|
||||
assertEquals("xyz</think>", GenerateResultProcessor.noSlashThink("xyz</think>"))
|
||||
assertNull(GenerateResultProcessor.noSlashThink(null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkTags_simpleBlock_parsesThinkingAndNormal() {
|
||||
val p = GenerateResultProcessor()
|
||||
p.generateBegin()
|
||||
|
||||
p.process("<think>abc</think>def")
|
||||
p.process(null)
|
||||
|
||||
// THINK_TAGS adds a trailing newline on think end
|
||||
assertEquals("abc\n", p.getThinkingContent())
|
||||
assertEquals("def", p.getNormalOutput())
|
||||
assertEquals("abc\ndef", p.getDisplayResult())
|
||||
assertTrue(p.thinkTime >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkTags_emptyThink_hasNoThinkingContent() {
|
||||
val p = GenerateResultProcessor()
|
||||
p.generateBegin()
|
||||
|
||||
p.process("<think></think>abc")
|
||||
p.process(null)
|
||||
|
||||
// No content inside <think> -> thinking content should be empty string
|
||||
assertEquals("", p.getThinkingContent())
|
||||
assertEquals("abc", p.getNormalOutput())
|
||||
assertEquals("abc", p.getDisplayResult())
|
||||
assertTrue(p.thinkTime >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkTags_retroactiveMove_onEndTagFirst() {
|
||||
val p = GenerateResultProcessor()
|
||||
p.generateBegin()
|
||||
|
||||
// Stream normal text first, then an unexpected </think> which should
|
||||
// retroactively move the pending normal text into thinking.
|
||||
p.process("Hello ")
|
||||
p.process("World")
|
||||
p.process("</think> Rest")
|
||||
p.process(null)
|
||||
|
||||
assertEquals("Hello World\n", p.getThinkingContent())
|
||||
assertEquals(" Rest", p.getNormalOutput())
|
||||
assertEquals("Hello World\n Rest", p.getDisplayResult())
|
||||
assertTrue(p.thinkTime >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gptOss_thinkingOnly_extractsMessage() {
|
||||
val p = GenerateResultProcessor()
|
||||
p.generateBegin()
|
||||
|
||||
// Contains a <|message|> block but no final<|message|>
|
||||
p.process("prefix<|message|>thinking...<|end|>suffix")
|
||||
// End of stream should set thinkTime if not already set
|
||||
p.process(null)
|
||||
|
||||
assertEquals("thinking...", p.getThinkingContent())
|
||||
assertEquals("", p.getNormalOutput())
|
||||
assertEquals("thinking...", p.getDisplayResult())
|
||||
assertTrue(p.thinkTime >= 0)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun gptOss_withFinalMessage_splitsThinkingAndNormal() {
|
||||
val p = GenerateResultProcessor()
|
||||
p.generateBegin()
|
||||
|
||||
// The last occurrence of "final<|message|>" marks the start of normal output
|
||||
val input = "noise<|message|>internal-think<|end|>final<|message|>Hello world"
|
||||
p.process(input)
|
||||
p.process(null)
|
||||
|
||||
assertEquals("internal-think", p.getThinkingContent())
|
||||
assertEquals("Hello world", p.getNormalOutput())
|
||||
assertEquals("internal-thinkHello world", p.getDisplayResult())
|
||||
assertTrue(p.thinkTime >= 0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
./gradlew installStandardDebug
|
|
@ -0,0 +1 @@
|
|||
./gradlew test -x preBuild
|
|
@ -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