You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

433 lines
15 KiB

{% 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>
— перетащите их в нужное место или нажмите «+&nbsp;переменная» внизу редактора для вставки.
</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 %}