mirror of https://github.com/alibaba/MNN.git
Merge pull request #3861 from Juude/master
fix qwen think/nothink switch and some ui update
This commit is contained in:
commit
333b3c4b5d
|
@ -61,6 +61,15 @@ This is our full multimodal language model (LLM) Android app
|
||||||
```
|
```
|
||||||
|
|
||||||
# Releases
|
# 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:
|
||||||
|
|
|
@ -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)
|
||||||
+ 新增模型:
|
+ 新增模型:
|
||||||
|
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -0,0 +1,538 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
|
<title>MNN Frontend</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f5f5f5;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 1.5rem 1rem;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
margin: 0 0 0.5rem 0;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-selector {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-bottom: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-row label {
|
||||||
|
font-weight: 500;
|
||||||
|
color: #495057;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#model-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 200px;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary {
|
||||||
|
background-color: #007bff;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-primary:hover {
|
||||||
|
background-color: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary {
|
||||||
|
background-color: #6c757d;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-secondary:hover {
|
||||||
|
background-color: #545b62;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container {
|
||||||
|
height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 8px;
|
||||||
|
max-width: 85%;
|
||||||
|
word-wrap: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user {
|
||||||
|
background-color: #e3f2fd;
|
||||||
|
margin-left: auto;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assistant {
|
||||||
|
background-color: #f1f3f4;
|
||||||
|
margin-right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message strong {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-area {
|
||||||
|
padding: 1rem;
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border-top: 1px solid #e9ecef;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid #ced4da;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
padding: 0.5rem;
|
||||||
|
background-color: #e8f4fd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: #495057;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 移动端优化 */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
body {
|
||||||
|
padding: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header h1 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header p {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.model-row {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
#model-select {
|
||||||
|
min-width: auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group .button {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container {
|
||||||
|
height: 300px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
max-width: 95%;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-row {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#user-input {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 滚动条样式 */
|
||||||
|
#chat-container::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container::-webkit-scrollbar-track {
|
||||||
|
background: #f1f1f1;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container::-webkit-scrollbar-thumb {
|
||||||
|
background: #c1c1c1;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chat-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #a8a8a8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="header">
|
||||||
|
<h1>Chat with MNN</h1>
|
||||||
|
<p>MNN-LLM's server API is OpenAI API compatible. You can use other frameworks like OpenWebUI or LobeChat.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="model-selector">
|
||||||
|
<div class="model-row">
|
||||||
|
<label for="model-select">Current Model:</label>
|
||||||
|
<select id="model-select">
|
||||||
|
<option value="unknown">Loading models...</option>
|
||||||
|
</select>
|
||||||
|
<button class="button button-secondary" onclick="refreshModels()">Refresh Models</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="chat-container"></div>
|
||||||
|
|
||||||
|
<div class="input-area">
|
||||||
|
<div class="input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="user-input"
|
||||||
|
placeholder="Type your message here..."
|
||||||
|
onkeydown="if(event.key==='Enter'){ sendMessage(); }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button id="send-btn" class="button button-primary" onclick="sendMessage()">Send</button>
|
||||||
|
<button id="reset-btn" class="button button-secondary" onclick="resetChat()">Reset</button>
|
||||||
|
</div>
|
||||||
|
<div class="status" id="status">Ready to chat</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// 从URL参数中获取token
|
||||||
|
function getTokenFromUrl() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
return urlParams.get('token') || "no";
|
||||||
|
}
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = getTokenFromUrl(); // 从URL参数获取token,如果没有则使用"no"
|
||||||
|
let currentModel = "unknown";
|
||||||
|
let availableModels = [];
|
||||||
|
let messages = [
|
||||||
|
{ role: "system", content: "You are a helpful assistant." },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Load available models on page load
|
||||||
|
window.onload = function() {
|
||||||
|
refreshModels();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function refreshModels() {
|
||||||
|
try {
|
||||||
|
updateStatus("Loading models...");
|
||||||
|
const response = await fetch("/v1/models", {
|
||||||
|
method: "GET",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error: ${response.status} - ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
availableModels = data.data || [];
|
||||||
|
|
||||||
|
const modelSelect = document.getElementById("model-select");
|
||||||
|
modelSelect.innerHTML = "";
|
||||||
|
|
||||||
|
if (availableModels.length === 0) {
|
||||||
|
modelSelect.innerHTML = '<option value="unknown">No models available</option>';
|
||||||
|
updateStatus("No models available");
|
||||||
|
} else {
|
||||||
|
availableModels.forEach(model => {
|
||||||
|
const option = document.createElement("option");
|
||||||
|
option.value = model.id;
|
||||||
|
// 只显示模型名称的后缀部分
|
||||||
|
const modelName = extractModelSuffix(model.id);
|
||||||
|
option.textContent = modelName;
|
||||||
|
modelSelect.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Set first model as default
|
||||||
|
if (availableModels.length > 0) {
|
||||||
|
currentModel = availableModels[0].id;
|
||||||
|
modelSelect.value = currentModel;
|
||||||
|
updateStatus(`Model loaded: ${extractModelSuffix(currentModel)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load models:", error);
|
||||||
|
updateStatus(`Error loading models: ${error.message}`);
|
||||||
|
document.getElementById("model-select").innerHTML = '<option value="unknown">Error loading models</option>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提取模型名称后缀的函数
|
||||||
|
function extractModelSuffix(modelId) {
|
||||||
|
if (!modelId) return "unknown";
|
||||||
|
|
||||||
|
// 移除常见的前缀
|
||||||
|
let suffix = modelId;
|
||||||
|
|
||||||
|
// 移除 ModelScope/MNN/ 前缀
|
||||||
|
if (suffix.startsWith("ModelScope/MNN/")) {
|
||||||
|
suffix = suffix.substring("ModelScope/MNN/".length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 移除其他常见前缀
|
||||||
|
const prefixes = [
|
||||||
|
"ModelScope/",
|
||||||
|
"HuggingFace/",
|
||||||
|
"MNN/",
|
||||||
|
"modelscope/",
|
||||||
|
"huggingface/"
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const prefix of prefixes) {
|
||||||
|
if (suffix.startsWith(prefix)) {
|
||||||
|
suffix = suffix.substring(prefix.length);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return suffix || modelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle model selection change
|
||||||
|
document.getElementById("model-select").addEventListener("change", function() {
|
||||||
|
currentModel = this.value;
|
||||||
|
updateStatus(`Model switched to: ${extractModelSuffix(currentModel)}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateStatus(message) {
|
||||||
|
document.getElementById("status").textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resetChat() {
|
||||||
|
document.getElementById("user-input").value = "";
|
||||||
|
document.getElementById("chat-container").innerHTML = "";
|
||||||
|
messages = [
|
||||||
|
{ role: "system", content: "You are a helpful assistant." },
|
||||||
|
];
|
||||||
|
updateStatus("Chat reset");
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fetch("/reset", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ reset: true }),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Reset failed:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendMessage() {
|
||||||
|
const userInput = document.getElementById("user-input").value.trim();
|
||||||
|
if (!userInput) return;
|
||||||
|
|
||||||
|
if (currentModel === "unknown") {
|
||||||
|
updateStatus("Please select a model first");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Display user message
|
||||||
|
displayMessage(userInput, "user");
|
||||||
|
document.getElementById("user-input").value = "";
|
||||||
|
updateStatus("Sending message...");
|
||||||
|
|
||||||
|
messages.push({ role: "user", content: userInput });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// We set "stream": true to indicate we want SSE streaming from our server
|
||||||
|
const payload = {
|
||||||
|
model: currentModel,
|
||||||
|
messages: messages,
|
||||||
|
max_tokens: 100,
|
||||||
|
temperature: 0.7,
|
||||||
|
stream: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch("/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Authorization: `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
Accept: "text/event-stream",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Error: ${response.status} - ${response.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare to stream the response
|
||||||
|
await handleStream(response);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
displayMessage(`Error: ${error.message}`, "assistant");
|
||||||
|
updateStatus(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStream(response) {
|
||||||
|
// We'll accumulate tokens into this variable
|
||||||
|
let assistantMessage = "";
|
||||||
|
|
||||||
|
// Create a DOM element for the assistant's streaming message
|
||||||
|
const chatContainer = document.getElementById("chat-container");
|
||||||
|
const messageElem = document.createElement("div");
|
||||||
|
messageElem.classList.add("message", "assistant");
|
||||||
|
messageElem.innerHTML = `<strong class="assistant">Assistant:</strong> <span></span>`;
|
||||||
|
chatContainer.appendChild(messageElem);
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
const messageTextSpan = messageElem.querySelector("span");
|
||||||
|
|
||||||
|
updateStatus("Receiving response...");
|
||||||
|
|
||||||
|
// Read the response body as a stream
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
const decoder = new TextDecoder("utf-8");
|
||||||
|
|
||||||
|
try {
|
||||||
|
while (true) {
|
||||||
|
const { value, done } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
|
const lines = chunk.split("\n");
|
||||||
|
for (let line of lines) {
|
||||||
|
if (!line || !line.startsWith("data: ")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const jsonStr = line.substring("data: ".length).trim();
|
||||||
|
if (jsonStr === "[DONE]") {
|
||||||
|
messages.push({ role: "assistant", content: assistantMessage });
|
||||||
|
updateStatus(`Response complete (${extractModelSuffix(currentModel)})`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonStr);
|
||||||
|
if (parsed.choices && parsed.choices.length > 0) {
|
||||||
|
const deltaContent = parsed.choices[0].delta.content;
|
||||||
|
if (deltaContent) {
|
||||||
|
assistantMessage += deltaContent;
|
||||||
|
// Update the DOM text
|
||||||
|
messageTextSpan.textContent = assistantMessage;
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Could not parse SSE line:", e, line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
reader.releaseLock();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayMessage(text, sender) {
|
||||||
|
const chatContainer = document.getElementById("chat-container");
|
||||||
|
const messageElem = document.createElement("div");
|
||||||
|
messageElem.classList.add("message", sender);
|
||||||
|
|
||||||
|
if (sender === "user") {
|
||||||
|
messageElem.innerHTML = `<strong class="user">User:</strong> ${text}`;
|
||||||
|
} else {
|
||||||
|
messageElem.innerHTML = `<strong class="assistant">Assistant:</strong> ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
chatContainer.appendChild(messageElem);
|
||||||
|
chatContainer.scrollTop = chatContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -398,7 +398,21 @@ Java_com_alibaba_mnnllm_android_llm_LlmSession_updateAssistantPromptNative(JNIEn
|
||||||
}
|
}
|
||||||
env->ReleaseStringUTFChars(assistant_prompt_j, assistant_prompt_cstr);
|
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"
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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服务历史消息推理方法
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
|
@ -0,0 +1,223 @@
|
||||||
|
package com.alibaba.mnnllm.android.benchmark
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.res.ColorStateList
|
||||||
|
import android.graphics.drawable.GradientDrawable
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.LayoutInflater
|
||||||
|
import android.widget.LinearLayout
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.alibaba.mnnllm.android.R
|
||||||
|
import com.alibaba.mnnllm.android.databinding.ViewPerformanceMetricBinding
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom view component for displaying performance metrics
|
||||||
|
* Similar to iOS PerformanceMetricView with icon, title, value, and subtitle
|
||||||
|
*/
|
||||||
|
class PerformanceMetricView @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null,
|
||||||
|
defStyleAttr: Int = 0
|
||||||
|
) : LinearLayout(context, attrs, defStyleAttr) {
|
||||||
|
|
||||||
|
private val binding: ViewPerformanceMetricBinding
|
||||||
|
|
||||||
|
init {
|
||||||
|
binding = ViewPerformanceMetricBinding.inflate(LayoutInflater.from(context), this, true)
|
||||||
|
orientation = VERTICAL
|
||||||
|
|
||||||
|
// Set default styling
|
||||||
|
val padding = resources.getDimensionPixelSize(R.dimen.performance_metric_padding)
|
||||||
|
setPadding(padding, padding, padding, padding)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set performance metric data
|
||||||
|
* @param icon Resource ID for the icon
|
||||||
|
* @param title Main title text
|
||||||
|
* @param value Primary value to display (e.g., "121.13 t/s")
|
||||||
|
* @param subtitle Secondary text (e.g., "Prompt Processing")
|
||||||
|
* @param colorResId Color resource ID for theming
|
||||||
|
* @param stdDev Standard deviation value to display next to the value (e.g., "±3.88")
|
||||||
|
*/
|
||||||
|
fun setMetricData(
|
||||||
|
icon: Int,
|
||||||
|
title: String,
|
||||||
|
value: String,
|
||||||
|
subtitle: String,
|
||||||
|
colorResId: Int,
|
||||||
|
stdDev: String? = null
|
||||||
|
) {
|
||||||
|
val color = ContextCompat.getColor(context, colorResId)
|
||||||
|
|
||||||
|
// Set icon with circular background
|
||||||
|
binding.metricIcon.setImageResource(icon)
|
||||||
|
setIconBackground(color)
|
||||||
|
|
||||||
|
// Set texts
|
||||||
|
binding.metricTitle.text = title
|
||||||
|
binding.metricValue.text = value
|
||||||
|
binding.metricSubtitle.text = subtitle
|
||||||
|
|
||||||
|
// Handle standard deviation display
|
||||||
|
if (stdDev != null && stdDev.isNotEmpty()) {
|
||||||
|
binding.metricStdDev.text = stdDev
|
||||||
|
binding.metricStdDev.setTextColor(color)
|
||||||
|
binding.metricStdDev.visibility = android.view.View.VISIBLE
|
||||||
|
} else {
|
||||||
|
binding.metricStdDev.visibility = android.view.View.GONE
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply color theming
|
||||||
|
binding.metricValue.setTextColor(color)
|
||||||
|
binding.metricIcon.imageTintList = ColorStateList.valueOf(color)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create circular gradient background for icon
|
||||||
|
*/
|
||||||
|
private fun setIconBackground(color: Int) {
|
||||||
|
val gradientDrawable = GradientDrawable().apply {
|
||||||
|
shape = GradientDrawable.OVAL
|
||||||
|
|
||||||
|
// Create gradient colors with transparency
|
||||||
|
val startColor = (color and 0x00FFFFFF) or 0x33000000 // 20% opacity
|
||||||
|
val endColor = (color and 0x00FFFFFF) or 0x1A000000 // 10% opacity
|
||||||
|
|
||||||
|
colors = intArrayOf(startColor, endColor)
|
||||||
|
gradientType = GradientDrawable.RADIAL_GRADIENT
|
||||||
|
gradientRadius = 50f
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.iconBackground.background = gradientDrawable
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for speed statistics with string resource support
|
||||||
|
*/
|
||||||
|
fun setSpeedMetric(
|
||||||
|
icon: Int,
|
||||||
|
titleResId: Int,
|
||||||
|
stats: SpeedStatistics?,
|
||||||
|
colorResId: Int
|
||||||
|
) {
|
||||||
|
val title = context.getString(titleResId)
|
||||||
|
if (stats != null) {
|
||||||
|
val value = "%.1f t/s".format(stats.average)
|
||||||
|
val stdDev = "±%.1f".format(stats.stdev)
|
||||||
|
setMetricData(icon, title, value, context.getString(R.string.prefill_speed_subtitle), colorResId, stdDev)
|
||||||
|
} else {
|
||||||
|
setMetricData(icon, title, context.getString(R.string.not_available), context.getString(R.string.prefill_speed_subtitle), colorResId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for memory metric
|
||||||
|
*/
|
||||||
|
fun setMemoryMetric(
|
||||||
|
maxMemoryKb: Long,
|
||||||
|
totalMemoryKb: Long,
|
||||||
|
colorResId: Int
|
||||||
|
) {
|
||||||
|
// Format memory values with appropriate units (MB or GB)
|
||||||
|
val maxMemoryFormatted = formatMemorySize(maxMemoryKb)
|
||||||
|
val totalMemoryFormatted = formatMemorySize(totalMemoryKb)
|
||||||
|
|
||||||
|
setMetricData(
|
||||||
|
R.drawable.ic_memorychip,
|
||||||
|
context.getString(R.string.memory_usage_title),
|
||||||
|
maxMemoryFormatted,
|
||||||
|
context.getString(R.string.memory_usage_subtitle),
|
||||||
|
colorResId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format memory size with appropriate unit (MB or GB)
|
||||||
|
*/
|
||||||
|
private fun formatMemorySize(memoryKb: Long): String {
|
||||||
|
val memoryMB = memoryKb / 1024.0
|
||||||
|
return if (memoryMB >= 1024.0) {
|
||||||
|
val memoryGB = memoryMB / 1024.0
|
||||||
|
"%.1f GB".format(memoryGB)
|
||||||
|
} else {
|
||||||
|
"%.0f MB".format(memoryMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update metric with simple title, value, and icon name
|
||||||
|
* @param title Metric title
|
||||||
|
* @param value Metric value
|
||||||
|
* @param iconName Icon resource name (e.g., "ic_clock")
|
||||||
|
*/
|
||||||
|
fun updateMetric(title: String, value: String, iconName: String) {
|
||||||
|
val iconResId = getIconResourceId(iconName)
|
||||||
|
val colorResId = R.color.benchmark_accent
|
||||||
|
|
||||||
|
setMetricData(
|
||||||
|
icon = iconResId,
|
||||||
|
title = title,
|
||||||
|
value = value,
|
||||||
|
subtitle = title,
|
||||||
|
colorResId = colorResId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get icon resource ID from resource name
|
||||||
|
*/
|
||||||
|
private fun getIconResourceId(iconName: String): Int {
|
||||||
|
return try {
|
||||||
|
val resourceName = if (iconName.startsWith("ic_")) iconName else "ic_$iconName"
|
||||||
|
val resourceId = resources.getIdentifier(resourceName, "drawable", context.packageName)
|
||||||
|
if (resourceId != 0) resourceId else R.drawable.ic_clock
|
||||||
|
} catch (e: Exception) {
|
||||||
|
R.drawable.ic_clock
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for total time metric
|
||||||
|
*/
|
||||||
|
fun setTotalTimeMetric(
|
||||||
|
totalTimeSeconds: Double,
|
||||||
|
colorResId: Int
|
||||||
|
) {
|
||||||
|
val formattedTime = formatTime(totalTimeSeconds)
|
||||||
|
setMetricData(
|
||||||
|
R.drawable.ic_clock,
|
||||||
|
context.getString(R.string.total_tokens_title),
|
||||||
|
formattedTime,
|
||||||
|
context.getString(R.string.total_tokens_subtitle),
|
||||||
|
colorResId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format time in seconds to appropriate unit (ms, s, or min)
|
||||||
|
*/
|
||||||
|
private fun formatTime(seconds: Double): String {
|
||||||
|
return when {
|
||||||
|
seconds < 1.0 -> "%.0f ms".format(seconds * 1000)
|
||||||
|
seconds < 60.0 -> "%.3f s".format(seconds)
|
||||||
|
else -> "%.1f min".format(seconds / 60.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convenience method for total tokens metric (deprecated, use setTotalTimeMetric instead)
|
||||||
|
*/
|
||||||
|
fun setTotalTokensMetric(
|
||||||
|
totalTokens: Int,
|
||||||
|
colorResId: Int
|
||||||
|
) {
|
||||||
|
setMetricData(
|
||||||
|
R.drawable.ic_clock,
|
||||||
|
context.getString(R.string.total_tokens_title),
|
||||||
|
"$totalTokens",
|
||||||
|
context.getString(R.string.total_tokens_subtitle),
|
||||||
|
colorResId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
|
@ -166,11 +166,8 @@ class ChatActivity : AppCompatActivity() {
|
||||||
private fun setupInputModule() {
|
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
|
||||||
|
|
||||||
|
|
|
@ -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()
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
|
@ -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
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
|
@ -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)) {
|
||||||
|
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,132 @@
|
||||||
|
package com.alibaba.mnnllm.api.openai.manager
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.widget.Toast
|
||||||
|
import android.net.Uri
|
||||||
|
import com.alibaba.mnnllm.api.openai.service.OpenAIService
|
||||||
|
import com.alibaba.mnnllm.api.openai.service.ApiServerConfig
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理通知栏操作按钮点击事件的广播接收器
|
||||||
|
*/
|
||||||
|
class ApiServiceActionReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_STOP_SERVICE = "com.alibaba.mnnllm.api.openai.STOP_SERVICE"
|
||||||
|
const val ACTION_COPY_URL = "com.alibaba.mnnllm.api.openai.COPY_URL"
|
||||||
|
const val ACTION_TEST_PAGE = "com.alibaba.mnnllm.api.openai.TEST_PAGE"
|
||||||
|
const val EXTRA_URL = "extra_url"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST RECEIVED ===")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Action: ${intent.action}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Package: ${intent.`package`}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Component: ${intent.component}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Intent extras: ${intent.extras}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Intent data: ${intent.data}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Intent flags: ${intent.flags}")
|
||||||
|
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_STOP_SERVICE -> {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Processing STOP_SERVICE action")
|
||||||
|
stopApiService(context)
|
||||||
|
}
|
||||||
|
ACTION_COPY_URL -> {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Processing COPY_URL action")
|
||||||
|
val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080"
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("URL to copy: $url")
|
||||||
|
copyUrlToClipboard(context, url)
|
||||||
|
}
|
||||||
|
ACTION_TEST_PAGE -> {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Processing TEST_PAGE action")
|
||||||
|
val url = intent.getStringExtra(EXTRA_URL) ?: "http://localhost:8080"
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("URL to open: $url")
|
||||||
|
openTestPageInBrowser(context, url)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").w("Unknown action: ${intent.action}")
|
||||||
|
Timber.tag("ApiServiceActionReceiver").w("Expected actions: $ACTION_STOP_SERVICE, $ACTION_COPY_URL, $ACTION_TEST_PAGE")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("=== BROADCAST PROCESSING COMPLETE ===")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止 API 服务
|
||||||
|
*/
|
||||||
|
private fun stopApiService(context: Context) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Starting to stop API service...")
|
||||||
|
try {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Calling OpenAIService.releaseService()")
|
||||||
|
OpenAIService.releaseService(context)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("API service stopped successfully")
|
||||||
|
Toast.makeText(context, "API Service stopped", Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to stop API service: ${e.message}")
|
||||||
|
Toast.makeText(context, "Failed to stop service", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 复制 URL 到剪贴板
|
||||||
|
*/
|
||||||
|
private fun copyUrlToClipboard(context: Context, url: String) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Starting to copy URL to clipboard: $url")
|
||||||
|
try {
|
||||||
|
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Clipboard service obtained: $clipboard")
|
||||||
|
|
||||||
|
val clip = ClipData.newPlainText("API URL", url)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("ClipData created: $clip")
|
||||||
|
|
||||||
|
clipboard.setPrimaryClip(clip)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("URL copied to clipboard successfully")
|
||||||
|
|
||||||
|
val successMessage = context.getString(com.alibaba.mnnllm.android.R.string.api_url_copied)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Showing toast: $successMessage")
|
||||||
|
Toast.makeText(context, successMessage, Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to copy URL to clipboard: ${e.message}")
|
||||||
|
Toast.makeText(context, "Failed to copy URL", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在浏览器中打开测试页面
|
||||||
|
*/
|
||||||
|
private fun openTestPageInBrowser(context: Context, url: String) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Starting to open test page in browser: $url")
|
||||||
|
try {
|
||||||
|
// 检查是否启用了认证,如果启用则添加token参数
|
||||||
|
val finalUrl = if (ApiServerConfig.isAuthEnabled(context)) {
|
||||||
|
val apiKey = ApiServerConfig.getApiKey(context)
|
||||||
|
val separator = if (url.contains("?")) "&" else "?"
|
||||||
|
val urlWithToken = "$url${separator}token=$apiKey"
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Auth enabled, adding token to URL: $urlWithToken")
|
||||||
|
urlWithToken
|
||||||
|
} else {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Auth disabled, using original URL")
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
|
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(finalUrl))
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Intent created: $intent")
|
||||||
|
|
||||||
|
context.startActivity(intent)
|
||||||
|
Timber.tag("ApiServiceActionReceiver").i("Test page opened in browser successfully")
|
||||||
|
|
||||||
|
Toast.makeText(context, "Opening test page in browser", Toast.LENGTH_SHORT).show()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("ApiServiceActionReceiver").e(e, "Failed to open test page in browser: ${e.message}")
|
||||||
|
Toast.makeText(context, "Failed to open browser", Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -4,8 +4,11 @@ import android.R
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
import androidx.core.app.NotificationCompat
|
import androidx.core.app.NotificationCompat
|
||||||
|
import com.alibaba.mnnllm.api.openai.service.ApiServerConfig
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -46,20 +49,86 @@ class ApiNotificationManager(private val context: Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build API service notification
|
* Build API service notification with IP address and action buttons
|
||||||
*
|
*
|
||||||
* @param contentTitle Notification title, uses default from string resources if not provided
|
* @param contentTitle Notification title, uses default from string resources if not provided
|
||||||
* @param contentText Notification content, uses default from string resources if not provided
|
* @param contentText Notification content, uses default from string resources if not provided
|
||||||
|
* @param port Server port number
|
||||||
* @return Built Notification object
|
* @return Built Notification object
|
||||||
*/
|
*/
|
||||||
fun buildNotification(
|
fun buildNotification(
|
||||||
contentTitle: String? = null,
|
contentTitle: String? = null,
|
||||||
contentText: String? = null
|
contentText: String? = null,
|
||||||
|
port: Int = 8080
|
||||||
): Notification {
|
): Notification {
|
||||||
val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running)
|
val title = contentTitle ?: context.getString(com.alibaba.mnnllm.android.R.string.api_service_running)
|
||||||
val text = contentText ?: context.getString(com.alibaba.mnnllm.android.R.string.server_running_message)
|
val ipAddress = ApiServerConfig.getIpAddress(context)
|
||||||
|
val url = "http://$ipAddress:$port"
|
||||||
|
val text = if (contentText.isNullOrBlank()) {
|
||||||
|
context.getString(com.alibaba.mnnllm.android.R.string.api_service_running_on, ipAddress, port)
|
||||||
|
} else {
|
||||||
|
contentText
|
||||||
|
}
|
||||||
|
|
||||||
return NotificationCompat.Builder(context, CHANNEL_ID)
|
Timber.tag("ApiNotificationManager").i("Building notification - Config IP: $ipAddress, Port: $port, Text: $text")
|
||||||
|
|
||||||
|
// 创建停止服务的 PendingIntent
|
||||||
|
val stopIntent = Intent(ApiServiceActionReceiver.ACTION_STOP_SERVICE).apply {
|
||||||
|
`package` = context.packageName
|
||||||
|
}
|
||||||
|
Timber.tag("ApiNotificationManager").i("Creating stop intent with action: ${ApiServiceActionReceiver.ACTION_STOP_SERVICE}")
|
||||||
|
Timber.tag("ApiNotificationManager").i("Stop intent package: ${context.packageName}")
|
||||||
|
val stopPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
1001,
|
||||||
|
stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Stop PendingIntent created: $stopPendingIntent")
|
||||||
|
|
||||||
|
// 创建复制 URL 的 PendingIntent
|
||||||
|
val copyIntent = Intent(ApiServiceActionReceiver.ACTION_COPY_URL).apply {
|
||||||
|
`package` = context.packageName
|
||||||
|
putExtra(ApiServiceActionReceiver.EXTRA_URL, url)
|
||||||
|
}
|
||||||
|
Timber.tag("ApiNotificationManager").i("Creating copy intent with action: ${ApiServiceActionReceiver.ACTION_COPY_URL}, URL: $url")
|
||||||
|
Timber.tag("ApiNotificationManager").i("Copy intent package: ${context.packageName}")
|
||||||
|
val copyPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
System.currentTimeMillis().toInt(),
|
||||||
|
copyIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Copy PendingIntent created: $copyPendingIntent")
|
||||||
|
|
||||||
|
// 创建打开测试页面的 PendingIntent
|
||||||
|
val testIntent = Intent(ApiServiceActionReceiver.ACTION_TEST_PAGE).apply {
|
||||||
|
`package` = context.packageName
|
||||||
|
putExtra(ApiServiceActionReceiver.EXTRA_URL, url)
|
||||||
|
}
|
||||||
|
Timber.tag("ApiNotificationManager").i("Creating test intent with action: ${ApiServiceActionReceiver.ACTION_TEST_PAGE}, URL: $url")
|
||||||
|
Timber.tag("ApiNotificationManager").i("Test intent package: ${context.packageName}")
|
||||||
|
val testPendingIntent = PendingIntent.getBroadcast(
|
||||||
|
context,
|
||||||
|
(System.currentTimeMillis() + 1).toInt(),
|
||||||
|
testIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Test PendingIntent created: $testPendingIntent")
|
||||||
|
|
||||||
|
// 创建点击通知时打开主Activity的PendingIntent
|
||||||
|
val mainActivityIntent = Intent(context, com.alibaba.mnnllm.android.main.MainActivity::class.java).apply {
|
||||||
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
|
}
|
||||||
|
val mainActivityPendingIntent = PendingIntent.getActivity(
|
||||||
|
context,
|
||||||
|
(System.currentTimeMillis() + 2).toInt(),
|
||||||
|
mainActivityIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
Timber.tag("ApiNotificationManager").i("MainActivity PendingIntent created: $mainActivityPendingIntent")
|
||||||
|
|
||||||
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(text)
|
.setContentText(text)
|
||||||
.setSmallIcon(R.drawable.ic_dialog_info)
|
.setSmallIcon(R.drawable.ic_dialog_info)
|
||||||
|
@ -67,7 +136,30 @@ class ApiNotificationManager(private val context: Context) {
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setAutoCancel(false)
|
.setAutoCancel(false)
|
||||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||||
|
.setContentIntent(mainActivityPendingIntent)
|
||||||
|
// .addAction(
|
||||||
|
// R.drawable.ic_dialog_alert,
|
||||||
|
// context.getString(com.alibaba.mnnllm.android.R.string.api_service_stop),
|
||||||
|
// stopPendingIntent
|
||||||
|
// )
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_dialog_info,
|
||||||
|
context.getString(com.alibaba.mnnllm.android.R.string.api_service_copy_url),
|
||||||
|
copyPendingIntent
|
||||||
|
)
|
||||||
|
.addAction(
|
||||||
|
R.drawable.ic_dialog_info,
|
||||||
|
context.getString(com.alibaba.mnnllm.android.R.string.api_service_test_page),
|
||||||
|
testPendingIntent
|
||||||
|
)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
Timber.tag("ApiNotificationManager").i("Notification built successfully with ${notification.actions?.size ?: 0} actions")
|
||||||
|
notification.actions?.forEachIndexed { index, action ->
|
||||||
|
Timber.tag("ApiNotificationManager").i("Action $index: ${action.title}, Intent: ${action.actionIntent}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return notification
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -75,11 +167,14 @@ class ApiNotificationManager(private val context: Context) {
|
||||||
*
|
*
|
||||||
* @param contentTitle New notification title
|
* @param contentTitle New notification title
|
||||||
* @param contentText New notification content
|
* @param contentText New notification content
|
||||||
|
* @param port Server port number
|
||||||
*/
|
*/
|
||||||
fun updateNotification(contentTitle: String, contentText: String) {
|
fun updateNotification(contentTitle: String, contentText: String, port: Int = 8080) {
|
||||||
val notification = buildNotification(contentTitle, contentText)
|
Timber.tag("ApiNotificationManager").i("updateNotification called - Title: $contentTitle, Text: $contentText, Port: $port")
|
||||||
Timber.tag("NotificationManager").i("Updating notification: $contentTitle - $contentText")
|
val notification = buildNotification(contentTitle, contentText, port)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Updating notification: $contentTitle - $contentText")
|
||||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||||
|
Timber.tag("ApiNotificationManager").i("Notification updated with ID: $NOTIFICATION_ID")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -90,9 +185,10 @@ class ApiNotificationManager(private val context: Context) {
|
||||||
fun cancelNotification() {
|
fun cancelNotification() {
|
||||||
try {
|
try {
|
||||||
notificationManager.cancel(NOTIFICATION_ID)
|
notificationManager.cancel(NOTIFICATION_ID)
|
||||||
Timber.tag("NotificationManager").i("Notification cancelled")
|
Timber.tag("ApiNotificationManager").i("Notification cancelled")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.tag("NotificationManager").w("Failed to cancel notification: ${e.message}")
|
Timber.tag("ApiNotificationManager").w("Failed to cancel notification: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
|
@ -5,6 +5,7 @@ import com.alibaba.mnnllm.android.chat.ChatActivity
|
||||||
import com.alibaba.mnnllm.android.llm.LlmSession
|
import com.alibaba.mnnllm.android.llm.LlmSession
|
||||||
import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes
|
import com.alibaba.mnnllm.api.openai.network.routes.chatRoutes
|
||||||
import com.alibaba.mnnllm.api.openai.network.routes.queueRoutes
|
import com.alibaba.mnnllm.api.openai.network.routes.queueRoutes
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.routes.modelsRoutes
|
||||||
import io.ktor.http.*
|
import io.ktor.http.*
|
||||||
import io.ktor.serialization.kotlinx.json.*
|
import io.ktor.serialization.kotlinx.json.*
|
||||||
import io.ktor.server.application.*
|
import io.ktor.server.application.*
|
||||||
|
@ -19,6 +20,8 @@ import io.ktor.server.routing.*
|
||||||
import io.ktor.server.sse.*
|
import io.ktor.server.sse.*
|
||||||
import io.ktor.sse.*
|
import io.ktor.sse.*
|
||||||
import org.slf4j.event.*
|
import org.slf4j.event.*
|
||||||
|
import android.content.Context
|
||||||
|
import java.io.InputStream
|
||||||
|
|
||||||
|
|
||||||
fun Application.configureRouting() {
|
fun Application.configureRouting() {
|
||||||
|
@ -26,8 +29,12 @@ fun Application.configureRouting() {
|
||||||
|
|
||||||
routing {
|
routing {
|
||||||
get("/") {
|
get("/") {
|
||||||
val response = "Hello, World!"
|
try {
|
||||||
call.respondText(response, contentType = ContentType.Text.Plain)
|
val htmlContent = loadHtmlFromAssets()
|
||||||
|
call.respondText(htmlContent, contentType = ContentType.Text.Html)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
call.respondText("Error loading test page: ${e.message}", contentType = ContentType.Text.Plain)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
sse("/hello") {
|
sse("/hello") {
|
||||||
send(ServerSentEvent("world"))
|
send(ServerSentEvent("world"))
|
||||||
|
@ -40,6 +47,8 @@ fun Application.configureRouting() {
|
||||||
// 在这里定义需要认证的路由
|
// 在这里定义需要认证的路由
|
||||||
// /v1/chat/completions
|
// /v1/chat/completions
|
||||||
chatRoutes()
|
chatRoutes()
|
||||||
|
// /v1/models
|
||||||
|
modelsRoutes()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -59,4 +68,19 @@ fun Application.configureRouting() {
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从assets目录加载HTML文件
|
||||||
|
*/
|
||||||
|
private fun loadHtmlFromAssets(): String {
|
||||||
|
return try {
|
||||||
|
// 使用Application Context来访问assets
|
||||||
|
val context = com.alibaba.mnnllm.android.MnnLlmApplication.getAppContext()
|
||||||
|
val inputStream: InputStream = context.assets.open("test_page.html")
|
||||||
|
|
||||||
|
inputStream.bufferedReader().use { reader ->
|
||||||
|
reader.readText()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
throw Exception("Failed to load HTML from assets: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
|
@ -64,4 +64,40 @@ data class CompletionChoice(
|
||||||
data class Message(
|
data class Message(
|
||||||
val role: String,
|
val role: String,
|
||||||
val content: String
|
val content: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Models API 响应数据模型
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModelsResponse(
|
||||||
|
val `object`: String = "list",
|
||||||
|
val data: List<ModelData>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ModelData(
|
||||||
|
val id: String,
|
||||||
|
val `object`: String = "model",
|
||||||
|
val created: Long,
|
||||||
|
val owned_by: String = "mnn",
|
||||||
|
val permission: List<ModelPermission> = emptyList(),
|
||||||
|
val root: String? = null,
|
||||||
|
val parent: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ModelPermission(
|
||||||
|
val id: String,
|
||||||
|
val `object`: String = "model_permission",
|
||||||
|
val created: Long,
|
||||||
|
val allow_create_engine: Boolean = false,
|
||||||
|
val allow_sampling: Boolean = true,
|
||||||
|
val allow_logprobs: Boolean = true,
|
||||||
|
val allow_search_indices: Boolean = true,
|
||||||
|
val allow_view: Boolean = true,
|
||||||
|
val allow_fine_tuning: Boolean = false,
|
||||||
|
val organization: String = "*",
|
||||||
|
val group: String? = null,
|
||||||
|
val is_blocking: Boolean = false
|
||||||
)
|
)
|
|
@ -23,14 +23,8 @@ fun Route.chatRoutes() {
|
||||||
|
|
||||||
post("/v1/chat/completions") {
|
post("/v1/chat/completions") {
|
||||||
val traceId = UUID.randomUUID().toString()
|
val traceId = UUID.randomUUID().toString()
|
||||||
|
|
||||||
// 记录请求开始
|
|
||||||
logger.logRequestStart(traceId, call)
|
logger.logRequestStart(traceId, call)
|
||||||
|
|
||||||
// 接收请求体
|
|
||||||
val chatRequest = call.receive<OpenAIChatRequest>()
|
val chatRequest = call.receive<OpenAIChatRequest>()
|
||||||
|
|
||||||
// 委托给服务层处理
|
|
||||||
MNNChatService.processChatCompletion(call, chatRequest, traceId)
|
MNNChatService.processChatCompletion(call, chatRequest, traceId)
|
||||||
}
|
}
|
||||||
}
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
package com.alibaba.mnnllm.api.openai.network.routes
|
||||||
|
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelData
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelPermission
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.services.MNNModelsService
|
||||||
|
import io.ktor.server.routing.Route
|
||||||
|
import io.ktor.server.routing.get
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型路由定义
|
||||||
|
* 负责定义 /v1/models API路由
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册模型相关的路由
|
||||||
|
*/
|
||||||
|
fun Route.modelsRoutes() {
|
||||||
|
val modelsService = MNNModelsService()
|
||||||
|
|
||||||
|
get("/v1/models") {
|
||||||
|
val traceId = UUID.randomUUID().toString()
|
||||||
|
modelsService.getAvailableModels(call, traceId)
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.alibaba.mnnllm.api.openai.network.services
|
||||||
|
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.logging.ChatLogger
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelData
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelPermission
|
||||||
|
import com.alibaba.mnnllm.api.openai.network.models.ModelsResponse
|
||||||
|
import com.alibaba.mnnllm.android.modelist.ModelListManager
|
||||||
|
import com.alibaba.mnnllm.android.MnnLlmApplication
|
||||||
|
import io.ktor.http.HttpStatusCode
|
||||||
|
import io.ktor.server.application.call
|
||||||
|
import io.ktor.server.response.respond
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import timber.log.Timber
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MNN 模型服务
|
||||||
|
* 负责处理模型相关的业务逻辑
|
||||||
|
*/
|
||||||
|
class MNNModelsService {
|
||||||
|
private val logger = ChatLogger()
|
||||||
|
|
||||||
|
suspend fun getAvailableModels(call: io.ktor.server.application.ApplicationCall, traceId: String) {
|
||||||
|
try {
|
||||||
|
logger.logRequestStart(traceId, call)
|
||||||
|
|
||||||
|
val context = MnnLlmApplication.getAppContext()
|
||||||
|
val availableModels = runBlocking {
|
||||||
|
ModelListManager.loadAvailableModels(context)
|
||||||
|
}
|
||||||
|
val modelDataList = availableModels.map { modelWrapper ->
|
||||||
|
ModelData(
|
||||||
|
id = modelWrapper.modelItem.modelId ?: "unknown",
|
||||||
|
created = System.currentTimeMillis() / 1000, // Unix timestamp
|
||||||
|
permission = listOf(
|
||||||
|
ModelPermission(
|
||||||
|
id = "modelperm-${modelWrapper.modelItem.modelId}",
|
||||||
|
created = System.currentTimeMillis() / 1000
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val response = ModelsResponse(data = modelDataList)
|
||||||
|
|
||||||
|
call.respond(response)
|
||||||
|
logger.logInfo(traceId, "Models list returned successfully")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("MNNModelsService").e(e, "Error getting available models")
|
||||||
|
logger.logError(traceId, e, "Failed to get available models")
|
||||||
|
call.respond(HttpStatusCode.InternalServerError, mapOf("error" to "Failed to get available models"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -82,7 +82,8 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
// 更新通知
|
// 更新通知
|
||||||
notificationManager?.updateNotification(
|
notificationManager?.updateNotification(
|
||||||
context.getString(R.string.api_service_running),
|
context.getString(R.string.api_service_running),
|
||||||
context.getString(R.string.api_service_port, app.getPort())
|
"", // 让 NotificationManager 使用默认的 IP 地址显示
|
||||||
|
app.getPort()
|
||||||
)
|
)
|
||||||
|
|
||||||
_isServerRunning = true
|
_isServerRunning = true
|
||||||
|
@ -123,8 +124,8 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
/**
|
/**
|
||||||
* 更新通知内容
|
* 更新通知内容
|
||||||
*/
|
*/
|
||||||
fun updateNotification(title: String, content: String) {
|
fun updateNotification(title: String, content: String, port: Int = 8080) {
|
||||||
notificationManager?.updateNotification(title, content)
|
notificationManager?.updateNotification(title, content, port)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -132,8 +133,9 @@ class ApiServiceCoordinator(private val context: Context) {
|
||||||
*/
|
*/
|
||||||
fun getNotification(
|
fun getNotification(
|
||||||
title: String = context.getString(R.string.api_service_running),
|
title: String = context.getString(R.string.api_service_running),
|
||||||
content: String = context.getString(R.string.api_service_port, 8080)
|
content: String = context.getString(R.string.api_service_port, 8080),
|
||||||
) = notificationManager?.buildNotification(title, content)
|
port: Int = 8080
|
||||||
|
) = notificationManager?.buildNotification(title, content, port)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取服务器端口
|
* 获取服务器端口
|
||||||
|
|
|
@ -1,15 +1,18 @@
|
||||||
package com.alibaba.mnnllm.api.openai.service
|
package com.alibaba.mnnllm.api.openai.service
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
import android.app.Service
|
import android.app.Service
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.content.ServiceConnection
|
import android.content.ServiceConnection
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.content.pm.ServiceInfo
|
import android.content.pm.ServiceInfo
|
||||||
import android.os.Binder
|
import android.os.Binder
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import androidx.annotation.RequiresApi
|
import androidx.annotation.RequiresApi
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import com.alibaba.mnnllm.android.chat.ChatActivity
|
import com.alibaba.mnnllm.android.chat.ChatActivity
|
||||||
import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator
|
import com.alibaba.mnnllm.api.openai.service.ApiServiceCoordinator
|
||||||
import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager
|
import com.alibaba.mnnllm.api.openai.manager.ApiNotificationManager
|
||||||
|
@ -29,10 +32,27 @@ class OpenAIService : Service() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
context,
|
||||||
|
Manifest.permission.POST_NOTIFICATIONS
|
||||||
|
) != PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
Timber.tag("ServiceStartCondition").w("Notification permission not granted, cannot start foreground service")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val serviceIntent = Intent(context, OpenAIService::class.java)
|
val serviceIntent = Intent(context, OpenAIService::class.java)
|
||||||
// 在启动服务前设置标志,避免onStartCommand中的检查失败
|
|
||||||
isServiceRunning = true
|
isServiceRunning = true
|
||||||
context.startForegroundService(serviceIntent)
|
try {
|
||||||
|
context.startForegroundService(serviceIntent)
|
||||||
|
Timber.tag("ServiceStartCondition").i("Foreground service started successfully")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Timber.tag("ServiceStartCondition").e(e, "Failed to start foreground service")
|
||||||
|
isServiceRunning = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val connection = object : ServiceConnection {
|
val connection = object : ServiceConnection {
|
||||||
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
|
||||||
|
@ -43,7 +63,6 @@ class OpenAIService : Service() {
|
||||||
|
|
||||||
override fun onServiceDisconnected(name: ComponentName?) {
|
override fun onServiceDisconnected(name: ComponentName?) {
|
||||||
serviceConnection = null
|
serviceConnection = null
|
||||||
// 服务断开连接时重置标志
|
|
||||||
isServiceRunning = false
|
isServiceRunning = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:id="@android:id/background">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#374151" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:id="@android:id/progress">
|
||||||
|
<clip>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_gradient_start"
|
||||||
|
android:endColor="@color/benchmark_gradient_end"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</clip>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
||||||
|
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/benchmark_button_disabled_gradient" android:state_enabled="false" />
|
||||||
|
<item android:drawable="@drawable/benchmark_button_gradient" />
|
||||||
|
</selector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_secondary"
|
||||||
|
android:endColor="@color/benchmark_secondary"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_gradient_start"
|
||||||
|
android:endColor="@color/benchmark_gradient_end"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#33FFFFFF" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:drawable="@drawable/benchmark_button_disabled_gradient" android:state_enabled="false" />
|
||||||
|
<item android:drawable="@drawable/benchmark_button_stop_gradient" />
|
||||||
|
</selector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_error"
|
||||||
|
android:endColor="@color/benchmark_error"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#0D6366f1" />
|
||||||
|
<corners android:radius="6dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#1A6366f1" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#1Af59e0b" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<item android:id="@android:id/background">
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<solid android:color="#E5E7EB" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</item>
|
||||||
|
<item android:id="@android:id/progress">
|
||||||
|
<clip>
|
||||||
|
<shape android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_gradient_start"
|
||||||
|
android:endColor="@color/benchmark_gradient_end"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
||||||
|
</clip>
|
||||||
|
</item>
|
||||||
|
</layer-list>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<gradient
|
||||||
|
android:startColor="@color/benchmark_gradient_start"
|
||||||
|
android:endColor="@color/benchmark_gradient_end"
|
||||||
|
android:angle="0" />
|
||||||
|
<corners android:radius="8dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/benchmark_card_bg" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#4D10b981" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="#3310b981" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="@color/benchmark_card_bg" />
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="#4Df59e0b" />
|
||||||
|
<corners android:radius="16dp" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="oval">
|
||||||
|
<solid android:color="?colorPrimaryContainer" />
|
||||||
|
</shape>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5v12h11L19,5zM19,19L8,19v2h11c1.1,0 2,-0.9 2,-2v-2h-2z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24"
|
||||||
|
android:tint="?attr/colorOnSurface">
|
||||||
|
<path
|
||||||
|
android:fillColor="@android:color/white"
|
||||||
|
android:pathData="M6,6h12v12H6z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM11,17l1,-1 1,1 1.41,-1.41L12,13.17l-2.41,2.42L11,17z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="16dp"
|
||||||
|
android:height="16dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M8.59,16.59L13.17,12L8.59,7.41L10,6l6,6l-6,6L8.59,16.59z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,7l-1,1 -1,-1 -1.41,1.41L12,10.83l2.41,-2.42L13,7z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M3,13h2v8h-2v-8zM7,9h2v12h-2v-12zM11,5h2v16h-2v-16zM15,8h2v13h-2v-13zM19,11h2v10h-2v-10z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M3.5,18.49l6,-6.01 4,4L22,6.92l-1.41,-1.41 -7.09,7.97 -4,-4L2,16.99z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12.5,7H11v6l5.25,3.15 0.75,-1.23 -4.5,-2.67z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4zM12,14c-1.1,0 -2,-0.9 -2,-2s0.9,-2 2,-2 2,0.9 2,2 -0.9,2 -2,2z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM13,17h-2v-6h2v6zM13,9h-2L11,7h2v2z"
|
||||||
|
android:fillColor="@color/benchmark_warning"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,12 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17,7H7A2,2 0 0,0 5,9V15A2,2 0 0,0 7,17H17A2,2 0 0,0 19,15V9A2,2 0 0,0 17,7M17,15H7V9H17V15M8,10H9V14H8V10M10.5,10H11.5V14H10.5V10M13,10H14V14H13V10M15.5,10H16.5V14H15.5V10Z" />
|
||||||
|
|
||||||
|
</vector>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- iOS SF Symbol memorychip style -->
|
||||||
|
<!-- Main chip body -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M7,6C5.9,6 5,6.9 5,8V16C5,17.1 5.9,18 7,18H17C18.1,18 19,17.1 19,16V8C19,6.9 18.1,6 17,6H7ZM17,16H7V8H17V16Z" />
|
||||||
|
|
||||||
|
<!-- Memory slots -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M8,10H9V14H8V10Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M10.5,10H11.5V14H10.5V10Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M13,10H14V14H13V10Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M15.5,10H16V14H15.5V10Z" />
|
||||||
|
|
||||||
|
<!-- Connection pins (left side) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,9H5V10H3V9Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,11H5V12H3V11Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M3,13H5V14H3V13Z" />
|
||||||
|
|
||||||
|
<!-- Connection pins (right side) -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19,9H21V10H19V9Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19,11H21V12H19V11Z" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19,13H21V14H19V13Z" />
|
||||||
|
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="16dp"
|
||||||
|
android:height="16dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M8,5v14l11,-7z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="12dp"
|
||||||
|
android:height="12dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M7,7h10v3l4,-4 -4,-4v3H5v6h2V7zm10,10H7v-3l-4,4 4,4v-3h12v-6h-2v4z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,10 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="#000000"
|
||||||
|
android:pathData="M15,5l4,0l0,4l-1.5,-1.5l-2.5,2.5l-1,-1l2.5,-2.5l-1.5,-1.5zM11,7l-6,0c-1.1,0 -2,0.9 -2,2l0,10c0,1.1 0.9,2 2,2l10,0c1.1,0 2,-0.9 2,-2l0,-6l-2,0l0,6l-10,0l0,-10l6,0l0,-2z"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,50 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
|
||||||
|
<!-- iOS SF Symbol speedometer style -->
|
||||||
|
<!-- Outer circle -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12C2,17.52 6.48,22 12,22C17.52,22 22,17.52 22,12C22,6.48 17.52,2 12,2ZM12,20C7.59,20 4,16.41 4,12C4,7.59 7.59,4 12,4C16.41,4 20,7.59 20,12C20,16.41 16.41,20 12,20Z" />
|
||||||
|
|
||||||
|
<!-- Speed scale marks -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M6.34,8.34L7.76,9.76" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M5,12L7,12" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M6.34,15.66L7.76,14.24" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,19L12,17" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.66,15.66L16.24,14.24" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M19,12L17,12" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M17.66,8.34L16.24,9.76" />
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,5L12,7" />
|
||||||
|
|
||||||
|
<!-- Needle pointing to speed -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,8L13.5,12.5L12,12L10.5,12.5L12,8Z" />
|
||||||
|
|
||||||
|
<!-- Center circle -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M12,10.5C12.8,10.5 13.5,11.2 13.5,12C13.5,12.8 12.8,13.5 12,13.5C11.2,13.5 10.5,12.8 10.5,12C10.5,11.2 11.2,10.5 12,10.5Z" />
|
||||||
|
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="18dp"
|
||||||
|
android:height="18dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2zM12,20c-4.41,0 -8,-3.59 -8,-8s3.59,-8 8,-8 8,3.59 8,8 -3.59,8 -8,8zM12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"
|
||||||
|
android:fillColor="@color/benchmark_accent"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="16dp"
|
||||||
|
android:height="16dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:pathData="M6,6h12v12H6z"
|
||||||
|
android:fillColor="#FFFFFF"/>
|
||||||
|
</vector>
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
|
||||||
|
<solid android:color="@color/benchmark_card_bg" />
|
||||||
|
|
||||||
|
<corners android:radius="12dp" />
|
||||||
|
|
||||||
|
<stroke
|
||||||
|
android:width="1dp"
|
||||||
|
android:color="@color/benchmark_card_border" />
|
||||||
|
|
||||||
|
</shape>
|
File diff suppressed because it is too large
Load Diff
|
@ -19,7 +19,6 @@
|
||||||
/>
|
/>
|
||||||
<androidx.recyclerview.widget.RecyclerView
|
<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"
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -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">
|
||||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
||||||
|
|
|
@ -0,0 +1,98 @@
|
||||||
|
package com.alibaba.mnnllm.android.chat
|
||||||
|
|
||||||
|
import org.junit.Assert.*
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class GenerateResultProcessorTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun noSlashThink_removesLeadingEndTag() {
|
||||||
|
assertEquals("abc", GenerateResultProcessor.noSlashThink("</think>abc"))
|
||||||
|
assertEquals("xyz</think>", GenerateResultProcessor.noSlashThink("xyz</think>"))
|
||||||
|
assertNull(GenerateResultProcessor.noSlashThink(null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkTags_simpleBlock_parsesThinkingAndNormal() {
|
||||||
|
val p = GenerateResultProcessor()
|
||||||
|
p.generateBegin()
|
||||||
|
|
||||||
|
p.process("<think>abc</think>def")
|
||||||
|
p.process(null)
|
||||||
|
|
||||||
|
// THINK_TAGS adds a trailing newline on think end
|
||||||
|
assertEquals("abc\n", p.getThinkingContent())
|
||||||
|
assertEquals("def", p.getNormalOutput())
|
||||||
|
assertEquals("abc\ndef", p.getDisplayResult())
|
||||||
|
assertTrue(p.thinkTime >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkTags_emptyThink_hasNoThinkingContent() {
|
||||||
|
val p = GenerateResultProcessor()
|
||||||
|
p.generateBegin()
|
||||||
|
|
||||||
|
p.process("<think></think>abc")
|
||||||
|
p.process(null)
|
||||||
|
|
||||||
|
// No content inside <think> -> thinking content should be empty string
|
||||||
|
assertEquals("", p.getThinkingContent())
|
||||||
|
assertEquals("abc", p.getNormalOutput())
|
||||||
|
assertEquals("abc", p.getDisplayResult())
|
||||||
|
assertTrue(p.thinkTime >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun thinkTags_retroactiveMove_onEndTagFirst() {
|
||||||
|
val p = GenerateResultProcessor()
|
||||||
|
p.generateBegin()
|
||||||
|
|
||||||
|
// Stream normal text first, then an unexpected </think> which should
|
||||||
|
// retroactively move the pending normal text into thinking.
|
||||||
|
p.process("Hello ")
|
||||||
|
p.process("World")
|
||||||
|
p.process("</think> Rest")
|
||||||
|
p.process(null)
|
||||||
|
|
||||||
|
assertEquals("Hello World\n", p.getThinkingContent())
|
||||||
|
assertEquals(" Rest", p.getNormalOutput())
|
||||||
|
assertEquals("Hello World\n Rest", p.getDisplayResult())
|
||||||
|
assertTrue(p.thinkTime >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gptOss_thinkingOnly_extractsMessage() {
|
||||||
|
val p = GenerateResultProcessor()
|
||||||
|
p.generateBegin()
|
||||||
|
|
||||||
|
// Contains a <|message|> block but no final<|message|>
|
||||||
|
p.process("prefix<|message|>thinking...<|end|>suffix")
|
||||||
|
// End of stream should set thinkTime if not already set
|
||||||
|
p.process(null)
|
||||||
|
|
||||||
|
assertEquals("thinking...", p.getThinkingContent())
|
||||||
|
assertEquals("", p.getNormalOutput())
|
||||||
|
assertEquals("thinking...", p.getDisplayResult())
|
||||||
|
assertTrue(p.thinkTime >= 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gptOss_withFinalMessage_splitsThinkingAndNormal() {
|
||||||
|
val p = GenerateResultProcessor()
|
||||||
|
p.generateBegin()
|
||||||
|
|
||||||
|
// The last occurrence of "final<|message|>" marks the start of normal output
|
||||||
|
val input = "noise<|message|>internal-think<|end|>final<|message|>Hello world"
|
||||||
|
p.process(input)
|
||||||
|
p.process(null)
|
||||||
|
|
||||||
|
assertEquals("internal-think", p.getThinkingContent())
|
||||||
|
assertEquals("Hello world", p.getNormalOutput())
|
||||||
|
assertEquals("internal-thinkHello world", p.getDisplayResult())
|
||||||
|
assertTrue(p.thinkTime >= 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
./gradlew installStandardDebug
|
|
@ -0,0 +1 @@
|
||||||
|
./gradlew test -x preBuild
|
|
@ -0,0 +1,176 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
MNN LLM Chat API Test Script
|
||||||
|
Supports multiple test scenarios
|
||||||
|
"""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import argparse
|
||||||
|
from typing import Dict, Any, List
|
||||||
|
|
||||||
|
class MnnApiTester:
|
||||||
|
def __init__(self, host: str = "localhost", port: int = 8080, token: str = "mnn-llm-chat"):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.token = token
|
||||||
|
self.base_url = f"http://{host}:{port}"
|
||||||
|
self.headers = {
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Content-Type": "application/json"
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_models(self) -> Dict[str, Any]:
|
||||||
|
"""Test the /v1/models endpoint"""
|
||||||
|
print("Testing /v1/models endpoint...")
|
||||||
|
url = f"{self.base_url}/v1/models"
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.get(url, headers=self.headers, timeout=10)
|
||||||
|
print(f"Status code: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
data = response.json()
|
||||||
|
print(f"Response: {json.dumps(data, indent=2, ensure_ascii=False)}")
|
||||||
|
return {"success": True, "data": data}
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return {"success": False, "error": response.text}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def test_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]:
|
||||||
|
"""Test the /v1/chat/completions endpoint"""
|
||||||
|
print(f"Testing /v1/chat/completions endpoint (Model: {model})...")
|
||||||
|
url = f"{self.base_url}/v1/chat/completions"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": message}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"temperature": 0.7
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=self.headers, json=data, timeout=30)
|
||||||
|
print(f"Status code: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
result = response.json()
|
||||||
|
print(f"Response: {json.dumps(result, indent=2, ensure_ascii=False)}")
|
||||||
|
return {"success": True, "data": result}
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return {"success": False, "error": response.text}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def test_stream_chat(self, model: str = "qwen2.5-7b-instruct", message: str = "Hello") -> Dict[str, Any]:
|
||||||
|
"""Test the streaming chat endpoint"""
|
||||||
|
print(f"Testing streaming chat endpoint (Model: {model})...")
|
||||||
|
url = f"{self.base_url}/v1/chat/completions"
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"model": model,
|
||||||
|
"messages": [{"role": "user", "content": message}],
|
||||||
|
"max_tokens": 100,
|
||||||
|
"temperature": 0.7,
|
||||||
|
"stream": True
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = requests.post(url, headers=self.headers, json=data, timeout=30, stream=True)
|
||||||
|
print(f"Status code: {response.status_code}")
|
||||||
|
|
||||||
|
if response.status_code == 200:
|
||||||
|
print("Streaming response:")
|
||||||
|
for line in response.iter_lines():
|
||||||
|
if line:
|
||||||
|
line_str = line.decode('utf-8')
|
||||||
|
if line_str.startswith('data: '):
|
||||||
|
data_str = line_str[6:]
|
||||||
|
if data_str.strip() == '[DONE]':
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = json.loads(data_str)
|
||||||
|
if 'choices' in chunk and len(chunk['choices']) > 0:
|
||||||
|
delta = chunk['choices'][0].get('delta', {})
|
||||||
|
if 'content' in delta:
|
||||||
|
print(delta['content'], end='', flush=True)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass
|
||||||
|
print("\n")
|
||||||
|
return {"success": True}
|
||||||
|
else:
|
||||||
|
print(f"Error: {response.text}")
|
||||||
|
return {"success": False, "error": response.text}
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Exception: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description='MNN LLM Chat API Test Tool')
|
||||||
|
parser.add_argument('--host', default='localhost', help='Server address')
|
||||||
|
parser.add_argument('--port', type=int, default=8080, help='Server port')
|
||||||
|
parser.add_argument('--token', default='mnn-llm-chat', help='Authentication token')
|
||||||
|
parser.add_argument('--test', choices=['models', 'chat', 'stream', 'all'], default='all', help='Test type')
|
||||||
|
parser.add_argument('--model', default='qwen2.5-7b-instruct', help='Test model')
|
||||||
|
parser.add_argument('--message', default='Hello, please briefly introduce yourself', help='Test message')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print("MNN LLM Chat API Test Tool")
|
||||||
|
print("=" * 50)
|
||||||
|
print(f"Server: {args.host}:{args.port}")
|
||||||
|
print(f"Token: {args.token}")
|
||||||
|
print(f"Test type: {args.test}")
|
||||||
|
print()
|
||||||
|
|
||||||
|
tester = MnnApiTester(args.host, args.port, args.token)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
|
||||||
|
if args.test in ['models', 'all']:
|
||||||
|
print("1. Test models endpoint")
|
||||||
|
print("-" * 30)
|
||||||
|
result = tester.test_models()
|
||||||
|
results.append(('models', result))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.test in ['chat', 'all']:
|
||||||
|
print("2. Test chat endpoint")
|
||||||
|
print("-" * 30)
|
||||||
|
result = tester.test_chat(args.model, args.message)
|
||||||
|
results.append(('chat', result))
|
||||||
|
print()
|
||||||
|
|
||||||
|
if args.test in ['stream', 'all']:
|
||||||
|
print("3. Test streaming chat endpoint")
|
||||||
|
print("-" * 30)
|
||||||
|
result = tester.test_stream_chat(args.model, args.message)
|
||||||
|
results.append(('stream', result))
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Summary
|
||||||
|
print("Test Summary:")
|
||||||
|
for test_name, result in results:
|
||||||
|
status = "Success" if result['success'] else "Failure"
|
||||||
|
print(f" {test_name}: {status}")
|
||||||
|
|
||||||
|
all_success = all(result['success'] for _, result in results)
|
||||||
|
if all_success:
|
||||||
|
print("\n✅ All tests passed!")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("\n❌ Some tests failed!")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
Loading…
Reference in New Issue