Добавлен новый API для генерации тегов через AI, включая валидацию входных данных и обработку ошибок. Реализованы компоненты для отображения модального окна с предложенными тегами в NoteEditor и NoteItem. Обновлены стили и логика для улучшения пользовательского интерфейса и взаимодействия с заметками.

This commit is contained in:
Fovway 2025-11-07 16:08:23 +07:00
parent 772f5b1955
commit 74ae2ead31
7 changed files with 808 additions and 20 deletions

View File

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

View File

@ -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$/]
});

View File

@ -16,4 +16,12 @@ export const aiApi = {
);
return data.mergedText;
},
generateTags: async (text: string): Promise<string[]> => {
const { data } = await axiosClient.post<{ tags: string[] }>(
"/ai/generate-tags",
{ text }
);
return data.tags;
},
};

View File

@ -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<GenerateTagsModalProps> = ({
isOpen,
onClose,
onSelectTags,
suggestedTags,
existingTags,
isLoading = false,
hasError = false,
}) => {
const [selectedTags, setSelectedTags] = useState<string[]>([]);
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 (
<div className="modal" style={{ display: "block" }} onClick={onClose}>
<div
className="modal-content"
style={{ maxWidth: "600px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Выберите теги</h3>
<span className="modal-close" onClick={onClose}>
&times;
</span>
</div>
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Генерирую теги через ИИ...</p>
</div>
) : hasError ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<p style={{ color: "#dc3545", marginBottom: "10px" }}>
Не удалось сгенерировать теги
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
Произошла ошибка при генерации тегов. Проверьте настройки AI или попробуйте еще раз.
</p>
</div>
) : suggestedTags.length === 0 ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<p style={{ color: "#dc3545", marginBottom: "10px" }}>
Не удалось сгенерировать теги
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
ИИ не смог предложить теги для этой заметки. Попробуйте еще раз или добавьте больше текста.
</p>
</div>
) : (
<>
{newTags.length > 0 && (
<>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
marginBottom: "15px",
}}
>
<h4 style={{ margin: 0, fontSize: "16px" }}>Предлагаемые теги:</h4>
<div style={{ display: "flex", gap: "10px" }}>
<button
className="btn-secondary"
onClick={handleSelectAll}
style={{ fontSize: "12px", padding: "5px 10px" }}
>
Выбрать все
</button>
<button
className="btn-secondary"
onClick={handleDeselectAll}
style={{ fontSize: "12px", padding: "5px 10px" }}
>
Снять все
</button>
</div>
</div>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "10px",
marginBottom: "20px",
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-secondary)",
minHeight: "60px",
}}
>
{newTags.map((tag) => (
<button
key={tag}
onClick={() => handleTagToggle(tag)}
className={`tag ${selectedTags.includes(tag) ? "active" : ""}`}
style={{
cursor: "pointer",
padding: "6px 12px",
borderRadius: "20px",
border: selectedTags.includes(tag)
? "2px solid var(--accent-color)"
: "1px solid var(--border-color)",
backgroundColor: selectedTags.includes(tag)
? "var(--accent-color)"
: "var(--bg-tertiary)",
color: selectedTags.includes(tag) ? "#fff" : "var(--text-primary)",
transition: "all 0.2s",
}}
>
#{tag}
</button>
))}
</div>
</>
)}
{existingInSuggested.length > 0 && (
<div style={{ marginTop: "20px" }}>
<h4 style={{ margin: "0 0 10px 0", fontSize: "14px", color: "#666" }}>
Теги, которые уже есть в заметке:
</h4>
<div
style={{
display: "flex",
flexWrap: "wrap",
gap: "10px",
padding: "10px",
backgroundColor: "var(--bg-tertiary)",
borderRadius: "8px",
opacity: 0.7,
}}
>
{existingInSuggested.map((tag) => (
<span
key={tag}
className="tag"
style={{
padding: "6px 12px",
borderRadius: "20px",
border: "1px solid var(--border-color)",
backgroundColor: "var(--bg-secondary)",
color: "var(--text-secondary)",
}}
>
#{tag}
</span>
))}
</div>
</div>
)}
{selectedTags.length > 0 && (
<div
style={{
marginTop: "20px",
padding: "10px",
backgroundColor: "var(--bg-hover)",
borderRadius: "8px",
fontSize: "14px",
}}
>
<strong>Будет добавлено тегов: {selectedTags.length}</strong>
</div>
)}
</>
)}
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleApply}
disabled={isLoading || selectedTags.length === 0}
>
Применить ({selectedTags.length})
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
Отмена
</button>
</div>
</div>
</div>
);
};

View File

@ -161,7 +161,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
<div className="modal-body">
{isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="spinner" style={{ margin: "0 auto 20px" }}></div>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Объединяю заметки через ИИ...</p>
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
Выбрано заметок: {selectedNotes.length}

View File

@ -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<NoteEditorProps> = ({ onSave }) => {
const [images, setImages] = useState<File[]>([]);
const [files, setFiles] = useState<File[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false);
const [showTagsModal, setShowTagsModal] = useState(false);
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
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<NoteEditorProps> = ({ 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<NoteEditorProps> = ({ onSave }) => {
<div className="save-button-container">
<div className="action-buttons">
{aiEnabled && (
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<>
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</button>
</>
)}
<button className="btnSave" onClick={handleSave}>
Сохранить
@ -1008,6 +1076,20 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
</div>
<span className="save-hint">или нажмите Alt + Enter</span>
</div>
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(content)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
</div>
);
};

View File

@ -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<NoteItemProps> = ({
const [localPreviewMode, setLocalPreviewMode] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [isLongNote, setIsLongNote] = useState(false);
const [showTagsModal, setShowTagsModal] = useState(false);
const [suggestedTags, setSuggestedTags] = useState<string[]>([]);
const [isGeneratingTags, setIsGeneratingTags] = useState(false);
const [tagsGenerationError, setTagsGenerationError] = useState(false);
const editTextareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -185,6 +191,57 @@ export const NoteItem: React.FC<NoteItemProps> = ({
}
};
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<NoteItemProps> = ({
<div className="save-button-container">
<div className="action-buttons">
{aiEnabled && (
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<>
<button
className="btnSave btnAI"
onClick={handleAiImprove}
disabled={isAiLoading}
title="Улучшить или создать текст через ИИ"
>
<Icon icon="mdi:robot" />
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</button>
</>
)}
<button className="btnSave" onClick={handleSaveEdit}>
Сохранить
@ -1545,6 +1613,20 @@ export const NoteItem: React.FC<NoteItemProps> = ({
confirmText="Архивировать"
cancelText="Отмена"
/>
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(editContent)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
</>
);
};