Files
infra/stacks/books/app/static/app.js
T

143 lines
7.7 KiB
JavaScript

// Книги: выделение → перевод/объяснение/вопрос, потоковые ответы ИИ, рекапы, отметки прочитанного.
"use strict";
const esc = s => s.replace(/[&<>"']/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
// Мини-Markdown: сначала экранируем всё, потом размечаем — HTML из ответов ИИ не исполняется.
function md(src) {
const lines = esc(src || "").split("\n");
const out = [];
let list = null, code = false;
const inline = t => t
.replace(/`([^`]+)`/g, "<code>$1</code>")
.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>")
.replace(/(^|[^*])\*([^*\n]+)\*/g, "$1<i>$2</i>")
.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
const close = () => { if (list) { out.push(`</${list}>`); list = null; } };
for (const l of lines) {
if (l.startsWith("```")) { close(); out.push(code ? "</pre>" : "<pre>"); code = !code; continue; }
if (code) { out.push(l); continue; }
let m;
if ((m = l.match(/^(#{1,4})\s+(.*)/))) { close(); const n = Math.min(4, m[1].length + 1); out.push(`<h${n}>${inline(m[2])}</h${n}>`); }
else if ((m = l.match(/^\s*[-*•]\s+(.*)/))) { if (list !== "ul") { close(); out.push("<ul>"); list = "ul"; } out.push(`<li>${inline(m[1])}</li>`); }
else if ((m = l.match(/^\s*\d+[.)]\s+(.*)/))) { if (list !== "ol") { close(); out.push("<ol>"); list = "ol"; } out.push(`<li>${inline(m[1])}</li>`); }
else if (!l.trim()) { close(); }
else { close(); out.push(`<p>${inline(l)}</p>`); }
}
close(); if (code) out.push("</pre>");
return out.join("\n");
}
document.querySelectorAll(".md").forEach(el => { el.innerHTML = md(el.textContent); });
// Потоковый запрос: текст ответа приходит кусками.
async function streamTo(url, body, el, method = "POST") {
el.innerHTML = '<p class="muted">Думаю…</p>';
let acc = "";
try {
const r = await fetch(url, {method, headers: {"Content-Type": "application/json"}, body: body ? JSON.stringify(body) : null});
if (!r.ok) { el.innerHTML = `<p class="err">Ошибка ${r.status}: ${esc(await r.text())}</p>`; return ""; }
const rd = r.body.getReader(), dec = new TextDecoder();
for (;;) {
const {done, value} = await rd.read();
if (done) break;
acc += dec.decode(value, {stream: true});
el.innerHTML = md(acc);
}
} catch (e) { el.innerHTML = `<p class="err">Сеть: ${esc(String(e))}</p>`; }
return acc;
}
// ---- вопросы (по книге и по главе)
document.querySelectorAll("form.ask").forEach(f => f.addEventListener("submit", async e => {
e.preventDefault();
const q = f.q.value.trim(); if (!q) return;
const btn = f.querySelector("button"); btn.disabled = true;
await streamTo("/api/ask", {q, book: f.dataset.book, ch: f.dataset.ch || null, web: f.web.checked},
f.parentElement.querySelector(".answer"));
btn.disabled = false;
}));
// ---- выделение в тексте главы
const text = document.getElementById("text");
const bar = document.getElementById("selbar"), panel = document.getElementById("panel");
let picked = "";
if (text && bar) {
const show = () => {
const s = window.getSelection();
const t = s && s.toString().trim();
if (!t || t.length < 2 || !text.contains(s.anchorNode)) { bar.hidden = true; return; }
picked = t;
const rect = s.getRangeAt(0).getBoundingClientRect();
bar.style.top = (window.scrollY + rect.bottom + 8) + "px";
bar.style.left = Math.max(8, Math.min(window.innerWidth - 300, rect.left)) + "px";
bar.hidden = false;
};
document.addEventListener("selectionchange", () => { clearTimeout(show.t); show.t = setTimeout(show, 350); });
const run = (act, extra) => {
panel.hidden = false; bar.hidden = true;
const body = document.getElementById("panel-body");
const ask = document.getElementById("panel-ask");
document.getElementById("panel-title").textContent = {translate: "Перевод", explain: "Объяснение", ask: "Вопрос о выделенном"}[act];
if (act === "ask") { ask.hidden = false; body.innerHTML = `<blockquote>${esc(picked.slice(0, 400))}</blockquote>`; ask.querySelector("textarea").focus(); return; }
ask.hidden = true;
streamTo(`/api/selection/${act}`, {book: text.dataset.book, ch: text.dataset.ch, text: picked}, body);
};
bar.addEventListener("mousedown", e => e.preventDefault()); // не сбрасывать выделение кликом
bar.querySelectorAll("button").forEach(b => b.addEventListener("click", () => run(b.dataset.act)));
document.getElementById("panel-close").onclick = () => { panel.hidden = true; };
document.getElementById("panel-ask-go").onclick = () => {
const q = document.querySelector("#panel-ask textarea").value.trim(); if (!q) return;
streamTo("/api/ask", {q, book: text.dataset.book, ch: text.dataset.ch, selection: picked,
web: document.getElementById("panel-web").checked}, document.getElementById("panel-body"));
};
}
// ---- рекап главы
const gen = document.getElementById("gen-recap");
if (gen) gen.onclick = async () => {
gen.disabled = true;
const view = document.getElementById("recap-view");
const t = await streamTo(`/api/recap/${gen.dataset.book}/${gen.dataset.ch}`, null, view);
if (t && !t.startsWith("⚠️")) location.reload(); else gen.disabled = false;
};
const save = document.getElementById("save-recap");
if (save) save.onclick = async () => {
const val = document.getElementById("recap-edit").value;
const r = await fetch(`/api/recap/${save.dataset.book}/${save.dataset.ch}`, {method: "PUT", headers: {"Content-Type": "application/json"}, body: JSON.stringify({md: val})});
if (r.ok) { document.getElementById("recap-view").innerHTML = md(val); save.textContent = "Сохранено ✓"; setTimeout(() => save.textContent = "Сохранить", 1500); }
};
const useAi = document.getElementById("use-ai");
if (useAi) useAi.onclick = e => { e.preventDefault(); document.getElementById("recap-edit").value = document.getElementById("ai-md").value; };
// ---- прочитано
const read = document.getElementById("read");
if (read) read.onchange = () => fetch(`/api/read/${read.dataset.book}/${read.dataset.ch}`, {method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({read: read.checked})});
// ---- рекапы выбранных глав по очереди
const many = document.getElementById("recap-selected");
if (many) many.onclick = async () => {
const only = document.getElementById("only-missing").checked;
const picks = [...document.querySelectorAll(".pick:checked")].filter(c => !(only && c.dataset.has));
if (!picks.length) { alert("Отметь главы галочками"); return; }
many.disabled = true;
for (const c of picks) {
const st = c.closest("li").querySelector(".state");
st.textContent = "⏳ рекап…";
const r = await fetch(`/api/recap/${many.dataset.book}/${c.value}`, {method: "POST"});
const t = r.ok ? await r.text() : "";
st.textContent = r.ok && !t.startsWith("⚠️") ? "📝 готово" : "⚠️ не вышло";
if (r.ok) { c.dataset.has = "1"; c.checked = false; }
}
many.disabled = false;
};
// ---- книга ещё обрабатывается — опрос статуса
const busy = document.getElementById("busy");
if (busy) {
const poll = async () => {
const r = await fetch(`/api/status/${busy.dataset.book}`); const s = await r.json();
if (s.status === "ready" || s.status === "error") location.reload(); else setTimeout(poll, 3000);
};
setTimeout(poll, 3000);
}