Add AI text correction button to journal edit modal
This commit is contained in:
@@ -5,6 +5,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.layout{display:flex;min-height:100dvh}
|
.layout{display:flex;min-height:100dvh}
|
||||||
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
.sidebar{width:var(--sidebar);background:var(--card);border-right:1px solid var(--border);padding:24px 0;position:fixed;top:0;left:0;height:100dvh;display:flex;flex-direction:column}
|
||||||
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
.sidebar .logo{font-size:1.1rem;font-weight:700;padding:0 20px 20px;border-bottom:1px solid var(--border)}
|
||||||
|
.user-box{padding:14px 20px;border-bottom:1px solid var(--border);display:flex;flex-direction:column;gap:2px}
|
||||||
|
.user-box .user-name{font-weight:600;font-size:.9rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.user-box .user-role{color:var(--muted);font-size:.72rem}
|
||||||
.sidebar nav{flex:1;padding:12px 0}
|
.sidebar nav{flex:1;padding:12px 0}
|
||||||
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
.sidebar nav a{display:flex;align-items:center;gap:10px;padding:10px 20px;color:var(--muted);text-decoration:none;font-size:.9rem;transition:all .15s;border-left:3px solid transparent}
|
||||||
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
.sidebar nav a:hover{color:var(--text);background:var(--bg)}
|
||||||
@@ -334,3 +337,9 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
|
|||||||
.audit-table tr:last-child td{border-bottom:none}
|
.audit-table tr:last-child td{border-bottom:none}
|
||||||
.audit-table tr:hover td{background:rgba(37,99,235,.04)}
|
.audit-table tr:hover td{background:rgba(37,99,235,.04)}
|
||||||
.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
|
.audit-wrap{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);overflow-x:auto}
|
||||||
|
|
||||||
|
/* --- AI Correction --- */
|
||||||
|
.ai-btn{background:none;border:none;color:var(--muted);cursor:pointer;font-size:1.1rem;padding:4px 8px;border-radius:6px;transition:color .15s,background .15s;vertical-align:middle;margin-left:6px}
|
||||||
|
.ai-btn:hover{color:var(--accent);background:rgba(37,99,235,.1)}
|
||||||
|
.ai-btn:disabled{opacity:.5;cursor:not-allowed}
|
||||||
|
#aiSuggestion{font-size:.9rem;line-height:1.5}
|
||||||
|
|||||||
+63
-35
@@ -54,7 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="settings-row">
|
<div class="settings-row">
|
||||||
<div class="settings-field" style="flex:1">
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Что делала(а) <span style="color:#ef4444">*</span></label>
|
<label>Что делала(а) <span style="color:#ef4444">*</span>
|
||||||
|
<button type="button" id="aiCorrectBtn" class="ai-btn" title="Исправить ошибки ИИ">✨</button>
|
||||||
|
</label>
|
||||||
<textarea id="editDesc" class="settings-input" required></textarea>
|
<textarea id="editDesc" class="settings-input" required></textarea>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -73,49 +75,75 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-overlay" id="aiCorrectModal">
|
||||||
|
<div class="edit-modal" style="max-width:560px">
|
||||||
|
<h3>✨ Исправление текста</h3>
|
||||||
|
<div style="font-size:.85rem;color:var(--muted);margin-bottom:12px">ИИ предлагает вариант:</div>
|
||||||
|
<div id="aiSuggestion" style="background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:12px;min-height:80px;max-height:300px;overflow:auto;white-space:pre-wrap;font-family:inherit"></div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px;margin-top:12px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="aiRejectBtn">Отклонить</button>
|
||||||
|
<button type="button" class="btn-primary" id="aiAcceptBtn">Применить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="modal-overlay" id="linkModal">
|
<div class="modal-overlay" id="linkModal">
|
||||||
<div class="edit-modal">
|
<div class="edit-modal" style="max-width:480px">
|
||||||
<div id="linkCreateView">
|
<div id="linkCreateView">
|
||||||
<h3>Создать ссылку</h3>
|
<h3>Создать ссылку</h3>
|
||||||
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
<div style="font-size:.85rem;color:var(--muted);background:var(--bg);border:1px solid var(--border);border-radius:8px;padding:10px 12px;line-height:1.5" id="linkPreview"></div>
|
||||||
<div>
|
<div class="settings-stack" style="gap:12px">
|
||||||
<label>Название ссылки</label>
|
<div class="settings-row">
|
||||||
<input type="text" id="linkNameInput">
|
<div class="settings-field" style="flex:1">
|
||||||
</div>
|
<label>Название ссылки <span style="color:#ef4444">*</span></label>
|
||||||
<div>
|
<input type="text" id="linkNameInput" class="settings-input" required placeholder="Например: Апрельский журнал">
|
||||||
<label>Группа (если выбрана)</label>
|
</div>
|
||||||
<input type="text" id="linkGroupPreview" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
</div>
|
||||||
</div>
|
<div class="settings-row">
|
||||||
<div>
|
<div class="settings-field" style="flex:1">
|
||||||
<label>Ученик (необязательно)</label>
|
<label>Группа (если выбрана)</label>
|
||||||
<input type="text" id="linkStudent" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
<input type="text" id="linkGroupPreview" class="settings-input" readonly style="background:var(--bg);color:var(--muted);cursor:not-allowed">
|
||||||
</div>
|
</div>
|
||||||
<div>
|
</div>
|
||||||
<label>Истекает через (дни)</label>
|
<div class="settings-row">
|
||||||
<input type="number" id="linkExpiresDays" min="1" max="365" value="7" placeholder="7">
|
<div class="settings-field" style="flex:1">
|
||||||
</div>
|
<label>Ученик (необязательно)</label>
|
||||||
<div>
|
<input type="text" id="linkStudent" class="settings-input" list="linkStudentList" placeholder="Все / введите имя..."><datalist id="linkStudentList"></datalist>
|
||||||
<label>
|
</div>
|
||||||
<input type="checkbox" id="linkAnonymizeNames">
|
</div>
|
||||||
Показывать только инициалы учеников (анонимизировать)
|
<div class="settings-row">
|
||||||
</label>
|
<div class="settings-field" style="flex:1">
|
||||||
</div>
|
<label>Истекает через (дни)</label>
|
||||||
<div>
|
<input type="number" id="linkExpiresDays" class="settings-input" min="1" max="365" value="7" placeholder="7">
|
||||||
<label>Пароль для доступа (необязательно)</label>
|
</div>
|
||||||
<input type="password" id="linkPassword" placeholder="Оставьте пустым для открытого доступа">
|
</div>
|
||||||
</div>
|
<div class="settings-row">
|
||||||
<div class="actions">
|
<div class="settings-field" style="flex:1">
|
||||||
<button class="cancel" type="button" id="linkCreateCloseBtn">Отмена</button>
|
<label style="display:flex;align-items:center;gap:8px;cursor:pointer">
|
||||||
<button class="save" type="button" id="linkCreateSaveBtn">Создать</button>
|
<input type="checkbox" id="linkAnonymizeNames">
|
||||||
|
Показывать только инициалы учеников (анонимизировать)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="settings-row">
|
||||||
|
<div class="settings-field" style="flex:1">
|
||||||
|
<label>Пароль для доступа (необязательно)</label>
|
||||||
|
<input type="password" id="linkPassword" class="settings-input" placeholder="Оставьте пустым для открытого доступа">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
|
<button type="button" class="btn-primary ghost" id="linkCreateCloseBtn">Отмена</button>
|
||||||
|
<button type="button" class="btn-primary" id="linkCreateSaveBtn">Создать</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="linkResultView" style="display:none">
|
<div id="linkResultView" style="display:none">
|
||||||
<h3>Ссылка готова</h3>
|
<h3>Ссылка готова</h3>
|
||||||
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
|
<div style="font-size:.85rem;color:var(--muted);margin-bottom:8px">Открывается публичная страница с карточками — без входа и выбора параметров. Ссылку можно изменить или удалить в разделе «Ссылки».</div>
|
||||||
<input type="text" id="linkUrlResult" readonly style="font-size:.8rem;color:var(--accent)">
|
<input type="text" id="linkUrlResult" class="settings-input" readonly style="font-size:.8rem;color:var(--accent)">
|
||||||
<div class="actions">
|
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||||
<a class="cancel-btn" href="links.html">К списку ссылок</a>
|
<a class="btn-primary ghost" href="links.html" style="text-decoration:none">К списку ссылок</a>
|
||||||
<button class="save" type="button" id="linkCopyBtn">Копировать</button>
|
<button class="btn-primary" type="button" id="linkCopyBtn">Копировать</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+56
-1
@@ -8,6 +8,7 @@ let page = 1;
|
|||||||
let totalEntries = 0;
|
let totalEntries = 0;
|
||||||
let searchTimer = null;
|
let searchTimer = null;
|
||||||
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
let view = localStorage.getItem('journalView') === 'cards' ? 'cards' : 'list';
|
||||||
|
let aiCorrecting = false;
|
||||||
|
|
||||||
function isImageFile(name) {
|
function isImageFile(name) {
|
||||||
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
return /\.(jpe?g|png|gif|webp|bmp|avif|svg|ico)$/i.test(name || '');
|
||||||
@@ -190,6 +191,53 @@ function closeEdit() {
|
|||||||
editId = null;
|
editId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function aiCorrect() {
|
||||||
|
if (aiCorrecting) return;
|
||||||
|
const textarea = document.getElementById('editDesc');
|
||||||
|
const text = textarea.value.trim();
|
||||||
|
if (!text) { showToast('Нет текста для исправления'); return; }
|
||||||
|
const btn = document.getElementById('aiCorrectBtn');
|
||||||
|
const modal = document.getElementById('aiCorrectModal');
|
||||||
|
const suggestionEl = document.getElementById('aiSuggestion');
|
||||||
|
aiCorrecting = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = '⏳';
|
||||||
|
suggestionEl.textContent = 'Обработка...';
|
||||||
|
modal.classList.add('open');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/api/ai/correct`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: hdrJson(),
|
||||||
|
body: JSON.stringify({ text })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json();
|
||||||
|
throw new Error(err.error || 'Ошибка ИИ');
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
suggestionEl.textContent = data.suggestion || text;
|
||||||
|
} catch (e) {
|
||||||
|
suggestionEl.textContent = 'Ошибка: ' + e.message;
|
||||||
|
showToast('ИИ недоступен: ' + e.message);
|
||||||
|
} finally {
|
||||||
|
aiCorrecting = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = '✨';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAiCorrect() {
|
||||||
|
document.getElementById('aiCorrectModal').classList.remove('open');
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptAiCorrect() {
|
||||||
|
const suggestion = document.getElementById('aiSuggestion').textContent;
|
||||||
|
if (suggestion && suggestion !== 'Обработка...' && !suggestion.startsWith('Ошибка:')) {
|
||||||
|
document.getElementById('editDesc').value = suggestion;
|
||||||
|
}
|
||||||
|
closeAiCorrect();
|
||||||
|
}
|
||||||
|
|
||||||
async function saveEdit() {
|
async function saveEdit() {
|
||||||
if (!editId) return;
|
if (!editId) return;
|
||||||
const name = document.getElementById('editName').value.trim();
|
const name = document.getElementById('editName').value.trim();
|
||||||
@@ -209,7 +257,7 @@ async function saveEdit() {
|
|||||||
for (const f of files) formData.append('files', f);
|
for (const f of files) formData.append('files', f);
|
||||||
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
|
const uploadRes = await fetch(`${API}/api/entries/${editId}/files`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'X-Admin-Token': token },
|
headers: hdr(),
|
||||||
body: formData
|
body: formData
|
||||||
});
|
});
|
||||||
if (!uploadRes.ok) {
|
if (!uploadRes.ok) {
|
||||||
@@ -388,6 +436,13 @@ document.getElementById('editFilesPreview').addEventListener('click', e => {
|
|||||||
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
if (btn) removeEditFile(parseInt(btn.dataset.removeFile, 10));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.getElementById('aiCorrectBtn').addEventListener('click', aiCorrect);
|
||||||
|
document.getElementById('aiAcceptBtn').addEventListener('click', acceptAiCorrect);
|
||||||
|
document.getElementById('aiRejectBtn').addEventListener('click', closeAiCorrect);
|
||||||
|
document.getElementById('aiCorrectModal').addEventListener('click', e => {
|
||||||
|
if (e.target === e.currentTarget) closeAiCorrect();
|
||||||
|
});
|
||||||
|
|
||||||
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
document.getElementById('exportCsvBtn').addEventListener('click', exportCSV);
|
||||||
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
document.getElementById('createLinkBtn').addEventListener('click', openLinkCreate);
|
||||||
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
document.querySelectorAll('#viewToggle button').forEach(b => b.addEventListener('click', () => setView(b.dataset.view)));
|
||||||
|
|||||||
@@ -621,6 +621,32 @@ const gunzipAsync = require('util').promisify(require('zlib').gunzip);
|
|||||||
const zlib = require('zlib');
|
const zlib = require('zlib');
|
||||||
const tar = require('tar');
|
const tar = require('tar');
|
||||||
const os = require('os');
|
const os = require('os');
|
||||||
|
const AI_URL = process.env.AI_URL || 'http://text-corrector:8080';
|
||||||
|
|
||||||
|
async function aiCorrectText(text) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${AI_URL}/v1/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: 'qwen2.5-1.5b-instruct',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: 'Ты — редактор текстов для педагогического журнала. Исправь грамматические, орфографические и пунктуационные ошибки. Сохрани смысл и стиль. Верни ТОЛЬКО исправленный текст, без пояснений.' },
|
||||||
|
{ role: 'user', content: text }
|
||||||
|
],
|
||||||
|
temperature: 0.1,
|
||||||
|
max_tokens: 512
|
||||||
|
})
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
|
||||||
|
const data = await res.json();
|
||||||
|
return data.choices?.[0]?.message?.content?.trim() || text;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('AI correct error:', e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const uploadBackup = multer({
|
const uploadBackup = multer({
|
||||||
storage: multer.diskStorage({
|
storage: multer.diskStorage({
|
||||||
destination: (_, __, cb) => {
|
destination: (_, __, cb) => {
|
||||||
@@ -2248,6 +2274,17 @@ app.delete('/api/entries/:id/permanent', requireAuth, async (req, res) => {
|
|||||||
res.json({ ok: true });
|
res.json({ ok: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/api/ai/correct', requireAuth, async (req, res) => {
|
||||||
|
const text = reqStr(req.body?.text, 5000);
|
||||||
|
if (!text) return res.status(400).json({ error: 'Текст не указан' });
|
||||||
|
try {
|
||||||
|
const corrected = await aiCorrectText(text);
|
||||||
|
res.json({ suggestion: corrected });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ error: 'Сервис ИИ недоступен: ' + e.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/trash', requireAuth, async (req, res) => {
|
app.get('/api/trash', requireAuth, async (req, res) => {
|
||||||
const { limit, offset } = req.query;
|
const { limit, offset } = req.query;
|
||||||
const bw = branchScope(req.user);
|
const bw = branchScope(req.user);
|
||||||
|
|||||||
Reference in New Issue
Block a user