добавлние блоков чата слева и правка багов с ним связанных.
This commit is contained in:
84
main.cjs
84
main.cjs
@@ -1,41 +1,52 @@
|
|||||||
const { app, BrowserWindow } = require('electron');
|
const { app, BrowserWindow } = require('electron');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn, exec } = require('child_process');
|
||||||
|
|
||||||
let mainWindow;
|
let mainWindow;
|
||||||
let serverProcess;
|
let serverProcess;
|
||||||
|
|
||||||
// Функция создания окна
|
|
||||||
function createWindow() {
|
function createWindow() {
|
||||||
mainWindow = new BrowserWindow({
|
mainWindow = new BrowserWindow({
|
||||||
width: 1200,
|
width: 1200,
|
||||||
height: 800,
|
height: 800,
|
||||||
|
autoHideMenuBar: true,
|
||||||
webPreferences: {
|
webPreferences: {
|
||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
contextIsolation: false
|
contextIsolation: false
|
||||||
},
|
}
|
||||||
icon: path.join(__dirname, 'public/uikit/bot.png') // Попытка найти иконку
|
});
|
||||||
|
|
||||||
|
mainWindow.setMenu(null);
|
||||||
|
|
||||||
|
// --- ОБРАБОТЧИК ЗАКРЫТИЯ ОКНА (ВНУТРИ ФУНКЦИИ) ---
|
||||||
|
mainWindow.on('close', () => {
|
||||||
|
console.log("⚠️ Окно закрывается.");
|
||||||
|
|
||||||
|
// 1. Пытаемся убить запущенный процесс
|
||||||
|
if (serverProcess) {
|
||||||
|
serverProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. ЗАПУСКАЕМ ЧИСТКУ ПОРТА
|
||||||
|
setTimeout(() => {
|
||||||
|
killPort3000();
|
||||||
|
}, 200);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Запускаем сервер
|
|
||||||
startServer();
|
startServer();
|
||||||
|
|
||||||
// Ждем 1.5 сек для старта сервера и загружаем страницу
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
mainWindow.loadURL('http://localhost:3000');
|
mainWindow.loadURL('http://localhost:3000');
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
|
|
||||||
function startServer() {
|
function startServer() {
|
||||||
// ИСПРАВЛЕНИЕ: Используем __dirname, который указывает на корень проекта
|
|
||||||
// Так как main.cjs лежит в корне, __dirname = C:\...\airllm-nodejs
|
|
||||||
const appPath = __dirname;
|
const appPath = __dirname;
|
||||||
console.log("Запуск сервера из:", appPath);
|
console.log("Запуск сервера из:", appPath);
|
||||||
|
|
||||||
// Запускаем node server/server.js
|
|
||||||
serverProcess = spawn('node', ['server/server.js'], {
|
serverProcess = spawn('node', ['server/server.js'], {
|
||||||
cwd: appPath, // Рабочая директория — корень проекта
|
cwd: appPath,
|
||||||
shell: true // Нужно для путей с пробелами (Robert Onelli)
|
shell: true
|
||||||
});
|
});
|
||||||
|
|
||||||
serverProcess.stdout.on('data', (data) => {
|
serverProcess.stdout.on('data', (data) => {
|
||||||
@@ -46,11 +57,54 @@ function startServer() {
|
|||||||
console.error(`[Ошибка сервера]: ${data}`);
|
console.error(`[Ошибка сервера]: ${data}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
// При закрытии окна убиваем сервер
|
serverProcess.on('close', (code) => {
|
||||||
app.on('window-all-closed', () => {
|
console.log(`Сервер завершил работу с кодом ${code}`);
|
||||||
if (serverProcess) serverProcess.kill();
|
|
||||||
if (process.platform !== 'darwin') app.quit();
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- ФУНКЦИЯ: Убийство всех процессов на порту 3000 ---
|
||||||
|
function killPort3000() {
|
||||||
|
console.log("🛑 Проверка и очистка порта 3000...");
|
||||||
|
|
||||||
|
exec('netstat -ano | findstr :3000', (err, stdout, stderr) => {
|
||||||
|
if (err || !stdout) {
|
||||||
|
console.log("Порт 3000 свободен.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = stdout.split('\n');
|
||||||
|
const pidsToKill = new Set();
|
||||||
|
|
||||||
|
lines.forEach(line => {
|
||||||
|
const parts = line.trim().split(/\s+/);
|
||||||
|
if (parts.length > 4) {
|
||||||
|
const pid = parts[parts.length - 1];
|
||||||
|
if (pid && line.includes('LISTENING')) {
|
||||||
|
pidsToKill.add(pid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (pidsToKill.size > 0) {
|
||||||
|
console.log(`🔪 Найдено процессов: ${Array.from(pidsToKill).join(', ')}`);
|
||||||
|
|
||||||
|
const killCommand = `taskkill /F /PID ${Array.from(pidsToKill).join(' /PID ')}`;
|
||||||
|
|
||||||
|
exec(killCommand, (killErr) => {
|
||||||
|
if (killErr) {
|
||||||
|
console.error("Не удалось убить процессы:", killErr);
|
||||||
|
} else {
|
||||||
|
console.log("✅ Процессы на порту 3000 успешно остановлены.");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
app.on('window-all-closed', () => {
|
||||||
|
if (process.platform !== 'darwin') {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.whenReady().then(createWindow);
|
app.whenReady().then(createWindow);
|
||||||
@@ -7,43 +7,76 @@
|
|||||||
<link rel="stylesheet" href="style.css">
|
<link rel="stylesheet" href="style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="container">
|
<div class="app-container">
|
||||||
<header>
|
|
||||||
|
<!-- САЙДБАР (450px) -->
|
||||||
|
<aside class="sidebar" id="sidebar">
|
||||||
|
|
||||||
|
<!-- Кнопка сворачивания -->
|
||||||
|
<div class="sidebar-toggle-header">
|
||||||
|
<button id="toggle-sidebar-btn" class="toggle-btn">◀</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-content">
|
||||||
|
<div class="sidebar-header">
|
||||||
|
<button id="new-chat-btn" class="new-chat-btn">
|
||||||
|
<span>+</span> Новый чат
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-list" id="chat-list">
|
||||||
|
<div class="chat-item active" data-id="tab-1">
|
||||||
|
<span class="chat-icon">💬</span>
|
||||||
|
<span class="chat-title">Новый чат</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<button id="stop-server-btn" class="sidebar-footer-btn">Остановить сервер</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- ОСНОВНАЯ ОБЛАСТЬ (Flex: 1) -->
|
||||||
|
<main class="main-area">
|
||||||
|
|
||||||
|
<!-- Шапка с моделью -->
|
||||||
|
<header class="main-header">
|
||||||
<div class="header-left">
|
<div class="header-left">
|
||||||
|
<div class="logo-container">
|
||||||
|
<img src="/uikit/bot.png" alt="Logo" class="logo-img" onerror="this.style.display='none'">
|
||||||
<h1>AirLLM Manager</h1>
|
<h1>AirLLM Manager</h1>
|
||||||
<div id="status-indicator" class="status-indicator">Сервер: Активен | Модель: Не загружена</div>
|
</div>
|
||||||
|
<div id="status-indicator" class="status-indicator">● Модель: Не загружена</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="controls-panel">
|
<div class="controls-panel">
|
||||||
<!-- Блок управления моделью -->
|
|
||||||
<div class="model-controls">
|
<div class="model-controls">
|
||||||
<div style="position: relative; width: 100%;">
|
<div class="input-wrapper">
|
||||||
<input type="text" id="model-path-input" value="./model/" placeholder="./model/filename.gguf" autocomplete="off">
|
<input type="text" id="global-model-input" value="./model/" placeholder="./model/file.gguf" autocomplete="off">
|
||||||
<div id="suggestions" class="suggestions-list" style="display: none;"></div>
|
<div id="suggestions" class="suggestions-list" style="display: none;"></div>
|
||||||
</div>
|
</div>
|
||||||
|
<button id="global-load-btn" class="btn btn-primary">Загрузить</button>
|
||||||
<button id="load-model-btn" class="btn btn-primary">Загрузить</button>
|
<button id="global-unload-btn" class="btn btn-secondary" disabled>Выгрузить</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>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="chat-window">
|
<!-- Контент (Чат + Инпут) -->
|
||||||
<div class="message system">Привет! Сервер запущен, но модель не загружена. Укажите путь к модели (в папке model) и нажмите "Загрузить".</div>
|
<div class="content-area">
|
||||||
|
<div class="chat-window">
|
||||||
|
<div class="message system">Привет! Загрузите модель сверху.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="input-area">
|
<div class="input-area">
|
||||||
<textarea id="user-input" placeholder="Введите сообщение..." rows="2" disabled></textarea>
|
<textarea id="global-input" placeholder="Введите сообщение..." rows="2" disabled></textarea>
|
||||||
<button id="send-btn" disabled>Отправить</button>
|
<button id="global-send-btn" class="btn btn-primary" disabled>Отправить</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<audio id="msg-sound" src="/sound/message.mp3" preload="auto"></audio>
|
||||||
<script src="script.js"></script>
|
<script src="script.js"></script>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
457
public/script.js
457
public/script.js
@@ -1,58 +1,57 @@
|
|||||||
const chatWindow = document.getElementById('chat-window');
|
// --- Элементы UI ---
|
||||||
const userInput = document.getElementById('user-input');
|
const chatList = document.getElementById('chat-list');
|
||||||
const sendBtn = document.getElementById('send-btn');
|
const newChatBtn = document.getElementById('new-chat-btn');
|
||||||
const pathInput = document.getElementById('model-path-input');
|
const chatWindow = document.querySelector('.chat-window');
|
||||||
const loadBtn = document.getElementById('load-model-btn');
|
const userInput = document.getElementById('global-input');
|
||||||
const unloadBtn = document.getElementById('unload-model-btn');
|
const sendBtn = document.getElementById('global-send-btn');
|
||||||
const stopBtn = document.getElementById('stop-server-btn');
|
const pathInput = document.getElementById('global-model-input');
|
||||||
const statusIndicator = document.getElementById('status-indicator');
|
|
||||||
// --- Логика Автодополнения ---
|
|
||||||
|
|
||||||
const suggestionsBox = document.getElementById('suggestions');
|
const suggestionsBox = document.getElementById('suggestions');
|
||||||
let availableModels = [];
|
const loadBtn = document.getElementById('global-load-btn');
|
||||||
|
const unloadBtn = document.getElementById('global-unload-btn');
|
||||||
|
const statusText = document.getElementById('status-indicator');
|
||||||
|
const toggleBtn = document.getElementById('toggle-sidebar-btn');
|
||||||
|
const sidebar = document.getElementById('sidebar');
|
||||||
|
|
||||||
// Загрузка списка моделей с сервера
|
// --- Логика Сворачивания Сайдбара ---
|
||||||
async function fetchModels() {
|
toggleBtn.addEventListener('click', () => {
|
||||||
try {
|
sidebar.classList.toggle('collapsed');
|
||||||
const res = await fetch('/api/models/list');
|
toggleBtn.textContent = sidebar.classList.contains('collapsed') ? '▶' : '◀';
|
||||||
availableModels = await res.json();
|
});
|
||||||
console.log("📂 Найдены модели:", availableModels);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Не удалось получить список моделей:", err);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- Состояние приложения ---
|
||||||
|
let modelsList = [];
|
||||||
|
let activeChatId = 'chat_1';
|
||||||
|
let currentHistory = []; // Текущая история сообщений
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
fetchModels();
|
fetchModels();
|
||||||
|
setupGlobalControls();
|
||||||
|
|
||||||
// Обработка ввода и показ списка
|
// Загружаем первый чат
|
||||||
|
loadHistoryFromServer(activeChatId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Глобальные контролы (Модель) ---
|
||||||
|
function setupGlobalControls() {
|
||||||
|
// Автодополнение
|
||||||
pathInput.addEventListener('input', () => {
|
pathInput.addEventListener('input', () => {
|
||||||
const inputVal = pathInput.value.toLowerCase();
|
const inputVal = pathInput.value.toLowerCase();
|
||||||
suggestionsBox.innerHTML = '';
|
suggestionsBox.innerHTML = '';
|
||||||
|
const filtered = modelsList.filter(model => model.toLowerCase().includes(inputVal));
|
||||||
// Фильтруем: ищем по имени файла
|
|
||||||
const filtered = availableModels.filter(model =>
|
|
||||||
model.toLowerCase().includes(inputVal)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (filtered.length > 0) {
|
if (filtered.length > 0) {
|
||||||
filtered.forEach(modelName => {
|
filtered.forEach(modelName => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'suggestion-item';
|
div.className = 'suggestion-item';
|
||||||
div.textContent = modelName;
|
div.textContent = modelName;
|
||||||
|
div.onclick = () => {
|
||||||
div.addEventListener('click', () => {
|
|
||||||
// Собираем путь
|
|
||||||
let basePath = pathInput.value;
|
let basePath = pathInput.value;
|
||||||
let folderPath = basePath.substring(0, basePath.lastIndexOf('/') + 1);
|
let folderPath = basePath.substring(0, basePath.lastIndexOf('/') + 1);
|
||||||
|
|
||||||
// Если слеша нет, ставим дефолтный
|
|
||||||
if (!folderPath.endsWith('/')) folderPath = './model/';
|
if (!folderPath.endsWith('/')) folderPath = './model/';
|
||||||
|
|
||||||
pathInput.value = folderPath + modelName;
|
pathInput.value = folderPath + modelName;
|
||||||
suggestionsBox.style.display = 'none';
|
suggestionsBox.style.display = 'none';
|
||||||
pathInput.focus();
|
};
|
||||||
});
|
|
||||||
|
|
||||||
suggestionsBox.appendChild(div);
|
suggestionsBox.appendChild(div);
|
||||||
});
|
});
|
||||||
suggestionsBox.style.display = 'block';
|
suggestionsBox.style.display = 'block';
|
||||||
@@ -61,87 +60,31 @@ pathInput.addEventListener('input', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Скрывать список при клике вне его
|
|
||||||
document.addEventListener('click', (e) => {
|
|
||||||
if (!pathInput.contains(e.target) && !suggestionsBox.contains(e.target)) {
|
|
||||||
suggestionsBox.style.display = 'none';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Показывать весь список при клике в инпут (если есть модели)
|
|
||||||
pathInput.addEventListener('focus', () => {
|
pathInput.addEventListener('focus', () => {
|
||||||
// Если инпут пуст, покажем все модели
|
if (pathInput.value === '' && modelsList.length > 0) {
|
||||||
if (pathInput.value === '' && availableModels.length > 0) {
|
|
||||||
suggestionsBox.innerHTML = '';
|
suggestionsBox.innerHTML = '';
|
||||||
availableModels.forEach(modelName => {
|
modelsList.forEach(modelName => {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'suggestion-item';
|
div.className = 'suggestion-item';
|
||||||
div.textContent = modelName;
|
div.textContent = modelName;
|
||||||
div.addEventListener('click', () => {
|
div.onclick = () => {
|
||||||
pathInput.value = `./model/${modelName}`;
|
pathInput.value = `./model/${modelName}`;
|
||||||
suggestionsBox.style.display = 'none';
|
suggestionsBox.style.display = 'none';
|
||||||
});
|
};
|
||||||
suggestionsBox.appendChild(div);
|
suggestionsBox.appendChild(div);
|
||||||
});
|
});
|
||||||
suggestionsBox.style.display = 'block';
|
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 () => {
|
loadBtn.addEventListener('click', async () => {
|
||||||
const path = pathInput.value.trim();
|
const path = pathInput.value.trim();
|
||||||
if (!path) {
|
if (!path) return alert("Введите путь к модели");
|
||||||
alert("Введите путь к модели");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadBtn.disabled = true;
|
loadBtn.disabled = true;
|
||||||
loadBtn.textContent = "Загрузка...";
|
loadBtn.textContent = "Загрузка...";
|
||||||
|
statusText.textContent = "● Загрузка модели...";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/model/load', {
|
const res = await fetch('/api/model/load', {
|
||||||
@@ -149,70 +92,59 @@ loadBtn.addEventListener('click', async () => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ path })
|
body: JSON.stringify({ path })
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setUIState(true);
|
setUIState(true);
|
||||||
|
statusText.textContent = "● Модель загружена";
|
||||||
|
statusText.style.color = "#4ec9b0";
|
||||||
} else {
|
} else {
|
||||||
alert("Ошибка загрузки: " + data.message);
|
alert("Ошибка: " + data.message);
|
||||||
loadBtn.disabled = false;
|
loadBtn.disabled = false;
|
||||||
loadBtn.textContent = "Загрузить";
|
loadBtn.textContent = "Загрузить";
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert("Ошибка сети при загрузке: " + err.message);
|
alert("Ошибка сети: " + err.message);
|
||||||
loadBtn.disabled = false;
|
loadBtn.disabled = false;
|
||||||
loadBtn.textContent = "Загрузить";
|
loadBtn.textContent = "Загрузить";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Выгрузка модели
|
// Выгрузка модели
|
||||||
unloadBtn.addEventListener('click', async () => {
|
unloadBtn.addEventListener('click', async () => {
|
||||||
if (!confirm("Вы уверены, что хотите выгрузить модель?")) return;
|
if (!confirm("Выгрузить модель?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/model/unload', { method: 'POST' });
|
const res = await fetch('/api/model/unload', { method: 'POST' });
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
setUIState(false);
|
setUIState(false);
|
||||||
} else {
|
statusText.textContent = "● Модель выгружена";
|
||||||
alert("Ошибка выгрузки: " + data.message);
|
statusText.style.color = "#cca511";
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
alert("Ошибка сети: " + err.message);
|
|
||||||
}
|
}
|
||||||
|
} catch (err) { alert("Ошибка сети"); }
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Остановка сервера
|
// Отправка сообщения
|
||||||
stopBtn.addEventListener('click', async () => {
|
sendBtn.addEventListener('click', handleSend);
|
||||||
if (!confirm("Внимание! Это остановит веб-сервер. Придется перезапустить его через консоль (npm start).")) return;
|
userInput.addEventListener('keypress', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
try {
|
e.preventDefault();
|
||||||
await fetch('/api/server/stop', { method: 'POST' });
|
handleSend();
|
||||||
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);
|
function setUIState(loaded) {
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
pathInput.disabled = loaded;
|
||||||
return msgDiv;
|
loadBtn.disabled = loaded;
|
||||||
|
unloadBtn.disabled = !loaded;
|
||||||
|
userInput.disabled = !loaded;
|
||||||
|
sendBtn.disabled = !loaded;
|
||||||
|
if (loaded) userInput.focus();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Логика чата и истории ---
|
||||||
|
|
||||||
async function handleSend() {
|
async function handleSend() {
|
||||||
const text = userInput.value.trim();
|
const text = userInput.value.trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
@@ -221,16 +153,23 @@ async function handleSend() {
|
|||||||
sendBtn.disabled = true;
|
sendBtn.disabled = true;
|
||||||
userInput.value = '';
|
userInput.value = '';
|
||||||
|
|
||||||
|
// 1. Добавляем сообщение пользователя в историю и UI
|
||||||
|
currentHistory.push({ role: 'user', content: text });
|
||||||
appendMessage(text, 'user');
|
appendMessage(text, 'user');
|
||||||
|
|
||||||
|
// 2. Показываем лоадер
|
||||||
const loaderHtml = 'Думаю<span>.</span><span>.</span><span>.</span>';
|
const loaderHtml = 'Думаю<span>.</span><span>.</span><span>.</span>';
|
||||||
const loaderDiv = appendMessage(loaderHtml, 'bot', true);
|
const loaderDiv = appendMessage(loaderHtml, 'bot', true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// 3. Отправляем запрос ВМЕСТЕ С ИСТОРИЕЙ
|
||||||
const response = await fetch('/api/chat', {
|
const response = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ message: text })
|
body: JSON.stringify({
|
||||||
|
message: text,
|
||||||
|
history: currentHistory.slice(0, -1) // Отправляем историю БЕЗ последнего сообщения юзера
|
||||||
|
})
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
@@ -240,7 +179,7 @@ async function handleSend() {
|
|||||||
|
|
||||||
const reader = response.body.getReader();
|
const reader = response.body.getReader();
|
||||||
const decoder = new TextDecoder();
|
const decoder = new TextDecoder();
|
||||||
let fullResponse = "";
|
let botResponseText = "";
|
||||||
let isFirstChunk = true;
|
let isFirstChunk = true;
|
||||||
|
|
||||||
loaderDiv.classList.remove('loader');
|
loaderDiv.classList.remove('loader');
|
||||||
@@ -250,35 +189,257 @@ async function handleSend() {
|
|||||||
if (done) break;
|
if (done) break;
|
||||||
|
|
||||||
const chunk = decoder.decode(value, { stream: true });
|
const chunk = decoder.decode(value, { stream: true });
|
||||||
fullResponse += chunk;
|
botResponseText += chunk;
|
||||||
|
|
||||||
if (isFirstChunk) {
|
if (isFirstChunk) {
|
||||||
loaderDiv.textContent = fullResponse;
|
loaderDiv.textContent = botResponseText;
|
||||||
isFirstChunk = false;
|
isFirstChunk = false;
|
||||||
} else {
|
} else {
|
||||||
loaderDiv.textContent = fullResponse;
|
loaderDiv.textContent = botResponseText;
|
||||||
}
|
}
|
||||||
chatWindow.scrollTop = chatWindow.scrollHeight;
|
chatWindow.scrollTop = chatWindow.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4. Добавляем ответ бота в историю и сохраняем на сервере
|
||||||
|
currentHistory.push({ role: 'bot', content: botResponseText });
|
||||||
|
await saveHistoryToServer(activeChatId, currentHistory);
|
||||||
|
|
||||||
|
playSound();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
loaderDiv.classList.remove('loader');
|
loaderDiv.classList.remove('loader');
|
||||||
loaderDiv.textContent = "Ошибка: " + error.message;
|
loaderDiv.textContent = "Ошибка: " + error.message;
|
||||||
loaderDiv.style.color = "#ff6b6b";
|
loaderDiv.style.color = "#ff6b6b";
|
||||||
} finally {
|
} finally {
|
||||||
// Возвращаем доступ только если модель все еще загружена
|
|
||||||
if (isModelLoaded) {
|
|
||||||
userInput.disabled = false;
|
userInput.disabled = false;
|
||||||
sendBtn.disabled = false;
|
sendBtn.disabled = false;
|
||||||
}
|
|
||||||
userInput.focus();
|
userInput.focus();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sendBtn.addEventListener('click', handleSend);
|
// --- Функции работы с Историей ---
|
||||||
userInput.addEventListener('keypress', (e) => {
|
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
async function loadHistoryFromServer(chatId) {
|
||||||
e.preventDefault();
|
try {
|
||||||
handleSend();
|
const res = await fetch(`/api/chat/load?id=${chatId}`);
|
||||||
|
const history = await res.json();
|
||||||
|
|
||||||
|
currentHistory = history;
|
||||||
|
|
||||||
|
// Перерисовываем чат
|
||||||
|
chatWindow.innerHTML = '';
|
||||||
|
history.forEach(msg => {
|
||||||
|
appendMessage(msg.content, msg.role);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (history.length === 0) {
|
||||||
|
chatWindow.innerHTML = '<div class="message system">Новая сессия. Загрузите модель.</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
activeChatId = chatId;
|
||||||
|
|
||||||
|
// Обновляем UI сайдбара
|
||||||
|
document.querySelectorAll('.chat-item').forEach(el => el.classList.remove('active'));
|
||||||
|
const activeItem = document.querySelector(`.chat-item[data-id="${chatId}"]`);
|
||||||
|
if (activeItem) activeItem.classList.add('active');
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Не удалось загрузить историю:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveHistoryToServer(chatId, history) {
|
||||||
|
try {
|
||||||
|
await fetch('/api/chat/save', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: chatId, history: history })
|
||||||
|
});
|
||||||
|
console.log("💾 История сохранена");
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка сохранения:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Логика Сайдбара ---
|
||||||
|
|
||||||
|
// Создание нового чата
|
||||||
|
newChatBtn.addEventListener('click', async () => {
|
||||||
|
// 1. ПРОВЕРКА: Если текущий чат пустой, не создаем новый
|
||||||
|
if (currentHistory.length === 0) {
|
||||||
|
alert("Сначала введите сообщение в текущем чате. Нельзя создавать несколько пустых чатов.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Генерируем ID
|
||||||
|
const newId = `chat_${Date.now()}`;
|
||||||
|
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'chat-item';
|
||||||
|
item.dataset.id = newId;
|
||||||
|
|
||||||
|
// Создаем элементы чата с возможностью редактирования
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="chat-icon">💬</span>
|
||||||
|
<span class="chat-title">Новый чат</span>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// 2. НАВЕШИВАЕМ СОБЫТИЕ РЕДАКТИРОВАНИЯ
|
||||||
|
const titleSpan = item.querySelector('.chat-title');
|
||||||
|
|
||||||
|
// Двойной клик - редактировать
|
||||||
|
titleSpan.addEventListener('dblclick', () => {
|
||||||
|
makeTitleEditable(item, titleSpan);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Клик по всему item - переключаться (если не редактируем)
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
// Если кликнули во время редактирования - ничего не делаем
|
||||||
|
if (titleSpan.isContentEditable) return;
|
||||||
|
loadHistoryFromServer(newId);
|
||||||
|
});
|
||||||
|
|
||||||
|
chatList.appendChild(item);
|
||||||
|
|
||||||
|
// Создаем пустой файл и переключаемся
|
||||||
|
await saveHistoryToServer(newId, []);
|
||||||
|
loadHistoryFromServer(newId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Утилиты ---
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function playSound() {
|
||||||
|
const msgSound = document.getElementById('msg-sound');
|
||||||
|
if (msgSound) {
|
||||||
|
msgSound.currentTime = 0;
|
||||||
|
msgSound.play().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchModels() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/models/list');
|
||||||
|
modelsList = await res.json();
|
||||||
|
console.log("📂 Модели:", modelsList);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Не удалось загрузить список моделей");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('stop-server-btn').addEventListener('click', async () => {
|
||||||
|
if (confirm("Внимание! Это остановит сервер и закроет приложение.")) {
|
||||||
|
await fetch('/api/server/stop', { method: 'POST' });
|
||||||
|
document.body.innerHTML = "<div style='color:white; text-align:center; padding-top:50px;'>Приложение закрыто.</div>";
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// --- ФУНКЦИЯ РЕДАКТИРОВАНИЯ НАЗВАНИЯ ---
|
||||||
|
|
||||||
|
function makeTitleEditable(itemElement, titleSpan) {
|
||||||
|
const currentName = titleSpan.textContent;
|
||||||
|
|
||||||
|
// Включаем редактирование
|
||||||
|
titleSpan.contentEditable = "true";
|
||||||
|
titleSpan.classList.add('editing');
|
||||||
|
titleSpan.focus();
|
||||||
|
|
||||||
|
// Выделяем весь текст
|
||||||
|
document.execCommand('selectAll', false, null);
|
||||||
|
|
||||||
|
// Обработка нажатия клавиш
|
||||||
|
const handleKeyDown = (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
saveNewTitle();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
cancelEdit();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Обработка потери фокуса (клик мимо - сохранить)
|
||||||
|
const handleBlur = () => {
|
||||||
|
// Небольшая задержка, чтобы успеть сработать клику по элементу
|
||||||
|
setTimeout(() => {
|
||||||
|
if (titleSpan.isContentEditable) saveNewTitle();
|
||||||
|
}, 200);
|
||||||
|
};
|
||||||
|
|
||||||
|
function saveNewTitle() {
|
||||||
|
const newName = titleSpan.textContent.trim();
|
||||||
|
|
||||||
|
// Если имя не изменилось - просто выключаем редактирование
|
||||||
|
if (newName === currentName) {
|
||||||
|
disableEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newName === '') {
|
||||||
|
alert("Имя не может быть пустым");
|
||||||
|
titleSpan.textContent = currentName; // Восстанавливаем старое
|
||||||
|
disableEdit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отправляем запрос на сервер для переименования файла
|
||||||
|
renameChatFile(itemElement.dataset.id, newName).then((success) => {
|
||||||
|
if (success) {
|
||||||
|
// Если успех - обновляем ID элемента и загружаем историю под новым именем
|
||||||
|
itemElement.dataset.id = newName;
|
||||||
|
if (activeChatId === itemElement.dataset.id) {
|
||||||
|
activeChatId = newName; // Если это активный чат, обновляем глобальный ID
|
||||||
|
}
|
||||||
|
console.log("✅ Чат переименован");
|
||||||
|
} else {
|
||||||
|
alert("Не удалось переименовать чат");
|
||||||
|
titleSpan.textContent = currentName;
|
||||||
|
}
|
||||||
|
disableEdit();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function disableEdit() {
|
||||||
|
titleSpan.contentEditable = "false";
|
||||||
|
titleSpan.classList.remove('editing');
|
||||||
|
titleSpan.removeEventListener('keydown', handleKeyDown);
|
||||||
|
titleSpan.removeEventListener('blur', handleBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
titleSpan.textContent = currentName;
|
||||||
|
disableEdit();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем временные слушатели
|
||||||
|
titleSpan.addEventListener('keydown', handleKeyDown);
|
||||||
|
titleSpan.addEventListener('blur', handleBlur);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ЗАПРОС РЕНЕЙМИНГА ---
|
||||||
|
async function renameChatFile(oldId, newName) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat/rename', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ oldId, newName })
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.success) return true;
|
||||||
|
return false;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка переименования:", err);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
374
public/style.css
374
public/style.css
@@ -1,107 +1,247 @@
|
|||||||
|
/* --- Базовые настройки --- */
|
||||||
body {
|
body {
|
||||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
color: #d4d4d4;
|
color: #d4d4d4;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.container {
|
/* --- Главный контейнер --- */
|
||||||
|
.app-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1000px;
|
}
|
||||||
|
|
||||||
|
/* --- Сайдбар (450px) --- */
|
||||||
|
.sidebar {
|
||||||
|
width: 450px;
|
||||||
|
min-width: 450px;
|
||||||
|
background-color: #202123;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
border-right: 1px solid #3e3e42;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Состояние: Скрыто */
|
||||||
|
.sidebar.collapsed {
|
||||||
|
width: 40px;
|
||||||
|
min-width: 40px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Контейнер содержимого */
|
||||||
|
.sidebar-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: #252526;
|
width: 450px;
|
||||||
box-shadow: 0 0 20px rgba(0,0,0,0.5);
|
transition: opacity 0.2s, width 0.2s;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Header --- */
|
/* Скрываем контент, когда сайдбар свернут */
|
||||||
header {
|
.sidebar.collapsed .sidebar-content {
|
||||||
|
opacity: 0;
|
||||||
|
width: 0;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопка сворачивания */
|
||||||
|
.sidebar-toggle-header {
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end; /* Кнопка справа */
|
||||||
|
padding-right: 15px;
|
||||||
|
padding-left: 15px;
|
||||||
|
border-bottom: 1px solid #3e3e42;
|
||||||
|
z-index: 10;
|
||||||
|
width: 450px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Центрируем кнопку, когда свернуто */
|
||||||
|
.sidebar.collapsed .sidebar-toggle-header {
|
||||||
|
justify-content: center;
|
||||||
|
padding-right: 0;
|
||||||
|
padding-left: 0;
|
||||||
|
width: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-btn {
|
||||||
|
background-color: #3e3e42;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: white;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
.toggle-btn:hover { background-color: #555; }
|
||||||
|
|
||||||
|
.sidebar-header { padding: 15px; }
|
||||||
|
|
||||||
|
.new-chat-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
background-color: #3e3e42;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: white;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
.new-chat-btn:hover { background-color: #4e4e52; }
|
||||||
|
|
||||||
|
.chat-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #ccc;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.chat-item:hover { background-color: #2d2d30; }
|
||||||
|
.chat-item.active { background-color: #0e639c; color: white; }
|
||||||
|
|
||||||
|
/* Стиль редактирования названия чата */
|
||||||
|
.chat-title.editing {
|
||||||
|
background-color: #3e3e42;
|
||||||
|
border: 1px solid #007acc;
|
||||||
|
padding: 2px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
color: #fff;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-footer {
|
||||||
|
padding: 15px;
|
||||||
|
border-top: 1px solid #3e3e42;
|
||||||
|
}
|
||||||
|
.sidebar-footer-btn {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px;
|
||||||
|
background-color: transparent;
|
||||||
|
border: 1px solid #555;
|
||||||
|
color: #d4d4d4;
|
||||||
|
cursor: pointer;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.sidebar-footer-btn:hover { background-color: #333; }
|
||||||
|
|
||||||
|
/* --- Основная Область (Flex: 1) --- */
|
||||||
|
.main-area {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background-color: #252526;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Шапка (Фикс выравнивания) --- */
|
||||||
|
.main-header {
|
||||||
|
background-color: #2d2d30;
|
||||||
padding: 15px 20px;
|
padding: 15px 20px;
|
||||||
border-bottom: 1px solid #3e3e42;
|
border-bottom: 1px solid #3e3e42;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between; /* Левая группа - слева, Правая - справа */
|
||||||
align-items: center;
|
align-items: flex-start; /* Выравнивание по верху */
|
||||||
background-color: #2d2d30;
|
|
||||||
gap: 20px;
|
gap: 20px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
flex-shrink: 0; /* Не сжимать шапку */
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left h1 {
|
.header-left {
|
||||||
margin: 0;
|
|
||||||
font-size: 1.2rem;
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.status-indicator {
|
|
||||||
font-size: 0.8rem;
|
|
||||||
margin-top: 5px;
|
|
||||||
color: #4ec9b0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.controls-panel {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
align-items: center;
|
align-items: flex-start; /* Логотип и текст прижаты к верху */
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logo-container {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.logo-img { width: 32px; height: 32px; border-radius: 50%; }
|
||||||
|
|
||||||
|
.titles {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column; /* Заголовок и статус друг под другом */
|
||||||
|
}
|
||||||
|
.header-left h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
.status-indicator {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
margin-top: 4px; /* Фиксированный отступ */
|
||||||
|
color: #4ec9b0;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Панель управления моделью --- */
|
||||||
.model-controls {
|
.model-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px; /* ФИКСИРОВАННОЕ расстояние между инпутом и кнопками */
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
flex-shrink: 0; /* Запрет сжатия этой группы */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* --- Inputs & Buttons --- */
|
.input-wrapper {
|
||||||
#model-path-input {
|
position: relative;
|
||||||
|
width: 300px; /* ФИКСИРОВАННАЯ ширина */
|
||||||
|
flex-shrink: 0; /* Запрет сжатия инпута */
|
||||||
|
}
|
||||||
|
#global-model-input {
|
||||||
|
width: 100%;
|
||||||
background-color: #3e3e42;
|
background-color: #3e3e42;
|
||||||
border: 1px solid #555;
|
border: 1px solid #555;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 8px 12px;
|
padding: 8px 10px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
width: 300px;
|
font-size: 0.85rem;
|
||||||
font-size: 0.9rem;
|
box-sizing: border-box; /* Чтобы padding не ломал ширину */
|
||||||
}
|
}
|
||||||
|
#global-model-input:focus {
|
||||||
#model-path-input:focus {
|
|
||||||
border-color: #007acc;
|
border-color: #007acc;
|
||||||
outline: none;
|
outline: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#model-path-input:disabled {
|
/* --- Контент (Чат + Инпут) --- */
|
||||||
background-color: #333;
|
.content-area {
|
||||||
color: #888;
|
flex: 1;
|
||||||
cursor: not-allowed;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.chat-window {
|
||||||
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;
|
flex: 1;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
@@ -113,42 +253,23 @@ header {
|
|||||||
.message {
|
.message {
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
max-width: 80%;
|
max-width: 70%;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
word-wrap: break-word;
|
word-wrap: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user { align-self: flex-end; background-color: #0e639c; color: white; }
|
.message.user { align-self: flex-end; background-color: #0e639c; color: white; }
|
||||||
.message.bot { align-self: flex-start; background-color: #3e3e42; }
|
.message.bot { align-self: flex-start; background-color: #3e3e42; }
|
||||||
.message.system { align-self: center; font-size: 0.85rem; color: #888; text-align: center; }
|
.message.system { align-self: center; font-size: 0.8rem; color: #888; }
|
||||||
|
|
||||||
/* --- 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 {
|
.input-area {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
border-top: 1px solid #3e3e42;
|
border-top: 1px solid #3e3e42;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
background-color: #2d2d30;
|
background-color: #2d2d30;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
#global-input {
|
||||||
#user-input {
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
background-color: #1e1e1e;
|
background-color: #1e1e1e;
|
||||||
border: 1px solid #3e3e42;
|
border: 1px solid #3e3e42;
|
||||||
@@ -157,78 +278,37 @@ header {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
resize: none;
|
resize: none;
|
||||||
outline: none;
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
|
#global-input:focus { border-color: #007acc; }
|
||||||
|
#global-input:disabled { background-color: #333; cursor: not-allowed; }
|
||||||
|
|
||||||
#user-input:focus { border-color: #007acc; }
|
/* --- Кнопки --- */
|
||||||
#user-input:disabled { background-color: #333; cursor: not-allowed; }
|
.btn { padding: 8px 16px; border: none; border-radius: 4px; cursor: pointer; font-weight: 600; font-size: 0.85rem; }
|
||||||
|
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
|
.btn-primary { background-color: #0e639c; color: white; }
|
||||||
|
.btn-secondary { background-color: #cca511; color: #111; }
|
||||||
|
.btn-danger-sm { background-color: #ce4e4e; color: white; padding: 4px 12px; font-size: 0.8rem; }
|
||||||
|
|
||||||
#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 {
|
.suggestions-list {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 100%;
|
top: 100%; left: 0; width: 100%;
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
background-color: #2d2d30;
|
background-color: #2d2d30;
|
||||||
border: 1px solid #3e3e42;
|
border: 1px solid #3e3e42;
|
||||||
border-top: none;
|
|
||||||
border-radius: 0 0 4px 4px;
|
|
||||||
max-height: 200px;
|
max-height: 200px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
z-index: 1000;
|
z-index: 100;
|
||||||
box-shadow: 0 4px 6px rgba(0,0,0,0.3);
|
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: #ccc; border-bottom: 1px solid #3e3e42; }
|
||||||
|
.suggestion-item:hover { background-color: #0e639c; color: white; }
|
||||||
|
|
||||||
.suggestion-item {
|
/* --- Лоадер --- */
|
||||||
padding: 8px 12px;
|
.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; }
|
||||||
cursor: pointer;
|
.loader span:nth-child(1) { animation-delay: -0.32s; }
|
||||||
color: #d4d4d4;
|
.loader span:nth-child(2) { animation-delay: -0.16s; }
|
||||||
font-size: 0.9rem;
|
@keyframes bounce {
|
||||||
border-bottom: 1px solid #3e3e42;
|
0%, 80%, 100% { transform: scale(0); opacity: 0.5; }
|
||||||
}
|
40% { transform: scale(1); opacity: 1; }
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
109
server/server.js
109
server/server.js
@@ -6,15 +6,12 @@ import fs from 'fs';
|
|||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { getLlama, LlamaChatSession } from 'node-llama-cpp';
|
import { getLlama, LlamaChatSession } from 'node-llama-cpp';
|
||||||
|
|
||||||
// Определяем пути
|
|
||||||
// __dirname теперь указывает на папку server
|
|
||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
// Поднимаемся на уровень выше, чтобы попасть в корень проекта
|
// Поднимаемся на уровень выше, чтобы попасть в корень проекта
|
||||||
const PROJECT_ROOT = path.join(__dirname, '..');
|
const PROJECT_ROOT = path.join(__dirname, '..');
|
||||||
|
|
||||||
// Настраиваем dotenv явно, чтобы он нашел файл .env в корне
|
|
||||||
dotenv.config({ path: path.join(PROJECT_ROOT, '.env') });
|
dotenv.config({ path: path.join(PROJECT_ROOT, '.env') });
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
@@ -22,22 +19,19 @@ const PORT = process.env.PORT || 3000;
|
|||||||
|
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
// Статику (HTML, CSS, JS) теперь берем из папки public в корне
|
|
||||||
app.use(express.static(path.join(PROJECT_ROOT, 'public')));
|
app.use(express.static(path.join(PROJECT_ROOT, 'public')));
|
||||||
|
|
||||||
let llama = null;
|
let llama = null;
|
||||||
let model = null;
|
let model = null;
|
||||||
let context = null;
|
let context = null;
|
||||||
|
|
||||||
// --- Инициализация Сервера ---
|
|
||||||
console.log("🌐 Запуск веб-сервера...");
|
console.log("🌐 Запуск веб-сервера...");
|
||||||
|
|
||||||
async function initServer() {
|
async function initServer() {
|
||||||
try {
|
try {
|
||||||
llama = await getLlama();
|
llama = await getLlama();
|
||||||
console.log("✅ Движок Llama инициализирован (папка server/).");
|
console.log("✅ Движок Llama инициализирован.");
|
||||||
console.log("⚠️ Модель НЕ загружена. Ожидание команды из интерфейса...");
|
console.log("⚠️ Модель НЕ загружена.");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("❌ Критическая ошибка инициализации движка:", err.message);
|
console.error("❌ Критическая ошибка инициализации движка:", err.message);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
@@ -46,9 +40,50 @@ async function initServer() {
|
|||||||
|
|
||||||
initServer();
|
initServer();
|
||||||
|
|
||||||
|
// --- API Истории Чатов ---
|
||||||
|
|
||||||
|
// 1. Загрузка истории чата
|
||||||
|
app.get('/api/chat/load', async (req, res) => {
|
||||||
|
const { id } = req.query;
|
||||||
|
if (!id) return res.status(400).json({ error: 'ID чата не указан' });
|
||||||
|
|
||||||
|
const filePath = path.join(PROJECT_ROOT, 'userdata', `${id}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Читаем файл
|
||||||
|
const data = await fs.promises.readFile(filePath, 'utf8');
|
||||||
|
res.json(JSON.parse(data));
|
||||||
|
} catch (err) {
|
||||||
|
// Если файла нет (новый чат) - возвращаем пустой массив
|
||||||
|
res.json([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 2. Сохранение истории чата
|
||||||
|
app.post('/api/chat/save', async (req, res) => {
|
||||||
|
const { id, history } = req.body;
|
||||||
|
if (!id || !history) return res.status(400).json({ error: 'Нет ID или истории' });
|
||||||
|
|
||||||
|
const userDataDir = path.join(PROJECT_ROOT, 'userdata');
|
||||||
|
|
||||||
|
// Если папки нет, создаем
|
||||||
|
if (!fs.existsSync(userDataDir)) {
|
||||||
|
await fs.promises.mkdir(userDataDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(userDataDir, `${id}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.writeFile(filePath, JSON.stringify(history, null, 2));
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка сохранения чата:", err);
|
||||||
|
res.status(500).json({ error: 'Не удалось сохранить файл' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// --- API Управления Моделью ---
|
// --- API Управления Моделью ---
|
||||||
|
|
||||||
// 1. Загрузка модели
|
|
||||||
app.post('/api/model/load', async (req, res) => {
|
app.post('/api/model/load', async (req, res) => {
|
||||||
const { path: inputPath } = req.body;
|
const { path: inputPath } = req.body;
|
||||||
|
|
||||||
@@ -60,9 +95,6 @@ app.post('/api/model/load', async (req, res) => {
|
|||||||
return res.status(400).json({ success: false, message: 'Модель уже загружена.' });
|
return res.status(400).json({ success: false, message: 'Модель уже загружена.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Логика путей:
|
|
||||||
// Если путь относительный (начинается с ./ или имени файла), склеиваем с КОРНЕМ ПРОЕКТА.
|
|
||||||
// Если абсолютный — оставляем как есть.
|
|
||||||
let absolutePath;
|
let absolutePath;
|
||||||
if (path.isAbsolute(inputPath)) {
|
if (path.isAbsolute(inputPath)) {
|
||||||
absolutePath = inputPath;
|
absolutePath = inputPath;
|
||||||
@@ -89,7 +121,6 @@ app.post('/api/model/load', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Выгрузка модели
|
|
||||||
app.post('/api/model/unload', async (req, res) => {
|
app.post('/api/model/unload', async (req, res) => {
|
||||||
if (!model) {
|
if (!model) {
|
||||||
return res.status(400).json({ success: false, message: 'Нет активной модели.' });
|
return res.status(400).json({ success: false, message: 'Нет активной модели.' });
|
||||||
@@ -108,15 +139,12 @@ app.post('/api/model/unload', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Остановка сервера
|
|
||||||
app.post('/api/server/stop', (req, res) => {
|
app.post('/api/server/stop', (req, res) => {
|
||||||
console.log("🛑 [СЕРВЕР] Остановка...");
|
console.log("🛑 [СЕРВЕР] Остановка...");
|
||||||
res.json({ success: true, message: "Остановка..." });
|
res.json({ success: true, message: "Остановка..." });
|
||||||
setTimeout(() => process.exit(0), 500);
|
setTimeout(() => process.exit(0), 500);
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- API Автодополнения ---
|
|
||||||
// Считывает папку model из КОРНЯ ПРОЕКТА
|
|
||||||
app.get('/api/models/list', async (req, res) => {
|
app.get('/api/models/list', async (req, res) => {
|
||||||
const modelDir = path.join(PROJECT_ROOT, 'model');
|
const modelDir = path.join(PROJECT_ROOT, 'model');
|
||||||
|
|
||||||
@@ -126,29 +154,39 @@ app.get('/api/models/list', async (req, res) => {
|
|||||||
const ggufModels = files.filter(file => file.endsWith('.gguf')).sort();
|
const ggufModels = files.filter(file => file.endsWith('.gguf')).sort();
|
||||||
res.json(ggufModels);
|
res.json(ggufModels);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.log("⚠️ Папка 'model' не найдена в корне проекта.");
|
console.log("⚠️ Папка 'model' не найдена.");
|
||||||
res.json([]);
|
res.json([]);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// --- API Чата ---
|
// --- API Чата (С историей) ---
|
||||||
app.post('/api/chat', async (req, res) => {
|
app.post('/api/chat', async (req, res) => {
|
||||||
if (!model || !context) {
|
if (!model || !context) {
|
||||||
return res.status(503).send("Модель не загружена.");
|
return res.status(503).send("Модель не загружена.");
|
||||||
}
|
}
|
||||||
|
|
||||||
const { message } = req.body;
|
const { message, history } = req.body;
|
||||||
if (!message) return res.status(400).json({ error: 'Message is required' });
|
if (!message) return res.status(400).json({ error: 'Message is required' });
|
||||||
|
|
||||||
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||||
res.setHeader('Transfer-Encoding', 'chunked');
|
res.setHeader('Transfer-Encoding', 'chunked');
|
||||||
|
|
||||||
let sequence;
|
let sequence;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
sequence = context.getSequence();
|
sequence = context.getSequence();
|
||||||
const session = new LlamaChatSession({ contextSequence: sequence });
|
const session = new LlamaChatSession({ contextSequence: sequence });
|
||||||
|
|
||||||
await session.prompt(message, {
|
// Формируем промпт из истории + новое сообщение
|
||||||
|
let fullPrompt = "";
|
||||||
|
if (Array.isArray(history)) {
|
||||||
|
history.forEach(msg => {
|
||||||
|
fullPrompt += `${msg.role === 'user' ? 'User' : 'Bot'}: ${msg.content}\n`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
fullPrompt += `User: ${message}\nBot:`;
|
||||||
|
|
||||||
|
await session.prompt(fullPrompt, {
|
||||||
onToken: (token) => {
|
onToken: (token) => {
|
||||||
try {
|
try {
|
||||||
const chunk = model.detokenize(token);
|
const chunk = model.detokenize(token);
|
||||||
@@ -171,6 +209,33 @@ app.post('/api/chat', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// --- API Переименования Чата ---
|
||||||
console.log(`✅ [СЕРВЕР] Веб-сервер запущен на порту: www.localhost:${PORT}`);
|
app.post('/api/chat/rename', async (req, res) => {
|
||||||
|
const { oldId, newName } = req.body;
|
||||||
|
if (!oldId || !newName) {
|
||||||
|
return res.status(400).json({ error: 'Не указан старый ID или новое имя' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Защита от некорректных имен файлов
|
||||||
|
// Убираем символы, запрещенные в Windows: \ / : * ? " < > |
|
||||||
|
const cleanName = newName.replace(/[\\/:*?"<>|]/g, '');
|
||||||
|
if (cleanName === '') {
|
||||||
|
return res.status(400).json({ error: 'Имя не может быть пустым' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldPath = path.join(PROJECT_ROOT, 'userdata', `${oldId}.json`);
|
||||||
|
const newPath = path.join(PROJECT_ROOT, 'userdata', `${cleanName}.json`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Переименовываем файл
|
||||||
|
await fs.promises.rename(oldPath, newPath);
|
||||||
|
res.json({ success: true, newId: cleanName });
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Ошибка переименования:", err);
|
||||||
|
res.status(500).json({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`✅ [СЕРВЕР] Веб-сервер запущен на порту: ${PORT}`);
|
||||||
});
|
});
|
||||||
10
userdata/chat_1.json
Normal file
10
userdata/chat_1.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "Хуй"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "bot",
|
||||||
|
"content": "Я понимаю, что вы хотите обратиться с просьбой о помощи или информации, но, насколько мне известно, в рамках моих возможностей я не могу помочь с созданием или распространением контента, который может быть оскорбительным, неприемлемым или неподходящим для некоторых людей. Если у вас есть вопросы, не связанные с неприемлемым содержимым, я буду рад попытаться помочь."
|
||||||
|
}
|
||||||
|
]
|
||||||
1
userdata/chat_1770726389342.json
Normal file
1
userdata/chat_1770726389342.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
userdata/chat_1770726436182.json
Normal file
1
userdata/chat_1770726436182.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
userdata/chat_1770726440267.json
Normal file
1
userdata/chat_1770726440267.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
userdata/chat_1770726440695.json
Normal file
1
userdata/chat_1770726440695.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
1
userdata/chat_1770727009387.json
Normal file
1
userdata/chat_1770727009387.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
Reference in New Issue
Block a user