Compare commits

..

No commits in common. "1d4cf84d6daa95b1ab85787b315818bf4b4c159d" and "300e881245c1e9ad3946111251387ad30d785baa" have entirely different histories.

18 changed files with 87 additions and 1390 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -158,8 +158,8 @@
<!-- Manifest --> <!-- Manifest -->
<link rel="manifest" href="/manifest.json" /> <link rel="manifest" href="/manifest.json" />
<script type="module" crossorigin src="/assets/index-Tw4PJyEU.js"></script> <script type="module" crossorigin src="/assets/index-vvy_XuzQ.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C3lVJ81m.css"> <link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head> <link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
<body> <body>
<div id="root"> <div id="root">

View File

@ -1 +1 @@
if(!self.define){let e,n={};const i=(i,c)=>(i=new URL(i+".js",c).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} didnt register its module`);return e}));self.define=(c,s)=>{const a=e||("document"in self?document.currentScript.src:"")||location.href;if(n[a])return;let o={};const r=e=>i(e,a),d={module:{uri:a},exports:o,require:r};n[a]=Promise.all(c.map(e=>d[e]||r(e))).then(e=>(s(...e),o))}}define(["./workbox-40c80ae4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-C3lVJ81m.css",revision:"b7d7a06e04415f937626bf2a2fa0e494"},{url:"assets/index-Tw4PJyEU.js",revision:"258806cb29e8a6a43d2fe456136dffbf"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"81dcb6a6346bcee9f6f569316322388e"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api/,/^\/uploads/]})),e.registerRoute(({request:e})=>"navigate"===e.mode,new e.CacheFirst({cacheName:"pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\.html$/,new e.CacheFirst({cacheName:"html-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/api\./,new e.NetworkFirst({cacheName:"api-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:3600})]}),"GET"),e.registerRoute(/\/api\//,new e.NetworkFirst({cacheName:"api-cache-local",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/uploads\//,new e.CacheFirst({cacheName:"uploads-cache",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/,new e.CacheFirst({cacheName:"images-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")}); if(!self.define){let e,n={};const i=(i,c)=>(i=new URL(i+".js",c).href,n[i]||new Promise(n=>{if("document"in self){const e=document.createElement("script");e.src=i,e.onload=n,document.head.appendChild(e)}else e=i,importScripts(i),n()}).then(()=>{let e=n[i];if(!e)throw new Error(`Module ${i} didnt register its module`);return e}));self.define=(c,s)=>{const o=e||("document"in self?document.currentScript.src:"")||location.href;if(n[o])return;let a={};const r=e=>i(e,o),d={module:{uri:o},exports:a,require:r};n[o]=Promise.all(c.map(e=>d[e]||r(e))).then(e=>(s(...e),a))}}define(["./workbox-40c80ae4"],function(e){"use strict";self.skipWaiting(),e.clientsClaim(),e.precacheAndRoute([{url:"assets/index-DK8OUj6L.css",revision:"b1e2c4e8724be2f2bcee585338910e99"},{url:"assets/index-vvy_XuzQ.js",revision:"054024cdea842a8b5c1681d0e44680fa"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-16x16.png",revision:"101c13808e9fd0956f247bc446a8ac1e"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-32x32.png",revision:"22ee5d42535bc339ab0e19cb496378a5"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"icons/icon-48x48.png",revision:"cfdd3bebd931375f2e0277d638ec8781"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"index.html",revision:"10119735705f0553541d8ce5fe13c02e"},{url:"logo.svg",revision:"11616ede8898b4c24203e331b3ec6dc3"},{url:"registerSW.js",revision:"1872c500de691dce40960bb85481de07"},{url:"icon.svg",revision:"537ae73d8f9e90e6a01816aa6d527d16"},{url:"icons/icon-192x192.png",revision:"7d86d2d2ada99d7cee015dff0fdcb497"},{url:"icons/icon-512x512.png",revision:"8731edef999b9e7deba310d72a739925"},{url:"icons/icon-72x72.png",revision:"6b3cb1b2537ec91921698260a9c2f47c"},{url:"icons/icon-96x96.png",revision:"7efd757a81217207d981de88ef199d86"},{url:"icons/icon-128x128.png",revision:"fa71db17e345406d5f7d847f88c65ac4"},{url:"icons/icon-144x144.png",revision:"e790ff42758ea1a2a46eb84201630757"},{url:"icons/icon-152x152.png",revision:"88f2400f6617a32cc9cd62c70fb49a05"},{url:"icons/icon-384x384.png",revision:"c601fa602952a903389e5e8f8a699617"},{url:"manifest.webmanifest",revision:"1c071cadebd7a1b0dc1eeb0270e73fb8"}],{ignoreURLParametersMatching:[/^utm_/,/^fbclid$/]}),e.cleanupOutdatedCaches(),e.registerRoute(new e.NavigationRoute(e.createHandlerBoundToURL("/index.html"),{denylist:[/^\/api/,/^\/uploads/]})),e.registerRoute(({request:e})=>"navigate"===e.mode,new e.CacheFirst({cacheName:"pages-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/\.html$/,new e.CacheFirst({cacheName:"html-cache",plugins:[new e.ExpirationPlugin({maxEntries:10,maxAgeSeconds:604800}),new e.CacheableResponsePlugin({statuses:[0,200]})]}),"GET"),e.registerRoute(/^https:\/\/api\./,new e.NetworkFirst({cacheName:"api-cache",plugins:[new e.ExpirationPlugin({maxEntries:50,maxAgeSeconds:3600})]}),"GET"),e.registerRoute(/\/api\//,new e.NetworkFirst({cacheName:"api-cache-local",networkTimeoutSeconds:10,plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:86400})]}),"GET"),e.registerRoute(/\/uploads\//,new e.CacheFirst({cacheName:"uploads-cache",plugins:[new e.ExpirationPlugin({maxEntries:200,maxAgeSeconds:2592e3})]}),"GET"),e.registerRoute(/\.(?:png|jpg|jpeg|svg|gif|webp)$/,new e.CacheFirst({cacheName:"images-cache",plugins:[new e.ExpirationPlugin({maxEntries:100,maxAgeSeconds:2592e3})]}),"GET")});

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2423,7 +2423,7 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
try { try {
// Получаем AI настройки пользователя // Получаем AI настройки пользователя
const getSettingsSql = const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?"; "SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => { db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) { if (err) {
console.error("Ошибка получения AI настроек:", err.message); console.error("Ошибка получения AI настроек:", err.message);
@ -2441,13 +2441,6 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
.json({ error: "Настройте AI настройки в параметрах" }); .json({ error: "Настройте AI настройки в параметрах" });
} }
// Проверяем, включены ли функции ИИ
if (!settings.ai_enabled || settings.ai_enabled === 0) {
return res
.status(403)
.json({ error: "Функции ИИ отключены в настройках" });
}
try { try {
// Парсим URL // Парсим URL
const url = new URL(settings.openai_base_url); const url = new URL(settings.openai_base_url);
@ -2553,356 +2546,6 @@ 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 // API для объединения заметок через AI
app.post("/api/ai/merge", requireApiAuth, async (req, res) => { app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
const { notes } = req.body; const { notes } = req.body;
@ -2916,7 +2559,7 @@ app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
try { try {
// Получаем AI настройки пользователя // Получаем AI настройки пользователя
const getSettingsSql = const getSettingsSql =
"SELECT openai_api_key, openai_base_url, openai_model, ai_enabled FROM users WHERE id = ?"; "SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
db.get(getSettingsSql, [req.session.userId], async (err, settings) => { db.get(getSettingsSql, [req.session.userId], async (err, settings) => {
if (err) { if (err) {
console.error("Ошибка получения AI настроек:", err.message); console.error("Ошибка получения AI настроек:", err.message);
@ -2934,13 +2577,6 @@ app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
.json({ error: "Настройте AI настройки в параметрах" }); .json({ error: "Настройте AI настройки в параметрах" });
} }
// Проверяем, включены ли функции ИИ
if (!settings.ai_enabled || settings.ai_enabled === 0) {
return res
.status(403)
.json({ error: "Функции ИИ отключены в настройках" });
}
try { try {
// Парсим URL // Парсим URL
const url = new URL(settings.openai_base_url); const url = new URL(settings.openai_base_url);

View File

@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
"revision": "3ca0b8505b4bec776b69afdba2768812" "revision": "3ca0b8505b4bec776b69afdba2768812"
}, { }, {
"url": "/index.html", "url": "/index.html",
"revision": "0.tafmhe7064g" "revision": "0.u6qfhq29adg"
}], { }], {
"ignoreURLParametersMatching": [/^utm_/, /^fbclid$/] "ignoreURLParametersMatching": [/^utm_/, /^fbclid$/]
}); });

View File

@ -16,12 +16,4 @@ export const aiApi = {
); );
return data.mergedText; 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

@ -1,265 +0,0 @@
import React, { useState, useEffect } from "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

@ -1,162 +0,0 @@
import React, { useState, useEffect } from "react";
import { Icon } from "@iconify/react";
interface ImproveTextModalProps {
isOpen: boolean;
onClose: () => void;
onApply: (improvedText: string) => void;
originalText: string;
improvedText: string;
isLoading?: boolean;
hasError?: boolean;
errorMessage?: string;
}
export const ImproveTextModal: React.FC<ImproveTextModalProps> = ({
isOpen,
onClose,
onApply,
originalText,
improvedText,
isLoading = false,
hasError = false,
errorMessage,
}) => {
const [localImprovedText, setLocalImprovedText] = useState(improvedText);
useEffect(() => {
if (isOpen) {
setLocalImprovedText(improvedText);
}
}, [isOpen, improvedText]);
const handleApply = () => {
if (!localImprovedText.trim()) {
return;
}
onApply(localImprovedText);
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;
return (
<div className="modal" style={{ display: "block" }} onClick={onClose}>
<div
className="modal-content"
style={{ maxWidth: "800px" }}
onClick={(e) => e.stopPropagation()}
>
<div className="modal-header">
<h3>Улучшенный текст</h3>
<span className="modal-close" onClick={onClose}>
&times;
</span>
</div>
<div className="modal-body" style={{ maxHeight: "70vh", overflowY: "auto" }}>
{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" }}>
<Icon
icon="mdi:alert-circle"
style={{ fontSize: "48px", color: "#dc3545", marginBottom: "15px" }}
/>
<p style={{ color: "#dc3545", marginBottom: "10px", fontSize: "18px" }}>
Не удалось улучшить текст
</p>
<p style={{ fontSize: "14px", color: "#666" }}>
{errorMessage || "Произошла ошибка при улучшении текста. Проверьте настройки AI или попробуйте еще раз."}
</p>
</div>
) : (
<>
<div style={{ marginBottom: "20px" }}>
<h4 style={{ margin: "0 0 10px 0", fontSize: "16px", color: "#666" }}>
Оригинальный текст:
</h4>
<div
style={{
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-secondary)",
maxHeight: "200px",
overflowY: "auto",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
fontSize: "14px",
lineHeight: "1.6",
}}
>
{originalText || "(пусто)"}
</div>
</div>
<div>
<h4 style={{ margin: "0 0 10px 0", fontSize: "16px" }}>
Улучшенный текст:
</h4>
<textarea
value={localImprovedText}
onChange={(e) => setLocalImprovedText(e.target.value)}
style={{
width: "100%",
minHeight: "200px",
maxHeight: "400px",
padding: "15px",
border: "1px solid var(--border-color)",
borderRadius: "8px",
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontSize: "14px",
lineHeight: "1.6",
fontFamily: "inherit",
resize: "vertical",
whiteSpace: "pre-wrap",
wordWrap: "break-word",
overflowY: "auto",
}}
placeholder="Улучшенный текст появится здесь..."
/>
<p style={{ fontSize: "12px", color: "#666", marginTop: "8px" }}>
Вы можете отредактировать улучшенный текст перед применением
</p>
</div>
</>
)}
</div>
<div className="modal-footer">
<button
className="btn-primary"
onClick={handleApply}
disabled={isLoading || hasError || !localImprovedText.trim()}
>
Применить
</button>
<button className="btn-secondary" onClick={onClose} disabled={isLoading}>
Отмена
</button>
</div>
</div>
</div>
);
};

View File

@ -21,7 +21,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
const [mergedContent, setMergedContent] = useState<string>(""); const [mergedContent, setMergedContent] = useState<string>("");
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [deleteOriginalNotes, setDeleteOriginalNotes] = useState(false);
const isClosedRef = useRef(false); const isClosedRef = useRef(false);
const { showNotification } = useNotification(); const { showNotification } = useNotification();
@ -35,7 +34,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setMergedContent(""); setMergedContent("");
setIsLoading(false); setIsLoading(false);
setIsSaving(false); setIsSaving(false);
setDeleteOriginalNotes(false);
isClosedRef.current = false; isClosedRef.current = false;
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -46,7 +44,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
setIsLoading(false); setIsLoading(false);
setIsSaving(false); setIsSaving(false);
setMergedContent(""); setMergedContent("");
setDeleteOriginalNotes(false);
onClose(); onClose();
}; };
@ -98,27 +95,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
time, time,
}); });
// Удаляем исходные заметки, если тумблер включен showNotification("Объединенная заметка сохранена!", "success");
if (deleteOriginalNotes) {
try {
await Promise.all(
selectedNotes.map((note) => offlineNotesApi.delete(note.id))
);
showNotification(
`Объединенная заметка сохранена! Удалено ${selectedNotes.length} исходных заметок.`,
"success"
);
} catch (deleteError) {
console.error("Ошибка удаления исходных заметок:", deleteError);
showNotification(
"Объединенная заметка сохранена, но произошла ошибка при удалении исходных заметок",
"warning"
);
}
} else {
showNotification("Объединенная заметка сохранена!", "success");
}
onSuccess(); onSuccess();
handleClose(); handleClose();
} catch (error) { } catch (error) {
@ -161,7 +138,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
<div className="modal-body"> <div className="modal-body">
{isLoading ? ( {isLoading ? (
<div style={{ textAlign: "center", padding: "40px 20px" }}> <div style={{ textAlign: "center", padding: "40px 20px" }}>
<div className="loading-spinner" style={{ margin: "0 auto 20px" }}></div> <div className="spinner" style={{ margin: "0 auto 20px" }}></div>
<p>Объединяю заметки через ИИ...</p> <p>Объединяю заметки через ИИ...</p>
<p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}> <p style={{ fontSize: "14px", color: "#666", marginTop: "10px" }}>
Выбрано заметок: {selectedNotes.length} Выбрано заметок: {selectedNotes.length}
@ -192,34 +169,6 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
> >
<NotePreview content={mergedContent} /> <NotePreview content={mergedContent} />
</div> </div>
<div
className="form-group ai-toggle-group"
style={{ marginTop: "20px", marginBottom: "10px" }}
>
<label className="ai-toggle-label">
<div className="toggle-label-content">
<span className="toggle-text-main">
Удалить исходные заметки
</span>
<span className="toggle-text-desc">
{deleteOriginalNotes
? "Исходные заметки будут удалены после сохранения объединенной заметки"
: "Исходные заметки останутся в списке после сохранения объединенной заметки"}
</span>
</div>
<div className="toggle-switch-wrapper">
<input
type="checkbox"
id="delete-original-notes-toggle"
className="toggle-checkbox"
checked={deleteOriginalNotes}
onChange={(e) => setDeleteOriginalNotes(e.target.checked)}
disabled={isSaving}
/>
<span className="toggle-slider"></span>
</div>
</label>
</div>
</> </>
)} )}
</div> </div>

View File

@ -9,9 +9,6 @@ import { useNotification } from "../../hooks/useNotification";
import { offlineNotesApi } from "../../api/offlineNotesApi"; import { offlineNotesApi } from "../../api/offlineNotesApi";
import { aiApi } from "../../api/aiApi"; import { aiApi } from "../../api/aiApi";
import { Icon } from "@iconify/react"; import { Icon } from "@iconify/react";
import { GenerateTagsModal } from "./GenerateTagsModal";
import { ImproveTextModal } from "./ImproveTextModal";
import { extractTags } from "../../utils/markdown";
interface NoteEditorProps { interface NoteEditorProps {
onSave: () => void; onSave: () => void;
@ -22,14 +19,6 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
const [images, setImages] = useState<File[]>([]); const [images, setImages] = useState<File[]>([]);
const [files, setFiles] = useState<File[]>([]); const [files, setFiles] = useState<File[]>([]);
const [isAiLoading, setIsAiLoading] = useState(false); 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 [showImproveModal, setShowImproveModal] = useState(false);
const [improvedText, setImprovedText] = useState("");
const [improveError, setImproveError] = useState(false);
const [improveErrorMessage, setImproveErrorMessage] = useState<string>("");
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false); const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 }); const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
const [hasSelection, setHasSelection] = useState(false); const [hasSelection, setHasSelection] = useState(false);
@ -94,85 +83,18 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
} }
setIsAiLoading(true); setIsAiLoading(true);
setImproveError(false);
setImproveErrorMessage("");
setImprovedText("");
setShowImproveModal(true);
try { try {
const improved = await aiApi.improveText(content); const improvedText = await aiApi.improveText(content);
setImprovedText(improved); setContent(improvedText);
setImproveError(false); showNotification("Текст улучшен!", "success");
} catch (error: any) { } catch (error) {
console.error("Ошибка улучшения текста:", error); console.error("Ошибка улучшения текста:", error);
setImproveError(true); showNotification("Ошибка улучшения текста", "error");
setImproveErrorMessage(
error.response?.data?.error || error.message || "Ошибка улучшения текста"
);
} finally { } finally {
setIsAiLoading(false); setIsAiLoading(false);
} }
}; };
const handleApplyImprovedText = (text: string) => {
setContent(text);
showNotification("Текст улучшен!", "success");
setShowImproveModal(false);
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
};
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 getActiveFormats = useCallback(() => {
const textarea = textareaRef.current; const textarea = textareaRef.current;
@ -1070,30 +992,15 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
<div className="save-button-container"> <div className="save-button-container">
<div className="action-buttons"> <div className="action-buttons">
{aiEnabled && ( {aiEnabled && (
<> <button
<button className="btnSave btnAI"
className="btnSave btnAI" onClick={handleAiImprove}
onClick={handleAiImprove} disabled={isAiLoading}
disabled={isAiLoading} title="Улучшить или создать текст через ИИ"
title="Улучшить или создать текст через ИИ" >
> <Icon icon="mdi:robot" />
<Icon icon="mdi:robot" /> {isAiLoading ? "Обработка..." : "Помощь ИИ"}
<span className="btnAI-text"> </button>
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</span>
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
<span className="btnAI-text">
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</span>
</button>
</>
)} )}
<button className="btnSave" onClick={handleSave}> <button className="btnSave" onClick={handleSave}>
Сохранить Сохранить
@ -1101,36 +1008,6 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
</div> </div>
<span className="save-hint">или нажмите Alt + Enter</span> <span className="save-hint">или нажмите Alt + Enter</span>
</div> </div>
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(content)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
<ImproveTextModal
isOpen={showImproveModal}
onClose={() => {
setShowImproveModal(false);
setImprovedText("");
setImproveError(false);
setImproveErrorMessage("");
}}
onApply={handleApplyImprovedText}
originalText={content}
improvedText={improvedText}
isLoading={isAiLoading}
hasError={improveError}
errorMessage={improveErrorMessage}
/>
</div> </div>
); );
}; };

View File

@ -20,8 +20,6 @@ import { NotePreview } from "./NotePreview";
import { ImageUpload } from "./ImageUpload"; import { ImageUpload } from "./ImageUpload";
import { FileUpload } from "./FileUpload"; import { FileUpload } from "./FileUpload";
import { aiApi } from "../../api/aiApi"; import { aiApi } from "../../api/aiApi";
import { GenerateTagsModal } from "./GenerateTagsModal";
import { extractTags } from "../../utils/markdown";
interface NoteItemProps { interface NoteItemProps {
note: Note; note: Note;
@ -61,10 +59,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
const [localPreviewMode, setLocalPreviewMode] = useState(false); const [localPreviewMode, setLocalPreviewMode] = useState(false);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isLongNote, setIsLongNote] = 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 editTextareaRef = useRef<HTMLTextAreaElement>(null);
const imageInputRef = useRef<HTMLInputElement>(null); const imageInputRef = useRef<HTMLInputElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -191,57 +185,6 @@ 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 getActiveFormats = useCallback(() => {
const textarea = editTextareaRef.current; const textarea = editTextareaRef.current;
@ -867,6 +810,10 @@ export const NoteItem: React.FC<NoteItemProps> = ({
} }
}; };
const handleArchiveClick = () => {
setShowArchiveModal(true);
};
// Авторасширение textarea в режиме редактирования // Авторасширение textarea в режиме редактирования
useEffect(() => { useEffect(() => {
if (!isEditing) return; if (!isEditing) return;
@ -1189,6 +1136,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
> >
<div className="date"> <div className="date">
<span className="date-text"> <span className="date-text">
<input
type="checkbox"
checked={isSelected}
onChange={() => onSelect && onSelect(note.id)}
onClick={(e) => e.stopPropagation()}
style={{
width: "18px",
height: "18px",
cursor: "pointer",
marginRight: "10px",
verticalAlign: "middle",
}}
/>
{formatDate()} {formatDate()}
{note.is_pinned ? ( {note.is_pinned ? (
<span className="pin-indicator"> <span className="pin-indicator">
@ -1222,19 +1182,13 @@ export const NoteItem: React.FC<NoteItemProps> = ({
> >
<Icon icon="mdi:pencil" /> <Icon icon="mdi:pencil" />
</div> </div>
<input <div
type="checkbox"
checked={isSelected}
onChange={() => onSelect && onSelect(note.id)}
onClick={(e) => e.stopPropagation()}
/>
{/* <div
className="notesHeaderBtn" className="notesHeaderBtn"
onClick={handleArchiveClick} onClick={handleArchiveClick}
title="В архив" title="В архив"
> >
<Icon icon="mdi:delete" /> <Icon icon="mdi:delete" />
</div> */} </div>
</div> </div>
</div> </div>
@ -1478,30 +1432,15 @@ export const NoteItem: React.FC<NoteItemProps> = ({
<div className="save-button-container"> <div className="save-button-container">
<div className="action-buttons"> <div className="action-buttons">
{aiEnabled && ( {aiEnabled && (
<> <button
<button className="btnSave btnAI"
className="btnSave btnAI" onClick={handleAiImprove}
onClick={handleAiImprove} disabled={isAiLoading}
disabled={isAiLoading} title="Улучшить или создать текст через ИИ"
title="Улучшить или создать текст через ИИ" >
> <Icon icon="mdi:robot" />
<Icon icon="mdi:robot" /> {isAiLoading ? "Обработка..." : "Помощь ИИ"}
<span className="btnAI-text"> </button>
{isAiLoading ? "Обработка..." : "Помощь ИИ"}
</span>
</button>
<button
className="btnSave btnAI"
onClick={handleGenerateTags}
disabled={isGeneratingTags || isAiLoading}
title="Сгенерировать теги через ИИ"
>
<Icon icon="mdi:tag-multiple" />
<span className="btnAI-text">
{isGeneratingTags ? "Генерация..." : "Теги ИИ"}
</span>
</button>
</>
)} )}
<button className="btnSave" onClick={handleSaveEdit}> <button className="btnSave" onClick={handleSaveEdit}>
Сохранить Сохранить
@ -1613,20 +1552,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
confirmText="Архивировать" confirmText="Архивировать"
cancelText="Отмена" cancelText="Отмена"
/> />
<GenerateTagsModal
isOpen={showTagsModal}
onClose={() => {
setShowTagsModal(false);
setSuggestedTags([]);
setTagsGenerationError(false);
}}
onSelectTags={handleSelectTags}
suggestedTags={suggestedTags}
existingTags={extractTags(editContent)}
isLoading={isGeneratingTags}
hasError={tagsGenerationError}
/>
</> </>
); );
}; };

View File

@ -33,7 +33,6 @@ const NotesPage: React.FC = () => {
const selectedDate = useAppSelector((state) => state.notes.selectedDate); const selectedDate = useAppSelector((state) => state.notes.selectedDate);
const selectedTag = useAppSelector((state) => state.notes.selectedTag); const selectedTag = useAppSelector((state) => state.notes.selectedTag);
const searchQuery = useAppSelector((state) => state.notes.searchQuery); const searchQuery = useAppSelector((state) => state.notes.searchQuery);
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
const hasFilters = !!(selectedDate || selectedTag || searchQuery); const hasFilters = !!(selectedDate || selectedTag || searchQuery);
@ -116,13 +115,13 @@ const NotesPage: React.FC = () => {
setIsDeleting(true); setIsDeleting(true);
try { try {
// Архивируем все выбранные заметки // Удаляем все выбранные заметки
await Promise.all( await Promise.all(
selectedNoteIds.map((id) => offlineNotesApi.archive(id)) selectedNoteIds.map((id) => offlineNotesApi.delete(id))
); );
showNotification( showNotification(
`Архивировано заметок: ${selectedNoteIds.length}`, `Удалено заметок: ${selectedNoteIds.length}`,
"success" "success"
); );
setSelectedNoteIds([]); setSelectedNoteIds([]);
@ -132,8 +131,8 @@ const NotesPage: React.FC = () => {
notesListRef.current.reloadNotes(); notesListRef.current.reloadNotes();
} }
} catch (error) { } catch (error) {
console.error("Ошибка архивирования заметок:", error); console.error("Ошибка удаления заметок:", error);
showNotification("Ошибка архивирования заметок", "error"); showNotification("Ошибка удаления заметок", "error");
} finally { } finally {
setIsDeleting(false); setIsDeleting(false);
} }
@ -194,7 +193,7 @@ const NotesPage: React.FC = () => {
zIndex: 1000, zIndex: 1000,
}} }}
> >
{selectedNoteIds.length >= 2 && aiEnabled && ( {selectedNoteIds.length >= 2 && (
<button <button
onClick={handleMergeNotes} onClick={handleMergeNotes}
style={{ style={{
@ -242,15 +241,15 @@ const NotesPage: React.FC = () => {
width: "56px", width: "56px",
height: "56px", height: "56px",
borderRadius: "50%", borderRadius: "50%",
backgroundColor: theme === "dark" ? "#FF9800" : "#FF9800", backgroundColor: theme === "dark" ? "#F44336" : "#E53935",
color: "white", color: "white",
border: "none", border: "none",
cursor: isDeleting ? "not-allowed" : "pointer", cursor: isDeleting ? "not-allowed" : "pointer",
opacity: isDeleting ? 0.6 : 1, opacity: isDeleting ? 0.6 : 1,
boxShadow: boxShadow:
theme === "dark" theme === "dark"
? "0 4px 12px rgba(255, 152, 0, 0.4)" ? "0 4px 12px rgba(244, 67, 54, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)", : "0 4px 12px rgba(229, 57, 53, 0.4)",
display: "flex", display: "flex",
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",
@ -262,20 +261,20 @@ const NotesPage: React.FC = () => {
e.currentTarget.style.transform = "scale(1.1)"; e.currentTarget.style.transform = "scale(1.1)";
e.currentTarget.style.boxShadow = e.currentTarget.style.boxShadow =
theme === "dark" theme === "dark"
? "0 6px 16px rgba(255, 152, 0, 0.6)" ? "0 6px 16px rgba(244, 67, 54, 0.6)"
: "0 6px 16px rgba(255, 152, 0, 0.6)"; : "0 6px 16px rgba(229, 57, 53, 0.6)";
} }
}} }}
onMouseLeave={(e) => { onMouseLeave={(e) => {
e.currentTarget.style.transform = "scale(1)"; e.currentTarget.style.transform = "scale(1)";
e.currentTarget.style.boxShadow = e.currentTarget.style.boxShadow =
theme === "dark" theme === "dark"
? "0 4px 12px rgba(255, 152, 0, 0.4)" ? "0 4px 12px rgba(244, 67, 54, 0.4)"
: "0 4px 12px rgba(255, 152, 0, 0.4)"; : "0 4px 12px rgba(229, 57, 53, 0.4)";
}} }}
title={`Архивировать ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`} title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
> >
<Icon icon="mdi:archive" /> <Icon icon="mdi:delete" />
</button> </button>
</div> </div>
)} )}
@ -290,22 +289,22 @@ const NotesPage: React.FC = () => {
isOpen={isDeleteModalOpen} isOpen={isDeleteModalOpen}
onClose={() => setIsDeleteModalOpen(false)} onClose={() => setIsDeleteModalOpen(false)}
onConfirm={handleDeleteConfirm} onConfirm={handleDeleteConfirm}
title="Архивирование заметок" title="Удаление заметок"
message={ message={
<p> <p>
Вы уверены, что хотите архивировать{" "} Вы уверены, что хотите удалить{" "}
<strong>{selectedNoteIds.length}</strong>{" "} <strong>{selectedNoteIds.length}</strong>{" "}
{selectedNoteIds.length === 1 {selectedNoteIds.length === 1
? "заметку" ? "заметку"
: selectedNoteIds.length > 4 : selectedNoteIds.length > 4
? "заметок" ? "заметок"
: "заметки"} : "заметки"}
? Заметки можно будет восстановить из архива в настройках. ? Это действие нельзя отменить.
</p> </p>
} }
confirmText={isDeleting ? "Архивирование..." : "Архивировать"} confirmText={isDeleting ? "Удаление..." : "Удалить"}
cancelText="Отмена" cancelText="Отмена"
confirmType="primary" confirmType="danger"
/> />
</> </>
); );

View File

@ -245,7 +245,7 @@ const SettingsPage: React.FC = () => {
setAiEnabled(checked); setAiEnabled(checked);
localStorage.setItem("ai_enabled", checked ? "1" : "0"); localStorage.setItem("ai_enabled", checked ? "1" : "0");
showNotification( showNotification(
checked ? "Функции ИИ включены" : "Функции ИИ отключены", checked ? "Помощь ИИ включена" : "Помощь ИИ отключена",
"success" "success"
); );
} catch (error: any) { } catch (error: any) {
@ -726,16 +726,11 @@ const SettingsPage: React.FC = () => {
}`} }`}
> >
<div className="toggle-label-content"> <div className="toggle-label-content">
<span className="toggle-text-main">Включить функции ИИ</span> <span className="toggle-text-main">Включить помощь ИИ</span>
<span className="toggle-text-desc"> <span className="toggle-text-desc">
{checkAiSettingsFilled() ? ( {checkAiSettingsFilled()
<ul style={{ margin: "8px 0 0 20px", padding: 0 }}> ? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
<li>Улучшение текста заметок</li> : "Сначала заполните API Key, Base URL и Модель ниже"}
<li>Объединение заметок</li>
</ul>
) : (
"Сначала заполните API Key, Base URL и Модель ниже"
)}
</span> </span>
</div> </div>
<div className="toggle-switch-wrapper"> <div className="toggle-switch-wrapper">

View File

@ -1072,9 +1072,7 @@ textarea:focus {
.action-buttons { .action-buttons {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 10px;
padding-right: 0;
flex-wrap: wrap;
} }
.btnAI { .btnAI {
@ -1082,13 +1080,7 @@ textarea:focus {
color: white; color: white;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 6px;
gap: 4px;
padding: 8px 12px;
min-height: 44px;
white-space: nowrap;
flex-shrink: 0;
font-size: 13px;
} }
.btnAI:hover { .btnAI:hover {
@ -1111,7 +1103,7 @@ textarea:focus {
} }
.btnSave { .btnSave {
padding: 8px 16px; padding: 10px 20px;
cursor: pointer; cursor: pointer;
border-width: 1px; border-width: 1px;
background: var(--bg-secondary); background: var(--bg-secondary);
@ -1120,13 +1112,7 @@ textarea:focus {
border-radius: 5px; border-radius: 5px;
font-family: "Open Sans", sans-serif; font-family: "Open Sans", sans-serif;
transition: all 0.3s ease; transition: all 0.3s ease;
font-size: 14px; font-size: 16px;
min-height: 44px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.btnSave:hover { .btnSave:hover {
@ -1168,58 +1154,6 @@ textarea:focus {
flex-wrap: wrap; flex-wrap: wrap;
} }
.note-actions input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
margin: 0;
vertical-align: middle;
flex-shrink: 0;
border: 2px solid var(--border-primary);
border-radius: 4px;
background-color: var(--bg-secondary);
transition: all 0.2s ease;
accent-color: var(--accent-color);
appearance: none;
-webkit-appearance: none;
position: relative;
}
.note-actions input[type="checkbox"]:hover {
border-color: var(--accent-color);
background-color: var(--bg-quaternary);
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.1);
}
.note-actions input[type="checkbox"]:checked {
background-color: var(--accent-color);
border-color: var(--accent-color);
}
.note-actions input[type="checkbox"]:checked::after {
content: "";
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -60%) rotate(45deg);
width: 4px;
height: 8px;
border: solid white;
border-width: 0 2px 2px 0;
}
.note-actions input[type="checkbox"]:checked:hover {
background-color: var(--accent-color);
border-color: var(--accent-color);
opacity: 0.9;
box-shadow: 0 0 0 2px rgba(var(--accent-color-rgb), 0.2);
}
.note-actions input[type="checkbox"]:focus {
outline: none;
box-shadow: 0 0 0 3px rgba(var(--accent-color-rgb), 0.15);
}
.notesHeaderBtn { .notesHeaderBtn {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -1581,8 +1515,6 @@ textarea:focus {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
min-height: 44px; min-height: 44px;
min-width: 44px;
flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -1596,8 +1528,6 @@ textarea:focus {
@media (max-width: 768px) { @media (max-width: 768px) {
.markdown-buttons.markdown-buttons--edit .btnMarkdown { .markdown-buttons.markdown-buttons--edit .btnMarkdown {
min-height: 48px; min-height: 48px;
min-width: 48px;
flex-shrink: 0;
padding: 8px 12px; padding: 8px 12px;
margin: 0; margin: 0;
} }
@ -1618,8 +1548,6 @@ textarea:focus {
user-select: none; user-select: none;
-webkit-user-select: none; -webkit-user-select: none;
min-height: 44px; /* Минимальная высота для touch */ min-height: 44px; /* Минимальная высота для touch */
min-width: 44px;
flex-shrink: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -1740,9 +1668,8 @@ textarea:focus {
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: all 0.2s ease; transition: all 0.2s ease;
min-width: 36px; min-width: 32px;
min-height: 36px; min-height: 32px;
flex-shrink: 0;
} }
.floating-toolbar-btn:hover { .floating-toolbar-btn:hover {
@ -3039,8 +2966,8 @@ textarea:focus {
} }
.markdown-buttons .btnMarkdown { .markdown-buttons .btnMarkdown {
flex: 0 0 auto; flex: 0 1 auto;
min-width: 44px; min-width: auto;
margin-right: 0; margin-right: 0;
padding: 8px 12px; padding: 8px 12px;
font-size: 14px; font-size: 14px;
@ -3945,8 +3872,6 @@ textarea:focus {
/* Улучшения для мобильных устройств */ /* Улучшения для мобильных устройств */
.markdown-buttons .btnMarkdown { .markdown-buttons .btnMarkdown {
min-height: 48px; /* Увеличиваем высоту для touch */ min-height: 48px; /* Увеличиваем высоту для touch */
min-width: 48px;
flex-shrink: 0;
padding: 8px 12px; padding: 8px 12px;
margin: 2px; margin: 2px;
} }
@ -4264,17 +4189,11 @@ textarea:focus {
background-color: var(--bg-tertiary); background-color: var(--bg-tertiary);
color: var(--text-primary); color: var(--text-primary);
border: 1px solid var(--border-secondary); border: 1px solid var(--border-secondary);
padding: 8px 16px; padding: 10px 20px;
border-radius: 5px; border-radius: 5px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
transition: all 0.3s ease; transition: all 0.3s ease;
min-height: 44px;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
} }
.btn-secondary:hover { .btn-secondary:hover {
@ -5074,8 +4993,6 @@ textarea:focus {
/* Markdown кнопки */ /* Markdown кнопки */
.markdown-buttons .btnMarkdown { .markdown-buttons .btnMarkdown {
min-height: 44px; min-height: 44px;
min-width: 44px;
flex-shrink: 0;
padding: 8px 10px; padding: 8px 10px;
margin: 2px; margin: 2px;
font-size: 13px; font-size: 13px;
@ -5125,8 +5042,6 @@ textarea:focus {
} }
.markdown-buttons .btnMarkdown { .markdown-buttons .btnMarkdown {
min-width: 40px;
flex-shrink: 0;
padding: 6px 8px; padding: 6px 8px;
font-size: 12px; font-size: 12px;
} }