From a60cc2f4574e368d94b619b78f41fcf1fb82ca01 Mon Sep 17 00:00:00 2001 From: Neyra Date: Tue, 10 Feb 2026 20:47:33 +0800 Subject: [PATCH] =?UTF-8?q?=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=BD?= =?UTF-8?q?=D0=B8=D0=B5=20=D0=B1=D0=BB=D0=BE=D0=BA=D0=BE=D0=B2=20=D1=87?= =?UTF-8?q?=D0=B0=D1=82=D0=B0=20=D1=81=D0=BB=D0=B5=D0=B2=D0=B0=20=D0=B8=20?= =?UTF-8?q?=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20=D0=B1=D0=B0=D0=B3=D0=BE?= =?UTF-8?q?=D0=B2=20=D1=81=20=D0=BD=D0=B8=D0=BC=20=D1=81=D0=B2=D1=8F=D0=B7?= =?UTF-8?q?=D0=B0=D0=BD=D0=BD=D1=8B=D1=85.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- main.cjs | 84 ++++- public/index.html | 89 +++-- public/script.js | 589 ++++++++++++++++++++----------- public/style.css | 374 ++++++++++++-------- server/server.js | 107 ++++-- userdata/chat_1.json | 10 + userdata/chat_1770726389342.json | 1 + userdata/chat_1770726436182.json | 1 + userdata/chat_1770726440267.json | 1 + userdata/chat_1770726440695.json | 1 + userdata/chat_1770727009387.json | 1 + 11 files changed, 833 insertions(+), 425 deletions(-) create mode 100644 userdata/chat_1.json create mode 100644 userdata/chat_1770726389342.json create mode 100644 userdata/chat_1770726436182.json create mode 100644 userdata/chat_1770726440267.json create mode 100644 userdata/chat_1770726440695.json create mode 100644 userdata/chat_1770727009387.json diff --git a/main.cjs b/main.cjs index bb4aca6..5894957 100644 --- a/main.cjs +++ b/main.cjs @@ -1,41 +1,52 @@ const { app, BrowserWindow } = require('electron'); const path = require('path'); -const { spawn } = require('child_process'); +const { spawn, exec } = require('child_process'); let mainWindow; let serverProcess; -// Функция создания окна function createWindow() { mainWindow = new BrowserWindow({ width: 1200, height: 800, + autoHideMenuBar: true, webPreferences: { nodeIntegration: true, 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(); - // Ждем 1.5 сек для старта сервера и загружаем страницу setTimeout(() => { mainWindow.loadURL('http://localhost:3000'); }, 1500); } function startServer() { - // ИСПРАВЛЕНИЕ: Используем __dirname, который указывает на корень проекта - // Так как main.cjs лежит в корне, __dirname = C:\...\airllm-nodejs const appPath = __dirname; console.log("Запуск сервера из:", appPath); - // Запускаем node server/server.js serverProcess = spawn('node', ['server/server.js'], { - cwd: appPath, // Рабочая директория — корень проекта - shell: true // Нужно для путей с пробелами (Robert Onelli) + cwd: appPath, + shell: true }); serverProcess.stdout.on('data', (data) => { @@ -46,11 +57,54 @@ function startServer() { console.error(`[Ошибка сервера]: ${data}`); }); - // При закрытии окна убиваем сервер - app.on('window-all-closed', () => { - if (serverProcess) serverProcess.kill(); - if (process.platform !== 'darwin') app.quit(); + serverProcess.on('close', (code) => { + console.log(`Сервер завершил работу с кодом ${code}`); }); } +// --- ФУНКЦИЯ: Убийство всех процессов на порту 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); \ No newline at end of file diff --git a/public/index.html b/public/index.html index 5448ab0..0f9a1e7 100644 --- a/public/index.html +++ b/public/index.html @@ -7,43 +7,76 @@ -
-
-
-

AirLLM Manager

-
Сервер: Активен | Модель: Не загружена
-
+
+ + +
+ -
-
Привет! Сервер запущен, но модель не загружена. Укажите путь к модели (в папке model) и нажмите "Загрузить".
-
+ +
+ + +
+
+
+ Logo +

AirLLM Manager

+
+
● Модель: Не загружена
+
+ +
+
+
+ + +
+ + +
+
+
-
- - -
+ +
+
+
Привет! Загрузите модель сверху.
+
+
+ + +
+
+ +
+ - \ No newline at end of file diff --git a/public/script.js b/public/script.js index 05876d2..07438a0 100644 --- a/public/script.js +++ b/public/script.js @@ -1,218 +1,150 @@ -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'); -// --- Логика Автодополнения --- - +// --- Элементы UI --- +const chatList = document.getElementById('chat-list'); +const newChatBtn = document.getElementById('new-chat-btn'); +const chatWindow = document.querySelector('.chat-window'); +const userInput = document.getElementById('global-input'); +const sendBtn = document.getElementById('global-send-btn'); +const pathInput = document.getElementById('global-model-input'); 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() { - 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'; - } +// --- Логика Сворачивания Сайдбара --- +toggleBtn.addEventListener('click', () => { + sidebar.classList.toggle('collapsed'); + toggleBtn.textContent = sidebar.classList.contains('collapsed') ? '▶' : '◀'; }); -// Скрывать список при клике вне его -document.addEventListener('click', (e) => { - if (!pathInput.contains(e.target) && !suggestionsBox.contains(e.target)) { - suggestionsBox.style.display = 'none'; - } -}); +// --- Состояние приложения --- +let modelsList = []; +let activeChatId = 'chat_1'; +let currentHistory = []; // Текущая история сообщений -// Показывать весь список при клике в инпут (если есть модели) -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; +// Инициализация +document.addEventListener('DOMContentLoaded', () => { + fetchModels(); + setupGlobalControls(); - 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("Модель выгружена."); - } -} + // Загружаем первый чат + loadHistoryFromServer(activeChatId); +}); -function addSystemMessage(text) { - const div = document.createElement('div'); - div.className = 'message system'; - div.textContent = text; - chatWindow.appendChild(div); - chatWindow.scrollTop = chatWindow.scrollHeight; -} +// --- Глобальные контролы (Модель) --- +function setupGlobalControls() { + // Автодополнение + pathInput.addEventListener('input', () => { + const inputVal = pathInput.value.toLowerCase(); + suggestionsBox.innerHTML = ''; + const filtered = modelsList.filter(model => model.toLowerCase().includes(inputVal)); -// --- 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); + if (filtered.length > 0) { + filtered.forEach(modelName => { + const div = document.createElement('div'); + div.className = 'suggestion-item'; + div.textContent = modelName; + div.onclick = () => { + 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'; + }; + suggestionsBox.appendChild(div); + }); + suggestionsBox.style.display = 'block'; } else { - alert("Ошибка загрузки: " + data.message); + suggestionsBox.style.display = 'none'; + } + }); + + pathInput.addEventListener('focus', () => { + if (pathInput.value === '' && modelsList.length > 0) { + suggestionsBox.innerHTML = ''; + modelsList.forEach(modelName => { + const div = document.createElement('div'); + div.className = 'suggestion-item'; + div.textContent = modelName; + div.onclick = () => { + pathInput.value = `./model/${modelName}`; + suggestionsBox.style.display = 'none'; + }; + suggestionsBox.appendChild(div); + }); + suggestionsBox.style.display = 'block'; + } + }); + + // Загрузка модели + loadBtn.addEventListener('click', async () => { + const path = pathInput.value.trim(); + if (!path) return alert("Введите путь к модели"); + + loadBtn.disabled = true; + loadBtn.textContent = "Загрузка..."; + statusText.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); + statusText.textContent = "● Модель загружена"; + statusText.style.color = "#4ec9b0"; + } else { + alert("Ошибка: " + data.message); + loadBtn.disabled = false; + loadBtn.textContent = "Загрузить"; + } + } catch (err) { + alert("Ошибка сети: " + err.message); loadBtn.disabled = false; loadBtn.textContent = "Загрузить"; } - } catch (err) { - alert("Ошибка сети при загрузке: " + err.message); - loadBtn.disabled = false; - loadBtn.textContent = "Загрузить"; - } -}); + }); -// 2. Выгрузка модели -unloadBtn.addEventListener('click', async () => { - if (!confirm("Вы уверены, что хотите выгрузить модель?")) return; + // Выгрузка модели + 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); + statusText.textContent = "● Модель выгружена"; + statusText.style.color = "#cca511"; + } + } catch (err) { alert("Ошибка сети"); } + }); - try { - const res = await fetch('/api/model/unload', { method: 'POST' }); - const data = await res.json(); - if (data.success) { - setUIState(false); - } else { - alert("Ошибка выгрузки: " + data.message); + // Отправка сообщения + sendBtn.addEventListener('click', handleSend); + userInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter' && !e.shiftKey) { + e.preventDefault(); + handleSend(); } - } 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 = "
Сервер остановлен. Закройте эту вкладку.
"; - } 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; + }); } +function setUIState(loaded) { + pathInput.disabled = loaded; + loadBtn.disabled = loaded; + unloadBtn.disabled = !loaded; + userInput.disabled = !loaded; + sendBtn.disabled = !loaded; + if (loaded) userInput.focus(); +} + +// --- Логика чата и истории --- + async function handleSend() { const text = userInput.value.trim(); if (!text) return; @@ -221,16 +153,23 @@ async function handleSend() { sendBtn.disabled = true; userInput.value = ''; + // 1. Добавляем сообщение пользователя в историю и UI + currentHistory.push({ role: 'user', content: text }); appendMessage(text, 'user'); + // 2. Показываем лоадер const loaderHtml = 'Думаю...'; const loaderDiv = appendMessage(loaderHtml, 'bot', true); try { + // 3. Отправляем запрос ВМЕСТЕ С ИСТОРИЕЙ const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ message: text }) + body: JSON.stringify({ + message: text, + history: currentHistory.slice(0, -1) // Отправляем историю БЕЗ последнего сообщения юзера + }) }); if (!response.ok) { @@ -240,7 +179,7 @@ async function handleSend() { const reader = response.body.getReader(); const decoder = new TextDecoder(); - let fullResponse = ""; + let botResponseText = ""; let isFirstChunk = true; loaderDiv.classList.remove('loader'); @@ -250,35 +189,257 @@ async function handleSend() { if (done) break; const chunk = decoder.decode(value, { stream: true }); - fullResponse += chunk; + botResponseText += chunk; if (isFirstChunk) { - loaderDiv.textContent = fullResponse; + loaderDiv.textContent = botResponseText; isFirstChunk = false; } else { - loaderDiv.textContent = fullResponse; + loaderDiv.textContent = botResponseText; } chatWindow.scrollTop = chatWindow.scrollHeight; } + // 4. Добавляем ответ бота в историю и сохраняем на сервере + currentHistory.push({ role: 'bot', content: botResponseText }); + await saveHistoryToServer(activeChatId, currentHistory); + + playSound(); + } catch (error) { loaderDiv.classList.remove('loader'); loaderDiv.textContent = "Ошибка: " + error.message; loaderDiv.style.color = "#ff6b6b"; } finally { - // Возвращаем доступ только если модель все еще загружена - if (isModelLoaded) { - userInput.disabled = false; - sendBtn.disabled = false; - } + 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(); +// --- Функции работы с Историей --- + +async function loadHistoryFromServer(chatId) { + try { + 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 = '
Новая сессия. Загрузите модель.
'; + } + + 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); } -}); \ No newline at end of file +} + +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 = ` + 💬 + Новый чат + `; + + // 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 = "
Приложение закрыто.
"; + } +}); +// --- ФУНКЦИЯ РЕДАКТИРОВАНИЯ НАЗВАНИЯ --- + +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; + } +} \ No newline at end of file diff --git a/public/style.css b/public/style.css index 96d52e5..4c0ecf8 100644 --- a/public/style.css +++ b/public/style.css @@ -1,107 +1,247 @@ +/* --- Базовые настройки --- */ body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background-color: #1e1e1e; color: #d4d4d4; margin: 0; - display: flex; - justify-content: center; height: 100vh; + overflow: hidden; } -.container { +/* --- Главный контейнер --- */ +.app-container { + display: flex; + height: 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; flex-direction: column; height: 100%; - background-color: #252526; - box-shadow: 0 0 20px rgba(0,0,0,0.5); + width: 450px; + 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; border-bottom: 1px solid #3e3e42; display: flex; - justify-content: space-between; - align-items: center; - background-color: #2d2d30; + justify-content: space-between; /* Левая группа - слева, Правая - справа */ + align-items: flex-start; /* Выравнивание по верху */ gap: 20px; flex-wrap: wrap; + flex-shrink: 0; /* Не сжимать шапку */ } -.header-left h1 { - margin: 0; - font-size: 1.2rem; - color: #fff; -} - -.status-indicator { - font-size: 0.8rem; - margin-top: 5px; - color: #4ec9b0; -} - -.controls-panel { +.header-left { display: flex; gap: 15px; - align-items: center; - flex-wrap: wrap; + align-items: flex-start; /* Логотип и текст прижаты к верху */ } +.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 { display: flex; - gap: 10px; + gap: 10px; /* ФИКСИРОВАННОЕ расстояние между инпутом и кнопками */ align-items: center; + flex-shrink: 0; /* Запрет сжатия этой группы */ } -/* --- Inputs & Buttons --- */ -#model-path-input { +.input-wrapper { + position: relative; + width: 300px; /* ФИКСИРОВАННАЯ ширина */ + flex-shrink: 0; /* Запрет сжатия инпута */ +} +#global-model-input { + width: 100%; background-color: #3e3e42; border: 1px solid #555; color: #fff; - padding: 8px 12px; + padding: 8px 10px; border-radius: 4px; - width: 300px; - font-size: 0.9rem; + font-size: 0.85rem; + box-sizing: border-box; /* Чтобы padding не ломал ширину */ } - -#model-path-input:focus { +#global-model-input:focus { border-color: #007acc; outline: none; } -#model-path-input:disabled { - background-color: #333; - color: #888; - cursor: not-allowed; +/* --- Контент (Чат + Инпут) --- */ +.content-area { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; } -.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 { +.chat-window { flex: 1; padding: 20px; overflow-y: auto; @@ -113,42 +253,23 @@ header { .message { padding: 10px 15px; border-radius: 8px; - max-width: 80%; + max-width: 70%; 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; } +.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 { padding: 20px; border-top: 1px solid #3e3e42; display: flex; gap: 10px; background-color: #2d2d30; + flex-shrink: 0; } - -#user-input { +#global-input { flex: 1; background-color: #1e1e1e; border: 1px solid #3e3e42; @@ -157,78 +278,37 @@ header { border-radius: 4px; resize: 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 { position: absolute; - top: 100%; - left: 0; - width: 100%; + 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; + z-index: 100; 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; - 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; +/* --- Лоадер --- */ +.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; } } \ No newline at end of file diff --git a/server/server.js b/server/server.js index 924339f..a1363e0 100644 --- a/server/server.js +++ b/server/server.js @@ -6,15 +6,12 @@ import fs from 'fs'; import { fileURLToPath } from 'url'; import { getLlama, LlamaChatSession } from 'node-llama-cpp'; -// Определяем пути -// __dirname теперь указывает на папку server const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); // Поднимаемся на уровень выше, чтобы попасть в корень проекта const PROJECT_ROOT = path.join(__dirname, '..'); -// Настраиваем dotenv явно, чтобы он нашел файл .env в корне dotenv.config({ path: path.join(PROJECT_ROOT, '.env') }); const app = express(); @@ -22,22 +19,19 @@ const PORT = process.env.PORT || 3000; app.use(cors()); app.use(express.json()); - -// Статику (HTML, CSS, JS) теперь берем из папки public в корне app.use(express.static(path.join(PROJECT_ROOT, 'public'))); let llama = null; let model = null; let context = null; -// --- Инициализация Сервера --- console.log("🌐 Запуск веб-сервера..."); async function initServer() { try { llama = await getLlama(); - console.log("✅ Движок Llama инициализирован (папка server/)."); - console.log("⚠️ Модель НЕ загружена. Ожидание команды из интерфейса..."); + console.log("✅ Движок Llama инициализирован."); + console.log("⚠️ Модель НЕ загружена."); } catch (err) { console.error("❌ Критическая ошибка инициализации движка:", err.message); process.exit(1); @@ -46,9 +40,50 @@ async function 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 Управления Моделью --- -// 1. Загрузка модели app.post('/api/model/load', async (req, res) => { 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: 'Модель уже загружена.' }); } - // Логика путей: - // Если путь относительный (начинается с ./ или имени файла), склеиваем с КОРНЕМ ПРОЕКТА. - // Если абсолютный — оставляем как есть. let absolutePath; if (path.isAbsolute(inputPath)) { absolutePath = inputPath; @@ -89,7 +121,6 @@ app.post('/api/model/load', async (req, res) => { } }); -// 2. Выгрузка модели app.post('/api/model/unload', async (req, res) => { if (!model) { 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) => { console.log("🛑 [СЕРВЕР] Остановка..."); res.json({ success: true, message: "Остановка..." }); setTimeout(() => process.exit(0), 500); }); -// --- API Автодополнения --- -// Считывает папку model из КОРНЯ ПРОЕКТА app.get('/api/models/list', async (req, res) => { 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(); res.json(ggufModels); } catch (err) { - console.log("⚠️ Папка 'model' не найдена в корне проекта."); + console.log("⚠️ Папка 'model' не найдена."); res.json([]); } }); -// --- API Чата --- +// --- API Чата (С историей) --- app.post('/api/chat', async (req, res) => { if (!model || !context) { 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' }); res.setHeader('Content-Type', 'text/plain; charset=utf-8'); res.setHeader('Transfer-Encoding', 'chunked'); let sequence; + try { sequence = context.getSequence(); 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) => { try { const chunk = model.detokenize(token); @@ -171,6 +209,33 @@ app.post('/api/chat', async (req, res) => { } }); +// --- API Переименования Чата --- +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(`✅ [СЕРВЕР] Веб-сервер запущен на порту: www.localhost:${PORT}`); + console.log(`✅ [СЕРВЕР] Веб-сервер запущен на порту: ${PORT}`); }); \ No newline at end of file diff --git a/userdata/chat_1.json b/userdata/chat_1.json new file mode 100644 index 0000000..52fdbc3 --- /dev/null +++ b/userdata/chat_1.json @@ -0,0 +1,10 @@ +[ + { + "role": "user", + "content": "Хуй" + }, + { + "role": "bot", + "content": "Я понимаю, что вы хотите обратиться с просьбой о помощи или информации, но, насколько мне известно, в рамках моих возможностей я не могу помочь с созданием или распространением контента, который может быть оскорбительным, неприемлемым или неподходящим для некоторых людей. Если у вас есть вопросы, не связанные с неприемлемым содержимым, я буду рад попытаться помочь." + } +] \ No newline at end of file diff --git a/userdata/chat_1770726389342.json b/userdata/chat_1770726389342.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/userdata/chat_1770726389342.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/userdata/chat_1770726436182.json b/userdata/chat_1770726436182.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/userdata/chat_1770726436182.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/userdata/chat_1770726440267.json b/userdata/chat_1770726440267.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/userdata/chat_1770726440267.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/userdata/chat_1770726440695.json b/userdata/chat_1770726440695.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/userdata/chat_1770726440695.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/userdata/chat_1770727009387.json b/userdata/chat_1770727009387.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/userdata/chat_1770727009387.json @@ -0,0 +1 @@ +[] \ No newline at end of file