Добавлен новый API для генерации тегов через AI, включая валидацию входных данных и обработку ошибок. Реализованы компоненты для отображения модального окна с предложенными тегами в NoteEditor и NoteItem. Обновлены стили и логика для улучшения пользовательского интерфейса и взаимодействия с заметками.
This commit is contained in:
parent
772f5b1955
commit
74ae2ead31
@ -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;
|
||||
|
||||
@ -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$/]
|
||||
});
|
||||
|
||||
@ -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;
|
||||
},
|
||||
};
|
||||
|
||||
266
src/components/notes/GenerateTagsModal.tsx
Normal file
266
src/components/notes/GenerateTagsModal.tsx
Normal 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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user