feat(feedback): personal AI models, AI run tracking and profiles/options endpoint
- user_settings allowlist: feedback_models (up to 10 personal model configs) - normalizeFeedbackModels() validation, UI block "Мои модели" in #fbPromptModal - GET /api/ai/profiles/options for tutors (no api_key), used by feedback/shorts - feedbacks columns ai_prompt/ai_model/ai_duration_ms + backup/restore support - "ИИ-запрос" popover on feedback card with status, model, timing and prompt - audit: feedback.update / feedback.ai.run / feedback.ai.error labels
This commit is contained in:
+81
-15
@@ -17,6 +17,26 @@
|
||||
.fb-keys { display:flex; flex-direction:column; gap:6px; }
|
||||
.fb-keys .fb-key { display:flex; gap:10px; align-items:baseline; font-size:13px; line-height:1.35; }
|
||||
.fb-keys code { background:rgba(127,127,127,.12); border:1px solid var(--border); border-radius:6px; padding:1px 6px; font-size:12px; white-space:nowrap; }
|
||||
.fb-keys-details { border:1px solid var(--border); border-radius:10px; background:rgba(127,127,127,.05); }
|
||||
.fb-keys-details summary { cursor:pointer; list-style:none; display:flex; align-items:center; gap:8px; padding:10px 14px; font-size:.86rem; font-weight:600; color:var(--text); user-select:none; }
|
||||
.fb-keys-details summary::-webkit-details-marker { display:none; }
|
||||
.fb-keys-details summary::before { content:''; width:7px; height:7px; flex:none; border-right:2px solid var(--muted); border-bottom:2px solid var(--muted); transform:rotate(-45deg); transition:transform .15s; margin-left:2px; }
|
||||
.fb-keys-details[open] summary::before { transform:rotate(45deg); }
|
||||
.fb-keys-details[open] summary { border-bottom:1px solid var(--border); }
|
||||
.fb-keys-details .fb-keys-body { display:flex; flex-direction:column; gap:10px; padding:12px 14px; }
|
||||
#fbPromptText { min-height:48vh; resize:vertical; }
|
||||
#fbPromptModal { overflow:auto; align-items:flex-start; padding:4vh 16px; }
|
||||
#fbPromptModal > .edit-modal { margin:auto; }
|
||||
.feedback-item { position:relative; }
|
||||
.fb-ai-pop { position:absolute; left:12px; right:12px; top:calc(100% + 6px); z-index:40; display:flex; flex-direction:column; gap:8px; background:var(--card); border:1px solid var(--border); border-radius:var(--radius); box-shadow:0 14px 34px rgba(0,0,0,.28); padding:14px 16px; }
|
||||
.fb-ai-pop[hidden] { display:none; }
|
||||
.fb-ai-row { display:flex; align-items:center; gap:10px; font-size:.85rem; flex-wrap:wrap; }
|
||||
.fb-ai-row > span { color:var(--muted); min-width:118px; }
|
||||
.fb-ai-row .ai-badge { margin-left:0; }
|
||||
.fb-ai-label { font-size:.75rem; font-weight:600; color:var(--muted); text-transform:uppercase; letter-spacing:.04em; }
|
||||
.fb-ai-pop pre { margin:0; max-height:240px; overflow:auto; white-space:pre-wrap; word-break:break-word; font-size:12px; line-height:1.45; background:rgba(127,127,127,.08); border:1px solid var(--border); border-radius:8px; padding:8px 10px; }
|
||||
.fb-ai-err { font-size:.83rem; line-height:1.4; color:#ef4444; background:rgba(239,68,68,.08); border:1px solid rgba(239,68,68,.3); border-radius:8px; padding:8px 10px; white-space:pre-wrap; }
|
||||
.fb-ai-empty { font-size:.83rem; color:var(--muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="feedback">
|
||||
@@ -60,7 +80,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="feedbackModal">
|
||||
<div class="edit-modal" style="max-width:680px">
|
||||
<div class="edit-modal" style="max-width:960px;width:96%;max-height:92dvh;overflow-y:auto">
|
||||
<div class="fb-modal-head">
|
||||
<h3 id="fbModalTitle">Добавить отзыв</h3>
|
||||
<button type="button" class="icon-btn" id="fbPromptBtn" title="Настройки ИИ-промпта"><i data-lucide="settings"></i></button>
|
||||
@@ -131,7 +151,7 @@
|
||||
</div>
|
||||
|
||||
<div class="modal-overlay" id="fbPromptModal">
|
||||
<div class="edit-modal" style="max-width:720px">
|
||||
<div class="edit-modal" style="max-width:900px">
|
||||
<div class="fb-modal-head">
|
||||
<h3>Настройки ИИ-промпта</h3>
|
||||
<button type="button" class="icon-btn" id="fbPromptClose" title="Закрыть"><i data-lucide="x"></i></button>
|
||||
@@ -142,24 +162,70 @@
|
||||
<select id="fbDefaultModel" class="settings-input"></select>
|
||||
<div class="hint">Подставляется в поле «Модель ИИ» при добавлении отзыва. Пусто — активная модель системы</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<details class="fb-keys-details" id="fbModelsDetails">
|
||||
<summary>Мои модели<span id="fbModelsCount" class="hint"></span></summary>
|
||||
<div class="fb-keys-body">
|
||||
<div id="fbModelsList"></div>
|
||||
<div class="hint">Свои подключения к ИИ. Личные модели идут в списках первыми, системные остаются запасными</div>
|
||||
<div id="fbModelEdit" class="settings-stack" hidden>
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>Название</label>
|
||||
<input id="fbMName" class="settings-input" maxlength="64" placeholder="Моя модель">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Модель</label>
|
||||
<input id="fbMModel" class="settings-input" maxlength="100" placeholder="gpt-oss:120b">
|
||||
</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Base URL</label>
|
||||
<input id="fbMUrl" class="settings-input" maxlength="300" placeholder="http://host:11434/v1">
|
||||
</div>
|
||||
<div class="settings-stack-grid">
|
||||
<div class="settings-field">
|
||||
<label>API-ключ</label>
|
||||
<input id="fbMKey" class="settings-input" maxlength="256" placeholder="необязательно">
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>max_tokens</label>
|
||||
<input id="fbMMaxTok" class="settings-input" type="number" min="16" max="131072" placeholder="авто">
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
<button type="button" class="btn-primary ghost" id="fbMCancel">Отмена</button>
|
||||
<button type="button" class="btn-primary" id="fbMSave">Готово</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="btn-primary ghost" id="fbModelAdd">+ Добавить модель</button>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Шаблон промпта</label>
|
||||
<textarea id="fbPromptText" class="settings-input" rows="14" maxlength="8000" placeholder="Загрузка…"></textarea>
|
||||
<textarea id="fbPromptText" class="settings-input" rows="24" maxlength="8000" placeholder="Загрузка…"></textarea>
|
||||
<div class="hint"><span id="fbPromptCount">0 / 8000</span> · Пусто — используется системный шаблон</div>
|
||||
</div>
|
||||
<div class="settings-field">
|
||||
<label>Ключи подстановки в шаблон</label>
|
||||
<div class="fb-keys">
|
||||
<div class="fb-key"><code>{group}</code><span>название группы</span></div>
|
||||
<div class="fb-key"><code>{date}</code><span>дата занятия (ГГГГ-ММ-ДД)</span></div>
|
||||
<div class="fb-key"><code>{resident}</code><span>фамилия и имя резидента</span></div>
|
||||
<div class="fb-key"><code>{topic}</code><span>тема занятия</span></div>
|
||||
<div class="fb-key"><code>{past_review}</code><span>прошлый отзыв, или «нет», если его нет</span></div>
|
||||
<div class="fb-key"><code>{shorts}</code><span>выбранные короткие сообщения: «- дата – тема – текст»</span></div>
|
||||
<div class="fb-key"><code>{context}</code><span>стандартный блок контекста занятия целиком: группа, дата, резидент, тема, прошлый отзыв, короткие сообщения</span></div>
|
||||
<div class="fb-key"><code>{text}</code><span>черновик текста отзыва из формы</span></div>
|
||||
</div>
|
||||
<div class="hint">Если шаблон содержит хотя бы один ключ — данные подставляются по ним, и автоматический блок «КОНТЕКСТ» в конец промпта не добавляется. Без ключей блок «КОНТЕКСТ» добавляется в конец автоматически.</div>
|
||||
<details class="fb-keys-details" id="fbPromptKeys">
|
||||
<summary>Ключи подстановки в шаблон</summary>
|
||||
<div class="fb-keys-body">
|
||||
<div class="fb-keys">
|
||||
<div class="fb-key"><code>{group}</code><span>название группы</span></div>
|
||||
<div class="fb-key"><code>{date}</code><span>дата занятия (ГГГГ-ММ-ДД)</span></div>
|
||||
<div class="fb-key"><code>{resident}</code><span>фамилия и имя резидента</span></div>
|
||||
<div class="fb-key"><code>{topic}</code><span>тема занятия</span></div>
|
||||
<div class="fb-key"><code>{past_review}</code><span>прошлый отзыв, или «нет», если его нет</span></div>
|
||||
<div class="fb-key"><code>{shorts}</code><span>выбранные короткие сообщения: «- дата – тема – текст»</span></div>
|
||||
<div class="fb-key"><code>{context}</code><span>стандартный блок контекста занятия целиком: группа, дата, резидент, тема, прошлый отзыв, короткие сообщения</span></div>
|
||||
<div class="fb-key"><code>{text}</code><span>черновик текста отзыва из формы</span></div>
|
||||
</div>
|
||||
<div class="hint">Если шаблон содержит хотя бы один ключ — данные подставляются по ним, и автоматический блок «КОНТЕКСТ» в конец промпта не добавляется. Без ключей блок «КОНТЕКСТ» добавляется в конец автоматически.</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="ai-status" id="fbPromptStatus" hidden></div>
|
||||
<div class="card-foot" style="justify-content:flex-end;gap:8px">
|
||||
|
||||
@@ -94,6 +94,9 @@ const ACTION_LABELS = {
|
||||
'lesson_report.ai.error': 'Ошибка проверки отчёта по шаблону',
|
||||
'lesson_report.ai.revert': 'Возвращён оригинал отчёта',
|
||||
'lesson_report.version.restore': 'Восстановлена версия отчёта',
|
||||
'feedback.update': 'Изменён отзыв',
|
||||
'feedback.ai.run': 'ИИ сформировал отзыв',
|
||||
'feedback.ai.error': 'Ошибка ИИ при формировании отзыва',
|
||||
};
|
||||
|
||||
const AUDIT_PAGE_SIZE = 50;
|
||||
|
||||
+205
-10
@@ -1,7 +1,7 @@
|
||||
(async function() {
|
||||
const API = location.origin;
|
||||
const PAGE_SIZE = 20;
|
||||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native';
|
||||
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native', myModels = [], modelEditIdx = null;
|
||||
const topicReportCache = new Map();
|
||||
|
||||
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
|
||||
@@ -27,6 +27,7 @@
|
||||
const text = (f.text||'').trim();
|
||||
const del = f.deleted_at ? ' style="opacity:.5"' : '';
|
||||
const day = fmtDay(f.feedback_date);
|
||||
const hasAiInfo = f.ai_status !== 'none' || !!f.ai_prompt;
|
||||
return `<div class="lesson-item feedback-item" data-id="${f.id}"${del}>
|
||||
<div class="lesson-item-head">
|
||||
<span class="lesson-author">${escapeHtml(f.group_name||'')}</span>
|
||||
@@ -35,6 +36,7 @@
|
||||
${aiBadge(f.ai_status)}
|
||||
${f.sent_at ? '<span class="ai-badge ai-badge-sent">Отправлено</span>' : ''}
|
||||
<div class="lesson-actions">
|
||||
${hasAiInfo ? `<button type="button" class="btn-link" data-aiinfo="${f.id}">ИИ-запрос</button>` : ''}
|
||||
${f.ai_status === 'error' ? `<button type="button" class="btn-link" data-retry="${f.id}">Повторить</button>` : ''}
|
||||
${f.deleted_at ? '' : `<button type="button" class="btn-link" data-sent="${f.id}">${f.sent_at ? 'Вернуть' : 'Отправлено'}</button>`}
|
||||
<button type="button" class="btn-link" data-edit="${f.id}">Изменить</button>
|
||||
@@ -45,9 +47,46 @@
|
||||
${f.resident ? `<div class="lesson-group">${escapeHtml(f.resident)}</div>` : ''}
|
||||
${f.topic ? `<div class="lesson-topic">${escapeHtml(f.topic)}</div>` : ''}
|
||||
${text ? `<div class="lesson-text">${escapeHtml(text)}</div>` : ''}
|
||||
<div class="fb-ai-pop" id="fbAiPop-${f.id}" hidden>${fbAiInfoHtml(f)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function fmtDurMs(ms) {
|
||||
if (!Number.isFinite(ms) || ms < 0) return '';
|
||||
return ms >= 1000 ? `${(ms / 1000).toFixed(1)} с` : `${Math.round(ms)} мс`;
|
||||
}
|
||||
|
||||
function fmtTs(v) {
|
||||
if (!v) return '';
|
||||
try { if (typeof fmtFull === 'function') return fmtFull(v) || String(v); } catch (e) {}
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function fbAiInfoHtml(f) {
|
||||
let html = `<div class="fb-ai-row"><span>Статус</span>${aiBadge(f.ai_status)}</div>`;
|
||||
html += `<div class="fb-ai-row"><span>Модель</span><b>${escapeHtml(f.ai_model || '—')}</b></div>`;
|
||||
const dur = fmtDurMs(f.ai_duration_ms);
|
||||
html += `<div class="fb-ai-row"><span>Время ответа</span><b>${escapeHtml(dur || '—')}</b></div>`;
|
||||
if (f.ai_checked_at) html += `<div class="fb-ai-row"><span>Проверено</span><b>${escapeHtml(fmtTs(f.ai_checked_at))}</b></div>`;
|
||||
if (f.ai_error) html += `<div class="fb-ai-err"><b>Ошибка:</b> ${escapeHtml(f.ai_error)}</div>`;
|
||||
if (f.ai_prompt) {
|
||||
html += `<div class="fb-ai-label">Промпт, отправленный в модель</div><pre>${escapeHtml(f.ai_prompt)}</pre>`;
|
||||
} else {
|
||||
html += '<div class="fb-ai-empty">Нет данных о запуске ИИ</div>';
|
||||
}
|
||||
return html;
|
||||
}
|
||||
|
||||
function closeAiPops() {
|
||||
document.querySelectorAll('.fb-ai-pop').forEach(p => { p.hidden = true; });
|
||||
}
|
||||
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('[data-aiinfo]') || e.target.closest('.fb-ai-pop')) return;
|
||||
closeAiPops();
|
||||
});
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeAiPops(); });
|
||||
|
||||
function ensureModal() {
|
||||
let m = document.getElementById('feedbackModal');
|
||||
if (m) return m;
|
||||
@@ -89,18 +128,120 @@
|
||||
const j = await r.json();
|
||||
userSettings.settings = j.settings || {};
|
||||
userSettings.defaults = j.defaults || {};
|
||||
parseMyModels();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
function openPromptModal() {
|
||||
function parseMyModels() {
|
||||
try { myModels = JSON.parse(userSettings.settings.feedback_models || '[]'); } catch (e) { myModels = []; }
|
||||
if (!Array.isArray(myModels)) myModels = [];
|
||||
}
|
||||
|
||||
function renderModelsList() {
|
||||
const el = document.getElementById('fbModelsList');
|
||||
if (!el) return;
|
||||
const cnt = document.getElementById('fbModelsCount');
|
||||
if (cnt) cnt.textContent = myModels.length ? ` (${myModels.length})` : '';
|
||||
if (!myModels.length) {
|
||||
el.innerHTML = '<div class="hint">Своих моделей нет — используются системные</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = myModels.map((m, i) => `
|
||||
<div style="display:flex;justify-content:space-between;gap:8px;align-items:center;padding:4px 0">
|
||||
<span>${escapeHtml(m.name)} — <span class="muted">${escapeHtml(m.model)}</span></span>
|
||||
<span style="display:flex;gap:6px">
|
||||
<button type="button" class="btn-xs" data-medit="${i}">Изменить</button>
|
||||
<button type="button" class="btn-xs" data-mdel="${i}">Удалить</button>
|
||||
</span>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function showModelForm(idx) {
|
||||
modelEditIdx = idx;
|
||||
const m = idx == null ? null : myModels[idx];
|
||||
document.getElementById('fbMName').value = m ? m.name : '';
|
||||
document.getElementById('fbMModel').value = m ? m.model : '';
|
||||
document.getElementById('fbMUrl').value = m ? m.base_url : '';
|
||||
document.getElementById('fbMKey').value = m ? (m.api_key || '') : '';
|
||||
document.getElementById('fbMMaxTok').value = m && m.max_tokens ? m.max_tokens : '';
|
||||
document.getElementById('fbModelEdit').hidden = false;
|
||||
document.getElementById('fbMName').focus();
|
||||
}
|
||||
|
||||
function hideModelForm() {
|
||||
const el = document.getElementById('fbModelEdit');
|
||||
if (el) el.hidden = true;
|
||||
modelEditIdx = null;
|
||||
}
|
||||
|
||||
function saveModelForm() {
|
||||
const name = document.getElementById('fbMName').value.trim();
|
||||
const model = document.getElementById('fbMModel').value.trim();
|
||||
const base = document.getElementById('fbMUrl').value.trim().replace(/\/+$/, '');
|
||||
const key = document.getElementById('fbMKey').value.trim();
|
||||
const tok = document.getElementById('fbMMaxTok').value.trim();
|
||||
if (!name) return alert('Укажите название модели');
|
||||
if (!model) return alert('Укажите имя модели');
|
||||
try {
|
||||
const u = new URL(base);
|
||||
if (u.protocol !== 'http:' && u.protocol !== 'https:') throw new Error('bad');
|
||||
} catch (e) { return alert('Base URL должен начинаться с http:// или https://'); }
|
||||
let maxTokens = null;
|
||||
if (tok) {
|
||||
maxTokens = parseInt(tok, 10);
|
||||
if (!Number.isFinite(maxTokens) || maxTokens < 16 || maxTokens > 131072) return alert('max_tokens: 16–131072');
|
||||
}
|
||||
if (modelEditIdx == null && myModels.length >= 10) return alert('Не больше 10 моделей');
|
||||
const entry = {
|
||||
id: modelEditIdx == null ? `me-${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}` : myModels[modelEditIdx].id,
|
||||
name, model, base_url: base, api_key: key, max_tokens: maxTokens
|
||||
};
|
||||
if (modelEditIdx == null) myModels.push(entry); else myModels[modelEditIdx] = entry;
|
||||
hideModelForm();
|
||||
renderModelsList();
|
||||
refreshModelSelects();
|
||||
}
|
||||
|
||||
function deleteModel(i) {
|
||||
const m = myModels[i];
|
||||
if (!m) return;
|
||||
if (!confirm(`Удалить модель «${m.name}»?`)) return;
|
||||
myModels.splice(i, 1);
|
||||
const dst = document.getElementById('fbDefaultModel');
|
||||
if (dst && dst.value === m.id) dst.value = '';
|
||||
renderModelsList();
|
||||
refreshModelSelects();
|
||||
}
|
||||
|
||||
function fillDefaultModelOptions(preserve) {
|
||||
const src = document.getElementById('fbModel');
|
||||
const dst = document.getElementById('fbDefaultModel');
|
||||
if (!src || !dst) return;
|
||||
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
|
||||
dst.value = userSettings.settings.feedback_profile || '';
|
||||
dst.value = preserve || '';
|
||||
if (preserve && dst.value !== preserve) {
|
||||
const o = document.createElement('option');
|
||||
o.value = preserve;
|
||||
o.textContent = `${preserve} (недоступна)`;
|
||||
dst.appendChild(o);
|
||||
dst.value = preserve;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshModelSelects() {
|
||||
const keep = document.getElementById('fbDefaultModel').value;
|
||||
await loadAiProfiles();
|
||||
fillDefaultModelOptions(keep);
|
||||
}
|
||||
|
||||
function openPromptModal() {
|
||||
fillDefaultModelOptions(userSettings.settings.feedback_profile);
|
||||
const ta = document.getElementById('fbPromptText');
|
||||
ta.value = userSettings.settings.feedback_prompt || '';
|
||||
ta.placeholder = userSettings.defaults.feedback_prompt || 'Системный шаблон';
|
||||
document.getElementById('fbPromptStatus').hidden = true;
|
||||
hideModelForm();
|
||||
renderModelsList();
|
||||
updatePromptCount();
|
||||
document.getElementById('fbPromptModal').classList.add('open');
|
||||
if (window.lucide) lucide.createIcons();
|
||||
@@ -108,10 +249,16 @@
|
||||
|
||||
async function savePromptSettings() {
|
||||
const st = document.getElementById('fbPromptStatus');
|
||||
if (!document.getElementById('fbModel').options.length) {
|
||||
st.hidden = false;
|
||||
st.innerHTML = '<span class="ai-badge error">Список моделей не загружен — сохранение отменено</span>';
|
||||
return;
|
||||
}
|
||||
const body = {
|
||||
settings: {
|
||||
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
|
||||
feedback_profile: document.getElementById('fbDefaultModel').value || '',
|
||||
feedback_models: myModels.length ? JSON.stringify(myModels) : '',
|
||||
}
|
||||
};
|
||||
try {
|
||||
@@ -124,6 +271,9 @@
|
||||
}
|
||||
userSettings.settings = j.settings || {};
|
||||
userSettings.defaults = j.defaults || userSettings.defaults;
|
||||
parseMyModels();
|
||||
renderModelsList();
|
||||
await loadAiProfiles();
|
||||
st.hidden = false;
|
||||
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
|
||||
setTimeout(closePromptModal, 700);
|
||||
@@ -224,16 +374,32 @@
|
||||
|
||||
async function loadAiProfiles() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
const r = await fetch(`${API}/api/ai/profiles/options`, { headers: hdr() });
|
||||
if (r.ok) {
|
||||
const d = await r.json();
|
||||
const sel = document.getElementById('fbModel');
|
||||
if (!sel) return;
|
||||
const keep = sel.value;
|
||||
sel.innerHTML = '';
|
||||
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sel.appendChild(nativeOpt);
|
||||
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sel.appendChild(o); });
|
||||
if (d.active) sel.value = d.active;
|
||||
if (myModels.length) {
|
||||
const mine = document.createElement('optgroup');
|
||||
mine.label = 'Мои модели';
|
||||
myModels.forEach(m => {
|
||||
const o = document.createElement('option');
|
||||
o.value = m.id;
|
||||
o.textContent = m.name;
|
||||
mine.appendChild(o);
|
||||
});
|
||||
sel.appendChild(mine);
|
||||
}
|
||||
const sys = document.createElement('optgroup');
|
||||
sys.label = 'Системные';
|
||||
const nativeOpt = document.createElement('option'); nativeOpt.value = 'native'; nativeOpt.textContent = 'Нативная (llama.cpp)'; sys.appendChild(nativeOpt);
|
||||
(d.profiles||[]).forEach(p => { const o = document.createElement('option'); o.value = p.id; o.textContent = p.name; sys.appendChild(o); });
|
||||
sel.appendChild(sys);
|
||||
aiActive = d.active || 'native';
|
||||
if (keep && [...sel.options].some(o => o.value === keep)) sel.value = keep;
|
||||
else sel.value = aiActive;
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
@@ -317,6 +483,8 @@
|
||||
lastFbAutoText = '';
|
||||
document.getElementById('fbTextHint').textContent = `${(f.text||'').length} / 5000`;
|
||||
document.getElementById('fbAiCheck').checked = (f.ai_status === 'pending' || f.ai_status === 'processing' || f.ai_status === 'none') ? true : false;
|
||||
const fms = document.getElementById('fbModel');
|
||||
if (f.ai_profile && [...fms.options].some(o => o.value === f.ai_profile)) fms.value = f.ai_profile;
|
||||
document.getElementById('fbModalTitle').textContent = 'Редактировать отзыв';
|
||||
document.getElementById('fbSubmit').textContent = 'Сохранить';
|
||||
if (f.short_message_ids && f.short_message_ids.length) {
|
||||
@@ -396,6 +564,15 @@
|
||||
updatePromptCount();
|
||||
});
|
||||
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
|
||||
document.getElementById('fbModelAdd').addEventListener('click', () => showModelForm(null));
|
||||
document.getElementById('fbMCancel').addEventListener('click', hideModelForm);
|
||||
document.getElementById('fbMSave').addEventListener('click', saveModelForm);
|
||||
document.getElementById('fbModelsList').addEventListener('click', e => {
|
||||
const ed = e.target.closest('[data-medit]');
|
||||
if (ed) { showModelForm(parseInt(ed.dataset.medit, 10)); return; }
|
||||
const del = e.target.closest('[data-mdel]');
|
||||
if (del) deleteModel(parseInt(del.dataset.mdel, 10));
|
||||
});
|
||||
document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); });
|
||||
document.getElementById('fbForm').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
@@ -436,6 +613,11 @@
|
||||
});
|
||||
document.getElementById('fbTopic').addEventListener('change', applyTopicText);
|
||||
document.getElementById('feedbackList').addEventListener('click', async e => {
|
||||
const aiinfo = e.target.closest('[data-aiinfo]'); if (aiinfo) {
|
||||
const pop = document.getElementById(`fbAiPop-${aiinfo.dataset.aiinfo}`);
|
||||
if (pop) { const wasOpen = !pop.hidden; closeAiPops(); pop.hidden = wasOpen; }
|
||||
return;
|
||||
}
|
||||
const sentBtn = e.target.closest('[data-sent]'); if (sentBtn) {
|
||||
const id = parseInt(sentBtn.dataset.sent, 10);
|
||||
if (!id) return;
|
||||
@@ -448,14 +630,27 @@
|
||||
const del = e.target.closest('[data-del]'); if (del) {
|
||||
const id = parseInt(del.dataset.id||del.dataset.del,10);
|
||||
if (!id) return;
|
||||
if (!confirm('Удалить отзыв?')) return;
|
||||
try { const r = await fetch(`${API}/api/feedbacks/${id}`, { method:'DELETE', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
|
||||
const row = feedbacks.find(x => x.id === id);
|
||||
const perm = !!(row && row.deleted_at);
|
||||
if (!confirm(perm ? 'Удалить отзыв навсегда? Это действие нельзя отменить.' : 'Удалить отзыв?')) return;
|
||||
const url = perm ? `${API}/api/feedbacks/${id}/permanent` : `${API}/api/feedbacks/${id}`;
|
||||
try {
|
||||
const r = await fetch(url, { method:'DELETE', headers: hdr() });
|
||||
if (r.ok) { loadFeedbacks(); return; }
|
||||
const err = await r.json().catch(() => null);
|
||||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||||
} catch (err) { alert('Ошибка сети'); }
|
||||
return;
|
||||
}
|
||||
const ret = e.target.closest('[data-restore]'); if (ret) {
|
||||
const id = parseInt(ret.dataset.id||ret.dataset.restore,10);
|
||||
if (!id) return;
|
||||
try { const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() }); if (r.ok) loadFeedbacks(); } catch (e) {}
|
||||
try {
|
||||
const r = await fetch(`${API}/api/feedbacks/${id}/restore`, { method:'PUT', headers: hdr() });
|
||||
if (r.ok) { loadFeedbacks(); return; }
|
||||
const err = await r.json().catch(() => null);
|
||||
alert((err && err.error) || `Ошибка ${r.status}`);
|
||||
} catch (err) { alert('Ошибка сети'); }
|
||||
return;
|
||||
}
|
||||
const retry = e.target.closest('[data-retry]'); if (retry) {
|
||||
|
||||
+1
-1
@@ -292,7 +292,7 @@ async function openShortsAi() {
|
||||
document.getElementById('aiCorrectModal').classList.add('open');
|
||||
const sel = document.getElementById('aiModelSelect');
|
||||
try {
|
||||
const res = await fetch(`${API}/api/ai/profiles`, { headers: hdr() });
|
||||
const res = await fetch(`${API}/api/ai/profiles/options`, { headers: hdr() });
|
||||
if (!res.ok) throw new Error('Профили недоступны');
|
||||
const data = await res.json();
|
||||
const profiles = data.profiles || [];
|
||||
|
||||
@@ -424,7 +424,7 @@
|
||||
<div class="settings-field">
|
||||
<label for="aiProfileMaxTokens">Max tokens (лимит ответа модели)</label>
|
||||
<input type="number" id="aiProfileMaxTokens" class="settings-input" min="16" max="32768" placeholder="Авто (1024–4096)">
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 1024+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
<span class="hint">Оставьте пустым для авто. Для reasoning-моделей (gpt-oss, deepseek-r1) нужно 4096+ — иначе рассуждения не уместятся и ответ придёт пустым.</span>
|
||||
</div>
|
||||
<div class="ai-test-status" id="aiProfileTestStatus"></div>
|
||||
<div class="actions" style="flex-wrap:wrap">
|
||||
|
||||
Reference in New Issue
Block a user