feat: per-user feedback AI prompt template with placeholder keys

This commit is contained in:
dev
2026-10-06 18:55:04 +03:00
parent 285ee84c98
commit a73b1b3f8c
8 changed files with 269 additions and 8 deletions
+49 -1
View File
@@ -12,6 +12,11 @@
.shorts-list { display:flex; flex-direction:column; gap:6px; width:100%; max-height:220px; overflow:auto; padding:2px 0; }
.shorts-list .item { display:flex; gap:8px; align-items:flex-start; }
.shorts-list .item label { flex:1; font-size:13px; line-height:1.3; color:var(--text); cursor:pointer; }
.fb-modal-head { display:flex; align-items:center; justify-content:space-between; gap:8px; }
.fb-modal-head h3 { margin:0; }
.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; }
</style>
</head>
<body data-page="feedback">
@@ -56,7 +61,10 @@
<div class="modal-overlay" id="feedbackModal">
<div class="edit-modal" style="max-width:680px">
<h3 id="fbModalTitle">Добавить отзыв</h3>
<div class="fb-modal-head">
<h3 id="fbModalTitle">Добавить отзыв</h3>
<button type="button" class="icon-btn" id="fbPromptBtn" title="Настройки ИИ-промпта"><i data-lucide="settings"></i></button>
</div>
<form id="fbForm" class="settings-stack" autocomplete="off">
<input type="hidden" id="fbId">
<div class="settings-stack-grid">
@@ -122,6 +130,46 @@
</div>
</div>
<div class="modal-overlay" id="fbPromptModal">
<div class="edit-modal" style="max-width:720px">
<div class="fb-modal-head">
<h3>Настройки ИИ-промпта</h3>
<button type="button" class="icon-btn" id="fbPromptClose" title="Закрыть"><i data-lucide="x"></i></button>
</div>
<div class="settings-stack">
<div class="settings-field">
<label>Модель по умолчанию</label>
<select id="fbDefaultModel" class="settings-input"></select>
<div class="hint">Подставляется в поле «Модель ИИ» при добавлении отзыва. Пусто — активная модель системы</div>
</div>
<div class="settings-field">
<label>Шаблон промпта</label>
<textarea id="fbPromptText" class="settings-input" rows="14" 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>
</div>
<div class="ai-status" id="fbPromptStatus" hidden></div>
<div class="card-foot" style="justify-content:flex-end;gap:8px">
<button type="button" class="btn-primary ghost" id="fbPromptReset">Сбросить</button>
<button type="button" class="btn-primary" id="fbPromptSave">Сохранить</button>
</div>
</div>
</div>
</div>
<script src="vendor/lucide.min.js"></script>
<script src="js/datetime.js"></script>
<script src="admin.js"></script>
+81 -1
View File
@@ -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 = '';
let page = 1, total = 0, searchTimer, groupsLoaded, feedbacks = [], shortsListCache = null, shortsFetchTimer = null, lastFbAutoText = '', userSettings = { settings: {}, defaults: {} }, aiActive = 'native';
const topicReportCache = new Map();
function hdr() { const t = sessionStorage.getItem('authToken'); return t ? { 'X-Auth-Token': t } : {}; }
@@ -68,6 +68,69 @@
function closeModal() {
const m = document.getElementById('feedbackModal');
if (m) m.classList.remove('open');
closePromptModal();
}
function closePromptModal() {
const m = document.getElementById('fbPromptModal');
if (m) m.classList.remove('open');
}
function updatePromptCount() {
const el = document.getElementById('fbPromptCount');
const ta = document.getElementById('fbPromptText');
if (el && ta) el.textContent = `${ta.value.length} / 8000`;
}
async function loadUserSettings() {
try {
const r = await fetch(`${API}/api/user-settings`, { headers: hdr() });
if (!r.ok) return;
const j = await r.json();
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || {};
} catch (e) {}
}
function openPromptModal() {
const src = document.getElementById('fbModel');
const dst = document.getElementById('fbDefaultModel');
dst.innerHTML = '<option value="">Активная модель по умолчанию</option>' + src.innerHTML;
dst.value = 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;
updatePromptCount();
document.getElementById('fbPromptModal').classList.add('open');
if (window.lucide) lucide.createIcons();
}
async function savePromptSettings() {
const st = document.getElementById('fbPromptStatus');
const body = {
settings: {
feedback_prompt: document.getElementById('fbPromptText').value.trim(),
feedback_profile: document.getElementById('fbDefaultModel').value || '',
}
};
try {
const r = await fetch(`${API}/api/user-settings`, { method: 'PUT', headers: {...hdr(), 'Content-Type': 'application/json'}, body: JSON.stringify(body) });
const j = await r.json().catch(() => ({}));
if (!r.ok) {
st.hidden = false;
st.innerHTML = `<span class="ai-badge error">${escapeHtml(j.error || 'Не удалось сохранить')}</span>`;
return;
}
userSettings.settings = j.settings || {};
userSettings.defaults = j.defaults || userSettings.defaults;
st.hidden = false;
st.innerHTML = '<span class="ai-badge done">Сохранено</span>';
setTimeout(closePromptModal, 700);
} catch (e) {
st.hidden = false;
st.innerHTML = '<span class="ai-badge error">Не удалось сохранить</span>';
}
}
function todayIso() {
@@ -170,6 +233,7 @@
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;
aiActive = d.active || 'native';
}
} catch (e) {}
}
@@ -226,6 +290,10 @@
document.getElementById('fbTextHint').textContent = '0 / 5000';
document.getElementById('fbAiCheck').checked = true;
document.getElementById('fbAiStatus').hidden = true;
const ms = document.getElementById('fbModel');
const pref = userSettings.settings.feedback_profile;
if (pref && [...ms.options].some(o => o.value === pref)) ms.value = pref;
else if (aiActive && [...ms.options].some(o => o.value === aiActive)) ms.value = aiActive;
document.getElementById('fbModalTitle').textContent = 'Добавить отзыв';
document.getElementById('fbSubmit').textContent = 'Отправить в очередь';
}
@@ -309,6 +377,7 @@
await checkAuth().catch(()=>{});
buildSidebar('feedback');
await loadGroups();
await loadUserSettings();
await loadAiProfiles();
await loadResidentNames();
loadTopics();
@@ -316,6 +385,17 @@
connectFeedbackLive();
document.getElementById('addFeedbackBtn').addEventListener('click', () => openModal({}));
document.getElementById('fbCancel').addEventListener('click', closeModal);
document.getElementById('fbPromptBtn').addEventListener('click', openPromptModal);
document.getElementById('fbPromptClose').addEventListener('click', closePromptModal);
document.getElementById('fbPromptModal').addEventListener('click', e => { if (e.target.id === 'fbPromptModal') closePromptModal(); });
document.getElementById('fbPromptText').addEventListener('input', updatePromptCount);
document.getElementById('fbPromptReset').addEventListener('click', () => {
document.getElementById('fbPromptText').value = '';
document.getElementById('fbDefaultModel').value = '';
document.getElementById('fbPromptStatus').hidden = true;
updatePromptCount();
});
document.getElementById('fbPromptSave').addEventListener('click', savePromptSettings);
document.getElementById('feedbackModal').addEventListener('click', e => { if (e.target.id === 'feedbackModal') closeModal(); });
document.getElementById('fbForm').addEventListener('submit', async e => {
e.preventDefault();