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:
@@ -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]);
|
||||
|
||||
Reference in New Issue
Block a user