First upload version 0.0.1
This commit is contained in:
48
public/index.html
Normal file
48
public/index.html
Normal file
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AirLLM Node.js</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<header>
|
||||
<div class="header-left">
|
||||
<h1>AirLLM Manager</h1>
|
||||
<div id="status-indicator" class="status-indicator">Сервер: Активен | Модель: Не загружена</div>
|
||||
</div>
|
||||
|
||||
<div class="controls-panel">
|
||||
<!-- Блок управления моделью -->
|
||||
<div class="model-controls">
|
||||
<div style="position: relative; width: 100%;">
|
||||
<input type="text" id="model-path-input" value="./model/" placeholder="./model/filename.gguf" autocomplete="off">
|
||||
<div id="suggestions" class="suggestions-list" style="display: none;"></div>
|
||||
</div>
|
||||
|
||||
<button id="load-model-btn" class="btn btn-primary">Загрузить</button>
|
||||
<button id="unload-model-btn" class="btn btn-secondary" disabled>Выгрузить</button>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка остановки -->
|
||||
<div class="server-controls">
|
||||
<button id="stop-server-btn" class="btn btn-danger">Остановить сервер</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div id="chat-window">
|
||||
<div class="message system">Привет! Сервер запущен, но модель не загружена. Укажите путь к модели (в папке model) и нажмите "Загрузить".</div>
|
||||
</div>
|
||||
|
||||
<div class="input-area">
|
||||
<textarea id="user-input" placeholder="Введите сообщение..." rows="2" disabled></textarea>
|
||||
<button id="send-btn" disabled>Отправить</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
284
public/script.js
Normal file
284
public/script.js
Normal file
@@ -0,0 +1,284 @@
|
||||
const chatWindow = document.getElementById('chat-window');
|
||||
const userInput = document.getElementById('user-input');
|
||||
const sendBtn = document.getElementById('send-btn');
|
||||
const pathInput = document.getElementById('model-path-input');
|
||||
const loadBtn = document.getElementById('load-model-btn');
|
||||
const unloadBtn = document.getElementById('unload-model-btn');
|
||||
const stopBtn = document.getElementById('stop-server-btn');
|
||||
const statusIndicator = document.getElementById('status-indicator');
|
||||
// --- Логика Автодополнения ---
|
||||
|
||||
const suggestionsBox = document.getElementById('suggestions');
|
||||
let availableModels = [];
|
||||
|
||||
// Загрузка списка моделей с сервера
|
||||
async function fetchModels() {
|
||||
try {
|
||||
const res = await fetch('/api/models/list');
|
||||
availableModels = await res.json();
|
||||
console.log("📂 Найдены модели:", availableModels);
|
||||
} catch (err) {
|
||||
console.error("Не удалось получить список моделей:", err);
|
||||
}
|
||||
}
|
||||
|
||||
fetchModels();
|
||||
|
||||
// Обработка ввода и показ списка
|
||||
pathInput.addEventListener('input', () => {
|
||||
const inputVal = pathInput.value.toLowerCase();
|
||||
suggestionsBox.innerHTML = '';
|
||||
|
||||
// Фильтруем: ищем по имени файла
|
||||
const filtered = availableModels.filter(model =>
|
||||
model.toLowerCase().includes(inputVal)
|
||||
);
|
||||
|
||||
if (filtered.length > 0) {
|
||||
filtered.forEach(modelName => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'suggestion-item';
|
||||
div.textContent = modelName;
|
||||
|
||||
div.addEventListener('click', () => {
|
||||
// Собираем путь
|
||||
let basePath = pathInput.value;
|
||||
let folderPath = basePath.substring(0, basePath.lastIndexOf('/') + 1);
|
||||
|
||||
// Если слеша нет, ставим дефолтный
|
||||
if (!folderPath.endsWith('/')) folderPath = './model/';
|
||||
|
||||
pathInput.value = folderPath + modelName;
|
||||
suggestionsBox.style.display = 'none';
|
||||
pathInput.focus();
|
||||
});
|
||||
|
||||
suggestionsBox.appendChild(div);
|
||||
});
|
||||
suggestionsBox.style.display = 'block';
|
||||
} else {
|
||||
suggestionsBox.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Скрывать список при клике вне его
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!pathInput.contains(e.target) && !suggestionsBox.contains(e.target)) {
|
||||
suggestionsBox.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Показывать весь список при клике в инпут (если есть модели)
|
||||
pathInput.addEventListener('focus', () => {
|
||||
// Если инпут пуст, покажем все модели
|
||||
if (pathInput.value === '' && availableModels.length > 0) {
|
||||
suggestionsBox.innerHTML = '';
|
||||
availableModels.forEach(modelName => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'suggestion-item';
|
||||
div.textContent = modelName;
|
||||
div.addEventListener('click', () => {
|
||||
pathInput.value = `./model/${modelName}`;
|
||||
suggestionsBox.style.display = 'none';
|
||||
});
|
||||
suggestionsBox.appendChild(div);
|
||||
});
|
||||
suggestionsBox.style.display = 'block';
|
||||
}
|
||||
});
|
||||
|
||||
let isModelLoaded = false;
|
||||
|
||||
// --- Функции Управления Интерфейсом ---
|
||||
|
||||
function setUIState(loaded) {
|
||||
isModelLoaded = loaded;
|
||||
|
||||
if (loaded) {
|
||||
// Модель загружена
|
||||
pathInput.disabled = true;
|
||||
loadBtn.disabled = true;
|
||||
unloadBtn.disabled = false;
|
||||
|
||||
userInput.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
|
||||
statusIndicator.textContent = "● Сервер: Активен | Модель: Загружена";
|
||||
statusIndicator.style.color = "#4ec9b0";
|
||||
addSystemMessage("Модель успешно загружена и готова к работе.");
|
||||
userInput.focus();
|
||||
} else {
|
||||
// Модель выгружена
|
||||
pathInput.disabled = false;
|
||||
loadBtn.disabled = false;
|
||||
unloadBtn.disabled = true;
|
||||
|
||||
userInput.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
|
||||
statusIndicator.textContent = "● Сервер: Активен | Модель: Не загружена";
|
||||
statusIndicator.style.color = "#cca511"; // Желтый
|
||||
addSystemMessage("Модель выгружена.");
|
||||
}
|
||||
}
|
||||
|
||||
function addSystemMessage(text) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'message system';
|
||||
div.textContent = text;
|
||||
chatWindow.appendChild(div);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
}
|
||||
|
||||
// --- API Запросы Управления ---
|
||||
|
||||
// 1. Загрузка модели
|
||||
loadBtn.addEventListener('click', async () => {
|
||||
const path = pathInput.value.trim();
|
||||
if (!path) {
|
||||
alert("Введите путь к модели");
|
||||
return;
|
||||
}
|
||||
|
||||
loadBtn.disabled = true;
|
||||
loadBtn.textContent = "Загрузка...";
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/model/load', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path })
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUIState(true);
|
||||
} else {
|
||||
alert("Ошибка загрузки: " + data.message);
|
||||
loadBtn.disabled = false;
|
||||
loadBtn.textContent = "Загрузить";
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сети при загрузке: " + err.message);
|
||||
loadBtn.disabled = false;
|
||||
loadBtn.textContent = "Загрузить";
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Выгрузка модели
|
||||
unloadBtn.addEventListener('click', async () => {
|
||||
if (!confirm("Вы уверены, что хотите выгрузить модель?")) return;
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/model/unload', { method: 'POST' });
|
||||
const data = await res.json();
|
||||
if (data.success) {
|
||||
setUIState(false);
|
||||
} else {
|
||||
alert("Ошибка выгрузки: " + data.message);
|
||||
}
|
||||
} catch (err) {
|
||||
alert("Ошибка сети: " + err.message);
|
||||
}
|
||||
});
|
||||
|
||||
// 3. Остановка сервера
|
||||
stopBtn.addEventListener('click', async () => {
|
||||
if (!confirm("Внимание! Это остановит веб-сервер. Придется перезапустить его через консоль (npm start).")) return;
|
||||
|
||||
try {
|
||||
await fetch('/api/server/stop', { method: 'POST' });
|
||||
document.body.innerHTML = "<div style='color:white; text-align:center; margin-top:50px;'>Сервер остановлен. Закройте эту вкладку.</div>";
|
||||
} catch (err) {
|
||||
// Ошибка может возникнуть, потому что сервер умирает
|
||||
console.log("Сервер остановлен (разрыв соединения ожидается)");
|
||||
}
|
||||
});
|
||||
|
||||
// --- Логика Чата ---
|
||||
|
||||
function appendMessage(text, sender, isLoader = false) {
|
||||
const msgDiv = document.createElement('div');
|
||||
msgDiv.classList.add('message', sender);
|
||||
|
||||
if (isLoader) {
|
||||
msgDiv.classList.add('loader');
|
||||
msgDiv.innerHTML = text;
|
||||
} else {
|
||||
msgDiv.textContent = text;
|
||||
}
|
||||
|
||||
chatWindow.appendChild(msgDiv);
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
return msgDiv;
|
||||
}
|
||||
|
||||
async function handleSend() {
|
||||
const text = userInput.value.trim();
|
||||
if (!text) return;
|
||||
|
||||
userInput.disabled = true;
|
||||
sendBtn.disabled = true;
|
||||
userInput.value = '';
|
||||
|
||||
appendMessage(text, 'user');
|
||||
|
||||
const loaderHtml = 'Думаю<span>.</span><span>.</span><span>.</span>';
|
||||
const loaderDiv = appendMessage(loaderHtml, 'bot', true);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ message: text })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errText = await response.text();
|
||||
throw new Error(errText);
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullResponse = "";
|
||||
let isFirstChunk = true;
|
||||
|
||||
loaderDiv.classList.remove('loader');
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
fullResponse += chunk;
|
||||
|
||||
if (isFirstChunk) {
|
||||
loaderDiv.textContent = fullResponse;
|
||||
isFirstChunk = false;
|
||||
} else {
|
||||
loaderDiv.textContent = fullResponse;
|
||||
}
|
||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
loaderDiv.classList.remove('loader');
|
||||
loaderDiv.textContent = "Ошибка: " + error.message;
|
||||
loaderDiv.style.color = "#ff6b6b";
|
||||
} finally {
|
||||
// Возвращаем доступ только если модель все еще загружена
|
||||
if (isModelLoaded) {
|
||||
userInput.disabled = false;
|
||||
sendBtn.disabled = false;
|
||||
}
|
||||
userInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
sendBtn.addEventListener('click', handleSend);
|
||||
userInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
});
|
||||
234
public/style.css
Normal file
234
public/style.css
Normal file
@@ -0,0 +1,234 @@
|
||||
body {
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
background-color: #1e1e1e;
|
||||
color: #d4d4d4;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1000px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
background-color: #252526;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* --- Header --- */
|
||||
header {
|
||||
padding: 15px 20px;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
background-color: #2d2d30;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.header-left h1 {
|
||||
margin: 0;
|
||||
font-size: 1.2rem;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.status-indicator {
|
||||
font-size: 0.8rem;
|
||||
margin-top: 5px;
|
||||
color: #4ec9b0;
|
||||
}
|
||||
|
||||
.controls-panel {
|
||||
display: flex;
|
||||
gap: 15px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.model-controls {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
/* --- Inputs & Buttons --- */
|
||||
#model-path-input {
|
||||
background-color: #3e3e42;
|
||||
border: 1px solid #555;
|
||||
color: #fff;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
width: 300px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#model-path-input:focus {
|
||||
border-color: #007acc;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#model-path-input:disabled {
|
||||
background-color: #333;
|
||||
color: #888;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 16px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.btn:hover:not(:disabled) {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary { background-color: #0e639c; color: white; }
|
||||
.btn-secondary { background-color: #cca511; color: #111; }
|
||||
.btn-danger { background-color: #ce4e4e; color: white; }
|
||||
|
||||
/* --- Chat --- */
|
||||
#chat-window {
|
||||
flex: 1;
|
||||
padding: 20px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.message {
|
||||
padding: 10px 15px;
|
||||
border-radius: 8px;
|
||||
max-width: 80%;
|
||||
line-height: 1.5;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.message.user { align-self: flex-end; background-color: #0e639c; color: white; }
|
||||
.message.bot { align-self: flex-start; background-color: #3e3e42; }
|
||||
.message.system { align-self: center; font-size: 0.85rem; color: #888; text-align: center; }
|
||||
|
||||
/* --- Loader --- */
|
||||
.loader span {
|
||||
display: inline-block;
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
margin: 0 2px;
|
||||
animation: bounce 1.4s infinite ease-in-out both;
|
||||
}
|
||||
.loader span:nth-child(1) { animation-delay: -0.32s; }
|
||||
.loader span:nth-child(2) { animation-delay: -0.16s; }
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); opacity: 0.5; }
|
||||
40% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
/* --- Input Area --- */
|
||||
.input-area {
|
||||
padding: 20px;
|
||||
border-top: 1px solid #3e3e42;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
background-color: #2d2d30;
|
||||
}
|
||||
|
||||
#user-input {
|
||||
flex: 1;
|
||||
background-color: #1e1e1e;
|
||||
border: 1px solid #3e3e42;
|
||||
color: white;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
resize: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#user-input:focus { border-color: #007acc; }
|
||||
#user-input:disabled { background-color: #333; cursor: not-allowed; }
|
||||
|
||||
#send-btn {
|
||||
padding: 0 24px;
|
||||
background-color: #0e639c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
}
|
||||
#send-btn:hover { background-color: #1177bb; }
|
||||
/* --- Автодополнение --- */
|
||||
.model-controls {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-controls > div:first-child {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.suggestions-list {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
background-color: #2d2d30;
|
||||
border: 1px solid #3e3e42;
|
||||
border-top: none;
|
||||
border-radius: 0 0 4px 4px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.suggestion-item {
|
||||
padding: 8px 12px;
|
||||
cursor: pointer;
|
||||
color: #d4d4d4;
|
||||
font-size: 0.9rem;
|
||||
border-bottom: 1px solid #3e3e42;
|
||||
}
|
||||
|
||||
.suggestion-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.suggestion-item:hover {
|
||||
background-color: #0e639c;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.suggestions-list::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.suggestions-list::-webkit-scrollbar-track {
|
||||
background: #1e1e1e;
|
||||
}
|
||||
.suggestions-list::-webkit-scrollbar-thumb {
|
||||
background: #555;
|
||||
border-radius: 4px;
|
||||
}
|
||||
Reference in New Issue
Block a user