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