fix(ai): adaptive max_tokens per backend (reasoning profiles get 1024+, native llama.cpp stays compact; per-profile max_tokens override); include entry texts in ai_status SSE notify so journal updates live without reload; audit log action filter; links pagination; settings validation & UI improvements

This commit is contained in:
dev
2026-09-17 14:12:36 +03:00
parent a93e37a6b1
commit 57e48a9d4f
12 changed files with 676 additions and 211 deletions
+72 -10
View File
@@ -44,7 +44,7 @@ lister.on('notification', (msg) => {
try { payload = JSON.parse(msg.payload || '{}'); } catch (e) { payload = null; }
const type = payload && payload.type ? payload.type : 'entry_created';
if (type === 'ai_status') {
broadcastAiStatus(payload.id, payload.status, payload.error);
broadcastAiStatus(payload);
} else {
broadcastEntryChanged();
}
@@ -105,8 +105,17 @@ function broadcastEntryChanged() {
}
}
function broadcastAiStatus(entryId, status, error) {
const frame = `event: ai_status\ndata: ${JSON.stringify({ id: entryId, ai_status: status, ai_error: error || null, ts: Date.now() })}\n\n`;
function broadcastAiStatus(payload) {
const data = {
id: payload.id,
ai_status: payload.status,
ai_error: payload.error || null,
description: payload.description === undefined ? null : payload.description,
description_ai: payload.description_ai === undefined ? null : payload.description_ai,
description_original: payload.description_original === undefined ? null : payload.description_original,
ts: Date.now()
};
const frame = `event: ai_status\ndata: ${JSON.stringify(data)}\n\n`;
for (const client of sseClients) {
try { client.write(frame); } catch (e) { sseClients.delete(client); }
}
@@ -325,7 +334,7 @@ function getDiskInfo() {
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
};
}
try {
@@ -344,7 +353,7 @@ function getDiskInfo() {
used_bytes: used,
free: formatBytes(free),
free_bytes: free,
used_pct: Math.round((used / total) * 100),
used_pct: Math.min(100, Math.max(0, Math.round((used / total) * 100))),
};
}
} catch {}
@@ -957,8 +966,8 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
for (const [key, value] of Object.entries(settings)) {
if (key === 'spam_interval_min') {
const n = parseInt(String(value), 10);
if (!Number.isFinite(n) || n < 1) {
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом ≥ 1' });
if (!Number.isFinite(n) || n < 1 || n > 10080) {
return res.status(400).json({ error: 'spam_interval_min должен быть целым числом от 1 до 10080 (7 дней)' });
}
}
}
@@ -990,11 +999,19 @@ app.put('/api/settings', requireAdmin, async (req, res) => {
app.get('/api/audit', requireAdmin, async (req, res) => {
const limit = Math.min(parseInt(req.query.limit, 10) || 100, 1000);
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
let where = '';
const params = [];
const action = typeof req.query.action === 'string' && req.query.action.trim() ? req.query.action.trim() : null;
if (action) {
where = 'WHERE a.action = $1';
params.push(action);
}
params.push(limit, offset);
const { rows } = await pool.query(
`SELECT a.id, a.action, a.target, a.ip, a.created_at, a.user_id, u.username AS user_name
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC LIMIT $1 OFFSET $2`,
[limit, offset]
${where} ORDER BY a.id DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
);
res.json(rows);
});
@@ -1043,12 +1060,14 @@ async function aiCorrectText(text) {
let url = `${AI_URL.replace(/\/+$/, '')}/v1/chat/completions`;
let model = AI_MODEL;
const headers = { 'Content-Type': 'application/json' };
let maxTokens = Math.min(256, Math.max(128, text.length + 64));
if (profile) {
const base = normalizeOpenAiBase(profile.base_url);
if (!base) throw new Error('Некорректный base_url профиля ИИ');
url = `${base}/chat/completions`;
model = profile.model;
if (profile.api_key) headers.Authorization = `Bearer ${profile.api_key}`;
maxTokens = parseInt(profile.max_tokens, 10) || Math.min(4096, Math.max(1024, text.length * 2 + 512));
}
try {
const systemPrompt = await getAiPrompt();
@@ -1062,7 +1081,7 @@ async function aiCorrectText(text) {
{ role: 'user', content: text }
],
temperature: 0.1,
max_tokens: 512
max_tokens: maxTokens
})
});
if (!res.ok) throw new Error(`AI service error: ${res.status}`);
@@ -1527,6 +1546,24 @@ app.get('/api/links', requireAuth, async (req, res) => {
where = `WHERE l.group_id IS NULL AND 1 = 0`;
}
}
const limit = optInt(req.query.limit, 1, 200);
const offset = optInt(req.query.offset, 0, Infinity) || 0;
if (limit != null) {
params.push(limit);
params.push(offset);
const { rows: totalRows } = await pool.query(
`SELECT COUNT(*)::int AS total FROM share_links l
LEFT JOIN groups g ON g.id = l.group_id ${where}`,
params.slice(0, params.length - 2)
);
const { rows } = await pool.query(
`SELECT l.*, g.name AS group_name FROM share_links l
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC LIMIT $${params.length - 1} OFFSET $${params.length}`,
params
);
rows.forEach(normDates);
return res.json({ items: rows, total: totalRows[0].total });
}
const { rows } = await pool.query(
`SELECT l.*, g.name AS group_name FROM share_links l
LEFT JOIN groups g ON g.id = l.group_id ${where} ORDER BY l.created_at DESC`,
@@ -2905,6 +2942,31 @@ app.get('/api/entries', requireAuth, async (req, res) => {
res.json({ entries: rows, total });
});
app.get('/api/entries/:id', requireAuth, 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: 'Нет доступа к этой записи' });
}
const { rows } = await pool.query(
'SELECT e.*, g.name AS group_name FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1',
[req.params.id]
);
if (!rows.length) return res.status(404).json({ error: 'Not found' });
const entry = rows[0];
const fRes = await pool.query(
'SELECT id, entry_id, token, name FROM project_files WHERE entry_id = $1 ORDER BY id',
[entry.id]
);
entry.files = fRes.rows;
const pRes = await pool.query(
'SELECT id, entry_id, photo_path, caption, sort_order FROM entry_photos WHERE entry_id = $1 ORDER BY sort_order, id',
[entry.id]
);
entry.photos = pRes.rows;
res.json(entry);
});
app.get('/api/entries/:id/files', requireAuth, async (req, res) => {
if (req.user.role !== 'admin') {
const { rows } = await pool.query(`SELECT e.group_id FROM entries e JOIN groups g ON g.id = e.group_id WHERE e.id = $1`, [req.params.id]);