434 lines
15 KiB
HTML
434 lines
15 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Настройки ИИ — промпты{% endblock %}
|
|
|
|
{% block head %}
|
|
<style>
|
|
.pe-wrap {
|
|
border: 1px solid #d1d5db;
|
|
border-radius: 0.625rem;
|
|
background: #fff;
|
|
transition: border-color 0.15s, box-shadow 0.15s;
|
|
}
|
|
.pe-wrap:focus-within {
|
|
border-color: #00645b;
|
|
box-shadow: 0 0 0 3px rgba(0,100,91,0.12);
|
|
}
|
|
.pe-field {
|
|
min-height: 72px;
|
|
padding: 10px 12px;
|
|
font-family: 'Inter', system-ui, sans-serif;
|
|
font-size: 13.5px;
|
|
line-height: 1.7;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
outline: none;
|
|
cursor: text;
|
|
}
|
|
.pe-chip {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
padding: 1px 9px 1px 7px;
|
|
height: 20px;
|
|
border-radius: 99px;
|
|
background: #d9efec;
|
|
color: #00574f;
|
|
font-size: 12px;
|
|
font-weight: 600;
|
|
border: 1px solid #9bd7d0;
|
|
cursor: grab;
|
|
user-select: none;
|
|
vertical-align: middle;
|
|
line-height: 1;
|
|
white-space: nowrap;
|
|
transition: background 0.12s, opacity 0.12s;
|
|
}
|
|
.pe-chip:hover { background: #bfe8e3; }
|
|
.pe-chip.is-dragging { opacity: 0.3; cursor: grabbing; }
|
|
.pe-caret {
|
|
display: inline-block;
|
|
width: 2px;
|
|
height: 1.1em;
|
|
background: #00645b;
|
|
border-radius: 1px;
|
|
vertical-align: middle;
|
|
pointer-events: none;
|
|
animation: pe-blink 0.7s steps(1) infinite;
|
|
}
|
|
@keyframes pe-blink { 50% { opacity: 0; } }
|
|
.pc {
|
|
border: 1px solid #e5e7eb;
|
|
border-radius: 1rem;
|
|
background: #fff;
|
|
overflow: hidden;
|
|
}
|
|
.pc-head {
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 13px 16px;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
background: #f9fafb;
|
|
border-bottom: 1px solid transparent;
|
|
}
|
|
.pc.open .pc-head { border-bottom-color: #e5e7eb; }
|
|
.pc-chevron { font-size: 18px; color: #6b7280; transition: transform 0.2s; }
|
|
.pc.open .pc-chevron { transform: rotate(90deg); }
|
|
.pc-body { display: none; padding: 16px; gap: 14px; flex-direction: column; }
|
|
.pc.open .pc-body { display: flex; }
|
|
.pe-label {
|
|
display: block;
|
|
font-size: 11px; font-weight: 700; letter-spacing: .06em;
|
|
text-transform: uppercase; color: #9ca3af;
|
|
margin-bottom: 5px;
|
|
}
|
|
.pe-palette {
|
|
display: flex; flex-wrap: wrap; gap: 5px;
|
|
padding: 7px 10px;
|
|
background: #f3f8f9;
|
|
border-top: 1px solid #e5e7eb;
|
|
border-radius: 0 0 0.625rem 0.625rem;
|
|
}
|
|
.pe-palette-chip {
|
|
display: inline-flex; align-items: center; gap: 3px;
|
|
padding: 2px 10px;
|
|
border-radius: 99px;
|
|
background: #ecfdf5;
|
|
color: #065f46;
|
|
font-size: 12px;
|
|
border: 1px dashed #6ee7b7;
|
|
cursor: pointer;
|
|
transition: background 0.1s;
|
|
}
|
|
.pe-palette-chip:hover { background: #d1fae5; }
|
|
.pc-badge { font-size: 11px; padding: 2px 8px; border-radius: 99px; }
|
|
.pc-badge--ok { background: #dcfce7; color: #166534; }
|
|
.pc-badge--err { background: #fee2e2; color: #991b1b; }
|
|
</style>
|
|
{% endblock %}
|
|
|
|
{% block content %}
|
|
{# Inject prompt data safely as JSON into JS scope #}
|
|
<script>
|
|
const PROMPTS_DATA = {{ prompts | tojson }};
|
|
</script>
|
|
|
|
<div class="flex items-center justify-between mb-5 gap-3 flex-wrap">
|
|
<div class="flex items-center gap-2">
|
|
<span class="material-symbols-outlined text-brand-500 text-2xl">psychology</span>
|
|
<h1 class="text-2xl font-semibold text-ink-900">Настройки ИИ</h1>
|
|
</div>
|
|
<a href="{{ url_for('main.index') }}"
|
|
class="inline-flex items-center gap-1 text-sm text-ink-500 hover:text-ink-800 transition">
|
|
<span class="material-symbols-outlined text-base">arrow_back</span>
|
|
Главная
|
|
</a>
|
|
</div>
|
|
|
|
<p class="text-sm text-ink-500 mb-5">
|
|
Переменные отображаются как
|
|
<span class="pe-chip" style="cursor:default; pointer-events:none;">Название теста</span>
|
|
— перетащите их в нужное место или нажмите «+ переменная» внизу редактора для вставки.
|
|
</p>
|
|
|
|
<div id="pc-list" class="flex flex-col gap-3">
|
|
{% for pid, p in prompts.items() %}
|
|
<div class="pc" data-pid="{{ pid }}">
|
|
<div class="pc-head" onclick="this.closest('.pc').classList.toggle('open')">
|
|
<span class="material-symbols-outlined pc-chevron">chevron_right</span>
|
|
<div class="flex-1 min-w-0">
|
|
<div class="text-sm font-semibold text-ink-900">{{ p.label }}</div>
|
|
{% if p.description %}
|
|
<div class="text-xs text-ink-400 mt-0.5">{{ p.description }}</div>
|
|
{% endif %}
|
|
</div>
|
|
<span class="pc-save-status pc-badge"></span>
|
|
</div>
|
|
|
|
<div class="pc-body">
|
|
{# System #}
|
|
<div>
|
|
<span class="pe-label">System</span>
|
|
<div class="pe-wrap">
|
|
<div class="pe-field" contenteditable="true" spellcheck="false"
|
|
data-pid="{{ pid }}" data-field="system"></div>
|
|
<div class="pe-palette" data-pid="{{ pid }}" data-field="system"></div>
|
|
</div>
|
|
</div>
|
|
{# User #}
|
|
<div>
|
|
<span class="pe-label">User</span>
|
|
<div class="pe-wrap">
|
|
<div class="pe-field" contenteditable="true" spellcheck="false"
|
|
data-pid="{{ pid }}" data-field="user"></div>
|
|
<div class="pe-palette" data-pid="{{ pid }}" data-field="user"></div>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3 pt-1">
|
|
<button type="button"
|
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-brand-600 hover:bg-brand-700 text-white text-sm font-medium transition"
|
|
onclick="savePrompt(this.closest('.pc'))">
|
|
<span class="material-symbols-outlined text-base">save</span>Сохранить
|
|
</button>
|
|
<button type="button"
|
|
class="inline-flex items-center gap-1.5 px-4 py-2 rounded-lg bg-white border border-ink-300 hover:bg-ink-100 text-ink-700 text-sm transition"
|
|
onclick="resetPrompt(this.closest('.pc'))">
|
|
<span class="material-symbols-outlined text-base">restart_alt</span>Сбросить
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
{% endblock %}
|
|
|
|
{% block scripts %}
|
|
<script>
|
|
(() => {
|
|
'use strict';
|
|
|
|
// ── DnD state ─────────────────────────────────────────────────────────
|
|
let _drag = null;
|
|
let _caret = null;
|
|
|
|
// ── Chip ──────────────────────────────────────────────────────────────
|
|
function makeChip(varName, label) {
|
|
const s = document.createElement('span');
|
|
s.className = 'pe-chip';
|
|
s.setAttribute('contenteditable', 'false');
|
|
s.setAttribute('draggable', 'true');
|
|
s.dataset.var = varName;
|
|
s.textContent = label;
|
|
bindChipDnD(s);
|
|
return s;
|
|
}
|
|
|
|
function bindChipDnD(chip) {
|
|
chip.addEventListener('dragstart', e => {
|
|
_drag = chip;
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
e.dataTransfer.setData('text/plain', '');
|
|
requestAnimationFrame(() => chip.classList.add('is-dragging'));
|
|
});
|
|
chip.addEventListener('dragend', () => {
|
|
chip.classList.remove('is-dragging');
|
|
removeCaret();
|
|
_drag = null;
|
|
});
|
|
}
|
|
|
|
// ── Render / serialize ────────────────────────────────────────────────
|
|
function renderText(el, text, vars) {
|
|
el.innerHTML = '';
|
|
const parts = text.split(/(\{[a-zA-Z_]+\})/g);
|
|
parts.forEach(part => {
|
|
const m = part.match(/^\{([a-zA-Z_]+)\}$/);
|
|
if (m && vars[m[1]] !== undefined) {
|
|
el.appendChild(makeChip(m[1], vars[m[1]]));
|
|
} else {
|
|
el.appendChild(document.createTextNode(part));
|
|
}
|
|
});
|
|
}
|
|
|
|
function serialize(el) {
|
|
let out = '';
|
|
el.childNodes.forEach(n => {
|
|
if (n.nodeType === Node.TEXT_NODE) out += n.textContent;
|
|
else if (n.dataset && n.dataset.var) out += '{' + n.dataset.var + '}';
|
|
});
|
|
return out;
|
|
}
|
|
|
|
// ── Editor DnD ────────────────────────────────────────────────────────
|
|
function removeCaret() {
|
|
if (_caret && _caret.parentNode) _caret.parentNode.removeChild(_caret);
|
|
_caret = null;
|
|
}
|
|
|
|
function caretAt(x, y) {
|
|
if (document.caretRangeFromPoint) {
|
|
const r = document.caretRangeFromPoint(x, y);
|
|
if (r) return [r.startContainer, r.startOffset];
|
|
}
|
|
if (document.caretPositionFromPoint) {
|
|
const p = document.caretPositionFromPoint(x, y);
|
|
if (p) return [p.offsetNode, p.offset];
|
|
}
|
|
return [null, 0];
|
|
}
|
|
|
|
function bindEditorDrop(el) {
|
|
el.addEventListener('dragover', e => {
|
|
if (!_drag) return;
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
removeCaret();
|
|
const [node, off] = caretAt(e.clientX, e.clientY);
|
|
if (!node) return;
|
|
const c = document.createElement('span');
|
|
c.className = 'pe-caret';
|
|
_caret = c;
|
|
try {
|
|
const r = document.createRange();
|
|
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
|
r.collapse(true);
|
|
r.insertNode(c);
|
|
} catch {}
|
|
});
|
|
|
|
el.addEventListener('dragleave', e => {
|
|
if (!el.contains(e.relatedTarget)) removeCaret();
|
|
});
|
|
|
|
el.addEventListener('drop', e => {
|
|
e.preventDefault();
|
|
removeCaret();
|
|
if (!_drag) return;
|
|
const [node, off] = caretAt(e.clientX, e.clientY);
|
|
if (_drag.parentNode) _drag.parentNode.removeChild(_drag);
|
|
if (node && el.contains(node)) {
|
|
try {
|
|
const r = document.createRange();
|
|
r.setStart(node, Math.min(off, node.nodeType === Node.TEXT_NODE ? node.length : node.childNodes.length));
|
|
r.collapse(true);
|
|
r.insertNode(_drag);
|
|
} catch { el.appendChild(_drag); }
|
|
} else {
|
|
el.appendChild(_drag);
|
|
}
|
|
bindChipDnD(_drag);
|
|
});
|
|
}
|
|
|
|
// ── Block deletion of chips ───────────────────────────────────────────
|
|
function bindEditorKeys(el) {
|
|
el.addEventListener('keydown', e => {
|
|
if (e.key !== 'Backspace' && e.key !== 'Delete') return;
|
|
const sel = window.getSelection();
|
|
if (!sel || !sel.rangeCount) return;
|
|
const range = sel.getRangeAt(0);
|
|
if (!range.collapsed && range.cloneContents().querySelector('.pe-chip')) {
|
|
e.preventDefault(); return;
|
|
}
|
|
if (range.collapsed) {
|
|
const siblings = (container, offset, dir) => {
|
|
if (container.nodeType === Node.TEXT_NODE) {
|
|
if (dir === 'prev' && offset > 0) return null;
|
|
if (dir === 'next' && offset < container.length) return null;
|
|
const arr = Array.from(container.parentNode.childNodes);
|
|
const i = arr.indexOf(container);
|
|
return dir === 'prev' ? arr[i - 1] : arr[i + 1];
|
|
}
|
|
return dir === 'prev' ? container.childNodes[offset - 1] : container.childNodes[offset];
|
|
};
|
|
const check = e.key === 'Backspace'
|
|
? siblings(range.startContainer, range.startOffset, 'prev')
|
|
: siblings(range.startContainer, range.startOffset, 'next');
|
|
if (check && check.classList && check.classList.contains('pe-chip')) {
|
|
e.preventDefault();
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Init from PROMPTS_DATA ────────────────────────────────────────────
|
|
function initEditor(el) {
|
|
const pid = el.dataset.pid;
|
|
const field = el.dataset.field;
|
|
const prompt = PROMPTS_DATA[pid];
|
|
if (!prompt) return;
|
|
const text = prompt[field] || '';
|
|
const vars = prompt.vars || {};
|
|
renderText(el, text, vars);
|
|
bindEditorDrop(el);
|
|
bindEditorKeys(el);
|
|
}
|
|
|
|
function initPalette(palette) {
|
|
const pid = palette.dataset.pid;
|
|
const field = palette.dataset.field;
|
|
const vars = (PROMPTS_DATA[pid] || {}).vars || {};
|
|
palette.innerHTML = '';
|
|
Object.entries(vars).forEach(([varName, label]) => {
|
|
const chip = document.createElement('span');
|
|
chip.className = 'pe-palette-chip';
|
|
chip.textContent = '+ ' + label;
|
|
chip.title = 'вставить переменную «' + label + '»';
|
|
chip.addEventListener('click', () => insertVar(pid, field, varName, label));
|
|
palette.appendChild(chip);
|
|
});
|
|
}
|
|
|
|
function insertVar(pid, field, varName, label) {
|
|
const el = document.querySelector(`.pe-field[data-pid="${pid}"][data-field="${field}"]`);
|
|
if (!el) return;
|
|
el.focus();
|
|
const sel = window.getSelection();
|
|
let range;
|
|
if (sel && sel.rangeCount && el.contains(sel.getRangeAt(0).commonAncestorContainer)) {
|
|
range = sel.getRangeAt(0);
|
|
range.deleteContents();
|
|
} else {
|
|
range = document.createRange();
|
|
range.selectNodeContents(el);
|
|
range.collapse(false);
|
|
}
|
|
const chip = makeChip(varName, label);
|
|
range.insertNode(chip);
|
|
range.setStartAfter(chip);
|
|
range.collapse(true);
|
|
sel.removeAllRanges();
|
|
sel.addRange(range);
|
|
}
|
|
|
|
document.querySelectorAll('.pe-field').forEach(initEditor);
|
|
document.querySelectorAll('.pe-palette').forEach(initPalette);
|
|
|
|
// ── Save ──────────────────────────────────────────────────────────────
|
|
window.savePrompt = async function(card) {
|
|
const pid = card.dataset.pid;
|
|
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
|
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
|
const badge = card.querySelector('.pc-save-status');
|
|
badge.textContent = '';
|
|
badge.className = 'pc-save-status pc-badge';
|
|
try {
|
|
const r = await fetch('/api/ai/prompts/' + pid, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ system: serialize(sysEl), user: serialize(usrEl) }),
|
|
});
|
|
const d = await r.json();
|
|
if (!r.ok) throw new Error(d.error || 'Ошибка');
|
|
// Update in-memory data
|
|
PROMPTS_DATA[pid].system = serialize(sysEl);
|
|
PROMPTS_DATA[pid].user = serialize(usrEl);
|
|
badge.textContent = '✓ Сохранено';
|
|
badge.className = 'pc-save-status pc-badge pc-badge--ok';
|
|
setTimeout(() => { badge.textContent = ''; badge.className = 'pc-save-status pc-badge'; }, 3000);
|
|
} catch (e) {
|
|
badge.textContent = e.message;
|
|
badge.className = 'pc-save-status pc-badge pc-badge--err';
|
|
}
|
|
};
|
|
|
|
// ── Reset ─────────────────────────────────────────────────────────────
|
|
window.resetPrompt = async function(card) {
|
|
if (!confirm('Сбросить к последней сохранённой версии?')) return;
|
|
const pid = card.dataset.pid;
|
|
try {
|
|
const r = await fetch('/api/ai/prompts');
|
|
const d = await r.json();
|
|
const p = d.prompts?.[pid];
|
|
if (!p) return;
|
|
PROMPTS_DATA[pid] = p;
|
|
const sysEl = card.querySelector('.pe-field[data-field="system"]');
|
|
const usrEl = card.querySelector('.pe-field[data-field="user"]');
|
|
renderText(sysEl, p.system || '', p.vars || {});
|
|
renderText(usrEl, p.user || '', p.vars || {});
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
|
|
})();
|
|
</script>
|
|
{% endblock %}
|