diff --git a/backend/server.js b/backend/server.js index f1575dd..66ceae2 100644 --- a/backend/server.js +++ b/backend/server.js @@ -2553,6 +2553,356 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => { } }); +// API для генерации тегов через AI +app.post("/api/ai/generate-tags", requireApiAuth, async (req, res) => { + const { text } = req.body; + + if (!text) { + return res.status(400).json({ error: "Текст обязателен" }); + } + + // Проверяем минимальную длину текста + if (text.trim().length < 10) { + return res.status(400).json({ + error: "Текст слишком короткий для генерации тегов. Минимум 10 символов.", + }); + } + + try { + // Получаем AI настройки пользователя + const getSettingsSql = + "SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?"; + db.get(getSettingsSql, [req.session.userId], async (err, settings) => { + if (err) { + console.error("Ошибка получения AI настроек:", err.message); + return res.status(500).json({ error: "Ошибка сервера" }); + } + + if ( + !settings || + !settings.openai_api_key || + !settings.openai_base_url || + !settings.openai_model + ) { + return res + .status(400) + .json({ error: "Настройте AI настройки в параметрах" }); + } + + // Проверяем, включены ли функции ИИ + if (!settings.ai_enabled || settings.ai_enabled === 0) { + return res + .status(403) + .json({ error: "Функции ИИ отключены в настройках" }); + } + + try { + // Парсим URL + const url = new URL(settings.openai_base_url); + const isHttps = url.protocol === "https:"; + const hostname = url.hostname; + const port = url.port || (isHttps ? 443 : 80); + // Формируем путь, избегая дублирования + let path = url.pathname || ""; + if (!path.endsWith("/chat/completions")) { + path = path.endsWith("/") + ? path + "chat/completions" + : path + "/chat/completions"; + } + + // Подготавливаем данные для запроса + const requestBody = { + model: settings.openai_model, + messages: [ + { + role: "system", + content: + "Ты помощник для генерации тегов. Проанализируй текст и предложи 3-8 релевантных тегов на русском языке через запятую. Теги должны быть краткими (1-3 слова). Избегай общих слов типа 'текст', 'заметка', 'информация'. Верни ТОЛЬКО теги через запятую, без знаков #, без нумерации, без точек. Пример: работа, проект, задачи, дедлайн", + }, + { + role: "user", + content: text.substring(0, 2000), // Ограничиваем длину текста + }, + ], + temperature: 0.7, + max_tokens: 200, + }; + + const requestData = JSON.stringify(requestBody); + + console.log("Отправляем запрос к AI API:"); + console.log("URL:", settings.openai_base_url); + console.log("Модель:", settings.openai_model); + console.log("Длина текста:", text.length); + console.log( + "Запрос (без ключа):", + JSON.stringify( + { + ...requestBody, + messages: requestBody.messages.map((m) => ({ + ...m, + content: + m.content.length > 100 + ? m.content.substring(0, 100) + "..." + : m.content, + })), + }, + null, + 2 + ) + ); + + // Выполняем HTTP запрос + const tagsResponse = await new Promise((resolve, reject) => { + const options = { + hostname: hostname, + port: port, + path: path, + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${settings.openai_api_key}`, + "Content-Length": Buffer.byteLength(requestData), + }, + }; + + const client = isHttps ? https : http; + const req = client.request(options, (res) => { + let data = ""; + + res.on("data", (chunk) => { + data += chunk; + }); + + res.on("end", () => { + if (res.statusCode >= 200 && res.statusCode < 300) { + try { + const responseData = JSON.parse(data); + console.log( + "Полный ответ от AI API:", + JSON.stringify(responseData, null, 2) + ); + + // Проверяем различные форматы ответа + let tagsText = ""; + + // Стандартный формат OpenAI + if ( + responseData.choices && + responseData.choices[0] && + responseData.choices[0].message + ) { + tagsText = responseData.choices[0].message.content || ""; + } + // Альтернативный формат (некоторые API) + else if ( + responseData.choices && + responseData.choices[0] && + responseData.choices[0].text + ) { + tagsText = responseData.choices[0].text || ""; + } + // Прямой content в корне (некоторые API) + else if (responseData.content) { + tagsText = responseData.content || ""; + } + // Прямой text в корне (некоторые API) + else if (responseData.text) { + tagsText = responseData.text || ""; + } + // Пробуем найти любой text или content в ответе + else { + const responseString = JSON.stringify(responseData); + console.log( + "Пробуем найти content/text в ответе:", + responseString + ); + // Если ничего не нашли, пробуем извлечь из ответа + if ( + responseData.choices && + responseData.choices.length > 0 + ) { + const firstChoice = responseData.choices[0]; + tagsText = + firstChoice.message?.content || + firstChoice.text || + firstChoice.content || + firstChoice.message?.text || + ""; + } + } + + console.log("Извлеченный текст тегов:", tagsText); + console.log("Длина текста:", tagsText.length); + + if (!tagsText || tagsText.trim().length === 0) { + console.error("Пустой content в ответе AI:", responseData); + console.error( + "Структура ответа:", + Object.keys(responseData) + ); + if (responseData.choices) { + console.error( + "Choices структура:", + JSON.stringify(responseData.choices, null, 2) + ); + } + reject( + new Error( + "ИИ вернул пустой ответ. Проверьте настройки модели и попробуйте еще раз." + ) + ); + return; + } + + resolve(tagsText); + } catch (err) { + console.error("Ошибка парсинга ответа:", err); + console.error("Данные ответа:", data); + reject( + new Error("Ошибка обработки ответа от AI: " + err.message) + ); + } + } else { + console.error("Ошибка OpenAI API:", res.statusCode, data); + let errorMessage = `Ошибка OpenAI API: ${res.statusCode}`; + try { + const errorData = JSON.parse(data); + if (errorData.error && errorData.error.message) { + errorMessage += " - " + errorData.error.message; + } + } catch (e) { + errorMessage += " - " + data.substring(0, 200); + } + reject(new Error(errorMessage)); + } + }); + }); + + req.on("error", (error) => { + console.error("Ошибка запроса к OpenAI:", error); + reject(new Error("Ошибка подключения к OpenAI API")); + }); + + req.write(requestData); + req.end(); + }); + + // Логируем сырой ответ от AI для отладки + console.log("AI ответ для генерации тегов:", tagsResponse); + console.log("Тип ответа:", typeof tagsResponse); + console.log("Длина ответа:", tagsResponse ? tagsResponse.length : 0); + + // Проверяем, что ответ не пустой + if ( + !tagsResponse || + typeof tagsResponse !== "string" || + tagsResponse.trim().length === 0 + ) { + console.error("AI вернул пустой ответ"); + return res.status(500).json({ + error: + "ИИ вернул пустой ответ. Попробуйте еще раз или проверьте настройки AI.", + }); + } + + // Парсим теги из ответа + // Пробуем разные разделители: запятая, точка с запятой, перенос строки + let tags = []; + const cleanResponse = tagsResponse.trim(); + + // Сначала пробуем разделить по запятой + if (cleanResponse.includes(",")) { + tags = cleanResponse.split(","); + } + // Затем пробуем точку с запятой + else if (cleanResponse.includes(";")) { + tags = cleanResponse.split(";"); + } + // Затем пробуем перенос строки + else if (cleanResponse.includes("\n")) { + tags = cleanResponse.split("\n"); + } + // Если ничего не найдено, пробуем пробел (как последний вариант) + else { + tags = cleanResponse.split(/\s+/); + } + + console.log("Теги до обработки:", tags); + + // Обрабатываем теги + tags = tags + .map((tag) => tag.trim()) + .filter((tag) => tag.length > 0) + .map((tag) => { + // Убираем # в начале и конце + tag = tag.replace(/^#+\s*/, "").replace(/\s*#+$/, ""); + // Убираем точки, дефисы в начале/конце если они есть + tag = tag.replace(/^[.\-\s]+|[.\-\s]+$/g, ""); + // Убираем кавычки если есть + tag = tag.replace(/^["']+|["']+$/g, ""); + return tag.trim(); + }) + .filter((tag) => { + // Фильтруем слишком короткие и слишком длинные теги + return tag.length > 0 && tag.length <= 50; + }) + .filter((tag) => { + // Фильтруем общие слова, которые не являются тегами + const commonWords = [ + "текст", + "заметка", + "информация", + "данные", + "файл", + "документ", + ]; + return !commonWords.includes(tag.toLowerCase()); + }) + .slice(0, 10); // Максимум 10 тегов + + console.log("Распарсенные теги после обработки:", tags); + + // Если тегов нет, это тоже ошибка + if (!tags || tags.length === 0) { + console.error( + "Не удалось распарсить теги из ответа AI. Исходный ответ:", + tagsResponse + ); + return res.status(500).json({ + error: + "ИИ вернул ответ, но не удалось извлечь теги. Попробуйте еще раз или добавьте больше текста в заметку.", + }); + } + + // Логируем использование AI + logAction( + req.session.userId, + "ai_generate_tags", + `Сгенерированы теги через AI: ${tags.length} тегов` + ); + + res.json({ success: true, tags }); + } catch (error) { + console.error("Ошибка вызова OpenAI API:", error); + console.error("Стек ошибки:", error.stack); + return res.status(500).json({ + error: error.message || "Ошибка подключения к OpenAI API", + details: + process.env.NODE_ENV === "development" ? error.stack : undefined, + }); + } + }); + } catch (error) { + console.error("Ошибка генерации тегов:", error); + console.error("Стек ошибки:", error.stack); + res.status(500).json({ + error: error.message || "Ошибка генерации тегов", + details: process.env.NODE_ENV === "development" ? error.stack : undefined, + }); + } +}); + // API для объединения заметок через AI app.post("/api/ai/merge", requireApiAuth, async (req, res) => { const { notes } = req.body; diff --git a/dev-dist/sw.js b/dev-dist/sw.js index 753490f..a01ad91 100644 --- a/dev-dist/sw.js +++ b/dev-dist/sw.js @@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict'; "revision": "3ca0b8505b4bec776b69afdba2768812" }, { "url": "/index.html", - "revision": "0.pkl0gk07ge8" + "revision": "0.tafmhe7064g" }], { "ignoreURLParametersMatching": [/^utm_/, /^fbclid$/] }); diff --git a/src/api/aiApi.ts b/src/api/aiApi.ts index 71cc55b..7af4fd4 100644 --- a/src/api/aiApi.ts +++ b/src/api/aiApi.ts @@ -16,4 +16,12 @@ export const aiApi = { ); return data.mergedText; }, + + generateTags: async (text: string): Promise => { + const { data } = await axiosClient.post<{ tags: string[] }>( + "/ai/generate-tags", + { text } + ); + return data.tags; + }, }; diff --git a/src/components/notes/GenerateTagsModal.tsx b/src/components/notes/GenerateTagsModal.tsx new file mode 100644 index 0000000..6a5ba56 --- /dev/null +++ b/src/components/notes/GenerateTagsModal.tsx @@ -0,0 +1,266 @@ +import React, { useState, useEffect } from "react"; +import { Icon } from "@iconify/react"; +import { useNotification } from "../../hooks/useNotification"; + +interface GenerateTagsModalProps { + isOpen: boolean; + onClose: () => void; + onSelectTags: (tags: string[]) => void; + suggestedTags: string[]; + existingTags: string[]; + isLoading?: boolean; + hasError?: boolean; +} + +export const GenerateTagsModal: React.FC = ({ + isOpen, + onClose, + onSelectTags, + suggestedTags, + existingTags, + isLoading = false, + hasError = false, +}) => { + const [selectedTags, setSelectedTags] = useState([]); + const { showNotification } = useNotification(); + + useEffect(() => { + if (isOpen) { + // При открытии модалки, выбираем только новые теги (которых еще нет в заметке) + const newTags = suggestedTags.filter( + (tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()) + ); + setSelectedTags(newTags); + } else { + // При закрытии сбрасываем выбор + setSelectedTags([]); + } + }, [isOpen, suggestedTags, existingTags]); + + const handleTagToggle = (tag: string) => { + setSelectedTags((prev) => { + if (prev.includes(tag)) { + return prev.filter((t) => t !== tag); + } else { + return [...prev, tag]; + } + }); + }; + + const handleSelectAll = () => { + const newTags = suggestedTags.filter( + (tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()) + ); + setSelectedTags(newTags); + }; + + const handleDeselectAll = () => { + setSelectedTags([]); + }; + + const handleApply = () => { + if (selectedTags.length === 0) { + showNotification("Выберите хотя бы один тег", "warning"); + return; + } + onSelectTags(selectedTags); + onClose(); + }; + + useEffect(() => { + const handleEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && isOpen) { + onClose(); + } + }; + + if (isOpen) { + document.addEventListener("keydown", handleEscape); + } + + return () => document.removeEventListener("keydown", handleEscape); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const newTags = suggestedTags.filter( + (tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()) + ); + const existingInSuggested = suggestedTags.filter((tag) => + existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase()) + ); + + return ( +
+
e.stopPropagation()} + > +
+

Выберите теги

+ + × + +
+
+ {isLoading ? ( +
+
+

Генерирую теги через ИИ...

+
+ ) : hasError ? ( +
+

+ Не удалось сгенерировать теги +

+

+ Произошла ошибка при генерации тегов. Проверьте настройки AI или попробуйте еще раз. +

+
+ ) : suggestedTags.length === 0 ? ( +
+

+ Не удалось сгенерировать теги +

+

+ ИИ не смог предложить теги для этой заметки. Попробуйте еще раз или добавьте больше текста. +

+
+ ) : ( + <> + {newTags.length > 0 && ( + <> +
+

Предлагаемые теги:

+
+ + +
+
+
+ {newTags.map((tag) => ( + + ))} +
+ + )} + + {existingInSuggested.length > 0 && ( +
+

+ Теги, которые уже есть в заметке: +

+
+ {existingInSuggested.map((tag) => ( + + #{tag} + + ))} +
+
+ )} + + {selectedTags.length > 0 && ( +
+ Будет добавлено тегов: {selectedTags.length} +
+ )} + + )} +
+
+ + +
+
+
+ ); +}; + diff --git a/src/components/notes/MergeNotesModal.tsx b/src/components/notes/MergeNotesModal.tsx index 7fff726..d1a3388 100644 --- a/src/components/notes/MergeNotesModal.tsx +++ b/src/components/notes/MergeNotesModal.tsx @@ -161,7 +161,7 @@ export const MergeNotesModal: React.FC = ({
{isLoading ? (
-
+

Объединяю заметки через ИИ...

Выбрано заметок: {selectedNotes.length} diff --git a/src/components/notes/NoteEditor.tsx b/src/components/notes/NoteEditor.tsx index ca33ddc..b1bbcf2 100644 --- a/src/components/notes/NoteEditor.tsx +++ b/src/components/notes/NoteEditor.tsx @@ -9,6 +9,8 @@ import { useNotification } from "../../hooks/useNotification"; import { offlineNotesApi } from "../../api/offlineNotesApi"; import { aiApi } from "../../api/aiApi"; import { Icon } from "@iconify/react"; +import { GenerateTagsModal } from "./GenerateTagsModal"; +import { extractTags } from "../../utils/markdown"; interface NoteEditorProps { onSave: () => void; @@ -19,6 +21,10 @@ export const NoteEditor: React.FC = ({ onSave }) => { const [images, setImages] = useState([]); const [files, setFiles] = useState([]); const [isAiLoading, setIsAiLoading] = useState(false); + const [showTagsModal, setShowTagsModal] = useState(false); + const [suggestedTags, setSuggestedTags] = useState([]); + const [isGeneratingTags, setIsGeneratingTags] = useState(false); + const [tagsGenerationError, setTagsGenerationError] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [hasSelection, setHasSelection] = useState(false); @@ -95,6 +101,57 @@ export const NoteEditor: React.FC = ({ onSave }) => { } }; + const handleGenerateTags = async () => { + if (!content.trim()) { + showNotification("Введите текст для генерации тегов", "warning"); + return; + } + + setIsGeneratingTags(true); + setTagsGenerationError(false); + setSuggestedTags([]); + setShowTagsModal(true); + try { + const tags = await aiApi.generateTags(content); + if (tags && tags.length > 0) { + setSuggestedTags(tags); + setTagsGenerationError(false); + } else { + setTagsGenerationError(true); + setShowTagsModal(false); + showNotification("ИИ не смог предложить теги для этой заметки", "info"); + } + } catch (error: any) { + console.error("Ошибка генерации тегов:", error); + console.error("Детали ошибки:", error.response?.data); + setTagsGenerationError(true); + setShowTagsModal(false); + const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов"; + showNotification(errorMessage, "error"); + } finally { + setIsGeneratingTags(false); + } + }; + + const handleSelectTags = (tags: string[]) => { + if (tags.length === 0) return; + + const existingTags = extractTags(content); + const tagsToAdd = tags + .filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())) + .map((tag) => `#${tag}`) + .join(" "); + + if (tagsToAdd) { + // Добавляем теги в конец заметки + const newContent = content.trim() + (content.trim() ? "\n\n" : "") + tagsToAdd; + setContent(newContent); + showNotification(`Добавлено тегов: ${tags.length}`, "success"); + } else { + showNotification("Все предлагаемые теги уже есть в заметке", "info"); + } + }; + // Функция для определения активных форматов в выделенном тексте const getActiveFormats = useCallback(() => { const textarea = textareaRef.current; @@ -992,15 +1049,26 @@ export const NoteEditor: React.FC = ({ onSave }) => {

{aiEnabled && ( - + <> + + + )}
или нажмите Alt + Enter
+ + { + setShowTagsModal(false); + setSuggestedTags([]); + setTagsGenerationError(false); + }} + onSelectTags={handleSelectTags} + suggestedTags={suggestedTags} + existingTags={extractTags(content)} + isLoading={isGeneratingTags} + hasError={tagsGenerationError} + />
); }; diff --git a/src/components/notes/NoteItem.tsx b/src/components/notes/NoteItem.tsx index b0e51b0..c7f24ca 100644 --- a/src/components/notes/NoteItem.tsx +++ b/src/components/notes/NoteItem.tsx @@ -20,6 +20,8 @@ import { NotePreview } from "./NotePreview"; import { ImageUpload } from "./ImageUpload"; import { FileUpload } from "./FileUpload"; import { aiApi } from "../../api/aiApi"; +import { GenerateTagsModal } from "./GenerateTagsModal"; +import { extractTags } from "../../utils/markdown"; interface NoteItemProps { note: Note; @@ -59,6 +61,10 @@ export const NoteItem: React.FC = ({ const [localPreviewMode, setLocalPreviewMode] = useState(false); const [isExpanded, setIsExpanded] = useState(false); const [isLongNote, setIsLongNote] = useState(false); + const [showTagsModal, setShowTagsModal] = useState(false); + const [suggestedTags, setSuggestedTags] = useState([]); + const [isGeneratingTags, setIsGeneratingTags] = useState(false); + const [tagsGenerationError, setTagsGenerationError] = useState(false); const editTextareaRef = useRef(null); const imageInputRef = useRef(null); const fileInputRef = useRef(null); @@ -185,6 +191,57 @@ export const NoteItem: React.FC = ({ } }; + const handleGenerateTags = async () => { + if (!editContent.trim()) { + showNotification("Введите текст для генерации тегов", "warning"); + return; + } + + setIsGeneratingTags(true); + setTagsGenerationError(false); + setSuggestedTags([]); + setShowTagsModal(true); + try { + const tags = await aiApi.generateTags(editContent); + if (tags && tags.length > 0) { + setSuggestedTags(tags); + setTagsGenerationError(false); + } else { + setTagsGenerationError(true); + setShowTagsModal(false); + showNotification("ИИ не смог предложить теги для этой заметки", "info"); + } + } catch (error: any) { + console.error("Ошибка генерации тегов:", error); + console.error("Детали ошибки:", error.response?.data); + setTagsGenerationError(true); + setShowTagsModal(false); + const errorMessage = error.response?.data?.error || error.message || "Ошибка генерации тегов"; + showNotification(errorMessage, "error"); + } finally { + setIsGeneratingTags(false); + } + }; + + const handleSelectTags = (tags: string[]) => { + if (tags.length === 0) return; + + const existingTags = extractTags(editContent); + const tagsToAdd = tags + .filter((tag) => !existingTags.some((existing) => existing.toLowerCase() === tag.toLowerCase())) + .map((tag) => `#${tag}`) + .join(" "); + + if (tagsToAdd) { + // Добавляем теги в конец заметки + const newContent = editContent.trim() + (editContent.trim() ? "\n\n" : "") + tagsToAdd; + setEditContent(newContent); + showNotification(`Добавлено тегов: ${tags.length}`, "success"); + } else { + showNotification("Все предлагаемые теги уже есть в заметке", "info"); + } + }; + // Функция для определения активных форматов в выделенном тексте const getActiveFormats = useCallback(() => { const textarea = editTextareaRef.current; @@ -1425,15 +1482,26 @@ export const NoteItem: React.FC = ({
{aiEnabled && ( - + <> + + + )}