Files
2026-07-13 13:33:03 +08:00

632 lines
17 KiB
C++

//
// Created by ruoyi.sjd on 2024/12/25.
// Copyright (c) 2024 Alibaba Group Holding Limited All rights reserved.
//
#pragma once
#include "../../../transformers/llm/engine/include/llm/llm.hpp"
#include "httplib.h"
#include "json.hpp"
using nlohmann::json;
using PromptItem = std::pair<std::string, std::string>;
namespace mnncli {
class LlmStreamBuffer : public std::streambuf {
public:
using CallBack = std::function<void(const char* str, size_t len)>;
explicit LlmStreamBuffer(CallBack callback) : callback_(std::move(callback)) {}
~LlmStreamBuffer() override = default;
protected:
virtual std::streamsize xsputn(const char* s, std::streamsize n) override {
if (callback_) {
callback_(s, n);
}
return n;
}
private:
CallBack callback_{};
};
class Utf8StreamProcessor {
public:
Utf8StreamProcessor(std::function<void(const std::string&)> callback)
: callback(callback) {}
void processStream(const char* str, size_t len) {
utf8Buffer.append(str, len);
size_t i = 0;
std::string completeChars;
while (i < utf8Buffer.size()) {
int length = utf8CharLength(static_cast<unsigned char>(utf8Buffer[i]));
if (length == 0 || i + length > utf8Buffer.size()) {
break;
}
completeChars.append(utf8Buffer, i, length);
i += length;
}
utf8Buffer = utf8Buffer.substr(i);
if (!completeChars.empty()) {
callback(completeChars);
}
}
int utf8CharLength(unsigned char byte) {
if ((byte & 0x80) == 0) return 1;
if ((byte & 0xE0) == 0xC0) return 2;
if ((byte & 0xF0) == 0xE0) return 3;
if ((byte & 0xF8) == 0xF0) return 4;
return 0;
}
private:
std::string utf8Buffer;
std::function<void(const std::string&)> callback;
};
class MnncliServer {
public:
const char* html_content = R"""(
<!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;
}
#model-select:disabled {
background-color: #f8f9fa;
color: #6c757d;
cursor: not-allowed;
opacity: 0.7;
}
.model-notice {
margin-top: 0.5rem;
padding: 0.5rem;
background-color: #fff3cd;
border: 1px solid #ffeaa7;
border-radius: 6px;
text-align: center;
}
.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" disabled>
<option value="unknown">Loading models...</option>
</select>
<button class="button button-secondary" onclick="showModelSwitchAlert()">Switch Model</button>
</div>
<div class="model-notice">
<small style="color: #6c757d;">⚠️ Model switching is temporarily not supported. Please change models in the app.</small>
</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();
};
// 显示模型切换提醒
function showModelSwitchAlert() {
alert("Model switching is temporarily not supported.\n\nTo change models, please:\n1. Close this web page\n2. Restart the MNN Chat app\n3. Select a different model from the model list");
}
async function refreshModels() {
try {
updateStatus("Loading current model...");
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 {
// 只显示第一个模型(当前使用的模型)
const currentModelData = availableModels[0];
const option = document.createElement("option");
option.value = currentModelData.id;
option.textContent = extractModelSuffix(currentModelData.id);
modelSelect.appendChild(option);
currentModel = currentModelData.id;
modelSelect.value = currentModel;
updateStatus(`Current model: ${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;
}
// Model selection change is disabled - no event listener needed
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>
)""";
void Start(MNN::Transformer::Llm* llm, bool is_r1, const std::string& host = "127.0.0.1", int port = 8000);
bool is_r1_{false};
private:
void Answer(MNN::Transformer::Llm* llm, const json &messages, std::function<void(const std::string&)> on_result);
void AnswerStreaming(MNN::Transformer::Llm* llm,
const json& messages,
std::function<void(const std::string&, bool end)> on_partial);
std::mutex llm_mutex_;
};
}