Merge pull request #3861 from Juude/master

fix qwen think/nothink switch and some ui update
This commit is contained in:
王召德 2025-09-05 16:02:56 +08:00 committed by GitHub
commit 333b3c4b5d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
97 changed files with 4014 additions and 767 deletions

View File

@ -61,6 +61,15 @@ This is our full multimodal language model (LLM) Android app
``` ```
# Releases # Releases
## Version 0.7.3
+ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_3.apk)
+ Optimize ApiService
## Version 0.7.2
+ 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 ## Version 0.7.1
+ Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk) + Click here to [download](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk)
+ add new models: + add new models:

View File

@ -53,7 +53,17 @@
``` ```
# Releases # 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 ## 版本 0.7.1
+ [点击此处下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk) + [点击此处下载](https://meta.alicdn.com/data/mnn/mnn_chat_0_7_1.apk)
+ 新增模型: + 新增模型:

View File

@ -59,8 +59,8 @@ android {
applicationId "com.alibaba.mnnllm.android" applicationId "com.alibaba.mnnllm.android"
minSdk 26 minSdk 26
targetSdk 35 targetSdk 35
versionCode 701 versionCode 703
versionName "0.7.1" versionName "0.7.3"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
externalNativeBuild { externalNativeBuild {
@ -114,7 +114,6 @@ android {
signingConfig signingConfigs.release signingConfig signingConfigs.release
} }
applicationIdSuffix ".release" applicationIdSuffix ".release"
versionNameSuffix ".gp"
} }
} }
} }
@ -127,6 +126,7 @@ android {
googleplay { googleplay {
dimension "store" dimension "store"
buildConfigField "boolean", "IS_GOOGLE_PLAY_BUILD", "true" buildConfigField "boolean", "IS_GOOGLE_PLAY_BUILD", "true"
versionNameSuffix ".gp"
} }
} }

View File

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

View File

@ -1,5 +1,5 @@
{ {
"version": "5", "version": "6",
"tagTranslations": { "tagTranslations": {
"Vision": "图像理解", "Vision": "图像理解",
"Video": "视频理解", "Video": "视频理解",
@ -95,7 +95,6 @@
{ {
"modelName": "MiniCPM4-0.5B-MNN", "modelName": "MiniCPM4-0.5B-MNN",
"tags": [ "tags": [
"Think"
], ],
"categories": [ "categories": [
"recommended", "recommended",
@ -113,7 +112,6 @@
{ {
"modelName": "MiniCPM4-8B-MNN", "modelName": "MiniCPM4-8B-MNN",
"tags": [ "tags": [
"Think"
], ],
"categories": [ "categories": [
"recommended", "recommended",
@ -359,6 +357,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -377,6 +376,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -415,6 +415,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -433,6 +434,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -469,6 +471,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -507,6 +510,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"
@ -525,6 +529,7 @@
"tags": [ "tags": [
"Think" "Think"
], ],
"extra_tags": ["ThinkingSwitch"],
"categories": [ "categories": [
"recommended", "recommended",
"qwen" "qwen"

View File

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

View File

@ -398,7 +398,21 @@ Java_com_alibaba_mnnllm_android_llm_LlmSession_updateAssistantPromptNative(JNIEn
} }
env->ReleaseStringUTFChars(assistant_prompt_j, assistant_prompt_cstr); 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" extern "C"
JNIEXPORT void JNICALL JNIEXPORT void JNICALL
Java_com_alibaba_mnnllm_android_llm_LlmSession_updateEnableAudioOutputNative(JNIEnv *env,jobject thiz, jlong llm_ptr, jboolean enable) { 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); return env->NewObject(resultClass, resultCtor, testInstance, (jboolean)result.success, errorMessage);
} }
} // extern "C"

View File

@ -236,6 +236,23 @@ void LlmSession::SetAssistantPrompt(const std::string& assistant_prompt) {
MNN_DEBUG("dumped config: %s", llm_->dump_config().c_str()); 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) { void LlmSession::enableAudioOutput(bool enable) {
enable_audio_output_ = enable; enable_audio_output_ = enable;
} }

View File

@ -40,6 +40,8 @@ public:
void SetAssistantPrompt(const std::string& assistant_prompt); void SetAssistantPrompt(const std::string& assistant_prompt);
void updateConfig(const std::string& config_json);
void enableAudioOutput(bool b); void enableAudioOutput(bool b);
// 新增API服务历史消息推理方法 // 新增API服务历史消息推理方法

View File

@ -32,6 +32,10 @@ class ModelItem {
} }
} }
fun getExtraTags(): List<String> {
return modelMarketItem?.extraTags ?: emptyList()
}
fun addTag(tag: String) { fun addTag(tag: String) {
tags.add(tag) tags.add(tag)
} }

View File

@ -29,8 +29,9 @@ class DownloadForegroundService : Service() {
val channel = NotificationChannel( val channel = NotificationChannel(
CHANNEL_ID, CHANNEL_ID,
getString(AppR.string.download_service_title), getString(AppR.string.download_service_title),
NotificationManager.IMPORTANCE_LOW NotificationManager.IMPORTANCE_DEFAULT
) )
channel.description = "Shows download progress for model files"
notificationManager.createNotificationChannel(channel) notificationManager.createNotificationChannel(channel)
} }
@ -41,7 +42,7 @@ class DownloadForegroundService : Service() {
val notification = createNotification() val notification = createNotification()
try { 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) startForeground(SERVICE_ID, notification, FOREGROUND_SERVICE_TYPE_DATA_SYNC)
} else { } else {
startForeground(SERVICE_ID, notification) startForeground(SERVICE_ID, notification)
@ -90,8 +91,10 @@ class DownloadForegroundService : Service() {
fun updateNotification(downloadCount: Int, modelName: String? = null) { fun updateNotification(downloadCount: Int, modelName: String? = null) {
currentDownloadCount = downloadCount currentDownloadCount = downloadCount
currentModelName = modelName currentModelName = modelName
android.util.Log.d("DownloadForegroundService", "updateNotification: count=$downloadCount, modelName=$modelName")
val notification = createNotification() val notification = createNotification()
notificationManager.notify(SERVICE_ID, notification) notificationManager.notify(SERVICE_ID, notification)
android.util.Log.d("DownloadForegroundService", "Notification updated successfully")
} }
override fun onDestroy() { override fun onDestroy() {

View File

@ -354,7 +354,7 @@ class ModelDownloadManager private constructor(context: Context) {
} }
if (count == 1) { 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( if (ContextCompat.checkSelfPermission(
appContext, appContext,
Manifest.permission.POST_NOTIFICATIONS Manifest.permission.POST_NOTIFICATIONS
@ -375,6 +375,9 @@ class ModelDownloadManager private constructor(context: Context) {
} else { } else {
startForegroundService() startForegroundService()
} }
} else {
// For Android 13 and below, start foreground service directly
startForegroundService()
} }
} }
updateNotification() updateNotification()
@ -383,6 +386,7 @@ class ModelDownloadManager private constructor(context: Context) {
private fun startForegroundService() { private fun startForegroundService() {
// Do not start foreground service in Google Play build // Do not start foreground service in Google Play build
if (disableForegroundService) { if (disableForegroundService) {
Log.d(TAG, "startForegroundService: skipped - disableForegroundService is true")
return return
} }
@ -393,8 +397,10 @@ class ModelDownloadManager private constructor(context: Context) {
foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_DOWNLOAD_COUNT, count) foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_DOWNLOAD_COUNT, count)
foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_MODEL_NAME, modelName) foregroundServiceIntent.putExtra(DownloadForegroundService.EXTRA_MODEL_NAME, modelName)
Log.d(TAG, "startForegroundService: starting service with count=$count, modelName=$modelName")
ApplicationProvider.get().startForegroundService(foregroundServiceIntent) ApplicationProvider.get().startForegroundService(foregroundServiceIntent)
foregroundServiceStarted = true foregroundServiceStarted = true
Log.d(TAG, "startForegroundService: service started successfully")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "start foreground service failed", e) Log.e(TAG, "start foreground service failed", e)
foregroundServiceStarted = false foregroundServiceStarted = false
@ -417,6 +423,7 @@ class ModelDownloadManager private constructor(context: Context) {
private fun updateNotification() { private fun updateNotification() {
if (!foregroundServiceStarted || disableForegroundService) { if (!foregroundServiceStarted || disableForegroundService) {
Log.d(TAG, "updateNotification: skipped - foregroundServiceStarted: $foregroundServiceStarted, disableForegroundService: $disableForegroundService")
return return
} }
@ -424,6 +431,7 @@ class ModelDownloadManager private constructor(context: Context) {
val count = activeDownloadCount.get() val count = activeDownloadCount.get()
val modelName = getActiveDownloadModelName() val modelName = getActiveDownloadModelName()
Log.d(TAG, "updateNotification: count=$count, modelName=$modelName")
// Use the static method to update notification // Use the static method to update notification
DownloadForegroundService.updateNotification(count, modelName) DownloadForegroundService.updateNotification(count, modelName)
} catch (e: Exception) { } 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") Log.v(TAG, "[updateDownloadingProgress] Notifying ${listeners.size} listeners for $modelId stage: $stage")
listeners.forEach { it.onDownloadProgress(modelId, downloadInfo) } listeners.forEach { it.onDownloadProgress(modelId, downloadInfo) }
// Update notification with progress information
updateNotification()
} }
suspend fun deleteModel(item: ModelMarketItem) { suspend fun deleteModel(item: ModelMarketItem) {

View File

@ -27,6 +27,24 @@ class BenchmarkContract {
fun updateStatus(message: String) fun updateStatus(message: String)
fun hideStatus() 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 // UI state
fun setStartButtonText(text: String) fun setStartButtonText(text: String)
fun setStartButtonEnabled(enabled: Boolean) fun setStartButtonEnabled(enabled: Boolean)

View File

@ -10,6 +10,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import android.widget.Toast import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
@ -60,15 +61,15 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
} }
private fun setupClickListeners() { private fun setupClickListeners() {
binding.startTestButton.setOnClickListener { binding.startTestButtonContainer.setOnClickListener {
Log.d(TAG, "Start test button clicked, current text: ${binding.startTestButton.text}") Log.d(TAG, "Start test button clicked, current text: ${binding.startTestText.text}")
presenter?.onStartBenchmarkClicked() presenter?.onStartBenchmarkClicked()
} }
// Back button click handler // Share button click handler
binding.backButton.setOnClickListener { binding.shareButton.setOnClickListener {
Log.d(TAG, "Back button clicked") Log.d(TAG, "Share button clicked")
presenter?.onBackClicked() shareResultCard()
} }
// Model selector click handler - now clicking the entire layout // Model selector click handler - now clicking the entire layout
@ -79,7 +80,7 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
showModelSelectionDialog() showModelSelectionDialog()
} else { } else {
Log.d(TAG, "Model selection disabled in state: $currentState") 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 // Update the new UI elements
if (models.isEmpty()) { if (models.isEmpty()) {
binding.modelSelectorTitle.text = requireContext().getString(R.string.no_models_available) _binding?.modelSelectorTitle?.text = requireContext().getString(R.string.no_models_available)
binding.modelSelectorStatus.text = requireContext().getString(R.string.please_download_model) _binding?.modelSelectorStatus?.text = requireContext().getString(R.string.please_download_model)
binding.modelAvatar.setModelName("") _binding?.modelAvatar?.setModelName("")
binding.modelTagsLayout.setTags(emptyList()) _binding?.modelTagsLayout?.setTags(emptyList())
} else { } else {
binding.modelSelectorTitle.text = "Select Model" _binding?.modelSelectorTitle?.text = requireContext().getString(R.string.select_model_title)
binding.modelSelectorStatus.text = "Click to select model" _binding?.modelSelectorStatus?.text = requireContext().getString(R.string.click_to_select_model)
binding.modelAvatar.setModelName("") _binding?.modelAvatar?.setModelName("")
binding.modelTagsLayout.setTags(emptyList()) _binding?.modelTagsLayout?.setTags(emptyList())
} }
// Keep the autocomplete for compatibility // Keep the autocomplete for compatibility
binding.modelSelectorAutocomplete.apply { _binding?.modelSelectorAutocomplete?.apply {
setText("Select Model") setText(getString(R.string.select_model_title))
isFocusable = false isFocusable = false
isClickable = true isClickable = true
} }
@ -162,65 +163,151 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
// Update the new UI elements with model information // Update the new UI elements with model information
val modelItem = modelWrapper.modelItem 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 // Set model title and avatar
binding.modelSelectorTitle.text = modelName _binding?.modelSelectorTitle?.text = modelName
binding.modelAvatar.setModelName(modelName) _binding?.modelAvatar?.setModelName(modelName)
// Set tags similar to ModelItemHolder // Set tags similar to ModelItemHolder
val tags = getDisplayTags(modelItem) val tags = getDisplayTags(modelItem)
binding.modelTagsLayout.setTags(tags) _binding?.modelTagsLayout?.setTags(tags)
// Set status with file size // Set status with file size
val formattedSize = getFormattedFileSize(modelWrapper) val formattedSize = getFormattedFileSize(modelWrapper)
binding.modelSelectorStatus.text = if (formattedSize.isNotEmpty()) { _binding?.modelSelectorStatus?.text = if (formattedSize.isNotEmpty()) {
getString(R.string.downloaded_click_to_chat, formattedSize) getString(R.string.downloaded_click_to_chat, formattedSize)
} else { } else {
"Ready for benchmark" getString(R.string.ready_for_benchmark)
} }
// Keep the autocomplete updated for compatibility // Keep the autocomplete updated for compatibility
binding.modelSelectorAutocomplete.setText(modelWrapper.displayName) _binding?.modelSelectorAutocomplete?.setText(modelWrapper.displayName)
Log.d(TAG, "Selected model: ${modelWrapper.displayName}") Log.d(TAG, "Selected model: ${modelWrapper.displayName}")
} }
override fun enableStartButton(enabled: Boolean) { 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) { override fun updateProgress(progress: BenchmarkProgress) {
binding.textStatus.text = progress.statusMessage Log.d(TAG, "updateProgress: $progress")
binding.textStatus.visibility = View.VISIBLE
binding.resultCard.visibility = View.INVISIBLE // 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) { override fun showResults(results: BenchmarkContract.BenchmarkResults) {
populateResultsUI(results) 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() { override fun hideResults() {
binding.testResultsTitle.visibility = View.INVISIBLE _binding?.resultCard?.visibility = View.GONE
binding.resultCard.visibility = View.INVISIBLE
} }
override fun updateStatus(message: String) { override fun updateStatus(message: String) {
binding.textStatus.text = message _binding?.statusMessage?.text = message
binding.textStatus.visibility = View.VISIBLE _binding?.statusCard?.visibility = View.VISIBLE
} }
override fun hideStatus() { override fun hideStatus() {
binding.textStatus.visibility = View.GONE _binding?.statusCard?.visibility = View.GONE
} }
override fun setStartButtonText(text: String) { override fun setStartButtonText(text: String) {
Log.d(TAG, "Setting start button text to: $text") 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) { 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() { override fun showProgressBar() {
@ -229,64 +316,132 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
override fun hideProgressBar() { override fun hideProgressBar() {
// binding.progressBar.visibility = View.GONE // binding.progressBar.visibility = View.GONE
// Hide textStatus if results are visible // Hide status card if results are visible
if (binding.resultCard.visibility == View.VISIBLE) { if (_binding?.resultCard?.visibility == View.VISIBLE) {
binding.textStatus.visibility = View.INVISIBLE _binding?.statusCard?.visibility = View.INVISIBLE
} }
} }
override fun showBenchmarkIcon(show: Boolean) { override fun showBenchmarkIcon(show: Boolean) {
binding.iconBenchmark.visibility = if (show) View.VISIBLE else View.INVISIBLE // Benchmark icon removed to match iOS - no large icon display
binding.iconBenchmarkParent.visibility = if (show) View.VISIBLE else View.INVISIBLE Log.d(TAG, "showBenchmarkIcon: $show (removed to match iOS)")
Log.d(TAG, "showBenchmarkIcon: $show")
} }
override fun showBenchmarkProgressBar(show: Boolean) { override fun showBenchmarkProgressBar(show: Boolean) {
binding.benchmarkProgressBar.visibility = if (show) View.VISIBLE else View.INVISIBLE // Progress bar removed with benchmark icon - using progress card instead
Log.d(TAG, "showBenchmarkProgressBar: $show") // Update button state for consistency
if (isFragmentValid()) {
updateButtonIconState()
}
Log.d(TAG, "showBenchmarkProgressBar: $show (using progress card instead)")
} }
override fun updateBenchmarkProgress(progress: Int) { override fun updateBenchmarkProgress(progress: Int) {
binding.benchmarkProgressBar.progress = progress if (isFragmentValid()) {
Log.d(TAG, "updateBenchmarkProgress: $progress%") // 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) { override fun enableModelSelector(enabled: Boolean) {
binding.modelSelectorLayout.isEnabled = enabled if (isFragmentValid()) {
binding.modelSelectorLayout.alpha = if (enabled) 1.0f else 0.6f binding.modelSelectorLayout.isEnabled = enabled
binding.modelSelectorLayout.alpha = if (enabled) 1.0f else 0.6f
}
Log.d(TAG, "enableModelSelector: $enabled") Log.d(TAG, "enableModelSelector: $enabled")
} }
override fun showBackButton(show: Boolean) { override fun showBackButton(show: Boolean) {
binding.backButton.visibility = if (show) View.VISIBLE else View.GONE // Back button removed, no longer needed
Log.d(TAG, "showBackButton: $show") Log.d(TAG, "showBackButton: $show (back button removed)")
} }
override fun showModelSelectorCard(show: Boolean) { override fun showModelSelectorCard(show: Boolean) {
binding.modelSelectorCard.visibility = if (show) View.VISIBLE else View.GONE if (isFragmentValid()) {
binding.modelSelectorCard.visibility = if (show) View.VISIBLE else View.GONE
}
Log.d(TAG, "showModelSelectorCard: $show") Log.d(TAG, "showModelSelectorCard: $show")
} }
override fun updateButtonLayout(showBackButton: Boolean) { override fun updateButtonLayout(showBackButton: Boolean) {
if (showBackButton) { // Back button removed, main button always full width
// Show back button and adjust main button layout Log.d(TAG, "updateButtonLayout: showBackButton=$showBackButton (back button removed)")
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")
} }
override fun shareResultCard() { override fun shareResultCard() {
@ -323,7 +478,7 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "Error sharing result card", e) 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) { override fun showUploadProgress(message: String) {
binding.textStatus.text = message _binding?.statusMessage?.text = message
binding.textStatus.visibility = View.VISIBLE _binding?.statusCard?.visibility = View.VISIBLE
// Disable the upload button while uploading // 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") Log.d(TAG, "Showing upload progress: $message")
} }
override fun hideUploadProgress() { override fun hideUploadProgress() {
binding.textStatus.visibility = View.GONE _binding?.statusCard?.visibility = View.GONE
// Re-enable the upload button // Re-enable the upload button
binding.startTestButton.isEnabled = true _binding?.startTestButtonContainer?.isEnabled = true
_binding?.startTestButtonContainer?.alpha = 1.0f
Log.d(TAG, "Hiding upload progress") Log.d(TAG, "Hiding upload progress")
} }
@ -376,58 +533,63 @@ class BenchmarkFragment : Fragment(), BenchmarkContract.View {
// ===== UI Helpers ===== // ===== UI Helpers =====
private fun populateResultsUI(results: BenchmarkContract.BenchmarkResults) { private fun populateResultsUI(results: BenchmarkContract.BenchmarkResults) {
binding.resultCard.visibility = View.VISIBLE _binding?.resultCard?.visibility = View.VISIBLE
binding.testResultsTitle.visibility = View.VISIBLE _binding?.modelName?.text = results.modelDisplayName
binding.modelName.text = results.modelDisplayName
DeviceName.with(requireContext()).request { info, error -> DeviceName.with(requireContext()).request { info, error ->
val deviceName = info?.marketName ?: info?.name ?: android.os.Build.MODEL 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 // Use BenchmarkResultsHelper to process test results
val statistics = BenchmarkResultsHelper.processTestResults(requireContext(), results.testResults) val statistics = BenchmarkResultsHelper.processTestResults(requireContext(), results.testResults)
// Display configuration // Display configuration
// binding.benchmarkConfig.text = statistics.configText _binding?.benchmarkConfigText?.text = statistics.configText
// Show prompt processing results (prefill) // Set up performance metrics using new PerformanceMetricView components
statistics.prefillStats?.let { stats -> _binding?.prefillSpeedMetric?.setSpeedMetric(
// Display average value R.drawable.ic_speed,
val averageText = "%.2f".format(stats.average) R.string.prefill_speed_title,
// Display standard deviation in label statistics.prefillStats,
val labelText = "tokens/s ±%.2f".format(stats.stdev) R.color.benchmark_gradient_start
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")
}
// Show token generation results (decode) _binding?.decodeSpeedMetric?.setSpeedMetric(
statistics.decodeStats?.let { stats -> R.drawable.ic_gauge,
// Display average value R.string.decode_speed_title,
val averageText = "%.2f".format(stats.average) statistics.decodeStats,
// Display standard deviation in label R.color.benchmark_gradient_end
val labelText = "tokens/s ±%.2f".format(stats.stdev) )
Log.d(TAG, "Setting decode - Average: '$averageText', Label: '$labelText'")
binding.tokenGenerationValue.text = averageText // Set up total time metric
binding.tokenGenerationLabel.text = labelText _binding?.totalTokensMetric?.setTotalTimeMetric(
} ?: run { statistics.totalTimeSeconds,
Log.d(TAG, "No decode stats available") 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 // Timestamp
binding.timestamp.text = results.timestamp _binding?.timestamp?.text = results.timestamp
Log.d(TAG, "Results populated - Memory: ${results.maxMemoryKb} KB, Model: ${results.modelDisplayName}") Log.d(TAG, "Results populated - Memory: ${results.maxMemoryKb} KB, Model: ${results.modelDisplayName}")
} }
// ===== Helper Methods ===== // ===== 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 * Get current benchmark state from presenter
*/ */

View File

@ -54,7 +54,10 @@ class BenchmarkPresenter(
statusMessage = "Loading models...", statusMessage = "Loading models...",
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, 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( BenchmarkState.LOADING_MODELS -> BenchmarkUIState(
startButtonText = context.getString(R.string.start_test), startButtonText = context.getString(R.string.start_test),
@ -65,7 +68,10 @@ class BenchmarkPresenter(
statusMessage = "Loading models...", statusMessage = "Loading models...",
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, 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( BenchmarkState.READY -> BenchmarkUIState(
startButtonText = context.getString(R.string.start_test), startButtonText = context.getString(R.string.start_test),
@ -76,7 +82,10 @@ class BenchmarkPresenter(
statusMessage = context.getString(R.string.select_a_model_to_start), statusMessage = context.getString(R.string.select_a_model_to_start),
enableModelSelector = true, enableModelSelector = true,
showBenchmarkIcon = 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( BenchmarkState.INITIALIZING -> BenchmarkUIState(
startButtonText = context.getString(R.string.stop_test), startButtonText = context.getString(R.string.stop_test),
@ -88,7 +97,10 @@ class BenchmarkPresenter(
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = 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( BenchmarkState.RUNNING -> BenchmarkUIState(
startButtonText = context.getString(R.string.stop_test), startButtonText = context.getString(R.string.stop_test),
@ -99,7 +111,10 @@ class BenchmarkPresenter(
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = 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( BenchmarkState.STOPPING -> BenchmarkUIState(
startButtonText = context.getString(R.string.stop_test), startButtonText = context.getString(R.string.stop_test),
@ -110,22 +125,24 @@ class BenchmarkPresenter(
statusMessage = context.getString(R.string.benchmark_stopping), statusMessage = context.getString(R.string.benchmark_stopping),
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = true showBenchmarkProgressBar = true,
showProgressCard = true,
showStatusCard = true,
showModelSelectorCard = true // Show model selector card (like iOS)
) )
BenchmarkState.COMPLETED -> BenchmarkUIState( BenchmarkState.COMPLETED -> BenchmarkUIState(
startButtonText = if (useLeaderboardUpload) startButtonText = context.getString(R.string.restart_test), // Changed to "重新评测"
context.getString(R.string.upload_to_leaderboard)
else
context.getString(R.string.share),
startButtonEnabled = true, startButtonEnabled = true,
showProgressBar = false, showProgressBar = false,
showResults = true, showResults = true,
showStatus = false, 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 showBenchmarkIcon = false, // Hide icon when showing results
showBenchmarkProgressBar = false, showBenchmarkProgressBar = false,
showBackButton = true, // Show back button in results view showBackButton = false, // Back button removed, share button in result card instead
showModelSelectorCard = false // Hide model selector card in results view showModelSelectorCard = true, // Show model selector card in results view (like iOS)
showProgressCard = false,
showStatusCard = false // Hide status card when showing results
) )
BenchmarkState.ERROR -> BenchmarkUIState( BenchmarkState.ERROR -> BenchmarkUIState(
startButtonText = context.getString(R.string.start_test), startButtonText = context.getString(R.string.start_test),
@ -135,7 +152,10 @@ class BenchmarkPresenter(
showStatus = false, showStatus = false,
enableModelSelector = true, enableModelSelector = true,
showBenchmarkIcon = 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( BenchmarkState.ERROR_MODEL_NOT_FOUND -> BenchmarkUIState(
startButtonText = context.getString(R.string.start_test), startButtonText = context.getString(R.string.start_test),
@ -147,7 +167,10 @@ class BenchmarkPresenter(
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = false, showBenchmarkProgressBar = false,
statusMessage = context.getString(R.string.no_models_found), 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
)
} }
applyUIState(uiState) applyUIState(uiState)
@ -157,7 +180,7 @@ class BenchmarkPresenter(
* Apply UI state to view * Apply UI state to view
*/ */
private fun applyUIState(uiState: BenchmarkUIState) { 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.setStartButtonText(uiState.startButtonText)
view.setStartButtonEnabled(uiState.startButtonEnabled) view.setStartButtonEnabled(uiState.startButtonEnabled)
@ -194,6 +217,10 @@ class BenchmarkPresenter(
// Apply new button layout controls // Apply new button layout controls
view.updateButtonLayout(uiState.showBackButton) view.updateButtonLayout(uiState.showBackButton)
view.showModelSelectorCard(uiState.showModelSelectorCard) view.showModelSelectorCard(uiState.showModelSelectorCard)
// Apply progress and status cards
view.showProgressCard(uiState.showProgressCard)
view.showStatusCard(uiState.showStatusCard)
} }
override fun onDestroy() { override fun onDestroy() {
@ -217,12 +244,13 @@ class BenchmarkPresenter(
} }
} }
BenchmarkState.COMPLETED -> { BenchmarkState.COMPLETED -> {
if (useLeaderboardUpload) { Log.d(TAG, "In COMPLETED state, restarting benchmark")
Log.d(TAG, "In COMPLETED state, uploading to leaderboard") // Restart benchmark instead of sharing
view.uploadToLeaderboard() if (stateMachine.canStart()) {
Log.d(TAG, "Restarting benchmark...")
startBenchmark()
} else { } else {
Log.d(TAG, "In COMPLETED state, sharing result card") Log.w(TAG, "Cannot restart benchmark in state: $currentState")
view.shareResultCard()
} }
} }
BenchmarkState.RUNNING, BenchmarkState.INITIALIZING -> { BenchmarkState.RUNNING, BenchmarkState.INITIALIZING -> {
@ -471,7 +499,10 @@ class BenchmarkPresenter(
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = true, showBenchmarkProgressBar = true,
benchmarkProgress = 10 // 5% start + 5% initialization benchmarkProgress = 10, // 10% for entering running state
showModelSelectorCard = true,
showProgressCard = true, // 关键修复:显示进度卡片
showStatusCard = true // 关键修复:显示状态卡片
) )
applyUIState(runningUIState) applyUIState(runningUIState)
@ -492,6 +523,7 @@ class BenchmarkPresenter(
// Calculate real progress based on token processing // Calculate real progress based on token processing
val realProgress = calculateRealProgress(progress) val realProgress = calculateRealProgress(progress)
Log.d(TAG, "onProgress: calculated realProgress=$realProgress for progressType=${progress.progressType}, nativeProgress=${progress.progress}")
// Update UI with real progress // Update UI with real progress
val uiState = when (currentState) { val uiState = when (currentState) {
@ -504,7 +536,10 @@ class BenchmarkPresenter(
enableModelSelector = false, enableModelSelector = false,
showBenchmarkIcon = true, showBenchmarkIcon = true,
showBenchmarkProgressBar = true, showBenchmarkProgressBar = true,
benchmarkProgress = realProgress benchmarkProgress = realProgress,
showModelSelectorCard = true,
showProgressCard = true, // 关键修复:显示进度卡片
showStatusCard = true // 关键修复:显示状态卡片
) )
else -> return else -> return
} }
@ -513,6 +548,34 @@ class BenchmarkPresenter(
// Format progress message based on structured data // Format progress message based on structured data
val formattedProgress = formatProgressMessage(progress) val formattedProgress = formatProgressMessage(progress)
view.updateProgress(formattedProgress) 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}") Log.d(TAG, "Benchmark Progress (${progress.progress}% -> ${realProgress}% real): ${formattedProgress.statusMessage}")
} }
@ -641,38 +704,67 @@ class BenchmarkPresenter(
} }
/** /**
* Calculate real progress based on token processing * Calculate real progress based on benchmark state
* - Start: 5% * - Running state start: 10%
* - Initialization: 5% (total 10%) * - After warming up: at least 20%
* - Running: 90% based on token progress * - Remaining realProgress distributed over remaining 80%
*/ */
private fun calculateRealProgress(progress: BenchmarkProgress): Int { private fun calculateRealProgress(progress: BenchmarkProgress): Int {
// Base progress: 5% for start + 5% for initialization = 10% Log.d(TAG, "calculateRealProgress: progressType=${progress.progressType}, nativeProgress=${progress.progress}, currentIteration=${progress.currentIteration}, totalIterations=${progress.totalIterations}")
val baseProgress = 10
// If we have iteration information, calculate based on that // Base progress for entering RUNNING state: 10%
if (progress.totalIterations > 0 && progress.currentIteration >= 0) { val runningStateStart = 10
val iterationProgress = (progress.currentIteration.toFloat() / progress.totalIterations.toFloat() * 90).toInt()
return (baseProgress + iterationProgress).coerceIn(10, 100) // 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() * remainingProgressRange).toInt()
(afterWarmupMin + iterationProgress).coerceIn(afterWarmupMin, 100)
}
// If we have token information, calculate based on tokens
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 * remainingProgressRange).toInt()
(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
}
} }
// If we have token information, calculate based on tokens Log.d(TAG, "calculateRealProgress result: $finalProgress")
if (progress.nPrompt > 0 && progress.nGenerate > 0) { return finalProgress
// 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
val nativeProgress = progress.progress.coerceIn(0, 100)
val scaledProgress = (nativeProgress / 100.0f * 90).toInt()
return (baseProgress + scaledProgress).coerceIn(10, 100)
}
// 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)
} }
/** /**

View File

@ -24,6 +24,8 @@ object BenchmarkResultsHelper {
var totalTokensProcessed = 0 var totalTokensProcessed = 0
var configText = context.getString(R.string.benchmark_config) + "\n" var configText = context.getString(R.string.benchmark_config) + "\n"
var totalTimeSeconds = 0.0
testResults.forEach { testInstance -> testResults.forEach { testInstance ->
// Calculate speeds for this test instance // Calculate speeds for this test instance
if (testInstance.prefillUs.isNotEmpty()) { if (testInstance.prefillUs.isNotEmpty()) {
@ -38,6 +40,11 @@ object BenchmarkResultsHelper {
totalTokensProcessed += testInstance.nPrompt + testInstance.nGenerate totalTokensProcessed += testInstance.nPrompt + testInstance.nGenerate
configText += "PP: ${testInstance.nPrompt} • TG: ${testInstance.nGenerate}\n" 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}") android.util.Log.d("BenchmarkResultsHelper", "Processing results: prefillSpeeds=${allPrefillSpeeds.size}, decodeSpeeds=${allDecodeSpeeds.size}")
@ -75,7 +82,8 @@ object BenchmarkResultsHelper {
prefillStats = prefillStats, prefillStats = prefillStats,
decodeStats = decodeStats, decodeStats = decodeStats,
totalTokensProcessed = totalTokensProcessed, totalTokensProcessed = totalTokensProcessed,
totalTests = testResults.size totalTests = testResults.size,
totalTimeSeconds = totalTimeSeconds
) )
} }
@ -93,14 +101,14 @@ object BenchmarkResultsHelper {
* Format speed statistics for display * Format speed statistics for display
*/ */
fun formatSpeedStatistics(stats: SpeedStatistics): String { 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 { 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 * Format speed value (average only) for display
*/ */
fun formatSpeedValue(stats: SpeedStatistics): String { 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. * 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". * 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 prefillStats: SpeedStatistics?,
val decodeStats: SpeedStatistics?, val decodeStats: SpeedStatistics?,
val totalTokensProcessed: Int, val totalTokensProcessed: Int,
val totalTests: Int val totalTests: Int,
val totalTimeSeconds: Double
) { ) {
companion object { companion object {
fun empty() = BenchmarkStatistics( fun empty() = BenchmarkStatistics(
@ -185,7 +228,8 @@ data class BenchmarkStatistics(
prefillStats = null, prefillStats = null,
decodeStats = null, decodeStats = null,
totalTokensProcessed = 0, totalTokensProcessed = 0,
totalTests = 0 totalTests = 0,
totalTimeSeconds = 0.0
) )
} }
} }

View File

@ -150,5 +150,7 @@ data class BenchmarkUIState(
val showBenchmarkProgressBar: Boolean = false, val showBenchmarkProgressBar: Boolean = false,
val benchmarkProgress: Int = 0, val benchmarkProgress: Int = 0,
val showBackButton: Boolean = false, val showBackButton: Boolean = false,
val showModelSelectorCard: Boolean = true val showModelSelectorCard: Boolean = true,
val showProgressCard: Boolean = false,
val showStatusCard: Boolean = false
) )

View File

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

View File

@ -166,11 +166,8 @@ class ChatActivity : AppCompatActivity() {
private fun setupInputModule() { private fun setupInputModule() {
this.chatInputModule!!.apply { this.chatInputModule!!.apply {
setOnThinkingModeChanged {isThinking -> setOnThinkingModeChanged {isThinking ->
(chatSession as LlmSession).updateAssistantPrompt(if (isThinking) { Log.d(TAG, "isThinking: $isThinking")
"<|im_start|>assistant\n%s<|im_end|>\n" (chatSession as LlmSession).updateThinking(isThinking)
} else {
"<|im_start|>assistant\n<think>\n</think>%s<|im_end|>\n"
})
} }
setOnAudioOutputModeChanged { setOnAudioOutputModeChanged {
chatPresenter.setEnableAudioOutput(it) chatPresenter.setEnableAudioOutput(it)
@ -326,6 +323,7 @@ class ChatActivity : AppCompatActivity() {
} }
fun onLoadingChanged(loading: Boolean) { fun onLoadingChanged(loading: Boolean) {
isLoading = loading
this.chatInputModule!!.onLoadingStatesChanged(loading) this.chatInputModule!!.onLoadingStatesChanged(loading)
layoutModelLoading!!.visibility = layoutModelLoading!!.visibility =
if (loading) View.VISIBLE else View.GONE 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 val sessionDebugInfo: String
get() = chatSession!!.debugInfo get() = chatSession!!.debugInfo

View File

@ -178,9 +178,7 @@ class GenerateResultProcessor {
private fun formatAndSetGptOssThinkingContent(content: String) { private fun formatAndSetGptOssThinkingContent(content: String) {
if (content.isNotBlank()) { if (content.isNotBlank()) {
thinkingStringBuilder.append("\n> ") thinkingStringBuilder.append(content)
thinkingStringBuilder.append(content.replace("\n", "\n> "))
thinkingStringBuilder.append("\n")
} }
} }
@ -223,7 +221,7 @@ class GenerateResultProcessor {
val text = buffer.substring(0, effectiveEndIndex) val text = buffer.substring(0, effectiveEndIndex)
if (text.isNotEmpty()) { if (text.isNotEmpty()) {
thinkingStringBuilder.append(text.replace("\n", "\n> ")) thinkingStringBuilder.append(text)
thinkHasContent = true thinkHasContent = true
} }
@ -268,7 +266,7 @@ class GenerateResultProcessor {
// 3. Add the pending text and the current text to the thinking block. // 3. Add the pending text and the current text to the thinking block.
val textToThink = pendingTextBuffer.toString() + textBefore val textToThink = pendingTextBuffer.toString() + textBefore
if (textToThink.isNotEmpty()) { if (textToThink.isNotEmpty()) {
thinkingStringBuilder.append(textToThink.replace("\n", "\n> ")) thinkingStringBuilder.append(textToThink)
thinkHasContent = true thinkHasContent = true
} }
@ -295,9 +293,6 @@ class GenerateResultProcessor {
isThinking = true isThinking = true
if (!hasThought) { if (!hasThought) {
hasThought = true 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 getRawResult(): String = rawStringBuilder.toString()
fun getThinkingContent(): String { fun getThinkingContent(): String {
return if (currentFormat == StreamFormat.THINK_TAGS) { val thinkingContent = if (currentFormat == StreamFormat.THINK_TAGS) {
if (thinkHasContent) thinkingStringBuilder.toString() else "" if (thinkHasContent) thinkingStringBuilder.toString() else ""
} else { } else {
thinkingStringBuilder.toString() thinkingStringBuilder.toString()
} }
return if (thinkingContent.isNotBlank()) thinkingContent else ""
} }
fun getNormalOutput(): String = normalStringBuilder.toString() fun getNormalOutput(): String = normalStringBuilder.toString()

View File

@ -142,6 +142,8 @@ object ChatViewHolders {
RecyclerView.ViewHolder(view), View.OnClickListener, OnLongClickListener { RecyclerView.ViewHolder(view), View.OnClickListener, OnLongClickListener {
private val viewText: TextView = view.findViewById(R.id.tv_chat_text) private val viewText: TextView = view.findViewById(R.id.tv_chat_text)
private val viewThinking: TextView = view.findViewById(R.id.tv_chat_thinking) 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 benchmarkInfo: TextView = view.findViewById(R.id.tv_chat_benchmark)
private val thinkingToggle: LinearLayout = view.findViewById(R.id.ll_thinking_toggle) private val thinkingToggle: LinearLayout = view.findViewById(R.id.ll_thinking_toggle)
private val textThinkingHeader:TextView = view.findViewById(R.id.tv_thinking_header) 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()) textThinkingHeader.resources.getString(R.string.r1_think_complete_template, (data.thinkingFinishedTime / 1000).toString())
else textThinkingHeader.resources.getString(R.string.r1_thinking_message) else textThinkingHeader.resources.getString(R.string.r1_thinking_message)
if (showThinking && !TextUtils.isEmpty(data.thinkingText)) { if (showThinking && !TextUtils.isEmpty(data.thinkingText)) {
val thinkingText = data.thinkingText!!
thinkingContainer.visibility = View.VISIBLE
viewThinking.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) ivThinkingHeader.setImageResource(R.drawable.ic_arrow_up)
} else { } else {
ivThinkingHeader.setImageResource(R.drawable.ic_arrow_down) ivThinkingHeader.setImageResource(R.drawable.ic_arrow_down)
viewThinking.visibility = View.GONE viewThinking.visibility = View.GONE
thinkingContainer.visibility = View.GONE
// Reset marker visible for next binds by default
thinkingMarker.visibility = View.VISIBLE
} }
} }

View File

@ -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.chatlist.ChatViewHolders
import com.alibaba.mnnllm.android.chat.model.ChatDataItem import com.alibaba.mnnllm.android.chat.model.ChatDataItem
import com.alibaba.mnnllm.android.databinding.ActivityChatBinding 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.utils.KeyboardUtils
import com.alibaba.mnnllm.android.model.ModelUtils import com.alibaba.mnnllm.android.model.ModelUtils
import com.alibaba.mnnllm.android.utils.Permissions.REQUEST_RECORD_AUDIO_PERMISSION import com.alibaba.mnnllm.android.utils.Permissions.REQUEST_RECORD_AUDIO_PERMISSION
import java.util.Date import java.util.Date
import com.alibaba.mnnllm.android.modelist.ModelListManager
import com.alibaba.mnnllm.android.modelsettings.ModelConfig
class ChatInputComponent( class ChatInputComponent(
private val chatActivity: ChatActivity, private val chatActivity: ChatActivity,
@ -87,6 +90,9 @@ class ChatInputComponent(
private fun setupToggleAudioOutput() { private fun setupToggleAudioOutput() {
binding.btnToggleAudioOutput.setOnClickListener { binding.btnToggleAudioOutput.setOnClickListener {
if (chatActivity.isLoading) {
return@setOnClickListener
}
if (!binding.btnToggleAudioOutput.isSelected) { if (!binding.btnToggleAudioOutput.isSelected) {
android.app.AlertDialog.Builder(chatActivity) android.app.AlertDialog.Builder(chatActivity)
.setMessage(R.string.audio_output_confirm) .setMessage(R.string.audio_output_confirm)
@ -117,13 +123,18 @@ class ChatInputComponent(
} }
private fun setupThinkingMode() { private fun setupThinkingMode() {
binding.btnToggleThinking.visibility = if (ModelUtils.isSupportThinkingSwitch(currentModelName)) { val extraTags = ModelListManager.getExtraTags(currentModelId)
binding.btnToggleThinking.isSelected = true binding.btnToggleThinking.visibility = if (ModelUtils.isSupportThinkingSwitchByTags(extraTags)) {
binding.btnToggleThinking.isSelected = ModelConfig.loadConfig(currentModelId)?.jinja?.context?.enableThinking != false
View.VISIBLE View.VISIBLE
} else { } else {
View.GONE View.GONE
} }
binding.btnToggleThinking.setOnClickListener { binding.btnToggleThinking.setOnClickListener {
Log.d(TAG, "handleSendClick isGenerating : ${chatActivity.isLoading}")
if (chatActivity.isLoading) {
return@setOnClickListener
}
binding.btnToggleThinking.isSelected = !binding.btnToggleThinking.isSelected binding.btnToggleThinking.isSelected = !binding.btnToggleThinking.isSelected
onThinkingModeChanged?.apply { onThinkingModeChanged?.apply {
this(binding.btnToggleThinking.isSelected) this(binding.btnToggleThinking.isSelected)
@ -268,7 +279,8 @@ class ChatInputComponent(
} }
override fun onLeaveRecordingMode() { override fun onLeaveRecordingMode() {
if (ModelUtils.isSupportThinkingSwitch(currentModelName)) { val extraTags = ModelListManager.getExtraTags(currentModelId)
if (ModelUtils.isSupportThinkingSwitchByTags(extraTags)) {
binding.btnToggleThinking.visibility = View.VISIBLE binding.btnToggleThinking.visibility = View.VISIBLE
} }
updateAudioOutput() updateAudioOutput()
@ -322,6 +334,7 @@ class ChatInputComponent(
if (!loading && ModelUtils.isAudioModel(currentModelName)) { if (!loading && ModelUtils.isAudioModel(currentModelName)) {
voiceRecordingModule.onEnabled() voiceRecordingModule.onEnabled()
} }
} }
fun onRequestPermissionsResult( fun onRequestPermissionsResult(

View File

@ -243,7 +243,12 @@ class ChatDataManager private constructor(context: Context) {
cursor.getString(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MODEL_ID)) cursor.getString(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_MODEL_ID))
val name = val name =
cursor.getString(cursor.getColumnIndex(ChatDatabaseHelper.COLUMN_SESSION_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() cursor.close()
} }

View File

@ -4,4 +4,5 @@ package com.alibaba.mnnllm.android.chat.model
class SessionItem(@JvmField val sessionId: String, class SessionItem(@JvmField val sessionId: String,
@JvmField val modelId: String, @JvmField val modelId: String,
@JvmField var title: String) @JvmField var title: String,
@JvmField val lastChatTime: Long = 0L)

View File

@ -453,6 +453,14 @@ class VoiceChatPresenter(
// Reset generation state // Reset generation state
isGenerationFinished = false 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 // Unregister from ChatPresenter
chatPresenter.removeGenerateListener(this) chatPresenter.removeGenerateListener(this)
@ -495,7 +503,15 @@ class VoiceChatPresenter(
if (isProcessingLlm || isSpeaking) { if (isProcessingLlm || isSpeaking) {
isStoppingGeneration = true isStoppingGeneration = true
isGenerationFinished = false 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() audioPlayer?.stop()
isProcessingLlm = false isProcessingLlm = false
isSpeaking = false isSpeaking = false

View File

@ -5,10 +5,16 @@ package com.alibaba.mnnllm.android.history
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.alibaba.mnnllm.android.R import com.alibaba.mnnllm.android.R
import com.alibaba.mnnllm.android.chat.model.SessionItem 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>() { class HistoryListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
private var historySessionList: MutableList<SessionItem>? = null 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 { class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), View.OnClickListener {
var textHistory: TextView var textHistory: TextView
var textTimestamp: TextView
var textModelName: TextView
var modelAvatarView: ImageView
var viewDelete: View var viewDelete: View
private var onHistoryCallback: OnHistoryCallback? = null private var onHistoryCallback: OnHistoryCallback? = null
@ -66,27 +75,103 @@ class HistoryListAdapter : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
this.viewDelete = itemView.findViewById(R.id.iv_delete_history) this.viewDelete = itemView.findViewById(R.id.iv_delete_history)
viewDelete.setOnClickListener(this) viewDelete.setOnClickListener(this)
textHistory = itemView.findViewById(R.id.text_history) 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) { fun bind(sessionItem: SessionItem) {
textHistory.text = sessionItem.title textHistory.text = sessionItem.title
textTimestamp.text = formatTimestamp(sessionItem.lastChatTime)
setModelAvatar(sessionItem.modelId)
textModelName.text = getModelDisplayName(sessionItem.modelId)
itemView.tag = sessionItem itemView.tag = sessionItem
viewDelete.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) { override fun onClick(v: View) {
val sessionItem = v.tag as SessionItem val sessionItem = v.tag as SessionItem
if (v.id == R.id.iv_delete_history) { if (v.id == R.id.iv_delete_history) {
if (onHistoryCallback != null) { showDeleteConfirmDialog(sessionItem)
onHistoryCallback!!.onSessionHistoryDelete(sessionItem) } else {
}
} else { //itemView
if (onHistoryCallback != null) { if (onHistoryCallback != null) {
onHistoryCallback!!.onSessionHistoryClick(sessionItem) 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?) { fun setOnHistoryClick(onHistoryCallback: OnHistoryCallback?) {
this.onHistoryCallback = onHistoryCallback this.onHistoryCallback = onHistoryCallback
} }

View File

@ -20,4 +20,5 @@ interface ChatSession {
fun setEnableAudioOutput(enable: Boolean) fun setEnableAudioOutput(enable: Boolean)
fun getHistory(): List<ChatDataItem>? fun getHistory(): List<ChatDataItem>?
fun setHistory(history:List<ChatDataItem>?) fun setHistory(history:List<ChatDataItem>?)
fun updateThinking(thinking: Boolean)
} }

View File

@ -122,6 +122,9 @@ class DiffusionSession(
savedHistory = history savedHistory = history
} }
override fun updateThinking(thinking: Boolean) {
}
private external fun initNative( private external fun initNative(
configPath: String, configPath: String,

View File

@ -18,6 +18,10 @@ import android.util.Pair
import com.alibaba.mnnllm.android.utils.MmapUtils import com.alibaba.mnnllm.android.utils.MmapUtils
import android.content.Context import android.content.Context
import android.app.ActivityManager 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 ( class LlmSession (
private val modelId: String, private val modelId: String,
@ -25,7 +29,6 @@ class LlmSession (
private val configPath: String, private val configPath: String,
var savedHistory: List<ChatDataItem>?, var savedHistory: List<ChatDataItem>?,
): ChatSession{ ): ChatSession{
private var extraAssistantPrompt: String? = null
override var supportOmni: Boolean = false override var supportOmni: Boolean = false
private var nativePtr: Long = 0 private var nativePtr: Long = 0
@ -66,16 +69,12 @@ class LlmSession (
rootCacheDir = MmapUtils.getMmapDir(modelId) rootCacheDir = MmapUtils.getMmapDir(modelId)
File(rootCacheDir).mkdirs() File(rootCacheDir).mkdirs()
} }
val backend = config.backendType
val configMap = HashMap<String, Any>().apply { val configMap = HashMap<String, Any>().apply {
put("is_r1", ModelUtils.isR1Model(modelId)) put("is_r1", ModelUtils.isR1Model(modelId))
put("mmap_dir", rootCacheDir ?: "") put("mmap_dir", rootCacheDir ?: "")
put("keep_history", keepHistory) put("keep_history", keepHistory)
} }
val extraConfig = ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))?.apply { val extraConfig = ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))
this.assistantPromptTemplate = extraAssistantPrompt
this.backendType = backend
}
Log.d(TAG, "MNN_DEBUG load initNative") Log.d(TAG, "MNN_DEBUG load initNative")
nativePtr = initNative( nativePtr = initNative(
configPath, configPath,
@ -94,6 +93,10 @@ class LlmSession (
} }
} }
fun getConfig(): ModelConfig? {
return ModelConfig.loadMergedConfig(configPath, getExtraConfigFile(modelId))
}
private fun generateNewSessionId(): String { private fun generateNewSessionId(): String {
this.sessionId = System.currentTimeMillis().toString() this.sessionId = System.currentTimeMillis().toString()
return this.sessionId return this.sessionId
@ -209,9 +212,18 @@ class LlmSession (
updateSystemPromptNative(nativePtr, systemPrompt) updateSystemPromptNative(nativePtr, systemPrompt)
} }
fun updateAssistantPrompt(assistantPrompt: String) { override fun updateThinking(thinking: Boolean) {
extraAssistantPrompt = assistantPrompt val loadedConfig = loadConfig(modelId)
updateAssistantPromptNative(nativePtr, assistantPrompt) 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) 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 updateAssistantPromptNative(llmPtr: Long, assistantPrompt: String)
private external fun updateConfigNative(llmPtr: Long, configJson: String)
companion object { companion object {
const val TAG: String = "LlmSession" const val TAG: String = "LlmSession"

View File

@ -19,8 +19,15 @@ import com.alibaba.mnnllm.android.modelist.ModelListManager
import com.alibaba.mnnllm.android.modelsettings.ModelConfig import com.alibaba.mnnllm.android.modelsettings.ModelConfig
object ModelUtils { object ModelUtils {
@Deprecated("Use ModelMarketItem.vendor field instead for market models")
fun getVendor(modelName: String):String { 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()) val modelLower = modelName.lowercase(Locale.getDefault())
if (modelLower.contains("deepseek")) { if (modelLower.contains("deepseek")) {
return ModelVendors.DeepSeek return ModelVendors.DeepSeek
@ -51,6 +58,21 @@ object ModelUtils {
} else if (modelLower.contains("openelm")) { } else if (modelLower.contains("openelm")) {
return ModelVendors.OpenElm return ModelVendors.OpenElm
} else { } 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 return ModelVendors.Others
} }
} }
@ -188,8 +210,8 @@ object ModelUtils {
return modelName.lowercase(Locale.getDefault()).contains("omni") return modelName.lowercase(Locale.getDefault()).contains("omni")
} }
fun isSupportThinkingSwitch(modelName: String): Boolean { fun isSupportThinkingSwitchByTags(extraTags: List<String>): Boolean {
return isQwen3(modelName) return extraTags.any { it.equals("ThinkingSwitch", ignoreCase = true) }
} }
fun supportAudioOutput(modelName: String): Boolean { fun supportAudioOutput(modelName: String): Boolean {

View File

@ -37,6 +37,14 @@ object ModelListManager {
return modelItem?.getTags() ?: emptyList() 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 * Check if a model is a thinking model by examining its tags
*/ */
@ -90,8 +98,7 @@ object ModelListManager {
val modelItem = ModelItem.fromDownloadModel(context, downloadedModel.modelId, downloadedModel.modelPath) val modelItem = ModelItem.fromDownloadModel(context, downloadedModel.modelId, downloadedModel.modelPath)
// Set market item data if available // Set market item data if available
modelItem.modelMarketItem = ModelMarketUtils.readMarketConfig(downloadedModel.modelId) modelItem.modelMarketItem = ModelMarketUtils.readMarketConfig(downloadedModel.modelId)
// Calculate download size
// Calculate download size
val downloadSize = try { val downloadSize = try {
val file = File(downloadedModel.modelPath) val file = File(downloadedModel.modelPath)
if (file.exists()) file.length() else 0L if (file.exists()) file.length() else 0L
@ -125,6 +132,9 @@ object ModelListManager {
// Load market tags for local model // Load market tags for local model
localModel.loadMarketTags(context) localModel.loadMarketTags(context)
// Set market item data if available (same as downloaded models)
localModel.modelMarketItem = ModelMarketUtils.readMarketConfig(localModel.modelId!!)
// Calculate local model size // Calculate local model size
val localSize = try { val localSize = try {
@ -166,6 +176,8 @@ object ModelListManager {
// Clear and cache modelId model to a map // Clear and cache modelId model to a map
modelIdModelMap.clear() modelIdModelMap.clear()
sortedModels.forEach { 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 modelIdModelMap[it.modelItem.modelId!!] = it.modelItem
} }
return@withContext sortedModels return@withContext sortedModels

View File

@ -12,6 +12,7 @@ data class ModelMarketItem(
val sources: Map<String, String>, val sources: Map<String, String>,
val description: String? = null, val description: String? = null,
@SerializedName("file_size") val fileSize: Long = 0L, // File size in bytes from model_market.json @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 currentSource: String = "", // e.g. "modelscope", "huggingface"
var currentRepoPath: String = "", // e.g. "MNN/Qwen-1.8B-Chat-Int4" var currentRepoPath: String = "", // e.g. "MNN/Qwen-1.8B-Chat-Int4"
var modelId: String = "" // e.g. "ModelScope/MNN/Qwen-1.8B-Chat-Int4" var modelId: String = "" // e.g. "ModelScope/MNN/Qwen-1.8B-Chat-Int4"

View File

@ -8,6 +8,7 @@ import com.alibaba.mls.api.ApplicationProvider
import com.alibaba.mnnllm.android.modelsettings.ModelConfig import com.alibaba.mnnllm.android.modelsettings.ModelConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.JsonParser
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
@ -16,16 +17,19 @@ object ModelMarketUtils {
const val TAG = "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' // Create a map with all fields except 'sources', and add 'modelId'
val configMap = mutableMapOf<String, Any?>() val configMap = mutableMapOf<String, Any?>()
configMap["modelName"] = modelItem.modelName configMap["modelName"] = modelItem.modelName
configMap["vendor"] = modelItem.vendor configMap["vendor"] = modelItem.vendor
configMap["size_gb"] = modelItem.sizeB configMap["size_gb"] = modelItem.sizeB
configMap["tags"] = modelItem.tags configMap["tags"] = modelItem.tags
configMap["extra_tags"] = modelItem.extraTags
configMap["categories"] = modelItem.categories configMap["categories"] = modelItem.categories
configMap["description"] = modelItem.description configMap["description"] = modelItem.description
configMap["modelId"] = modelItem.modelId configMap["modelId"] = modelItem.modelId
// Record current market data version
configMap["market_version"] = marketVersion
// Pretty-print JSON // Pretty-print JSON
val gson = GsonBuilder().setPrettyPrinting().create() val gson = GsonBuilder().setPrettyPrinting().create()
val jsonString = gson.toJson(configMap) val jsonString = gson.toJson(configMap)
@ -43,25 +47,56 @@ object ModelMarketUtils {
suspend fun readMarketConfig(modelId:String):ModelMarketItem? { suspend fun readMarketConfig(modelId:String):ModelMarketItem? {
Log.d(TAG, "readMarketConfig for $modelId")
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
var marketItem: ModelMarketItem? = readMarketConfigFromLocal(modelId) var marketItem: ModelMarketItem? = null
if (marketItem == null) { try {
//read from ModelRepository val context = ApplicationProvider.get()
val repository = ModelRepository(context)
// 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 { try {
val context = ApplicationProvider.get()
val repository = ModelRepository(context)
// Get all models from different categories and find the one with matching modelId
val allModels = mutableListOf<ModelMarketItem>() val allModels = mutableListOf<ModelMarketItem>()
allModels.addAll(repository.getModels()) allModels.addAll(repository.getModels())
allModels.addAll(repository.getTtsModels()) allModels.addAll(repository.getTtsModels())
allModels.addAll(repository.getAsrModels()) allModels.addAll(repository.getAsrModels())
marketItem = allModels.find { it.modelId == modelId } marketItem = allModels.find { it.modelId == modelId }
if (marketItem != null) { 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) { } catch (e: Exception) {
Log.w(TAG, "Failed to read from ModelRepository for $modelId", e) 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 marketItem
} }
@ -84,4 +119,20 @@ object ModelMarketUtils {
marketItem 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
}
}
}
} }

View File

@ -94,6 +94,16 @@ class ModelRepository(private val context: Context) {
val cacheData = loadFromCache() val cacheData = loadFromCache()
if (cacheData != null) { if (cacheData != null) {
Log.d(TAG, "Successfully loaded data from local cache") 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 cachedModelMarketData = cacheData
return@withContext cacheData return@withContext cacheData
} }
@ -151,6 +161,37 @@ class ModelRepository(private val context: Context) {
return@withContext null 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) { private suspend fun loadFromCache(): ModelMarketData? = withContext(Dispatchers.IO) {
// If not allowed to use network, skip cache and use assets // If not allowed to use network, skip cache and use assets
if (!isAllowNetwork(context)) { if (!isAllowNetwork(context)) {

View File

@ -15,6 +15,14 @@ import com.google.gson.JsonParser
import java.io.File import java.io.File
import com.google.gson.annotations.SerializedName 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( data class ModelConfig(
@SerializedName("llm_model") var llmModel: String?, @SerializedName("llm_model") var llmModel: String?,
@SerializedName("llm_weight") var llmWeight: String?, @SerializedName("llm_weight") var llmWeight: String?,
@ -37,7 +45,8 @@ data class ModelConfig(
@SerializedName("ngram_factor")var nGramFactor:Float?, @SerializedName("ngram_factor")var nGramFactor:Float?,
@SerializedName("max_new_tokens")var maxNewTokens:Int?, @SerializedName("max_new_tokens")var maxNewTokens:Int?,
@SerializedName("assistant_prompt_template")var assistantPromptTemplate:String?, @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 { fun deepCopy(): ModelConfig {
return ModelConfig( return ModelConfig(
@ -62,7 +71,10 @@ data class ModelConfig(
maxNewTokens = this.maxNewTokens, maxNewTokens = this.maxNewTokens,
assistantPromptTemplate = this.assistantPromptTemplate, assistantPromptTemplate = this.assistantPromptTemplate,
penaltySampler = this.penaltySampler, 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 { fun toJson(): String {
return Gson().toJson(this) return GsonBuilder()
.disableHtmlEscaping()
.create()
.toJson(this)
} }
fun saveConfig(filePath: String, config: ModelConfig): Boolean { fun saveConfig(filePath: String, config: ModelConfig): Boolean {
@ -153,7 +168,10 @@ data class ModelConfig(
Log.d(TAG, "file is : $filePath") Log.d(TAG, "file is : $filePath")
val file = File(filePath) val file = File(filePath)
FileUtils.ensureParentDirectoriesExist(file) FileUtils.ensureParentDirectoriesExist(file)
val gson = GsonBuilder().setPrettyPrinting().create() val gson = GsonBuilder()
.setPrettyPrinting()
.disableHtmlEscaping()
.create()
val jsonString = gson.toJson(config) val jsonString = gson.toJson(config)
file.writeText(jsonString) file.writeText(jsonString)
true true
@ -201,7 +219,8 @@ data class ModelConfig(
maxNewTokens = 2048, maxNewTokens = 2048,
assistantPromptTemplate = "", assistantPromptTemplate = "",
penaltySampler = "greedy", penaltySampler = "greedy",
useMmap = false useMmap = false,
jinja = null
) )
} }

View File

@ -4,6 +4,7 @@ import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.TextView import android.widget.TextView
import com.alibaba.mnnllm.android.R import com.alibaba.mnnllm.android.R
@ -18,6 +19,7 @@ class ModelAvatarView @JvmOverloads constructor(
private val tvModelName: TextView private val tvModelName: TextView
private val headerIcon: ImageView private val headerIcon: ImageView
private var isCompactMode: Boolean = false
init { init {
LayoutInflater.from(context).inflate(R.layout.view_model_avatar, this, true) LayoutInflater.from(context).inflate(R.layout.view_model_avatar, this, true)
@ -35,9 +37,11 @@ class ModelAvatarView @JvmOverloads constructor(
try { try {
val modelName = typedArray.getString(R.styleable.ModelAvatarView_modelName) val modelName = typedArray.getString(R.styleable.ModelAvatarView_modelName)
val compactMode = typedArray.getBoolean(R.styleable.ModelAvatarView_compactMode, false)
if (!modelName.isNullOrEmpty()) { if (!modelName.isNullOrEmpty()) {
setModelName(modelName) setModelName(modelName)
} }
setCompactMode(compactMode)
} finally { } finally {
typedArray.recycle() typedArray.recycle()
} }
@ -67,15 +71,20 @@ class ModelAvatarView @JvmOverloads constructor(
) else headerText ) else headerText
tvModelName.visibility = View.VISIBLE tvModelName.visibility = View.VISIBLE
} }
// }
// if (name.contains("qwen", ignoreCase = true)) {
// tvModelName.visibility = View.GONE fun setCompactMode(compactMode: Boolean) {
// headerIcon.visibility = View.VISIBLE isCompactMode = compactMode
// headerIcon.setImageResource(R.drawable.qwen_icon) if (compactMode) {
// } else { // 在紧凑模式下移除 ImageView 的 margin 和 CardView 的背景
// tvModelName.visibility = View.VISIBLE val layoutParams = headerIcon.layoutParams as? ViewGroup.MarginLayoutParams
// headerIcon.visibility = View.GONE layoutParams?.setMargins(0, 0, 0, 0)
// tvModelName.text = name.split("-").joinToString("") { it.firstOrNull()?.toString() ?: "" }.take(2).uppercase() headerIcon.layoutParams = layoutParams
// }
// 移除 CardView 的背景
setCardBackgroundColor(android.graphics.Color.TRANSPARENT)
cardElevation = 0f
strokeWidth = 0
}
} }
} }

View File

@ -0,0 +1,132 @@
package com.alibaba.mnnllm.api.openai.manager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.ClipboardManager
import android.content.ClipData
import android.widget.Toast
import android.net.Uri
import com.alibaba.mnnllm.api.openai.service.OpenAIService
import com.alibaba.mnnllm.api.openai.service.ApiServerConfig
import timber.log.Timber
/**
* 处理通知栏操作按钮点击事件的广播接收器
*/
class ApiServiceActionReceiver : BroadcastReceiver() {
companion object {
const val ACTION_STOP_SERVICE = "com.alibaba.mnnllm.api.openai.STOP_SERVICE"
const val ACTION_COPY_URL = "com.alibaba.mnnllm.api.openai.COPY_URL"
const val ACTION_TEST_PAGE = "com.alibaba.mnnllm.api.openai.TEST_PAGE"
const val EXTRA_URL = "extra_url"
}
override fun onReceive(context: Context, intent: Intent) {
Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST RECEIVED ===")
Timber.tag("ApiServiceActionReceiver").i("Action: ${intent.action}")
Timber.tag("ApiServiceActionReceiver").i("Package: ${intent.`package`}")
Timber.tag("ApiServiceActionReceiver").i("Component: ${intent.component}")
Timber.tag("ApiServiceActionReceiver").i("Intent extras: ${intent.extras}")
Timber.tag("ApiServiceActionReceiver").i("Intent data: ${intent.data}")
Timber.tag("ApiServiceActionReceiver").i("Intent flags: ${intent.flags}")
when (intent.action) {
ACTION_STOP_SERVICE -> {
Timber.tag("ApiServiceActionReceiver").i("Processing STOP_SERVICE action")
stopApiService(context)
}
ACTION_COPY_URL -> {
Timber.tag("ApiServiceActionReceiver").i("Processing COPY_URL action")
val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080"
Timber.tag("ApiServiceActionReceiver").i("URL to copy: $url")
copyUrlToClipboard(context, url)
}
ACTION_TEST_PAGE -> {
Timber.tag("ApiServiceActionReceiver").i("Processing TEST_PAGE action")
val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080"
Timber.tag("ApiServiceActionReceiver").i("URL to open: $url")
openTestPageInBrowser(context, url)
}
else -> {
Timber.tag("ApiServiceActionReceiver").w("Unknown action: ${intent.action}")
Timber.tag("ApiServiceActionReceiver").w("Expected actions: $ACTION_STOP_SERVICE, $ACTION_COPY_URL, $ACTION_TEST_PAGE")
}
}
Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST PROCESSING COMPLETE ===")
}
/**
* 停止 API 服务
*/
private fun stopApiService(context: Context) {
Timber.tag("ApiServiceActionReceiver").i("Starting to stop API service...")
try {
Timber.tag("ApiServiceActionReceiver").i("Calling OpenAIService.releaseService()")
OpenAIService.releaseService(context)
Timber.tag("ApiServiceActionReceiver").i("API service stopped successfully")
Toast.makeText(context, "API Service stopped", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to stop API service: ${e.message}")
Toast.makeText(context, "Failed to stop service", Toast.LENGTH_SHORT).show()
}
}
/**
* 复制 URL 到剪贴板
*/
private fun copyUrlToClipboard(context: Context, url: String) {
Timber.tag("ApiServiceActionReceiver").i("Starting to copy URL to clipboard: $url")
try {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
Timber.tag("ApiServiceActionReceiver").i("Clipboard service obtained: $clipboard")
val clip = ClipData.newPlainText("API URL", url)
Timber.tag("ApiServiceActionReceiver").i("ClipData created: $clip")
clipboard.setPrimaryClip(clip)
Timber.tag("ApiServiceActionReceiver").i("URL copied to clipboard successfully")
val successMessage = context.getString(com.alibaba.mnnllm.android.R.string.api_url_copied)
Timber.tag("ApiServiceActionReceiver").i("Showing toast: $successMessage")
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to copy URL to clipboard: ${e.message}")
Toast.makeText(context, "Failed to copy URL", Toast.LENGTH_SHORT).show()
}
}
/**
* 在浏览器中打开测试页面
*/
private fun openTestPageInBrowser(context: Context, url: String) {
Timber.tag("ApiServiceActionReceiver").i("Starting to open test page in browser: $url")
try {
// 检查是否启用了认证如果启用则添加token参数
val finalUrl = if (ApiServerConfig.isAuthEnabled(context)) {
val apiKey = ApiServerConfig.getApiKey(context)
val separator = if (url.contains("?")) "&" else "?"
val urlWithToken = "$url${separator}token=$apiKey"
Timber.tag("ApiServiceActionReceiver").i("Auth enabled, adding token to URL: $urlWithToken")
urlWithToken
} else {
Timber.tag("ApiServiceActionReceiver").i("Auth disabled, using original URL")
url
}
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(finalUrl))
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
Timber.tag("ApiServiceActionReceiver").i("Intent created: $intent")
context.startActivity(intent)
Timber.tag("ApiServiceActionReceiver").i("Test page opened in browser successfully")
Toast.makeText(context, "Opening test page in browser", Toast.LENGTH_SHORT).show()
} catch (e: Exception) {
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to open test page in browser: ${e.message}")
Toast.makeText(context, "Failed to open browser", Toast.LENGTH_SHORT).show()
}
}
}

View File

@ -4,8 +4,11 @@ import android.R
import android.app.Notification import android.app.Notification
import android.app.NotificationChannel import android.app.NotificationChannel
import android.app.NotificationManager import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import com.alibaba.mnnllm.api.openai.service.ApiServerConfig
import timber.log.Timber import timber.log.Timber
/** /**
@ -46,20 +49,86 @@ class ApiNotificationManager(private val context: Context) {
} }
/** /**
* Build API service notification * Build API service notification with IP address and action buttons
* *
* @param contentTitle Notification title, uses default from string resources if not provided * @param contentTitle Notification title, uses default from string resources if not provided
* @param contentText Notification content, uses default from string resources if not provided * @param contentText Notification content, uses default from string resources if not provided
* @param port Server port number
* @return Built Notification object * @return Built Notification object
*/ */
fun buildNotification( fun buildNotification(
contentTitle: String? = null, contentTitle: String? = null,
contentText: String? = null contentText: String? = null,
port: Int = 8080
): Notification { ): Notification {
val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running) val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running)
val text = contentText ?: context.getString(com.alibaba.mnnllm.android.R.string.server_running_message) val ipAddress = ApiServerConfig.getIpAddress(context)
val url = "http://$ipAddress:$port"
val text = if (contentText.isNullOrBlank()) {
context.getString(com.alibaba.mnnllm.android.R.string.api_service_running_on, ipAddress, port)
} else {
contentText
}
return NotificationCompat.Builder(context, CHANNEL_ID) Timber.tag("ApiNotificationManager").i("Building notification - Config IP: $ipAddress, Port: $port, Text: $text")
// 创建停止服务的 PendingIntent
val stopIntent = Intent(ApiServiceActionReceiver.ACTION_STOP_SERVICE).apply {
`package` = context.packageName
}
Timber.tag("ApiNotificationManager").i("Creating stop intent with action: ${ApiServiceActionReceiver.ACTION_STOP_SERVICE}")
Timber.tag("ApiNotificationManager").i("Stop intent package: ${context.packageName}")
val stopPendingIntent = PendingIntent.getBroadcast(
context,
1001,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Timber.tag("ApiNotificationManager").i("Stop PendingIntent created: $stopPendingIntent")
// 创建复制 URL 的 PendingIntent
val copyIntent = Intent(ApiServiceActionReceiver.ACTION_COPY_URL).apply {
`package` = context.packageName
putExtra(ApiServiceActionReceiver.EXTRA_URL, url)
}
Timber.tag("ApiNotificationManager").i("Creating copy intent with action: ${ApiServiceActionReceiver.ACTION_COPY_URL}, URL: $url")
Timber.tag("ApiNotificationManager").i("Copy intent package: ${context.packageName}")
val copyPendingIntent = PendingIntent.getBroadcast(
context,
System.currentTimeMillis().toInt(),
copyIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Timber.tag("ApiNotificationManager").i("Copy PendingIntent created: $copyPendingIntent")
// 创建打开测试页面的 PendingIntent
val testIntent = Intent(ApiServiceActionReceiver.ACTION_TEST_PAGE).apply {
`package` = context.packageName
putExtra(ApiServiceActionReceiver.EXTRA_URL, url)
}
Timber.tag("ApiNotificationManager").i("Creating test intent with action: ${ApiServiceActionReceiver.ACTION_TEST_PAGE}, URL: $url")
Timber.tag("ApiNotificationManager").i("Test intent package: ${context.packageName}")
val testPendingIntent = PendingIntent.getBroadcast(
context,
(System.currentTimeMillis() + 1).toInt(),
testIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Timber.tag("ApiNotificationManager").i("Test PendingIntent created: $testPendingIntent")
// 创建点击通知时打开主Activity的PendingIntent
val mainActivityIntent = Intent(context, com.alibaba.mnnllm.android.main.MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val mainActivityPendingIntent = PendingIntent.getActivity(
context,
(System.currentTimeMillis() + 2).toInt(),
mainActivityIntent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
Timber.tag("ApiNotificationManager").i("MainActivity PendingIntent created: $mainActivityPendingIntent")
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
.setContentTitle(title) .setContentTitle(title)
.setContentText(text) .setContentText(text)
.setSmallIcon(R.drawable.ic_dialog_info) .setSmallIcon(R.drawable.ic_dialog_info)
@ -67,7 +136,30 @@ class ApiNotificationManager(private val context: Context) {
.setOngoing(true) .setOngoing(true)
.setAutoCancel(false) .setAutoCancel(false)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(mainActivityPendingIntent)
// .addAction(
// R.drawable.ic_dialog_alert,
// context.getString(com.alibaba.mnnllm.android.R.string.api_service_stop),
// stopPendingIntent
// )
.addAction(
R.drawable.ic_dialog_info,
context.getString(com.alibaba.mnnllm.android.R.string.api_service_copy_url),
copyPendingIntent
)
.addAction(
R.drawable.ic_dialog_info,
context.getString(com.alibaba.mnnllm.android.R.string.api_service_test_page),
testPendingIntent
)
.build() .build()
Timber.tag("ApiNotificationManager").i("Notification built successfully with ${notification.actions?.size ?: 0} actions")
notification.actions?.forEachIndexed { index, action ->
Timber.tag("ApiNotificationManager").i("Action $index: ${action.title}, Intent: ${action.actionIntent}")
}
return notification
} }
/** /**
@ -75,11 +167,14 @@ class ApiNotificationManager(private val context: Context) {
* *
* @param contentTitle New notification title * @param contentTitle New notification title
* @param contentText New notification content * @param contentText New notification content
* @param port Server port number
*/ */
fun updateNotification(contentTitle: String, contentText: String) { fun updateNotification(contentTitle: String, contentText: String, port: Int = 8080) {
val notification = buildNotification(contentTitle, contentText) Timber.tag("ApiNotificationManager").i("updateNotification called - Title: $contentTitle, Text: $contentText, Port: $port")
Timber.tag("NotificationManager").i("Updating notification: $contentTitle - $contentText") val notification = buildNotification(contentTitle, contentText, port)
Timber.tag("ApiNotificationManager").i("Updating notification: $contentTitle - $contentText")
notificationManager.notify(NOTIFICATION_ID, notification) notificationManager.notify(NOTIFICATION_ID, notification)
Timber.tag("ApiNotificationManager").i("Notification updated with ID: $NOTIFICATION_ID")
} }
/** /**
@ -90,9 +185,10 @@ class ApiNotificationManager(private val context: Context) {
fun cancelNotification() { fun cancelNotification() {
try { try {
notificationManager.cancel(NOTIFICATION_ID) notificationManager.cancel(NOTIFICATION_ID)
Timber.tag("NotificationManager").i("Notification cancelled") Timber.tag("ApiNotificationManager").i("Notification cancelled")
} catch (e: Exception) { } catch (e: Exception) {
Timber.tag("NotificationManager").w("Failed to cancel notification: ${e.message}") Timber.tag("ApiNotificationManager").w("Failed to cancel notification: ${e.message}")
} }
} }
} }

View File

@ -5,6 +5,7 @@ import com.alibaba.mnnllm.android.chat.ChatActivity
import com.alibaba.mnnllm.android.llm.LlmSession import com.alibaba.mnnllm.android.llm.LlmSession
import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes
import com.alibaba.mnnllm.api.openai.network.routes.queueRoutes import com.alibaba.mnnllm.api.openai.network.routes.queueRoutes
import com.alibaba.mnnllm.api.openai.network.routes.modelsRoutes
import io.ktor.http.* import io.ktor.http.*
import io.ktor.serialization.kotlinx.json.* import io.ktor.serialization.kotlinx.json.*
import io.ktor.server.application.* import io.ktor.server.application.*
@ -19,6 +20,8 @@ import io.ktor.server.routing.*
import io.ktor.server.sse.* import io.ktor.server.sse.*
import io.ktor.sse.* import io.ktor.sse.*
import org.slf4j.event.* import org.slf4j.event.*
import android.content.Context
import java.io.InputStream
fun Application.configureRouting() { fun Application.configureRouting() {
@ -26,8 +29,12 @@ fun Application.configureRouting() {
routing { routing {
get("/") { get("/") {
val response = "Hello, World!" try {
call.respondText(response, contentType = ContentType.Text.Plain) val htmlContent = loadHtmlFromAssets()
call.respondText(htmlContent, contentType = ContentType.Text.Html)
} catch (e: Exception) {
call.respondText("Error loading test page: ${e.message}", contentType = ContentType.Text.Plain)
}
} }
sse("/hello") { sse("/hello") {
send(ServerSentEvent("world")) send(ServerSentEvent("world"))
@ -40,6 +47,8 @@ fun Application.configureRouting() {
// 在这里定义需要认证的路由 // 在这里定义需要认证的路由
// /v1/chat/completions // /v1/chat/completions
chatRoutes() chatRoutes()
// /v1/models
modelsRoutes()
} }
} }
@ -59,4 +68,19 @@ fun Application.configureRouting() {
} }
/**
* 从assets目录加载HTML文件
*/
private fun loadHtmlFromAssets(): String {
return try {
// 使用Application Context来访问assets
val context = com.alibaba.mnnllm.android.MnnLlmApplication.getAppContext()
val inputStream: InputStream = context.assets.open("test_page.html")
inputStream.bufferedReader().use { reader ->
reader.readText()
}
} catch (e: Exception) {
throw Exception("Failed to load HTML from assets: ${e.message}")
}
}

View File

@ -64,4 +64,40 @@ data class CompletionChoice(
data class Message( data class Message(
val role: String, val role: String,
val content: String val content: String
)
/**
* Models API 响应数据模型
*/
@Serializable
data class ModelsResponse(
val `object`: String = "list",
val data: List<ModelData>
)
@Serializable
data class ModelData(
val id: String,
val `object`: String = "model",
val created: Long,
val owned_by: String = "mnn",
val permission: List<ModelPermission> = emptyList(),
val root: String? = null,
val parent: String? = null
)
@Serializable
data class ModelPermission(
val id: String,
val `object`: String = "model_permission",
val created: Long,
val allow_create_engine: Boolean = false,
val allow_sampling: Boolean = true,
val allow_logprobs: Boolean = true,
val allow_search_indices: Boolean = true,
val allow_view: Boolean = true,
val allow_fine_tuning: Boolean = false,
val organization: String = "*",
val group: String? = null,
val is_blocking: Boolean = false
) )

View File

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

View File

@ -0,0 +1,26 @@
package com.alibaba.mnnllm.api.openai.network.routes
import com.alibaba.mnnllm.api.openai.network.models.ModelData
import com.alibaba.mnnllm.api.openai.network.models.ModelPermission
import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse
import com.alibaba.mnnllm.api.openai.network.services.MNNModelsService
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import java.util.UUID
/**
* 模型路由定义
* 负责定义 /v1/models API路由
*/
/**
* 注册模型相关的路由
*/
fun Route.modelsRoutes() {
val modelsService = MNNModelsService()
get("/v1/models") {
val traceId = UUID.randomUUID().toString()
modelsService.getAvailableModels(call, traceId)
}
}

View File

@ -0,0 +1,53 @@
package com.alibaba.mnnllm.api.openai.network.services
import com.alibaba.mnnllm.api.openai.network.logging.ChatLogger
import com.alibaba.mnnllm.api.openai.network.models.ModelData
import com.alibaba.mnnllm.api.openai.network.models.ModelPermission
import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse
import com.alibaba.mnnllm.android.modelist.ModelListManager
import com.alibaba.mnnllm.android.MnnLlmApplication
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.call
import io.ktor.server.response.respond
import kotlinx.coroutines.runBlocking
import timber.log.Timber
/**
* MNN 模型服务
* 负责处理模型相关的业务逻辑
*/
class MNNModelsService {
private val logger = ChatLogger()
suspend fun getAvailableModels(call: io.ktor.server.application.ApplicationCall, traceId: String) {
try {
logger.logRequestStart(traceId, call)
val context = MnnLlmApplication.getAppContext()
val availableModels = runBlocking {
ModelListManager.loadAvailableModels(context)
}
val modelDataList = availableModels.map { modelWrapper ->
ModelData(
id = modelWrapper.modelItem.modelId ?: "unknown",
created = System.currentTimeMillis() / 1000, // Unix timestamp
permission = listOf(
ModelPermission(
id = "modelperm-${modelWrapper.modelItem.modelId}",
created = System.currentTimeMillis() / 1000
)
)
)
}
val response = ModelsResponse(data = modelDataList)
call.respond(response)
logger.logInfo(traceId, "Models list returned successfully")
} catch (e: Exception) {
Timber.tag("MNNModelsService").e(e, "Error getting available models")
logger.logError(traceId, e, "Failed to get available models")
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Failed to get available models"))
}
}
}

View File

@ -82,7 +82,8 @@ class ApiServiceCoordinator(private val context: Context) {
// 更新通知 // 更新通知
notificationManager?.updateNotification( notificationManager?.updateNotification(
context.getString(R.string.api_service_running), context.getString(R.string.api_service_running),
context.getString(R.string.api_service_port, app.getPort()) "", // 让 NotificationManager 使用默认的 IP 地址显示
app.getPort()
) )
_isServerRunning = true _isServerRunning = true
@ -123,8 +124,8 @@ class ApiServiceCoordinator(private val context: Context) {
/** /**
* 更新通知内容 * 更新通知内容
*/ */
fun updateNotification(title: String, content: String) { fun updateNotification(title: String, content: String, port: Int = 8080) {
notificationManager?.updateNotification(title, content) notificationManager?.updateNotification(title, content, port)
} }
/** /**
@ -132,8 +133,9 @@ class ApiServiceCoordinator(private val context: Context) {
*/ */
fun getNotification( fun getNotification(
title: String = context.getString(R.string.api_service_running), title: String = context.getString(R.string.api_service_running),
content: String = context.getString(R.string.api_service_port, 8080) content: String = context.getString(R.string.api_service_port, 8080),
) = notificationManager?.buildNotification(title, content) port: Int = 8080
) = notificationManager?.buildNotification(title, content, port)
/** /**
* 获取服务器端口 * 获取服务器端口

View File

@ -1,15 +1,18 @@
package com.alibaba.mnnllm.api.openai.service package com.alibaba.mnnllm.api.openai.service
import android.Manifest
import android.app.Service import android.app.Service
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.os.Binder import android.os.Binder
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import com.alibaba.mnnllm.android.chat.ChatActivity import com.alibaba.mnnllm.android.chat.ChatActivity
import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator
import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager
@ -29,10 +32,27 @@ class OpenAIService : Service() {
return return
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
Timber.tag("ServiceStartCondition").w("Notification permission not granted, cannot start foreground service")
return
}
}
val serviceIntent = Intent(context, OpenAIService::class.java) val serviceIntent = Intent(context, OpenAIService::class.java)
// 在启动服务前设置标志避免onStartCommand中的检查失败
isServiceRunning = true isServiceRunning = true
context.startForegroundService(serviceIntent) try {
context.startForegroundService(serviceIntent)
Timber.tag("ServiceStartCondition").i("Foreground service started successfully")
} catch (e: Exception) {
Timber.tag("ServiceStartCondition").e(e, "Failed to start foreground service")
isServiceRunning = false
return
}
val connection = object : ServiceConnection { val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) { override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
@ -43,7 +63,6 @@ class OpenAIService : Service() {
override fun onServiceDisconnected(name: ComponentName?) { override fun onServiceDisconnected(name: ComponentName?) {
serviceConnection = null serviceConnection = null
// 服务断开连接时重置标志
isServiceRunning = false isServiceRunning = false
} }
} }
@ -110,7 +129,6 @@ class OpenAIService : Service() {
@RequiresApi(Build.VERSION_CODES.Q)
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (!isServiceRunning) { if (!isServiceRunning) {
Timber.tag("ServiceLifecycle").w("Service started illegally and will be stopped immediately.") Timber.tag("ServiceLifecycle").w("Service started illegally and will be stopped immediately.")
@ -119,25 +137,26 @@ class OpenAIService : Service() {
} }
val notification = coordinator.getNotification() val notification = coordinator.getNotification()
if (notification != null) { if (notification != null) {
startForeground(ApiNotificationManager.NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) 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 return START_NOT_STICKY
} }
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
coordinator = ApiServiceCoordinator(this) coordinator = ApiServiceCoordinator(this)
coordinator.initialize() coordinator.initialize()
val notification = coordinator.getNotification()
if (notification != null) {
startForeground(ApiNotificationManager.NOTIFICATION_ID, notification)
}
} }
override fun onDestroy() { override fun onDestroy() {
Timber.tag(TAG).i("Service is being destroyed") Timber.tag(TAG).i("Service is being destroyed")
cleanup() cleanup()

View File

@ -34,6 +34,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
private var _binding: FragmentApiConsoleSheetBinding? = null private var _binding: FragmentApiConsoleSheetBinding? = null
private val binding get() = _binding!! private val binding get() = _binding!!
private var chatActivity: ChatActivity? = null private var chatActivity: ChatActivity? = null
private var bottomSheetBehavior: BottomSheetBehavior<FrameLayout>? = null
companion object { companion object {
const val TAG = "ApiConsoleBottomSheetFragment" const val TAG = "ApiConsoleBottomSheetFragment"
@ -49,7 +50,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
private val serverEventManager = ServerEventManager.getInstance() private val serverEventManager = ServerEventManager.getInstance()
private val logCollector = LogCollector.getInstance() private val logCollector = LogCollector.getInstance()
// 管理协程订阅 // Manage coroutine subscriptions
private var serverStateJob: Job? = null private var serverStateJob: Job? = null
private var serverInfoJob: Job? = null private var serverInfoJob: Job? = null
private var logCollectorJob: 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) val bottomSheet: FrameLayout? = dialog.findViewById(com.google.android.material.R.id.design_bottom_sheet)
if (bottomSheet != null) { if (bottomSheet != null) {
val behavior = BottomSheetBehavior.from(bottomSheet) val behavior = BottomSheetBehavior.from(bottomSheet)
this.bottomSheetBehavior = behavior // Store the behavior instance
bottomSheet.post { bottomSheet.post {
behavior.state = BottomSheetBehavior.STATE_EXPANDED behavior.state = BottomSheetBehavior.STATE_EXPANDED
} }
behavior.skipCollapsed = false 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() setupLogArea()
setupActionButtons() setupActionButtons()
observeServerEvents() 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() { override fun onResume() {
super.onResume() super.onResume()
// 重新订阅事件,确保状态监听正常工作 // Re-subscribe to events to ensure status monitoring works correctly
//observeServerEvents() //observeServerEvents()
// 每次Fragment可见时刷新状态确保显示最新的服务器状态 // Refresh status every time the fragment is visible to ensure the latest server status is displayed
// updateServiceStatus() // updateServiceStatus()
// 延迟再次检查状态,确保服务重启后能正确获取状态 // Check status again after a delay to ensure correct status is obtained after service restart
//binding.root.postDelayed({ //binding.root.postDelayed({
// if (isAdded && !isDetached) { // if (isAdded && !isDetached) {
// updateServiceStatus() // updateServiceStatus()
@ -115,7 +146,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
val serverState = serverEventManager.getCurrentState() val serverState = serverEventManager.getCurrentState()
val serverInfo = serverEventManager.getCurrentInfo() val serverInfo = serverEventManager.getCurrentInfo()
// 获取配置的IP和端口用于显示API端点 // Get configured IP and port to display the API endpoint
val configuredHost = ApiServerConfig.getIpAddress(context) val configuredHost = ApiServerConfig.getIpAddress(context)
val configuredPort = ApiServerConfig.getPort(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.textServiceStatus.setTextColor(resources.getColor(android.R.color.holo_green_dark, null))
binding.textListenAddress.visibility = View.GONE binding.textListenAddress.visibility = View.GONE
binding.labelListenAddress.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 displayHost = if (serverInfo.host.isNotEmpty()) serverInfo.host else configuredHost
val displayPort = if (serverInfo.port > 0) serverInfo.port else configuredPort val displayPort = if (serverInfo.port > 0) serverInfo.port else configuredPort
val endpointUrl = "http://${displayHost}:${displayPort}/v1/chat/completions" 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.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) 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 { binding.layoutConfigHeader.setOnClickListener {
val isVisible = binding.layoutConfigDetails.isVisible val isVisible = binding.layoutConfigDetails.isVisible
binding.layoutConfigDetails.isVisible = !isVisible binding.layoutConfigDetails.isVisible = !isVisible
@ -197,20 +228,21 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
private fun setupLogArea() { private fun setupLogArea() {
// 设置RecyclerView // Set up RecyclerView
binding.recyclerLogContent.apply { binding.recyclerLogContent.apply {
layoutManager = LinearLayoutManager(context) layoutManager = LinearLayoutManager(context)
adapter = logAdapter adapter = logAdapter
// 设置触摸事件拦截,防止滑动冲突 // Intercept touch events to prevent scrolling conflicts
setOnTouchListener { view, event -> setOnTouchListener {
// 请求父容器不要拦截触摸事件 view, event ->
// Request parent container not to intercept touch events
view.parent.requestDisallowInterceptTouchEvent(true) view.parent.requestDisallowInterceptTouchEvent(true)
false false
} }
} }
Timber.tag("ApiConsoleUI").d("[Log] Initializing log area") Timber.tag("ApiConsoleUI").d("[Log] Initializing log area")
// 添加初始日志 // Add initial log message
addLogMessage(getString(R.string.console_started)) addLogMessage(getString(R.string.console_started))
val serverState = serverEventManager.getCurrentState() val serverState = serverEventManager.getCurrentState()
@ -227,9 +259,10 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
} }
// 订阅实时日志 // Subscribe to real-time logs
logCollector.logFlow logCollector.logFlow
.onEach { logEntry -> .onEach {
logEntry ->
if (isAdded && !isDetached) { if (isAdded && !isDetached) {
val (formattedLog, clickableInfo) = logCollector.formatLogEntryWithClickableInfo(logEntry) val (formattedLog, clickableInfo) = logCollector.formatLogEntryWithClickableInfo(logEntry)
addRawLogEntryWithClickInfo(formattedLog, clickableInfo) addRawLogEntryWithClickInfo(formattedLog, clickableInfo)
@ -237,7 +270,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
// 设置折叠/展开功能 // Set up collapse/expand functionality
binding.layoutLogHeader.setOnClickListener { binding.layoutLogHeader.setOnClickListener {
val isVisible = binding.layoutLogContent.isVisible val isVisible = binding.layoutLogContent.isVisible
binding.layoutLogContent.isVisible = !isVisible binding.layoutLogContent.isVisible = !isVisible
@ -251,14 +284,14 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
val logEntry = "[$timestamp] $message" val logEntry = "[$timestamp] $message"
logAdapter.addLogMessage(logEntry) logAdapter.addLogMessage(logEntry)
// 自动滚动到底部 // Auto-scroll to the bottom
scrollToBottom() scrollToBottom()
} }
private fun addRawLogMessage(message: String) { private fun addRawLogMessage(message: String) {
logAdapter.addLogMessage(message) logAdapter.addLogMessage(message)
// 自动滚动到底部 // Auto-scroll to the bottom
scrollToBottom() scrollToBottom()
} }
@ -266,7 +299,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
val logEntry = LogAdapter.LogEntryData(message, clickableInfo) val logEntry = LogAdapter.LogEntryData(message, clickableInfo)
logAdapter.addLogEntry(logEntry) logAdapter.addLogEntry(logEntry)
// 自动滚动到底部 // Auto-scroll to the bottom
scrollToBottom() 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() { private fun setupActionButtons() {
binding.buttonClose.setOnClickListener { binding.buttonClose.setOnClickListener {
dismiss() dismiss()
@ -293,7 +377,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
copyLogToClipboard() copyLogToClipboard()
} }
// 添加测试按钮(长按清空日志按钮) // Add test button (long press to clear log)
binding.buttonClearLog.setOnLongClickListener { binding.buttonClearLog.setOnLongClickListener {
addTestLogWithCodeLocation() addTestLogWithCodeLocation()
true true
@ -301,7 +385,7 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
/** /**
* 添加测试日志包含代码行号信息 * Add test log, including code line number information
*/ */
private fun addTestLogWithCodeLocation() { private fun addTestLogWithCodeLocation() {
Timber.tag("TestLog").i(getString(R.string.test_log_message1)) Timber.tag("TestLog").i(getString(R.string.test_log_message1))
@ -326,21 +410,22 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
private fun observeServerEvents() { private fun observeServerEvents() {
// 取消之前的订阅 // Cancel previous subscriptions
cancelObservations() cancelObservations()
Timber.tag("ApiConsoleUI").d("[ServerEvent] Subscribing to server events") Timber.tag("ApiConsoleUI").d("[ServerEvent] Subscribing to server events")
// 立即获取当前状态并更新UI // Immediately get the current status and update the UI
updateServiceStatus() updateServiceStatus()
// 观察服务器状态变化 // Observe server status changes
serverStateJob = serverEventManager.serverState serverStateJob = serverEventManager.serverState
.onEach { state -> .onEach {
state ->
if (isAdded && !isDetached) { if (isAdded && !isDetached) {
// 强制更新UI状态 // Force update UI status
updateServiceStatus() updateServiceStatus()
// 根据状态变化添加日志 // Add log based on status change
when (state) { when (state) {
ServerEventManager.ServerState.STARTING -> { ServerEventManager.ServerState.STARTING -> {
addLogMessage(getString(R.string.server_starting_message)) addLogMessage(getString(R.string.server_starting_message))
@ -365,11 +450,12 @@ class ApiConsoleBottomSheetFragment : BottomSheetDialogFragment() {
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
// 观察服务器信息变化 // Observe server info changes
serverInfoJob = serverEventManager.serverInfo serverInfoJob = serverEventManager.serverInfo
.onEach { info -> .onEach {
info ->
if (isAdded && !isDetached) { if (isAdded && !isDetached) {
// 当服务器信息变化时强制更新状态 // Force update status when server info changes
updateServiceStatus() updateServiceStatus()
if (info.isRunning) { if (info.isRunning) {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -19,7 +19,6 @@
/> />
<androidx.recyclerview.widget.RecyclerView <androidx.recyclerview.widget.RecyclerView
android:id="@+id/chat_history_recycler_view" android:id="@+id/chat_history_recycler_view"
android:layout_marginStart="20dp"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent" android:layout_height="match_parent"
android:layout_below="@id/history_title" android:layout_below="@id/history_title"

View File

@ -41,25 +41,41 @@
app:tint="?colorOnSurfaceVariant" /> app:tint="?colorOnSurfaceVariant" />
</LinearLayout> </LinearLayout>
<TextView <LinearLayout
android:id="@+id/tv_chat_thinking" android:id="@+id/ll_thinking_container"
android:layout_toEndOf="@id/ic_header"
android:layout_below="@id/ll_thinking_toggle"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:padding="@dimen/space10" android:layout_toEndOf="@id/ic_header"
android:textAppearance="@style/Light" android:layout_below="@id/ll_thinking_toggle"
android:textColor="?colorOnSurfaceVariant" android:orientation="horizontal"
android:textSize="@dimen/h4" tools:visibility="visible"
android:visibility="gone" android:visibility="gone"
tools:text="This is the thinking process..." android:padding="@dimen/space10">
tools:visibility="visible" />
<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:textAppearance="@style/Light"
android:textColor="?colorOnSurfaceVariant"
android:textSize="@dimen/h4"
tools:text="This is the thinking process..."
tools:visibility="visible" />
</LinearLayout>
<TextView <TextView
android:id="@+id/tv_chat_text" android:id="@+id/tv_chat_text"
tools:text="this is the generated text" tools:text="this is the generated text"
android:layout_toEndOf="@id/ic_header" 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_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="@dimen/space10" android:paddingStart="@dimen/space10"

View File

@ -1,50 +1,90 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="50dp"
android:paddingStart="5dp"
android:paddingEnd="20dp"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"> xmlns:tools="http://schemas.android.com/tools"
<ImageView android:layout_width="match_parent"
app:srcCompat="@drawable/ic_chat" android:layout_height="wrap_content"
android:id="@+id/iv_header" android:layout_marginStart="12dp"
android:layout_width="20dp" android:layout_marginEnd="12dp"
android:layout_height="20dp" android:layout_marginTop="3dp"
android:layout_centerVertical="true" android:layout_marginBottom="3dp"
app:tint="?colorOnSurfaceVariant" android:orientation="vertical"
tools:ignore="ContentDescription" /> android:minHeight="56dp"
android:background="?attr/selectableItemBackground"
<View android:clickable="true"
android:layout_width="match_parent" android:paddingLeft="16dp"
android:layout_height="1px" android:paddingTop="10dp"
android:layout_alignParentBottom="true" android:paddingBottom="10dp"
android:background="?colorOutlineVariant"/> android:focusable="true">
<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>
<TextView <TextView
android:id="@+id/text_history" android:id="@+id/text_history"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:textColor="?colorOnSurfaceVariant" android:textColor="?colorOnSurface"
android:maxLines="1" android:textSize="15sp"
android:fontFamily="sans-serif"
android:lineSpacingExtra="2dp"
android:maxLines="3"
android:ellipsize="end" android:ellipsize="end"
android:layout_toEndOf="@id/iv_header" tools:text="AI聊天记录演示文本内容这里应该显示最后一条消息的前100个字符" />
android:layout_toStartOf="@id/iv_delete_history"
android:layout_marginStart="16dp" <LinearLayout
android:layout_centerVertical="true" android:layout_width="match_parent"
tools:text="聊天记录 demo" android:layout_height="wrap_content"
tools:ignore="RelativeOverlap" /> android:orientation="horizontal"
</RelativeLayout> 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"
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>

View File

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

View File

@ -45,5 +45,17 @@
<color name="benchmark_result_text_secondary">#B3B3B3</color> <color name="benchmark_result_text_secondary">#B3B3B3</color>
<color name="chip_background">#2c2c2e</color> <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> </resources>

View File

@ -47,7 +47,7 @@
<string name="nav_close">Close navigation drawer</string> <string name="nav_close">Close navigation drawer</string>
<string name="models">模型列表</string> <string name="models">模型列表</string>
<string name="models_market">模型市场</string> <string name="models_market">模型市场</string>
<string name="history">历史会话</string> <string name="history">对话历史</string>
<string name="history_title">Chat History</string> <string name="history_title">Chat History</string>
<string name="history_delete_success">历史被删除</string> <string name="history_delete_success">历史被删除</string>
<string name="diffusion_generated_message">已生成图片:</string> <string name="diffusion_generated_message">已生成图片:</string>
@ -106,9 +106,14 @@
<string name="api_service_not_started">API 服务未启动</string> <string name="api_service_not_started">API 服务未启动</string>
<string name="no_active_session">未找到活跃会话</string> <string name="no_active_session">未找到活跃会话</string>
<string name="api_service_running">API 服务运行中</string> <string name="api_service_running">API 服务运行中</string>
<string name="api_service_running_on">API 服务运行在 %1$s:%2$d</string>
<string name="api_service_port">端口:%1$d</string> <string name="api_service_port">端口:%1$d</string>
<string name="api_service_start_failed">API 服务启动失败</string> <string name="api_service_start_failed">API 服务启动失败</string>
<string name="api_service_error">错误:%1$s</string> <string name="api_service_error">错误:%1$s</string>
<string name="api_service_stop">停止</string>
<string name="api_service_copy_url">复制URL</string>
<string name="api_service_test_page">测试页面</string>
<string name="api_url_copied">API URL已复制到剪贴板</string>
<string name="modelscope">魔搭</string> <string name="modelscope">魔搭</string>
<string name="modelers">魔乐</string> <string name="modelers">魔乐</string>
<string name="huggingface">HuggingFace</string> <string name="huggingface">HuggingFace</string>
@ -236,9 +241,16 @@
<string name="audio_output_confirm">当前音频输出可能较慢。您是否仍要打开?</string> <string name="audio_output_confirm">当前音频输出可能较慢。您是否仍要打开?</string>
<string name="confirm_delete_model_title">确认删除</string> <string name="confirm_delete_model_title">确认删除</string>
<string name="confirm_delete_model_message">您确定要删除这个模型吗?此操作无法恢复。</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="vendor_menu_title">厂商</string>
<string name="modality_menu_title">模态</string> <string name="modality_menu_title">模态</string>
<string name="downloaded">已下载</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="last_chat">最后聊天</string>
<string name="show_model_info">显示模型信息</string> <string name="show_model_info">显示模型信息</string>
<string name="model_info_title">模型信息</string> <string name="model_info_title">模型信息</string>
@ -327,7 +339,7 @@
<string name="voice_chat_stopping">正在停止...</string> <string name="voice_chat_stopping">正在停止...</string>
<string name="voice_chat_ready_greeting">有什么可以帮助您的?</string> <string name="voice_chat_ready_greeting">有什么可以帮助您的?</string>
<string name="voice_chat_usage_notice">使用语音聊天需要下载TTS和ASR模型请注意多语言支持。</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="benchmark">性能评测</string>
<string name="download_source">选择下载源</string> <string name="download_source">选择下载源</string>
<string name="filter">筛选</string> <string name="filter">筛选</string>
@ -375,10 +387,15 @@
<string name="no"></string> <string name="no"></string>
<string name="select_a_model_to_start">选择好模型后,就可以开始评测了</string> <string name="select_a_model_to_start">选择好模型后,就可以开始评测了</string>
<string name="start_test">开始测试</string> <string name="start_test">开始测试</string>
<string name="restart_test">重新评测</string>
<string name="prefill_speed">"预填充速度: "</string> <string name="prefill_speed">"预填充速度: "</string>
<string name="memory_title">峰值内存:</string> <string name="memory_title">峰值内存:</string>
<string name="decode_speed">解码速度:</string> <string name="decode_speed">解码速度:</string>
<string name="test_result">评测结果</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> <string name="share">分享</string>
<!-- Benchmark Strings --> <!-- Benchmark Strings -->
@ -497,4 +514,28 @@
<string name="download_service_title">MNN Chat 下载服务</string> <string name="download_service_title">MNN Chat 下载服务</string>
<string name="downloading_single_model">正在下载 %1$s</string> <string name="downloading_single_model">正在下载 %1$s</string>
<string name="downloading_multiple_models">正在下载 %1$d 个模型</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> </resources>

View File

@ -38,6 +38,7 @@
<declare-styleable name="ModelAvatarView"> <declare-styleable name="ModelAvatarView">
<attr name="modelName" /> <attr name="modelName" />
<attr name="compactMode" format="boolean" />
</declare-styleable> </declare-styleable>
<declare-styleable name="ProgressPieView"> <declare-styleable name="ProgressPieView">

View File

@ -57,4 +57,16 @@
<!-- Chip --> <!-- Chip -->
<color name="chip_background">#e8f1ff</color> <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> </resources>

View File

@ -37,4 +37,7 @@
<dimen name="filter_chip_height">30dp</dimen> <dimen name="filter_chip_height">30dp</dimen>
<dimen name="filter_chip_margin_end">8dp</dimen> <dimen name="filter_chip_margin_end">8dp</dimen>
<dimen name="bottom_tab_icon_size">30dp</dimen> <dimen name="bottom_tab_icon_size">30dp</dimen>
<!-- Performance Metric View -->
<dimen name="performance_metric_padding">0dp</dimen>
</resources> </resources>

View File

@ -113,9 +113,14 @@
<string name="api_service_not_started">API Service Not Started</string> <string name="api_service_not_started">API Service Not Started</string>
<string name="no_active_session">No active session found</string> <string name="no_active_session">No active session found</string>
<string name="api_service_running">API Service Running</string> <string name="api_service_running">API Service Running</string>
<string name="api_service_running_on">API Service running on %1$s:%2$d</string>
<string name="api_service_port">Port: %1$d</string> <string name="api_service_port">Port: %1$d</string>
<string name="api_service_start_failed">API Service Start Failed</string> <string name="api_service_start_failed">API Service Start Failed</string>
<string name="api_service_error">Error: %1$s</string> <string name="api_service_error">Error: %1$s</string>
<string name="api_service_stop">Stop</string>
<string name="api_service_copy_url">Copy URL</string>
<string name="api_service_test_page">Test Page</string>
<string name="api_url_copied">API URL copied to clipboard</string>
<string name="modelscope">Modelscope</string> <string name="modelscope">Modelscope</string>
<string name="modelers">Modelers</string> <string name="modelers">Modelers</string>
<string name="huggingface">HuggingFace</string> <string name="huggingface">HuggingFace</string>
@ -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="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_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="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="vendor_menu_title">Vendor</string>
<string name="modality_menu_title">Modality</string> <string name="modality_menu_title">Modality</string>
<string name="downloaded">Downloaded</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="last_chat">Last chat</string>
<string name="show_model_info">Show model info</string> <string name="show_model_info">Show model info</string>
<string name="model_info_title">Model Information</string> <string name="model_info_title">Model Information</string>
@ -332,7 +344,7 @@
<string name="voice_chat_stopping">Stopping...</string> <string name="voice_chat_stopping">Stopping...</string>
<string name="voice_chat_ready_greeting">What can I help you with?</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="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="benchmark">Benchmark</string>
<string name="download_source">Select Download Source</string> <string name="download_source">Select Download Source</string>
<string name="filter">Filter</string> <string name="filter">Filter</string>
@ -384,10 +396,15 @@
<string name="no">No</string> <string name="no">No</string>
<string name="select_a_model_to_start">Start benchmark after selected your model</string> <string name="select_a_model_to_start">Start benchmark after selected your model</string>
<string name="start_test">Start Test</string> <string name="start_test">Start Test</string>
<string name="restart_test">Restart Test</string>
<string name="prefill_speed">"Prefill Speed: "</string> <string name="prefill_speed">"Prefill Speed: "</string>
<string name="memory_title">Peak Memory: </string> <string name="memory_title">Peak Memory: </string>
<string name="decode_speed">Decode Speed: </string> <string name="decode_speed">Decode Speed: </string>
<string name="test_result">Test Result</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> <string name="share">Share</string>
<!-- Benchmark Strings --> <!-- Benchmark Strings -->
@ -500,4 +517,28 @@
<string name="download_service_title">MNN Chat Download Service</string> <string name="download_service_title">MNN Chat Download Service</string>
<string name="downloading_single_model">Downloading %1$s</string> <string name="downloading_single_model">Downloading %1$s</string>
<string name="downloading_multiple_models">Downloading %1$d models</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> </resources>

View File

@ -1,3 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:tools="http://schemas.android.com/tools"> <resources xmlns:tools="http://schemas.android.com/tools">
<!-- <style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar"> <item name="colorPrimary">@color/...</item>--> <!-- <style name="AppTheme" parent="Theme.Material3.DayNight.NoActionBar"> <item name="colorPrimary">@color/...</item>-->
@ -151,4 +152,14 @@
<style name="BottomSheetStyle" parent="Widget.Material3.BottomSheet"> <style name="BottomSheetStyle" parent="Widget.Material3.BottomSheet">
<item name="android:background">@drawable/bottom_sheet_background</item> <item name="android:background">@drawable/bottom_sheet_background</item>
</style> </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> </resources>

View File

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

View File

@ -0,0 +1 @@
./gradlew installStandardDebug

View File

@ -0,0 +1 @@
./gradlew test -x preBuild

View File

@ -0,0 +1,176 @@
#!/usr/bin/env python3
"""
MNN LLM Chat API Test Script
Supports multiple test scenarios
"""
import requests
import json
import sys
import time
import argparse
from typing import Dict, Any, List
class MnnApiTester:
def __init__(self, host: str = "localhost", port: int = 8080, token: str = "mnn-llm-chat"):
self.host = host
self.port = port
self.token = token
self.base_url = f"http://{host}:{port}"
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
def test_models(self) -> Dict[str, Any]:
"""Test the /v1/models endpoint"""
print("Testing /v1/models endpoint...")
url = f"{self.base_url}/v1/models"
try:
response = requests.get(url, headers=self.headers, timeout=10)
print(f"Status code: {response.status_code}")
if response.status_code == 200:
data = response.json()
print(f"Response: {json.dumps(data, indent=2, ensure_ascii=False)}")
return {"success": True, "data": data}
else:
print(f"Error: {response.text}")
return {"success": False, "error": response.text}
except Exception as e:
print(f"Exception: {e}")
return {"success": False, "error": str(e)}
def test_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]:
"""Test the /v1/chat/completions endpoint"""
print(f"Testing /v1/chat/completions endpoint (Model: {model})...")
url = f"{self.base_url}/v1/chat/completions"
data = {
"model": model,
"messages": [{"role": "user", "content": message}],
"max_tokens": 100,
"temperature": 0.7
}
try:
response = requests.post(url, headers=self.headers, json=data, timeout=30)
print(f"Status code: {response.status_code}")
if response.status_code == 200:
result = response.json()
print(f"Response: {json.dumps(result, indent=2, ensure_ascii=False)}")
return {"success": True, "data": result}
else:
print(f"Error: {response.text}")
return {"success": False, "error": response.text}
except Exception as e:
print(f"Exception: {e}")
return {"success": False, "error": str(e)}
def test_stream_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]:
"""Test the streaming chat endpoint"""
print(f"Testing streaming chat endpoint (Model: {model})...")
url = f"{self.base_url}/v1/chat/completions"
data = {
"model": model,
"messages": [{"role": "user", "content": message}],
"max_tokens": 100,
"temperature": 0.7,
"stream": True
}
try:
response = requests.post(url, headers=self.headers, json=data, timeout=30, stream=True)
print(f"Status code: {response.status_code}")
if response.status_code == 200:
print("Streaming response:")
for line in response.iter_lines():
if line:
line_str = line.decode('utf-8')
if line_str.startswith('data: '):
data_str = line_str[6:]
if data_str.strip() == '[DONE]':
break
try:
chunk = json.loads(data_str)
if 'choices' in chunk and len(chunk['choices']) > 0:
delta = chunk['choices'][0].get('delta', {})
if 'content' in delta:
print(delta['content'], end='', flush=True)
except json.JSONDecodeError:
pass
print("\n")
return {"success": True}
else:
print(f"Error: {response.text}")
return {"success": False, "error": response.text}
except Exception as e:
print(f"Exception: {e}")
return {"success": False, "error": str(e)}
def main():
parser = argparse.ArgumentParser(description='MNN LLM Chat API Test Tool')
parser.add_argument('--host', default='localhost', help='Server address')
parser.add_argument('--port', type=int, default=8080, help='Server port')
parser.add_argument('--token', default='mnn-llm-chat', help='Authentication token')
parser.add_argument('--test', choices=['models', 'chat', 'stream', 'all'], default='all', help='Test type')
parser.add_argument('--model', default='qwen2.5-7b-instruct', help='Test model')
parser.add_argument('--message', default='Hello, please briefly introduce yourself', help='Test message')
args = parser.parse_args()
print("MNN LLM Chat API Test Tool")
print("=" * 50)
print(f"Server: {args.host}:{args.port}")
print(f"Token: {args.token}")
print(f"Test type: {args.test}")
print()
tester = MnnApiTester(args.host, args.port, args.token)
results = []
if args.test in ['models', 'all']:
print("1. Test models endpoint")
print("-" * 30)
result = tester.test_models()
results.append(('models', result))
print()
if args.test in ['chat', 'all']:
print("2. Test chat endpoint")
print("-" * 30)
result = tester.test_chat(args.model, args.message)
results.append(('chat', result))
print()
if args.test in ['stream', 'all']:
print("3. Test streaming chat endpoint")
print("-" * 30)
result = tester.test_stream_chat(args.model, args.message)
results.append(('stream', result))
print()
# Summary
print("Test Summary:")
for test_name, result in results:
status = "Success" if result['success'] else "Failure"
print(f" {test_name}: {status}")
all_success = all(result['success'] for _, result in results)
if all_success:
print("\n✅ All tests passed!")
return 0
else:
print("\n❌ Some tests failed!")
return 1
if __name__ == "__main__":
sys.exit(main())