ework-web 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,380 @@
1
+ // Session follow mode: poll /api/sessions/:id/since for server-rendered new
2
+ // messages and append them. Server returns rendered HTML (see renderNewMessages),
3
+ // so this stays ~50 lines with no duplicated render logic. Mirrors the issue
4
+ // thread's ?since polling pattern.
5
+ (() => {
6
+ const dataEl = document.getElementById("session-data");
7
+ if (!dataEl) return;
8
+ let meta;
9
+ try { meta = JSON.parse(dataEl.textContent || "{}"); } catch { return; }
10
+ const sid = meta.id;
11
+ if (!sid) return;
12
+
13
+ const btn = document.getElementById("followBtn");
14
+ const list = document.getElementById("mlist");
15
+ if (!btn || !list) return;
16
+
17
+ let last = typeof meta.lastCreated === "number" ? meta.lastCreated : 0;
18
+ let desc = meta.desc === true;
19
+ const seen = new Set();
20
+ let timer = null;
21
+ const INTERVAL = 4000;
22
+
23
+ // Track the last text selection per message root: clicking 🔊 clears the live
24
+ // selection, so we remember the selection's Range and use it when the button fires.
25
+ let ttsSelRoot = null;
26
+ let ttsSelRange = null;
27
+ document.addEventListener("selectionchange", () => {
28
+ const sel = window.getSelection();
29
+ if (!sel || sel.isCollapsed || sel.rangeCount === 0) return;
30
+ const r = sel.getRangeAt(0);
31
+ const n = r.startContainer;
32
+ const el = n.nodeType === 3 ? n.parentElement : n;
33
+ const mb = el && el.closest ? el.closest(".mb") : null;
34
+ if (mb) { ttsSelRoot = mb; ttsSelRange = r.cloneRange(); }
35
+ });
36
+
37
+ function setBtn(label) { btn.textContent = label; }
38
+
39
+ function flashNew(n) {
40
+ btn.classList.add("flash");
41
+ setBtn(`✨ +${n}`);
42
+ setTimeout(() => { btn.classList.remove("flash"); setBtn(timer ? "⏸ Stop" : "🔄 Follow"); }, 1500);
43
+ }
44
+
45
+ // Skip the poll while the user is mid-selection: inserting HTML cancels it.
46
+ // Safe — `last` stays, so the next poll fetches everything since (no loss).
47
+ function hasTextSelection() {
48
+ const s = window.getSelection();
49
+ return !!s && !s.isCollapsed && s.toString().length > 0;
50
+ }
51
+
52
+ async function poll() {
53
+ if (hasTextSelection() || (window.TTS && window.TTS.isActive())) return;
54
+ try {
55
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sid)}/since?since=${last}`, { headers: { "accept": "application/json" } });
56
+ if (!res.ok) return;
57
+ const body = await res.json();
58
+ if (!body || !Array.isArray(body.items)) return;
59
+ const pos = desc ? "afterbegin" : "beforeend";
60
+ let got = 0;
61
+ for (const it of body.items) {
62
+ if (!it || !it.html || seen.has(it.id)) continue;
63
+ seen.add(it.id);
64
+ list.insertAdjacentHTML(pos, it.html);
65
+ if (typeof it.created === "number" && it.created > last) last = it.created;
66
+ got++;
67
+ }
68
+ if (got > 0) flashNew(got);
69
+ } catch { /* transient; next tick retries */ }
70
+ }
71
+
72
+ function start() {
73
+ btn.classList.add("on");
74
+ setBtn("⏸ Stop");
75
+ poll();
76
+ timer = setInterval(poll, INTERVAL);
77
+ }
78
+ function stop() {
79
+ btn.classList.remove("on");
80
+ setBtn("🔄 Follow");
81
+ if (timer) { clearInterval(timer); timer = null; }
82
+ }
83
+
84
+ btn.addEventListener("click", () => { timer ? stop() : start(); });
85
+
86
+ // Auto-follow on by default; ?follow=0 opts out.
87
+ const params = new URLSearchParams(location.search);
88
+ if (params.get("follow") !== "0") start();
89
+
90
+ const cbtn = document.getElementById("collapseBtn");
91
+ if (cbtn) {
92
+ let collapsed = true;
93
+ cbtn.addEventListener("click", () => {
94
+ collapsed = !collapsed;
95
+ document.querySelectorAll("#mlist details").forEach((d) => {
96
+ d.open = !collapsed;
97
+ });
98
+ cbtn.textContent = collapsed ? "📂 展开全部" : "📂 折叠全部";
99
+ });
100
+ }
101
+
102
+ // Incremental "load more": fetch next batch and insert without full reload.
103
+ const loadBtn = document.getElementById("loadMoreBtn");
104
+ const moreBar = document.getElementById("moreBar");
105
+ if (loadBtn && moreBar) {
106
+ loadBtn.addEventListener("click", async () => {
107
+ const offset = Number(loadBtn.dataset.offset) || 30;
108
+ loadBtn.disabled = true;
109
+ const oldText = loadBtn.textContent;
110
+ loadBtn.textContent = "加载中…";
111
+ try {
112
+ const res = await fetch(`/api/sessions/${encodeURIComponent(meta.id)}/batch?offset=${offset}&limit=30&asc=${desc ? 0 : 1}`);
113
+ if (!res.ok) throw new Error("http " + res.status);
114
+ const batch = await res.json();
115
+ // Insert: desc (newest-first) → older msgs go at the BOTTOM (before moreBar);
116
+ // asc → older msgs go at the TOP (after #top anchor), with scroll anchoring.
117
+ if (desc) {
118
+ moreBar.insertAdjacentHTML("beforebegin", batch.html);
119
+ } else {
120
+ const beforeH = document.documentElement.scrollHeight;
121
+ const beforeY = window.scrollY;
122
+ const topAnchor = list.querySelector("#top");
123
+ if (topAnchor) topAnchor.insertAdjacentHTML("afterend", batch.html);
124
+ else list.insertAdjacentHTML("afterbegin", batch.html);
125
+ window.scrollTo(0, beforeY + (document.documentElement.scrollHeight - beforeH));
126
+ }
127
+ const shown = offset + 30;
128
+ loadBtn.dataset.offset = String(shown);
129
+ if (batch.hasMore) {
130
+ loadBtn.disabled = false;
131
+ loadBtn.textContent = oldText;
132
+ const total = batch.total;
133
+ moreBar.firstChild && (moreBar.childNodes[0].nodeValue = `共 ${total} 条,当前显示最新 ${shown} 条 · `);
134
+ } else {
135
+ moreBar.remove();
136
+ }
137
+ } catch (e) {
138
+ loadBtn.disabled = false;
139
+ loadBtn.textContent = oldText;
140
+ }
141
+ });
142
+ }
143
+
144
+
145
+ // Copy visible message text to clipboard, with fallback for non-secure (HTTP) contexts.
146
+ // Resolve the content root for an action button: .mb inside a .msg, or .acp-sum inside
147
+ // a compression marker — so copy/tts/translate work on both regular messages and blocks.
148
+ function contentRoot(btn) {
149
+ const msg = btn.closest(".msg");
150
+ if (msg) return msg.querySelector(".mb") || null;
151
+ const mark = btn.closest(".acp-mark");
152
+ if (mark) return mark.querySelector(".acp-sum") || null;
153
+ return null;
154
+ }
155
+ list.addEventListener("click", (e) => {
156
+ const cb = e.target.closest(".cbtn");
157
+ if (!cb) return;
158
+ const mb = contentRoot(cb);
159
+ if (!mb) return;
160
+ let text;
161
+ const mds = [];
162
+ for (const p of mb.querySelectorAll("[data-md]")) { if (p.dataset.md) mds.push(p.dataset.md); }
163
+ text = mds.length ? mds.join("\n\n") : mb.innerText;
164
+ const done = () => { cb.textContent = "✅"; cb.classList.add("done"); setTimeout(() => { cb.textContent = "📋"; cb.classList.remove("done"); }, 1500); };
165
+ const fallback = () => {
166
+ const ta = document.createElement("textarea");
167
+ ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
168
+ document.body.appendChild(ta); ta.select();
169
+ let ok = false; try { ok = document.execCommand("copy"); } catch {}
170
+ document.body.removeChild(ta);
171
+ if (ok) done();
172
+ };
173
+ if (navigator.clipboard && navigator.clipboard.writeText) {
174
+ navigator.clipboard.writeText(text).then(done).catch(fallback);
175
+ } else { fallback(); }
176
+ });
177
+
178
+ // Per-message link copy: copy /sessions/:id#m<msgid> so the floor can be shared.
179
+ list.addEventListener("click", (e) => {
180
+ const lb = e.target.closest(".linkbtn");
181
+ if (!lb) return;
182
+ const msgEl = lb.closest(".msg");
183
+ if (!msgEl || !msgEl.id) return;
184
+ const url = location.origin + location.pathname + "#" + msgEl.id;
185
+ const done = () => { lb.textContent = "✅"; lb.classList.add("done"); setTimeout(() => { lb.textContent = "🔗"; lb.classList.remove("done"); }, 1500); };
186
+ const fallback = () => {
187
+ const ta = document.createElement("textarea");
188
+ ta.value = url; ta.style.position = "fixed"; ta.style.opacity = "0";
189
+ document.body.appendChild(ta); ta.select();
190
+ let ok = false; try { ok = document.execCommand("copy"); } catch {}
191
+ document.body.removeChild(ta);
192
+ if (ok) done();
193
+ };
194
+ if (navigator.clipboard && navigator.clipboard.writeText) {
195
+ navigator.clipboard.writeText(url).then(done).catch(fallback);
196
+ } else { fallback(); }
197
+ });
198
+
199
+ list.addEventListener("click", (e) => {
200
+ if (e.target.closest(".ttsstop")) { e.stopPropagation(); if (window.TTS) window.TTS.stop(); return; }
201
+ const tb = e.target.closest(".ttsbtn");
202
+ if (!tb) return;
203
+ const mb = contentRoot(tb);
204
+ if (!mb) return;
205
+ let text = "";
206
+ if (ttsSelRoot === mb && ttsSelRange) {
207
+ const r = ttsSelRange.cloneRange();
208
+ try { r.setEnd(mb, mb.childNodes.length); } catch (e2) {}
209
+ text = r.toString().trim();
210
+ }
211
+ if (!text) text = mb.innerText.trim();
212
+ if (!text) return;
213
+ window.TTS.start({ text, btn: tb });
214
+ });
215
+
216
+ // Per-part copy: copy just this reasoning/tool block's own text (button excluded).
217
+ list.addEventListener("click", (e) => {
218
+ const pb = e.target.closest(".pcbtn");
219
+ if (!pb) return;
220
+ e.preventDefault();
221
+ const io = pb.closest(".tool-io") || pb.closest("details")?.querySelector(".tool-io");
222
+ if (!io) return;
223
+ const clone = io.cloneNode(true);
224
+ clone.querySelectorAll(".pcbtn").forEach((b) => b.remove());
225
+ const text = io.dataset.md !== undefined ? io.dataset.md : clone.innerText;
226
+ const done = () => { pb.textContent = "✅"; pb.classList.add("done"); setTimeout(() => { pb.textContent = "📋"; pb.classList.remove("done"); }, 1500); };
227
+ const fallback = () => {
228
+ const ta = document.createElement("textarea");
229
+ ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0";
230
+ document.body.appendChild(ta); ta.select();
231
+ let ok = false; try { ok = document.execCommand("copy"); } catch {}
232
+ document.body.removeChild(ta);
233
+ if (ok) done();
234
+ };
235
+ if (navigator.clipboard && navigator.clipboard.writeText) {
236
+ navigator.clipboard.writeText(text).then(done).catch(fallback);
237
+ } else { fallback(); }
238
+ });
239
+
240
+ // Translate per-part in place: text divs + reasoning bodies; tools and
241
+ // summary bars are skipped (never sent).
242
+ const partCache = new WeakMap();
243
+ const partShow = new WeakMap();
244
+ const msgTr = new WeakMap();
245
+ let trActive = 0;
246
+ const trRunning = new WeakMap();
247
+ const partsOf = (mb) => {
248
+ const out = [];
249
+ mb.querySelectorAll(":scope > details.reasoning[open] > .tool-io").forEach((d) => out.push(d));
250
+ mb.querySelectorAll(":scope > div").forEach((d) => out.push(d));
251
+ return out;
252
+ };
253
+ // Live streaming mini-markdown: escape first, then render lists/bold/code so
254
+ // miniMd: streaming-state renderer (the final {h} full marked render still
255
+ // swaps in at stream end). Handles headings, fenced code, lists, bold, inline
256
+ // code — enough that the streaming phase looks close to the final render.
257
+ const esc = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
258
+ const inlineMd = (s) => esc(s)
259
+ .replace(/`([^`]+)`/g, "<code>$1</code>")
260
+ .replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
261
+ const miniMd = (s) => {
262
+ const lines = s.split("\n");
263
+ let out = "", inUl = false, inCode = false, codeBuf = "", tableBuf = [];
264
+ const flushUl = () => { if (inUl) { out += "</ul>"; inUl = false; } };
265
+ const isRow = (l) => /^\|.+\|$/.test(l);
266
+ const isSep = (l) => /^\|[\s:|-]+\|$/.test(l);
267
+ const cell = (r) => r.replace(/^\|/, "").replace(/\|$/, "").split("|").map(c => c.trim());
268
+ const renderTable = (buf) => {
269
+ if (buf.length < 2 || !isSep(buf[1])) return buf.map(l => inlineMd(l) + "<br>").join("");
270
+ let h = "<table><thead><tr>" + cell(buf[0]).map(c => "<th>" + inlineMd(c) + "</th>").join("") + "</tr></thead>";
271
+ if (buf.length > 2) h += "<tbody>" + buf.slice(2).map(r => "<tr>" + cell(r).map(c => "<td>" + inlineMd(c) + "</td>").join("") + "</tr>").join("") + "</tbody>";
272
+ return h + "</table>";
273
+ };
274
+ const flushTable = () => { if (tableBuf.length) { out += renderTable(tableBuf); tableBuf = []; } };
275
+ for (const raw of lines) {
276
+ const line = raw.replace(/\s+$/, "");
277
+ if (/^```/.test(line)) {
278
+ flushUl(); flushTable();
279
+ if (inCode) { out += `<pre><code>${esc(codeBuf.replace(/\n$/, ""))}</code></pre>`; inCode = false; codeBuf = ""; }
280
+ else { inCode = true; }
281
+ continue;
282
+ }
283
+ if (inCode) { codeBuf += raw + "\n"; continue; }
284
+ if (isRow(line)) { flushUl(); tableBuf.push(line); continue; }
285
+ flushTable();
286
+ const hm = line.match(/^(#{1,6})\s+(.*)$/);
287
+ if (hm) { flushUl(); out += `<h${hm[1].length}>${inlineMd(hm[2])}</h${hm[1].length}>`; continue; }
288
+ const m = line.match(/^[-*]\s+(.*)$/);
289
+ if (m) { if (!inUl) { out += "<ul>"; inUl = true; } out += "<li>" + inlineMd(m[1]) + "</li>"; }
290
+ else { flushUl(); out += line ? inlineMd(line) + "<br>" : "<br>"; }
291
+ }
292
+ flushUl(); flushTable();
293
+ if (inCode) out += `<pre><code>${esc(codeBuf.replace(/\n$/, ""))}</code></pre>`;
294
+ return out;
295
+ };
296
+ const applyPart = (p) => { const c = partCache.get(p); if (c) p.innerHTML = partShow.get(p) ? c.tr : c.orig; };
297
+ list.addEventListener("click", async (e) => {
298
+ const tb = e.target.closest(".tbtn");
299
+ if (!tb) return;
300
+ const mb = contentRoot(tb);
301
+ if (!mb) return;
302
+ if (trRunning.has(tb)) { trRunning.get(tb).abort(); return; }
303
+ const parts = partsOf(mb).filter((p) => p.textContent.trim());
304
+ if (parts.length === 0) return;
305
+ if (parts.every((p) => partCache.has(p))) {
306
+ const next = !msgTr.get(mb);
307
+ for (const p of parts) { partShow.set(p, next); applyPart(p); }
308
+ msgTr.set(mb, next);
309
+ tb.textContent = next ? "原文" : "翻译";
310
+ return;
311
+ }
312
+ const ac = new AbortController();
313
+ trRunning.set(tb, ac);
314
+ trActive++;
315
+ tb.textContent = "⏹ 停止";
316
+ let allDone = true;
317
+ try {
318
+ for (const p of parts) {
319
+ if (ac.signal.aborted) { allDone = false; break; }
320
+ if (partCache.has(p)) continue;
321
+ const orig = p.innerHTML;
322
+ const text = p.dataset.md || p.textContent.trim();
323
+ p.innerHTML = `<div class="tr-prog"></div>`;
324
+ const prog = p.querySelector(".tr-prog");
325
+ let html = null;
326
+ try {
327
+ const res = await fetch("/api/translate/stream", {
328
+ method: "POST",
329
+ headers: { "content-type": "application/json" },
330
+ body: JSON.stringify({ text }),
331
+ signal: ac.signal,
332
+ });
333
+ if (!res.ok || !res.body) throw new Error("http " + res.status);
334
+ const reader = res.body.getReader();
335
+ const dec = new TextDecoder();
336
+ let buf = "", full = "";
337
+ for (;;) {
338
+ const { done, value } = await reader.read();
339
+ if (done) break;
340
+ buf += dec.decode(value, { stream: true });
341
+ const lines = buf.split("\n");
342
+ buf = lines.pop() || "";
343
+ for (const line of lines) {
344
+ const s = line.trim();
345
+ if (!s) continue;
346
+ let obj; try { obj = JSON.parse(s); } catch { continue; }
347
+ if (obj.d) { full += obj.d; prog.innerHTML = miniMd(full); }
348
+ else if (obj.h) { html = obj.h; }
349
+ else if (obj.e) { throw new Error(obj.e); }
350
+ }
351
+ }
352
+ if (buf.trim()) { try { const o = JSON.parse(buf.trim()); if (o && o.h) html = o.h; } catch {} }
353
+ if (html) { partCache.set(p, { orig, tr: html }); partShow.set(p, true); p.innerHTML = html; }
354
+ else if (full.trim()) { const md = miniMd(full); partCache.set(p, { orig, tr: md }); partShow.set(p, true); p.innerHTML = md; }
355
+ else { partCache.set(p, { orig, tr: orig }); partShow.set(p, true); p.innerHTML = orig; }
356
+ } catch (err) {
357
+ p.innerHTML = orig;
358
+ if (err.name === "AbortError") { allDone = false; break; }
359
+ }
360
+ }
361
+ msgTr.set(mb, allDone);
362
+ } finally {
363
+ trRunning.delete(tb);
364
+ trActive--;
365
+ tb.textContent = allDone ? "原文" : "翻译";
366
+ }
367
+ });
368
+
369
+ // DESC (newest top) → scroll up; ASC → scroll down only if already near bottom.
370
+ const mo = new MutationObserver(() => {
371
+ if (!timer) return;
372
+ if (trActive > 0) return;
373
+ if (desc) {
374
+ if (window.scrollY < 200) window.scrollTo({ top: 0, behavior: "smooth" });
375
+ } else if ((window.innerHeight + window.scrollY) > (document.body.scrollHeight - 200)) {
376
+ window.scrollTo({ top: document.body.scrollHeight, behavior: "smooth" });
377
+ }
378
+ });
379
+ mo.observe(list, { childList: true });
380
+ })();
@@ -0,0 +1,101 @@
1
+ // TTS playback: plays one GET /api/tts/stream/:id URL via a native <audio>. An engine
2
+ // picker (<select id="tts-be">) injected into the nav switches backends (kokoro /
3
+ // cosyvoice3 …); the choice persists in localStorage.
4
+ (function () {
5
+ let audio = null;
6
+ let state = "idle";
7
+ let activeBtn = null;
8
+ let backends = null;
9
+
10
+ function setBtn(btn, s) {
11
+ if (!btn) return;
12
+ btn.classList.toggle("playing", s === "playing" || s === "fetching");
13
+ btn.textContent = s === "playing" ? "⏸" : s === "fetching" ? "⏳" : s === "paused" ? "▶️" : "🔊";
14
+ }
15
+ function setState(s) { state = s; setBtn(activeBtn, s); }
16
+
17
+ function stop() {
18
+ if (audio) {
19
+ audio.onended = null; audio.onerror = null;
20
+ audio.pause(); audio.src = "";
21
+ audio = null;
22
+ }
23
+ setState("idle");
24
+ activeBtn = null;
25
+ }
26
+
27
+ function selectedBackend() {
28
+ const sel = document.getElementById("tts-be");
29
+ if (sel && sel.value) return sel.value;
30
+ return backends && backends.length ? backends[0].id : "";
31
+ }
32
+
33
+ // Inject one engine <select> into the nav, populated from /api/tts/backends. Hidden
34
+ // when there's only one backend (nothing to choose). Persists the choice per-browser.
35
+ function ensurePicker() {
36
+ if (!backends || backends.length === 0) return;
37
+ if (document.getElementById("tts-be")) return;
38
+ const nav = document.querySelector(".nav");
39
+ if (!nav) return;
40
+ const sel = document.createElement("select");
41
+ sel.id = "tts-be";
42
+ sel.title = "朗读引擎";
43
+ sel.className = "tts-be";
44
+ sel.style.cssText = "margin-left:auto;font:inherit;font-size:12px;padding:.2rem .3rem;border:1px solid rgba(255,255,255,.3);border-radius:5px;background:rgba(255,255,255,.1);color:inherit;cursor:pointer";
45
+ for (const b of backends) {
46
+ const o = document.createElement("option");
47
+ o.value = b.id; o.textContent = b.label;
48
+ sel.appendChild(o);
49
+ }
50
+ const saved = localStorage.getItem("tts-backend");
51
+ if (saved && backends.some((b) => b.id === saved)) sel.value = saved;
52
+ sel.addEventListener("change", () => localStorage.setItem("tts-backend", sel.value));
53
+ nav.appendChild(sel);
54
+ }
55
+
56
+ async function loadBackends() {
57
+ if (backends) return;
58
+ try {
59
+ const r = await fetch("/api/tts/backends");
60
+ if (r.ok) backends = await r.json();
61
+ } catch { /* picker just won't render; 🔇 still uses the default backend */ }
62
+ if (backends && backends.length > 1) ensurePicker();
63
+ }
64
+
65
+ async function start(opts) {
66
+ if (activeBtn === opts.btn && audio) {
67
+ if (audio.paused) audio.play().then(() => setState("playing")).catch(() => {});
68
+ else { audio.pause(); setState("paused"); }
69
+ return;
70
+ }
71
+ if (activeBtn) stop();
72
+ const text = (opts.text || "").replace(/\r/g, "").trim();
73
+ if (!text) return;
74
+ activeBtn = opts.btn;
75
+ setState("fetching");
76
+ // Create the element in the click gesture so mobile autoplay policy unlocks it.
77
+ audio = new Audio();
78
+ let id;
79
+ try {
80
+ const res = await fetch("/api/tts", {
81
+ method: "POST",
82
+ headers: { "content-type": "application/json" },
83
+ body: JSON.stringify({ text, backend: selectedBackend() }),
84
+ });
85
+ if (!res.ok) { stop(); return; }
86
+ id = (await res.json()).id;
87
+ } catch (e) { stop(); return; }
88
+ if (!id || !audio || state === "idle") return;
89
+ audio.src = "/api/tts/stream/" + encodeURIComponent(id);
90
+ audio.onended = () => stop();
91
+ audio.onerror = () => stop();
92
+ audio.play().then(() => { if (state !== "idle") setState("playing"); }).catch(() => stop());
93
+ }
94
+
95
+ function isActive() { return state !== "idle"; }
96
+
97
+ window.TTS = { start, stop, isActive };
98
+
99
+ if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", loadBackends);
100
+ else loadBackends();
101
+ })();