Files
airllm-fork-nodejs/public/script.js
2026-02-10 20:49:59 +08:00

446 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// --- Элементы 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;
}
}