143 lines
7.7 KiB
JavaScript
143 lines
7.7 KiB
JavaScript
// Книги: выделение → перевод/объяснение/вопрос, потоковые ответы ИИ, рекапы, отметки прочитанного.
|
|
"use strict";
|
|
|
|
const esc = s => s.replace(/[&<>"']/g, c => ({"&": "&", "<": "<", ">": ">", '"': """, "'": "'"}[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);
|
|
}
|