Compare commits
5 Commits
300e881245
...
1d4cf84d6d
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d4cf84d6d | |||
| 80946a3c4a | |||
| cb40f032d2 | |||
| 74ae2ead31 | |||
| 772f5b1955 |
1
backend/public/assets/index-C3lVJ81m.css
Normal file
1
backend/public/assets/index-C3lVJ81m.css
Normal file
File diff suppressed because one or more lines are too long
166
backend/public/assets/index-Tw4PJyEU.js
Normal file
166
backend/public/assets/index-Tw4PJyEU.js
Normal file
File diff suppressed because one or more lines are too long
@ -158,8 +158,8 @@
|
||||
|
||||
<!-- Manifest -->
|
||||
<link rel="manifest" href="/manifest.json" />
|
||||
<script type="module" crossorigin src="/assets/index-vvy_XuzQ.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DK8OUj6L.css">
|
||||
<script type="module" crossorigin src="/assets/index-Tw4PJyEU.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C3lVJ81m.css">
|
||||
<link rel="manifest" href="/manifest.webmanifest"><script id="vite-plugin-pwa:register-sw" src="/registerSW.js"></script></head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
||||
@ -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} didn’t 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")});
|
||||
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} didn’t 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")});
|
||||
|
||||
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
@ -2423,7 +2423,7 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||
try {
|
||||
// Получаем AI настройки пользователя
|
||||
const getSettingsSql =
|
||||
"SELECT openai_api_key, openai_base_url, openai_model FROM users WHERE id = ?";
|
||||
"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);
|
||||
@ -2441,6 +2441,13 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||
.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);
|
||||
@ -2546,20 +2553,25 @@ app.post("/api/ai/improve", requireApiAuth, async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
// API для объединения заметок через AI
|
||||
app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
|
||||
const { notes } = req.body;
|
||||
// API для генерации тегов через AI
|
||||
app.post("/api/ai/generate-tags", requireApiAuth, async (req, res) => {
|
||||
const { text } = req.body;
|
||||
|
||||
if (!notes || !Array.isArray(notes) || notes.length < 2) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Необходимо передать минимум 2 заметки" });
|
||||
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 FROM users WHERE id = ?";
|
||||
"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);
|
||||
@ -2577,6 +2589,358 @@ app.post("/api/ai/merge", requireApiAuth, async (req, res) => {
|
||||
.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;
|
||||
|
||||
if (!notes || !Array.isArray(notes) || notes.length < 2) {
|
||||
return res
|
||||
.status(400)
|
||||
.json({ error: "Необходимо передать минимум 2 заметки" });
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@ -82,7 +82,7 @@ define(['./workbox-47da91e0'], (function (workbox) { 'use strict';
|
||||
"revision": "3ca0b8505b4bec776b69afdba2768812"
|
||||
}, {
|
||||
"url": "/index.html",
|
||||
"revision": "0.u6qfhq29adg"
|
||||
"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;
|
||||
},
|
||||
};
|
||||
|
||||
265
src/components/notes/GenerateTagsModal.tsx
Normal file
265
src/components/notes/GenerateTagsModal.tsx
Normal file
@ -0,0 +1,265 @@
|
||||
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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
162
src/components/notes/ImproveTextModal.tsx
Normal file
162
src/components/notes/ImproveTextModal.tsx
Normal file
@ -0,0 +1,162 @@
|
||||
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}>
|
||||
×
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -21,6 +21,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
const [mergedContent, setMergedContent] = useState<string>("");
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteOriginalNotes, setDeleteOriginalNotes] = useState(false);
|
||||
const isClosedRef = useRef(false);
|
||||
const { showNotification } = useNotification();
|
||||
|
||||
@ -34,6 +35,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
setMergedContent("");
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
setDeleteOriginalNotes(false);
|
||||
isClosedRef.current = false;
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
@ -44,6 +46,7 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
setIsLoading(false);
|
||||
setIsSaving(false);
|
||||
setMergedContent("");
|
||||
setDeleteOriginalNotes(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@ -95,7 +98,27 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
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();
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
@ -138,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}
|
||||
@ -169,6 +192,34 @@ export const MergeNotesModal: React.FC<MergeNotesModalProps> = ({
|
||||
>
|
||||
<NotePreview content={mergedContent} />
|
||||
</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>
|
||||
|
||||
@ -9,6 +9,9 @@ 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 { ImproveTextModal } from "./ImproveTextModal";
|
||||
import { extractTags } from "../../utils/markdown";
|
||||
|
||||
interface NoteEditorProps {
|
||||
onSave: () => void;
|
||||
@ -19,6 +22,14 @@ 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 [showImproveModal, setShowImproveModal] = useState(false);
|
||||
const [improvedText, setImprovedText] = useState("");
|
||||
const [improveError, setImproveError] = useState(false);
|
||||
const [improveErrorMessage, setImproveErrorMessage] = useState<string>("");
|
||||
const [showFloatingToolbar, setShowFloatingToolbar] = useState(false);
|
||||
const [toolbarPosition, setToolbarPosition] = useState({ top: 0, left: 0 });
|
||||
const [hasSelection, setHasSelection] = useState(false);
|
||||
@ -83,18 +94,85 @@ export const NoteEditor: React.FC<NoteEditorProps> = ({ onSave }) => {
|
||||
}
|
||||
|
||||
setIsAiLoading(true);
|
||||
setImproveError(false);
|
||||
setImproveErrorMessage("");
|
||||
setImprovedText("");
|
||||
setShowImproveModal(true);
|
||||
try {
|
||||
const improvedText = await aiApi.improveText(content);
|
||||
setContent(improvedText);
|
||||
showNotification("Текст улучшен!", "success");
|
||||
} catch (error) {
|
||||
const improved = await aiApi.improveText(content);
|
||||
setImprovedText(improved);
|
||||
setImproveError(false);
|
||||
} catch (error: any) {
|
||||
console.error("Ошибка улучшения текста:", error);
|
||||
showNotification("Ошибка улучшения текста", "error");
|
||||
setImproveError(true);
|
||||
setImproveErrorMessage(
|
||||
error.response?.data?.error || error.message || "Ошибка улучшения текста"
|
||||
);
|
||||
} finally {
|
||||
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 textarea = textareaRef.current;
|
||||
@ -992,15 +1070,30 @@ 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" />
|
||||
<span className="btnAI-text">
|
||||
{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}>
|
||||
Сохранить
|
||||
@ -1008,6 +1101,36 @@ 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}
|
||||
/>
|
||||
|
||||
<ImproveTextModal
|
||||
isOpen={showImproveModal}
|
||||
onClose={() => {
|
||||
setShowImproveModal(false);
|
||||
setImprovedText("");
|
||||
setImproveError(false);
|
||||
setImproveErrorMessage("");
|
||||
}}
|
||||
onApply={handleApplyImprovedText}
|
||||
originalText={content}
|
||||
improvedText={improvedText}
|
||||
isLoading={isAiLoading}
|
||||
hasError={improveError}
|
||||
errorMessage={improveErrorMessage}
|
||||
/>
|
||||
</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;
|
||||
@ -810,10 +867,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchiveClick = () => {
|
||||
setShowArchiveModal(true);
|
||||
};
|
||||
|
||||
// Авторасширение textarea в режиме редактирования
|
||||
useEffect(() => {
|
||||
if (!isEditing) return;
|
||||
@ -1136,19 +1189,6 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
>
|
||||
<div className="date">
|
||||
<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()}
|
||||
{note.is_pinned ? (
|
||||
<span className="pin-indicator">
|
||||
@ -1182,13 +1222,19 @@ export const NoteItem: React.FC<NoteItemProps> = ({
|
||||
>
|
||||
<Icon icon="mdi:pencil" />
|
||||
</div>
|
||||
<div
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onSelect && onSelect(note.id)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
{/* <div
|
||||
className="notesHeaderBtn"
|
||||
onClick={handleArchiveClick}
|
||||
title="В архив"
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1432,15 +1478,30 @@ 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" />
|
||||
<span className="btnAI-text">
|
||||
{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}>
|
||||
Сохранить
|
||||
@ -1552,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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@ -33,6 +33,7 @@ const NotesPage: React.FC = () => {
|
||||
const selectedDate = useAppSelector((state) => state.notes.selectedDate);
|
||||
const selectedTag = useAppSelector((state) => state.notes.selectedTag);
|
||||
const searchQuery = useAppSelector((state) => state.notes.searchQuery);
|
||||
const aiEnabled = useAppSelector((state) => state.profile.aiEnabled);
|
||||
|
||||
const hasFilters = !!(selectedDate || selectedTag || searchQuery);
|
||||
|
||||
@ -115,13 +116,13 @@ const NotesPage: React.FC = () => {
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
// Удаляем все выбранные заметки
|
||||
// Архивируем все выбранные заметки
|
||||
await Promise.all(
|
||||
selectedNoteIds.map((id) => offlineNotesApi.delete(id))
|
||||
selectedNoteIds.map((id) => offlineNotesApi.archive(id))
|
||||
);
|
||||
|
||||
showNotification(
|
||||
`Удалено заметок: ${selectedNoteIds.length}`,
|
||||
`Архивировано заметок: ${selectedNoteIds.length}`,
|
||||
"success"
|
||||
);
|
||||
setSelectedNoteIds([]);
|
||||
@ -131,8 +132,8 @@ const NotesPage: React.FC = () => {
|
||||
notesListRef.current.reloadNotes();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Ошибка удаления заметок:", error);
|
||||
showNotification("Ошибка удаления заметок", "error");
|
||||
console.error("Ошибка архивирования заметок:", error);
|
||||
showNotification("Ошибка архивирования заметок", "error");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
@ -193,7 +194,7 @@ const NotesPage: React.FC = () => {
|
||||
zIndex: 1000,
|
||||
}}
|
||||
>
|
||||
{selectedNoteIds.length >= 2 && (
|
||||
{selectedNoteIds.length >= 2 && aiEnabled && (
|
||||
<button
|
||||
onClick={handleMergeNotes}
|
||||
style={{
|
||||
@ -241,15 +242,15 @@ const NotesPage: React.FC = () => {
|
||||
width: "56px",
|
||||
height: "56px",
|
||||
borderRadius: "50%",
|
||||
backgroundColor: theme === "dark" ? "#F44336" : "#E53935",
|
||||
backgroundColor: theme === "dark" ? "#FF9800" : "#FF9800",
|
||||
color: "white",
|
||||
border: "none",
|
||||
cursor: isDeleting ? "not-allowed" : "pointer",
|
||||
opacity: isDeleting ? 0.6 : 1,
|
||||
boxShadow:
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(244, 67, 54, 0.4)"
|
||||
: "0 4px 12px rgba(229, 57, 53, 0.4)",
|
||||
? "0 4px 12px rgba(255, 152, 0, 0.4)"
|
||||
: "0 4px 12px rgba(255, 152, 0, 0.4)",
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
@ -261,20 +262,20 @@ const NotesPage: React.FC = () => {
|
||||
e.currentTarget.style.transform = "scale(1.1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 6px 16px rgba(244, 67, 54, 0.6)"
|
||||
: "0 6px 16px rgba(229, 57, 53, 0.6)";
|
||||
? "0 6px 16px rgba(255, 152, 0, 0.6)"
|
||||
: "0 6px 16px rgba(255, 152, 0, 0.6)";
|
||||
}
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = "scale(1)";
|
||||
e.currentTarget.style.boxShadow =
|
||||
theme === "dark"
|
||||
? "0 4px 12px rgba(244, 67, 54, 0.4)"
|
||||
: "0 4px 12px rgba(229, 57, 53, 0.4)";
|
||||
? "0 4px 12px rgba(255, 152, 0, 0.4)"
|
||||
: "0 4px 12px rgba(255, 152, 0, 0.4)";
|
||||
}}
|
||||
title={`Удалить ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
|
||||
title={`Архивировать ${selectedNoteIds.length} ${selectedNoteIds.length === 1 ? "заметку" : selectedNoteIds.length > 4 ? "заметок" : "заметки"}`}
|
||||
>
|
||||
<Icon icon="mdi:delete" />
|
||||
<Icon icon="mdi:archive" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -289,22 +290,22 @@ const NotesPage: React.FC = () => {
|
||||
isOpen={isDeleteModalOpen}
|
||||
onClose={() => setIsDeleteModalOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
title="Удаление заметок"
|
||||
title="Архивирование заметок"
|
||||
message={
|
||||
<p>
|
||||
Вы уверены, что хотите удалить{" "}
|
||||
Вы уверены, что хотите архивировать{" "}
|
||||
<strong>{selectedNoteIds.length}</strong>{" "}
|
||||
{selectedNoteIds.length === 1
|
||||
? "заметку"
|
||||
: selectedNoteIds.length > 4
|
||||
? "заметок"
|
||||
: "заметки"}
|
||||
? Это действие нельзя отменить.
|
||||
? Заметки можно будет восстановить из архива в настройках.
|
||||
</p>
|
||||
}
|
||||
confirmText={isDeleting ? "Удаление..." : "Удалить"}
|
||||
confirmText={isDeleting ? "Архивирование..." : "Архивировать"}
|
||||
cancelText="Отмена"
|
||||
confirmType="danger"
|
||||
confirmType="primary"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
@ -245,7 +245,7 @@ const SettingsPage: React.FC = () => {
|
||||
setAiEnabled(checked);
|
||||
localStorage.setItem("ai_enabled", checked ? "1" : "0");
|
||||
showNotification(
|
||||
checked ? "Помощь ИИ включена" : "Помощь ИИ отключена",
|
||||
checked ? "Функции ИИ включены" : "Функции ИИ отключены",
|
||||
"success"
|
||||
);
|
||||
} catch (error: any) {
|
||||
@ -726,11 +726,16 @@ const SettingsPage: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="toggle-label-content">
|
||||
<span className="toggle-text-main">Включить помощь ИИ</span>
|
||||
<span className="toggle-text-main">Включить функции ИИ</span>
|
||||
<span className="toggle-text-desc">
|
||||
{checkAiSettingsFilled()
|
||||
? 'Показывать кнопку "Помощь ИИ" в редакторах заметок'
|
||||
: "Сначала заполните API Key, Base URL и Модель ниже"}
|
||||
{checkAiSettingsFilled() ? (
|
||||
<ul style={{ margin: "8px 0 0 20px", padding: 0 }}>
|
||||
<li>Улучшение текста заметок</li>
|
||||
<li>Объединение заметок</li>
|
||||
</ul>
|
||||
) : (
|
||||
"Сначала заполните API Key, Base URL и Модель ниже"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="toggle-switch-wrapper">
|
||||
|
||||
@ -1072,7 +1072,9 @@ textarea:focus {
|
||||
.action-buttons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
padding-right: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btnAI {
|
||||
@ -1080,7 +1082,13 @@ textarea:focus {
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
padding: 8px 12px;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.btnAI:hover {
|
||||
@ -1103,7 +1111,7 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.btnSave {
|
||||
padding: 10px 20px;
|
||||
padding: 8px 16px;
|
||||
cursor: pointer;
|
||||
border-width: 1px;
|
||||
background: var(--bg-secondary);
|
||||
@ -1112,7 +1120,13 @@ textarea:focus {
|
||||
border-radius: 5px;
|
||||
font-family: "Open Sans", sans-serif;
|
||||
transition: all 0.3s ease;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
min-height: 44px;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btnSave:hover {
|
||||
@ -1154,6 +1168,58 @@ textarea:focus {
|
||||
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 {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@ -1515,6 +1581,8 @@ textarea:focus {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -1528,6 +1596,8 @@ textarea:focus {
|
||||
@media (max-width: 768px) {
|
||||
.markdown-buttons.markdown-buttons--edit .btnMarkdown {
|
||||
min-height: 48px;
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
@ -1548,6 +1618,8 @@ textarea:focus {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
min-height: 44px; /* Минимальная высота для touch */
|
||||
min-width: 44px;
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@ -1668,8 +1740,9 @@ textarea:focus {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.2s ease;
|
||||
min-width: 32px;
|
||||
min-height: 32px;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.floating-toolbar-btn:hover {
|
||||
@ -2966,8 +3039,8 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.markdown-buttons .btnMarkdown {
|
||||
flex: 0 1 auto;
|
||||
min-width: auto;
|
||||
flex: 0 0 auto;
|
||||
min-width: 44px;
|
||||
margin-right: 0;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
@ -3872,6 +3945,8 @@ textarea:focus {
|
||||
/* Улучшения для мобильных устройств */
|
||||
.markdown-buttons .btnMarkdown {
|
||||
min-height: 48px; /* Увеличиваем высоту для touch */
|
||||
min-width: 48px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
margin: 2px;
|
||||
}
|
||||
@ -4189,11 +4264,17 @@ textarea:focus {
|
||||
background-color: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-secondary);
|
||||
padding: 10px 20px;
|
||||
padding: 8px 16px;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
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 {
|
||||
@ -4993,6 +5074,8 @@ textarea:focus {
|
||||
/* Markdown кнопки */
|
||||
.markdown-buttons .btnMarkdown {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 10px;
|
||||
margin: 2px;
|
||||
font-size: 13px;
|
||||
@ -5042,6 +5125,8 @@ textarea:focus {
|
||||
}
|
||||
|
||||
.markdown-buttons .btnMarkdown {
|
||||
min-width: 40px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user