Добавлен field_label в результат переименования файлов, исправлена загрузка черновиков, обновлен формат пути S3 с project_name

This commit is contained in:
Fedor
2025-11-22 09:38:38 +03:00
parent d3ba054027
commit 486f3619ff
212 changed files with 6704 additions and 123 deletions

View File

@@ -1,20 +1,24 @@
/* AI Drawer - основные стили */
.ai-drawer {
position: fixed;
right: -400px; /* Начально скрыт */
right: 0; /* Всегда прижат к правому краю */
top: 0;
width: 400px;
min-width: 300px; /* Минимальная ширина */
max-width: 50vw; /* Максимальная ширина - половина экрана */
height: 100vh;
max-height: 100vh; /* Не превышаем высоту экрана */
background: #ffffff; /* Чистый белый фон */
box-shadow: -2px 0 15px rgba(0,0,0,0.1);
transition: right 0.3s ease;
transform: translateX(100%); /* Начально скрыт - сдвинут вправо на 100% своей ширины */
transition: transform 0.3s ease;
z-index: 999999;
display: flex;
flex-direction: column;
font-size: 14px; /* Базовый размер шрифта */
border-left: 1px solid #e9ecef;
overflow: hidden; /* Предотвращаем выход элементов за пределы */
box-sizing: border-box; /* Учитываем padding и border в ширине */
}
/* Полоска для изменения ширины */
@@ -44,13 +48,18 @@
user-select: none;
}
/* Убираем transition при изменении размера, чтобы не было задержек */
.ai-drawer.resizing.open {
transform: translateX(0) !important;
}
.ai-drawer.resizing .ai-drawer-resize-handle {
background: #007bff;
width: 4px;
}
.ai-drawer.open {
right: 0;
transform: translateX(0); /* Показываем - сдвигаем на место */
}
/* Скрываем кнопку AI когда drawer открыт */
@@ -91,6 +100,9 @@ body.ai-drawer-open .ai-drawer-toggle {
align-items: center;
font-weight: 600;
border-bottom: 1px solid #0056b3;
flex-shrink: 0; /* Не сжимается при изменении размера */
min-height: 50px; /* Минимальная высота для кнопки закрытия */
box-sizing: border-box;
}
.ai-drawer-close {
@@ -436,12 +448,42 @@ body.ai-drawer-open .ai-drawer-toggle {
.ai-message-content p {
margin: 0 0 5px 0;
word-wrap: break-word;
word-break: break-word;
}
.ai-message-content p:last-child {
margin-bottom: 0;
}
/* Стили для ссылок в сообщениях */
.ai-message-link {
color: #007bff;
text-decoration: underline;
cursor: pointer;
word-break: break-word;
display: inline-block;
margin: 2px 0;
padding: 2px 4px;
border-radius: 3px;
transition: all 0.2s ease;
}
.ai-message-link:hover {
color: #0056b3;
text-decoration: none;
background-color: #e7f3ff;
padding: 2px 6px;
}
.ai-message-link:visited {
color: #6f42c1;
}
.ai-message-link:active {
color: #004085;
}
.ai-message-time {
font-size: 11px;
color: #6c757d; /* Серый цвет для времени */
@@ -456,6 +498,8 @@ body.ai-drawer-open .ai-drawer-toggle {
border-top: 1px solid #dee2e6;
display: flex;
gap: 10px;
flex-shrink: 0; /* Не сжимается при изменении размера */
box-sizing: border-box;
align-items: center;
}
@@ -510,7 +554,8 @@ body.ai-drawer-open .ai-drawer-toggle {
@media (max-width: 768px) {
.ai-drawer {
width: 100%;
right: -100%;
right: 0;
transform: translateX(100%); /* Начально скрыт на мобильных */
height: 100vh;
height: 100dvh; /* Динамическая высота viewport для мобильных */
display: flex;
@@ -731,7 +776,8 @@ body.ai-drawer-open .ai-drawer-toggle {
width: 400px;
min-width: 300px;
max-width: 50vw;
right: -400px;
right: 0;
transform: translateX(100%); /* Начально скрыт на планшетах */
height: 100vh;
display: flex;
flex-direction: column;

View File

@@ -48,7 +48,11 @@ class AIDrawer {
'<div class="ai-avatar assistant"></div>' +
'<div class="ai-message-content">' +
'<p>Привет! Я ваш AI ассистент. Чем могу помочь?</p>' +
'<div class="ai-message-time">' + new Date().toLocaleTimeString() + '</div>' +
'<div class="ai-message-time">' + new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
}) + '</div>' +
'</div>' +
'</div>' +
'</div>' +
@@ -128,7 +132,7 @@ class AIDrawer {
// Обработчик изменения размера окна - ограничиваем ширину если нужно
window.addEventListener('resize', () => {
if (this.drawerWidth > window.innerWidth / 2) {
if (this.isOpen && this.drawerWidth > window.innerWidth / 2) {
const maxWidth = window.innerWidth / 2;
this.setDrawerWidth(maxWidth);
}
@@ -144,8 +148,18 @@ class AIDrawer {
const savedWidth = localStorage.getItem('ai-drawer-width');
if (savedWidth) {
const width = parseInt(savedWidth, 10);
if (width >= 300 && width <= window.innerWidth / 2) {
const maxWidth = window.innerWidth / 2;
// Проверяем что ширина в допустимых пределах для текущего экрана
if (width >= 300 && width <= maxWidth) {
this.setDrawerWidth(width);
} else if (width > maxWidth) {
// Если сохраненная ширина больше максимума - ограничиваем
console.log('AI Drawer: Saved width', width, 'exceeds max', maxWidth, ', adjusting');
this.setDrawerWidth(maxWidth);
} else {
// Если меньше минимума - устанавливаем минимум
console.log('AI Drawer: Saved width', width, 'is less than minimum, setting to 300');
this.setDrawerWidth(300);
}
}
@@ -220,6 +234,23 @@ class AIDrawer {
open() {
console.log('AI Drawer: Opening drawer');
if (this.drawer) {
// Проверяем и корректируем ширину перед открытием
const maxWidth = window.innerWidth / 2;
if (this.drawerWidth > maxWidth) {
console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to', maxWidth);
this.setDrawerWidth(maxWidth);
} else if (this.drawerWidth < 300) {
console.log('AI Drawer: Adjusting width from', this.drawerWidth, 'to 300');
this.setDrawerWidth(300);
}
// Убеждаемся что ширина применена к drawer
this.drawer.style.width = this.drawerWidth + 'px';
// Убеждаемся что drawer правильно позиционирован перед открытием
this.drawer.style.right = '0';
this.drawer.style.transform = 'translateX(0)';
this.drawer.classList.add('open');
}
@@ -234,6 +265,33 @@ class AIDrawer {
mainContainer.setAttribute('data-drawer-width', this.drawerWidth);
}
// Прокручиваем вниз к последнему сообщению при открытии
const scrollToBottomOnOpen = () => {
const drawerContent = this.drawer?.querySelector('.ai-drawer-content');
const chatMessages = this.drawer?.querySelector('.ai-chat-messages');
if (drawerContent) {
const scroll = () => {
drawerContent.scrollTop = drawerContent.scrollHeight;
console.log('AI Drawer: Scrolled on open, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight);
};
// Прокручиваем последнее сообщение в видимую область
if (chatMessages && chatMessages.lastElementChild) {
chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' });
}
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(scroll);
});
}
};
// Прокручиваем после анимации открытия
setTimeout(scrollToBottomOnOpen, 300);
setTimeout(scrollToBottomOnOpen, 600);
// История уже загружена при инициализации страницы
// Не нужно дополнительных запросов при открытии
}
@@ -242,6 +300,10 @@ class AIDrawer {
console.log('AI Drawer: Closing drawer');
if (this.drawer) {
this.drawer.classList.remove('open');
// Убеждаемся что drawer скрыт через transform
if (!this.drawer.classList.contains('open')) {
this.drawer.style.transform = 'translateX(100%)';
}
}
document.body.classList.remove('ai-drawer-open');
@@ -295,6 +357,142 @@ class AIDrawer {
}
}
// Функция для определения читаемого текста ссылки на основе URL
getLinkText(url) {
const urlLower = url.toLowerCase();
const maxLength = 60; // Максимальная длина ссылки до замены
// Если ссылка короткая, показываем её полностью
if (url.length <= maxLength) {
return url;
}
// Анализируем URL и определяем тип действия
if (urlLower.includes('download') || urlLower.includes('file') || urlLower.includes('скачать')) {
return '📥 Скачать документ';
}
if (urlLower.includes('edit') || urlLower.includes('редактир') || urlLower.includes('onlyoffice')) {
return '✏️ Открыть для редактирования';
}
if (urlLower.includes('view') || urlLower.includes('просмотр') || urlLower.includes('preview')) {
return '👁️ Открыть для просмотра';
}
if (urlLower.includes('document') || urlLower.includes('документ') || urlLower.includes('.docx') || urlLower.includes('.pdf')) {
return '📄 Открыть документ';
}
if (urlLower.includes('create') || urlLower.includes('создать')) {
return ' Создать документ';
}
// Общие варианты для длинных ссылок
const linkTexts = [
'🔗 Открыть ссылку',
'👉 Смотреть здесь',
'📋 Подробнее',
'🔍 Перейти к документу',
'📎 Открыть'
];
// Выбираем случайный вариант для разнообразия
return linkTexts[Math.floor(Math.random() * linkTexts.length)];
}
// Функция для преобразования URL в кликабельные ссылки
convertUrlsToLinks(text) {
if (!text) return '';
let result = text;
// ШАГ 1: Обрабатываем Markdown ссылки [текст](url)
const markdownLinkRegex = /\[([^\]]+)\]\(([^)]+)\)/g;
result = result.replace(markdownLinkRegex, (match, linkText, url) => {
// Проверяем, что это валидный URL
if (url.match(/^https?:\/\//i)) {
return `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
}
return match; // Если не URL, оставляем как есть
});
// ШАГ 2: Временно заменяем уже существующие HTML-ссылки на плейсхолдеры
const htmlLinks = [];
const htmlLinkRegex = /<a\s+[^>]*href\s*=\s*["']([^"']+)["'][^>]*>([^<]*)<\/a>/gi;
result = result.replace(htmlLinkRegex, (match, href, linkText) => {
const placeholder = `__HTML_LINK_${htmlLinks.length}__`;
htmlLinks.push({ href, linkText, match });
return placeholder;
});
// ШАГ 3: Экранируем оставшийся HTML для безопасности
const escaped = result
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
// ШАГ 4: Восстанавливаем HTML-ссылки (плейсхолдеры не экранированы, т.к. не содержат < >)
let finalResult = escaped;
htmlLinks.forEach((link, index) => {
const placeholder = `__HTML_LINK_${index}__`;
// Используем оригинальную ссылку, но добавляем класс если его нет
let htmlLink = link.match;
if (!htmlLink.includes('class=')) {
htmlLink = htmlLink.replace('<a', '<a class="ai-message-link"');
} else if (!htmlLink.includes('ai-message-link')) {
htmlLink = htmlLink.replace('class="', 'class="ai-message-link ');
htmlLink = htmlLink.replace("class='", "class='ai-message-link ");
}
// Убеждаемся что есть target="_blank"
if (!htmlLink.includes('target=')) {
htmlLink = htmlLink.replace('<a', '<a target="_blank" rel="noopener noreferrer"');
}
// Заменяем плейсхолдер на правильный HTML (не экранированный)
finalResult = finalResult.replace(placeholder, htmlLink);
});
// ШАГ 5: Преобразуем обычные URL в ссылки (только те, что не внутри уже существующих ссылок)
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+)/gi;
let urlMatches = [];
let match;
// Сначала находим все URL и проверяем их контекст
while ((match = urlRegex.exec(finalResult)) !== null) {
const url = match[0];
const offset = match.index;
const beforeMatch = finalResult.substring(0, offset);
// Проверяем, нет ли открывающего тега <a перед этим URL
const lastOpenTag = beforeMatch.lastIndexOf('<a');
const lastCloseTag = beforeMatch.lastIndexOf('</a>');
// Если есть открывающий тег <a и нет закрывающего после него - значит мы внутри ссылки
if (lastOpenTag > lastCloseTag) {
continue; // Пропускаем, это уже часть ссылки
}
// Проверяем, не является ли это частью href атрибута
if (beforeMatch.lastIndexOf('href=') > lastCloseTag) {
continue; // Пропускаем, это часть href
}
urlMatches.push({ url, offset });
}
// Заменяем URL в обратном порядке (чтобы не сбить индексы)
for (let i = urlMatches.length - 1; i >= 0; i--) {
const { url, offset } = urlMatches[i];
const linkText = this.getLinkText(url);
const linkHtml = `<a href="${url}" target="_blank" rel="noopener noreferrer" class="ai-message-link" title="${url}">${linkText}</a>`;
finalResult = finalResult.substring(0, offset) + linkHtml + finalResult.substring(offset + url.length);
}
return finalResult;
}
addMessage(text, isUser = false, customTime = null) {
console.log('AI Drawer: addMessage called with:', {text: text.substring(0, 50), isUser, customTime});
@@ -331,17 +529,69 @@ class AIDrawer {
contentDiv.className = 'ai-message-content';
const textDiv = document.createElement('p');
textDiv.textContent = text;
// Преобразуем URL в кликабельные ссылки
textDiv.innerHTML = this.convertUrlsToLinks(text);
contentDiv.appendChild(textDiv);
const timeDiv = document.createElement('div');
timeDiv.className = 'ai-message-time';
if (customTime) {
// Если передано время из истории, используем его
const historyTime = new Date(customTime);
timeDiv.textContent = historyTime.toLocaleTimeString();
try {
// Логируем для отладки
console.log('AI Drawer: Parsing timestamp:', customTime);
const historyTime = new Date(customTime);
// Проверяем что дата валидна
if (isNaN(historyTime.getTime())) {
// Если дата невалидна, пытаемся распарсить как строку времени (старый формат)
console.warn('AI Drawer: Invalid timestamp format:', customTime, 'Parsed as:', historyTime);
timeDiv.textContent = customTime; // Показываем как есть
} else {
// Определяем, нужно ли показывать дату
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const messageDate = new Date(historyTime.getFullYear(), historyTime.getMonth(), historyTime.getDate());
const isToday = messageDate.getTime() === today.getTime();
let formattedTime;
if (isToday) {
// Если сообщение сегодня - показываем только время
formattedTime = historyTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
} else {
// Если сообщение не сегодня - показываем дату и время
const dateStr = historyTime.toLocaleDateString('ru-RU', {
day: '2-digit',
month: '2-digit'
});
const timeStr = historyTime.toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false
});
formattedTime = `${dateStr} ${timeStr}`;
}
console.log('AI Drawer: Successfully formatted timestamp:', customTime, '->', formattedTime);
timeDiv.textContent = formattedTime;
}
} catch (error) {
console.error('AI Drawer: Error parsing timestamp:', customTime, error);
timeDiv.textContent = customTime || new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
}
} else {
timeDiv.textContent = new Date().toLocaleTimeString();
timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
}
contentDiv.appendChild(timeDiv);
@@ -388,7 +638,11 @@ class AIDrawer {
const timeDiv = document.createElement('div');
timeDiv.className = 'ai-message-time';
timeDiv.textContent = new Date().toLocaleTimeString();
timeDiv.textContent = new Date().toLocaleTimeString('ru-RU', {
hour: '2-digit',
minute: '2-digit',
hour12: false // 24-часовой формат
});
contentDiv.appendChild(timeDiv);
messageDiv.appendChild(avatarDiv);
@@ -404,9 +658,12 @@ class AIDrawer {
streamText(element, text, speed = 30) {
let index = 0;
let currentText = '';
const interval = setInterval(() => {
if (index < text.length) {
element.textContent += text[index];
currentText += text[index];
// Преобразуем URL в кликабельные ссылки по мере добавления текста
element.innerHTML = this.convertUrlsToLinks(currentText);
index++;
const content = this.drawer.querySelector('.ai-drawer-content');
@@ -880,6 +1137,41 @@ class AIDrawer {
}
});
// Прокручиваем вниз к последнему сообщению после загрузки истории
const scrollToBottom = () => {
const drawerContent = this.drawer?.querySelector('.ai-drawer-content');
const chatMessages = this.drawer?.querySelector('.ai-chat-messages');
if (drawerContent) {
// Способ 1: Прокручиваем контейнер
const scroll = () => {
drawerContent.scrollTop = drawerContent.scrollHeight;
console.log('AI Drawer: Scrolled container, scrollTop:', drawerContent.scrollTop, 'scrollHeight:', drawerContent.scrollHeight);
};
// Способ 2: Прокручиваем последнее сообщение в видимую область
if (chatMessages && chatMessages.lastElementChild) {
chatMessages.lastElementChild.scrollIntoView({ behavior: 'smooth', block: 'end' });
console.log('AI Drawer: Scrolled last message into view');
}
// Используем requestAnimationFrame для более надежной прокрутки
requestAnimationFrame(() => {
scroll();
requestAnimationFrame(() => {
scroll();
});
});
} else {
console.warn('AI Drawer: Drawer content not found for scrolling');
}
};
// Прокручиваем с несколькими задержками для надежности
setTimeout(scrollToBottom, 100);
setTimeout(scrollToBottom, 300);
setTimeout(scrollToBottom, 600);
console.log('AI Drawer: Chat history restored -', data.history.length, 'messages');
} else {
console.log('AI Drawer: No chat history found. Response:', data);