добавлние блоков чата слева и правка багов с ним связанных.

This commit is contained in:
Neyra
2026-02-10 20:47:33 +08:00
parent a1bba1d3d1
commit a60cc2f457
11 changed files with 833 additions and 425 deletions

View File

@@ -7,43 +7,76 @@
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="container">
<header>
<div class="header-left">
<h1>AirLLM Manager</h1>
<div id="status-indicator" class="status-indicator">Сервер: Активен | Модель: Не загружена</div>
</div>
<div class="app-container">
<!-- САЙДБАР (450px) -->
<aside class="sidebar" id="sidebar">
<div class="controls-panel">
<!-- Блок управления моделью -->
<div class="model-controls">
<div style="position: relative; width: 100%;">
<input type="text" id="model-path-input" value="./model/" placeholder="./model/filename.gguf" autocomplete="off">
<div id="suggestions" class="suggestions-list" style="display: none;"></div>
<!-- Кнопка сворачивания -->
<div class="sidebar-toggle-header">
<button id="toggle-sidebar-btn" class="toggle-btn"></button>
</div>
<button id="load-model-btn" class="btn btn-primary">Загрузить</button>
<button id="unload-model-btn" class="btn btn-secondary" disabled>Выгрузить</button>
</div>
<!-- Кнопка остановки -->
<div class="server-controls">
<button id="stop-server-btn" class="btn btn-danger">Остановить сервер</button>
<div 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>
</header>
</aside>
<div id="chat-window">
<div class="message system">Привет! Сервер запущен, но модель не загружена. Укажите путь к модели (в папке model) и нажмите "Загрузить".</div>
</div>
<!-- ОСНОВНАЯ ОБЛАСТЬ (Flex: 1) -->
<main class="main-area">
<!-- Шапка с моделью -->
<header class="main-header">
<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>
</div>
<div id="status-indicator" class="status-indicator">● Модель: Не загружена</div>
</div>
<div class="controls-panel">
<div class="model-controls">
<div class="input-wrapper">
<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>
<button id="global-load-btn" class="btn btn-primary">Загрузить</button>
<button id="global-unload-btn" class="btn btn-secondary" disabled>Выгрузить</button>
</div>
</div>
</header>
<div class="input-area">
<textarea id="user-input" placeholder="Введите сообщение..." rows="2" disabled></textarea>
<button id="send-btn" disabled>Отправить</button>
</div>
<!-- Контент (Чат + Инпут) -->
<div class="content-area">
<div class="chat-window">
<div class="message system">Привет! Загрузите модель сверху.</div>
</div>
<div class="input-area">
<textarea id="global-input" placeholder="Введите сообщение..." rows="2" disabled></textarea>
<button id="global-send-btn" class="btn btn-primary" disabled>Отправить</button>
</div>
</div>
</main>
</div>
<audio id="msg-sound" src="/sound/message.mp3" preload="auto"></audio>
<script src="script.js"></script>
</body>
</html>

View File

@@ -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 = "<div style='color:white; text-align:center; margin-top:50px;'>Сервер остановлен. Закройте эту вкладку.</div>";
} catch (err) {
// Ошибка может возникнуть, потому что сервер умирает
console.log("Сервер остановлен (разрыв соединения ожидается)");
}
});
// --- Логика Чата ---
function appendMessage(text, sender, isLoader = false) {
const msgDiv = document.createElement('div');
msgDiv.classList.add('message', sender);
if (isLoader) {
msgDiv.classList.add('loader');
msgDiv.innerHTML = text;
} else {
msgDiv.textContent = text;
}
chatWindow.appendChild(msgDiv);
chatWindow.scrollTop = chatWindow.scrollHeight;
return msgDiv;
});
}
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 = 'Думаю<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 })
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 = '<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;
}
}

View File

@@ -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; }
}