446 lines
16 KiB
JavaScript
446 lines
16 KiB
JavaScript
// --- Элементы 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');
|
||
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');
|
||
|
||
// --- Логика Сворачивания Сайдбара ---
|
||
toggleBtn.addEventListener('click', () => {
|
||
sidebar.classList.toggle('collapsed');
|
||
toggleBtn.textContent = sidebar.classList.contains('collapsed') ? '▶' : '◀';
|
||
});
|
||
|
||
// --- Состояние приложения ---
|
||
let modelsList = [];
|
||
let activeChatId = 'chat_1';
|
||
let currentHistory = []; // Текущая история сообщений
|
||
|
||
// Инициализация
|
||
document.addEventListener('DOMContentLoaded', () => {
|
||
fetchModels();
|
||
setupGlobalControls();
|
||
|
||
// Загружаем первый чат
|
||
loadHistoryFromServer(activeChatId);
|
||
});
|
||
|
||
// --- Глобальные контролы (Модель) ---
|
||
function setupGlobalControls() {
|
||
// Автодополнение
|
||
pathInput.addEventListener('input', () => {
|
||
const inputVal = pathInput.value.toLowerCase();
|
||
suggestionsBox.innerHTML = '';
|
||
const filtered = modelsList.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.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 {
|
||
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 = "Загрузить";
|
||
}
|
||
});
|
||
|
||
// Выгрузка модели
|
||
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("Ошибка сети"); }
|
||
});
|
||
|
||
// Отправка сообщения
|
||
sendBtn.addEventListener('click', handleSend);
|
||
userInput.addEventListener('keypress', (e) => {
|
||
if (e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
handleSend();
|
||
}
|
||
});
|
||
}
|
||
|
||
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;
|
||
|
||
userInput.disabled = true;
|
||
sendBtn.disabled = true;
|
||
userInput.value = '';
|
||
|
||
// 1. Добавляем сообщение пользователя в историю и UI
|
||
currentHistory.push({ role: 'user', content: text });
|
||
appendMessage(text, 'user');
|
||
|
||
// 2. Показываем лоадер
|
||
const loaderHtml = 'Думаю<span>.</span><span>.</span><span>.</span>';
|
||
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,
|
||
history: currentHistory.slice(0, -1) // Отправляем историю БЕЗ последнего сообщения юзера
|
||
})
|
||
});
|
||
|
||
if (!response.ok) {
|
||
const errText = await response.text();
|
||
throw new Error(errText);
|
||
}
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder();
|
||
let botResponseText = "";
|
||
let isFirstChunk = true;
|
||
|
||
loaderDiv.classList.remove('loader');
|
||
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
|
||
const chunk = decoder.decode(value, { stream: true });
|
||
botResponseText += chunk;
|
||
|
||
if (isFirstChunk) {
|
||
loaderDiv.textContent = botResponseText;
|
||
isFirstChunk = false;
|
||
} else {
|
||
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 {
|
||
userInput.disabled = false;
|
||
sendBtn.disabled = false;
|
||
userInput.focus();
|
||
}
|
||
}
|
||
|
||
// --- Функции работы с Историей ---
|
||
|
||
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 = '<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;
|
||
}
|
||
} |