fr1: books.ruzzy.dev — читалка с ИИ (PDF/EPUB → главы, рекапы, перевод/объяснение выделенного, вопросы с RAG; Claude Code по подписке без инструментов; вход через Telegram)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8fd84239be
commit
9aab5b3f40
@@ -0,0 +1,142 @@
|
||||
// Книги: выделение → перевод/объяснение/вопрос, потоковые ответы ИИ, рекапы, отметки прочитанного.
|
||||
"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);
|
||||
}
|
||||
Reference in New Issue
Block a user