diff --git a/public/journal.html b/public/journal.html
index 6e0acd5..e54ed8a 100644
--- a/public/journal.html
+++ b/public/journal.html
@@ -161,6 +161,38 @@
+
+
+
+
+ Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.
+
diff --git a/server.js b/server.js
index 703880b..24e8472 100644
--- a/server.js
+++ b/server.js
@@ -278,7 +278,7 @@ async function sendImageThumb(res, fp) {
try {
if (!fs.existsSync(tp)) {
const tmp = tp + '.' + crypto.randomBytes(4).toString('hex') + '.tmp';
- await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 72 }).toFile(tmp);
+ await sharp(fp).rotate().resize({ width: THUMB_WIDTH, withoutEnlargement: true }).webp({ quality: 85 }).toFile(tmp);
fs.renameSync(tmp, tp);
}
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
@@ -579,7 +579,7 @@ async function convertPhoto(file) {
// HEIC/HEIF conversion
const outName = `${path.basename(file.path, path.extname(file.path))}.jpg`;
const outPath = path.join(path.dirname(file.path), outName);
- const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.85 });
+ const jpeg = await heicConvert({ buffer: fs.readFileSync(file.path), format: 'JPEG', quality: 0.92 });
fs.writeFileSync(outPath, jpeg);
safeUnlink(file.path);
file.path = outPath;
@@ -673,6 +673,20 @@ async function ensureEntryAiColumns() {
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_checked_at TIMESTAMPTZ`);
await pool.query(`ALTER TABLE entries ADD COLUMN IF NOT EXISTS ai_error TEXT`);
await pool.query(`CREATE INDEX IF NOT EXISTS idx_entries_ai_pending ON entries(id) WHERE ai_status = 'pending' AND deleted_at IS NULL`);
+ await pool.query(`
+ CREATE OR REPLACE FUNCTION notify_entries_changed() RETURNS trigger AS $$
+ BEGIN
+ IF (TG_OP = 'INSERT') THEN
+ PERFORM pg_notify('entries_changed', json_build_object('type', 'entry_created', 'id', NEW.id)::text);
+ ELSIF (TG_OP = 'UPDATE' AND OLD.ai_status IS DISTINCT FROM NEW.ai_status) THEN
+ PERFORM pg_notify('entries_changed', json_build_object('type', 'ai_status', 'id', NEW.id, 'status', NEW.ai_status, 'error', NEW.ai_error, 'description', NEW.description, 'description_ai', NEW.description_ai, 'description_original', NEW.description_original)::text);
+ END IF;
+ RETURN NULL;
+ END;
+ $$ LANGUAGE plpgsql
+ `);
+ await pool.query(`DROP TRIGGER IF EXISTS trg_entries_notify ON entries`);
+ await pool.query(`CREATE TRIGGER trg_entries_notify AFTER INSERT OR UPDATE OF ai_status ON entries FOR EACH ROW EXECUTE FUNCTION notify_entries_changed()`);
await pool.query(`INSERT INTO settings (key, value) VALUES ('ai_autocheck_enabled', 'true') ON CONFLICT (key) DO NOTHING`);
const marker = await getSetting('ai_autocheck_migrated', '');
if (marker !== '1') {
@@ -951,10 +965,20 @@ app.get('/api/settings', requireAdmin, async (_, res) => {
app.get('/api/public-settings', apiLimiter, async (_, res) => {
const out = await cacheWrap('public-settings', PUBLIC_TTL_MS, async () => {
- const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min'];
- const defaults = { spam_interval_min: '30' };
+ const keys = ['footer_left', 'footer_right', 'share_show_student_message', 'share_show_entry_date', 'share_show_student_names', 'share_show_group_photos', 'cookie_notice_text', 'spam_interval_min', 'photo_capture_resolution', 'photo_capture_quality'];
+ const defaults = { spam_interval_min: '30', photo_capture_resolution: '640x480', photo_capture_quality: '0.92' };
const result = {};
for (const k of keys) result[k] = await getSetting(k, defaults[k] || '');
+ const rm = /^(\d{2,5})x(\d{2,5})$/.exec(result.photo_capture_resolution);
+ if (rm) {
+ result.photo_capture_width = rm[1];
+ result.photo_capture_height = rm[2];
+ } else {
+ result.photo_capture_width = '640';
+ result.photo_capture_height = '480';
+ }
+ const q = parseFloat(result.photo_capture_quality);
+ result.photo_capture_quality = Number.isFinite(q) && q >= 0.5 && q <= 1 ? String(q) : '0.92';
return result;
});
res.json(out);
@@ -970,6 +994,17 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
}
}
+ if (key === 'photo_capture_resolution') {
+ if (!/^\d{2,5}x\d{2,5}$/.test(String(value))) {
+ return res.status(400).json({ error: 'photo_capture_resolution должен быть в формате ШИРИНАxВЫСОТА, например 640x480' });
+ }
+ }
+ if (key === 'photo_capture_quality') {
+ const q = parseFloat(String(value));
+ if (!Number.isFinite(q) || q < 0.5 || q > 1) {
+ return res.status(400).json({ error: 'photo_capture_quality должен быть числом от 0.5 до 1' });
+ }
+ }
}
const client = await pool.connect();
try {
@@ -3627,6 +3662,40 @@ app.put('/api/entries/:id/photos/:photoId/main', requireAuth, async (req, res) =
invalidateEntries();
res.json({ ok: true, photo_path: rows[0].photo_path });
});
+app.put('/api/entries/:id/photo/enhance', requireAuth, (req, res, next) => { upload.single('photo')(req, res, next); }, async (req, res) => {
+ if (req.user.role !== 'admin') {
+ const acc = await entryAccessible(req.user, req.params.id);
+ if (!acc.found) return res.status(404).json({ error: 'Not found' });
+ if (!acc.allowed) return res.status(403).json({ error: 'Нет доступа к этой записи' });
+ }
+ try {
+ const { rows: cur } = await pool.query('SELECT photo_path FROM entries WHERE id = $1', [req.params.id]);
+ if (!cur.length) {
+ if (req.file) safeUnlink(req.file.path);
+ return res.status(404).json({ error: 'Запись не найдена' });
+ }
+ const oldPath = cur[0].photo_path;
+ if (!oldPath) {
+ if (req.file) safeUnlink(req.file.path);
+ return res.status(400).json({ error: 'У записи нет фото' });
+ }
+ if (!req.file) return res.status(400).json({ error: 'Нет файла' });
+ await convertPhoto(req.file);
+ const newPath = `/uploads/${req.file.filename}`;
+ await pool.query('UPDATE entries SET photo_path = $1 WHERE id = $2', [newPath, req.params.id]);
+ await pool.query('UPDATE entry_photos SET photo_path = $1 WHERE entry_id = $2 AND photo_path = $3', [newPath, req.params.id, oldPath]);
+ safeUnlink(oldPath);
+ const oldThumb = path.join('uploads', '.thumbs', path.basename(oldPath).replace(/\.[^.]+$/, '') + '.webp');
+ safeUnlink(oldThumb);
+ await logAudit(req, 'entry.photo.enhance', { entry_id: req.params.id, old_path: oldPath, new_path: newPath });
+ invalidateEntries();
+ res.json({ ok: true, photo_path: newPath });
+ } catch (e) {
+ if (req.file) safeUnlink(req.file.path);
+ console.error('PUT /api/entries/:id/photo/enhance:', e);
+ res.status(500).json({ error: 'Ошибка замены фото' });
+ }
+});
app.delete('/api/entries/:id', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
@@ -3695,11 +3764,18 @@ function validateProfileBody(body) {
const base = String(body?.base_url || '').trim().replace(/\/+$/, '');
const model = String(body?.model || '').trim();
const apiKey = String(body?.api_key || '').trim();
+ let maxTokens = null;
+ if (body?.max_tokens !== null && body?.max_tokens !== undefined && String(body.max_tokens).trim() !== '') {
+ maxTokens = parseInt(String(body.max_tokens), 10);
+ if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 32768) {
+ return { error: 'max_tokens должен быть целым числом от 16 до 32768' };
+ }
+ }
if (!name || name.length > 100) return { error: 'Укажите название профиля (до 100 символов)' };
if (!/^https?:\/\//i.test(base) || base.length > 300) return { error: 'Base URL должен быть корректным http(s)://… (до 300 символов)' };
if (!model || model.length > 150) return { error: 'Укажите название модели (до 150 символов)' };
if (apiKey.length > 300) return { error: 'API-ключ слишком длинный' };
- return { name, base_url: base, model, api_key: apiKey };
+ return { name, base_url: base, model, api_key: apiKey, max_tokens: maxTokens };
}
app.get('/api/ai/profiles', requireAdmin, async (_, res) => {
diff --git a/worker.js b/worker.js
index ff2be29..0c14380 100644
--- a/worker.js
+++ b/worker.js
@@ -128,7 +128,12 @@ function createEntryAutoChecker({ pool, getSetting, logAudit, aiUrl, defaultProm
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
const data = await res.json();
- return (data.choices?.[0]?.message?.content || '').trim();
+ const choice = data.choices && data.choices[0];
+ const content = (choice && choice.message && choice.message.content || '').trim();
+ if (!content) {
+ throw new Error(`Модель вернула пустой ответ (finish_reason=${choice && choice.finish_reason || 'unknown'})`);
+ }
+ return content;
} finally {
clearTimeout(timer);
}