app.js 9.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310
  1. (function () {
  2. const STORAGE_KEY = "historical_review_web_v1";
  3. const $ = (id) => document.getElementById(id);
  4. function readForm() {
  5. return {
  6. api_key: $("cfg-api-key").value.trim(),
  7. base_url: $("cfg-base-url").value.trim(),
  8. model: $("cfg-model").value.trim(),
  9. debate_temperature: parseFloat($("cfg-debate-temp").value) || 0.72,
  10. synthesizer_temperature: parseFloat($("cfg-synth-temp").value) || 0.22,
  11. max_tokens: parseInt($("cfg-max-tokens").value, 10) || 4096,
  12. timeout: parseInt($("cfg-timeout").value, 10) || 180,
  13. use_evidence_bundle: $("cfg-use-evidence").checked,
  14. };
  15. }
  16. function applyForm(data) {
  17. if (!data) return;
  18. if (data.api_key != null) $("cfg-api-key").value = data.api_key;
  19. if (data.base_url != null) $("cfg-base-url").value = data.base_url;
  20. if (data.model != null) $("cfg-model").value = data.model;
  21. if (data.debate_temperature != null) $("cfg-debate-temp").value = data.debate_temperature;
  22. if (data.synthesizer_temperature != null) $("cfg-synth-temp").value = data.synthesizer_temperature;
  23. if (data.max_tokens != null) $("cfg-max-tokens").value = data.max_tokens;
  24. if (data.timeout != null) $("cfg-timeout").value = data.timeout;
  25. if (data.use_evidence_bundle != null) $("cfg-use-evidence").checked = data.use_evidence_bundle;
  26. }
  27. function loadStorage() {
  28. try {
  29. const raw = localStorage.getItem(STORAGE_KEY);
  30. if (!raw) return;
  31. const data = JSON.parse(raw);
  32. applyForm(data);
  33. } catch (_) {
  34. /* ignore */
  35. }
  36. }
  37. function saveStorage() {
  38. const data = readForm();
  39. localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
  40. }
  41. function clearStorage() {
  42. localStorage.removeItem(STORAGE_KEY);
  43. }
  44. function renderMarkdown(md) {
  45. const el = $("output-md");
  46. if (!md) {
  47. el.innerHTML = "";
  48. return;
  49. }
  50. if (typeof marked === "undefined" || typeof DOMPurify === "undefined") {
  51. el.textContent = md;
  52. return;
  53. }
  54. const raw = marked.parse(md, { mangle: false, headerIds: false });
  55. el.innerHTML = DOMPurify.sanitize(raw);
  56. }
  57. function setStatus(text) {
  58. $("status-text").textContent = text || "";
  59. }
  60. function setError(msg) {
  61. const box = $("output-error");
  62. if (!msg) {
  63. box.classList.add("hidden");
  64. box.textContent = "";
  65. return;
  66. }
  67. box.textContent = msg;
  68. box.classList.remove("hidden");
  69. }
  70. function padTime(n) {
  71. return n < 10 ? "0" + n : String(n);
  72. }
  73. function nowTime() {
  74. const d = new Date();
  75. return `${padTime(d.getHours())}:${padTime(d.getMinutes())}:${padTime(d.getSeconds())}`;
  76. }
  77. function appendLog(text) {
  78. const log = $("progress-log");
  79. const line = document.createElement("div");
  80. line.className = "log-line";
  81. line.innerHTML = `<time>${nowTime()}</time>${escapeHtml(text)}`;
  82. log.appendChild(line);
  83. log.scrollTop = log.scrollHeight;
  84. }
  85. function escapeHtml(s) {
  86. const d = document.createElement("div");
  87. d.textContent = s;
  88. return d.innerHTML;
  89. }
  90. function setProgressBar(step, total) {
  91. const totalN = typeof total === "number" && total > 0 ? total : 15;
  92. const stepN = typeof step === "number" ? step : 0;
  93. const pct = Math.min(100, Math.round(((stepN + 1) / totalN) * 100));
  94. const fill = $("progress-fill");
  95. const track = $("progress-track");
  96. fill.style.width = pct + "%";
  97. track.setAttribute("aria-valuenow", String(pct));
  98. $("progress-meta").textContent = `进度约 ${pct}%(阶段 ${Math.min(stepN + 1, totalN)} / ${totalN})`;
  99. }
  100. function resetProgressUi() {
  101. $("progress-log").innerHTML = "";
  102. $("progress-fill").style.width = "0%";
  103. $("progress-track").setAttribute("aria-valuenow", "0");
  104. $("progress-meta").textContent = "准备开始…";
  105. }
  106. function handleStreamEvent(ev, state) {
  107. const t = ev.total;
  108. const s = typeof ev.step === "number" ? ev.step : state.lastStep;
  109. switch (ev.event) {
  110. case "progress":
  111. appendLog(ev.message || "");
  112. setStatus(ev.message || "");
  113. if (typeof ev.step === "number") {
  114. state.lastStep = ev.step;
  115. setProgressBar(ev.step, ev.total);
  116. }
  117. break;
  118. case "evidence_done":
  119. appendLog(`考据附录已就绪(约 ${ev.chars || 0} 字)`);
  120. if (ev.preview) appendLog("附录预览:" + ev.preview.slice(0, 200).replace(/\s+/g, " ") + "…");
  121. setProgressBar(ev.step, t);
  122. break;
  123. case "round1_start":
  124. case "round2_start":
  125. case "digest_start":
  126. case "synthesis_start":
  127. appendLog(ev.message || `${ev.event} · ${ev.role || ""}`);
  128. setStatus(ev.message || "调用模型中…");
  129. if (typeof ev.step === "number") {
  130. state.lastStep = ev.step;
  131. setProgressBar(ev.step, t);
  132. }
  133. break;
  134. case "round1_end":
  135. case "round2_end":
  136. case "digest_end":
  137. case "synthesis_end":
  138. if (ev.markdown_section) {
  139. state.md += ev.markdown_section;
  140. renderMarkdown(state.md);
  141. }
  142. appendLog(`✓ 已完成:${ev.role ? ev.role : ev.event.replace("_end", "")}`);
  143. if (typeof ev.step === "number") {
  144. state.lastStep = ev.step;
  145. setProgressBar(ev.step, t);
  146. }
  147. break;
  148. case "complete":
  149. state.completed = true;
  150. if (ev.markdown) {
  151. state.md = ev.markdown;
  152. renderMarkdown(state.md);
  153. }
  154. setProgressBar((t || 15) - 1, t || 15);
  155. $("progress-meta").textContent = "全部完成";
  156. setStatus("完成");
  157. appendLog(ev.message || "全部完成");
  158. break;
  159. case "error":
  160. setError(ev.message || "未知错误");
  161. setStatus("");
  162. appendLog("错误:" + (ev.message || ""));
  163. break;
  164. default:
  165. break;
  166. }
  167. }
  168. async function consumeSseStream(response, state) {
  169. const reader = response.body.getReader();
  170. const decoder = new TextDecoder();
  171. let buffer = "";
  172. while (true) {
  173. const { done, value } = await reader.read();
  174. if (done) break;
  175. buffer += decoder.decode(value, { stream: true });
  176. const parts = buffer.split("\n\n");
  177. buffer = parts.pop() || "";
  178. for (const block of parts) {
  179. const line = block.trim();
  180. if (!line.startsWith("data:")) continue;
  181. const jsonStr = line.slice(5).trim();
  182. if (!jsonStr) continue;
  183. let ev;
  184. try {
  185. ev = JSON.parse(jsonStr);
  186. } catch {
  187. continue;
  188. }
  189. handleStreamEvent(ev, state);
  190. if (ev.event === "error") return false;
  191. }
  192. }
  193. return !!state.completed;
  194. }
  195. async function runDebate() {
  196. const topic = $("topic-input").value.trim();
  197. if (!topic) {
  198. setError("请先填写历史议题。");
  199. return;
  200. }
  201. const cfg = readForm();
  202. $("btn-run").disabled = true;
  203. setError("");
  204. resetProgressUi();
  205. renderMarkdown("");
  206. setStatus("连接服务器…");
  207. appendLog("已提交议题,等待流式响应…");
  208. const body = {
  209. topic,
  210. api_key: cfg.api_key || null,
  211. base_url: cfg.base_url || null,
  212. model: cfg.model || null,
  213. debate_temperature: cfg.debate_temperature,
  214. synthesizer_temperature: cfg.synthesizer_temperature,
  215. max_tokens: cfg.max_tokens,
  216. timeout: cfg.timeout,
  217. use_evidence_bundle: cfg.use_evidence_bundle,
  218. };
  219. const state = { md: "", lastStep: 0, completed: false };
  220. const ac = new AbortController();
  221. const to = setTimeout(() => ac.abort(), 920000);
  222. try {
  223. const res = await fetch("/api/debate/stream", {
  224. method: "POST",
  225. headers: { "Content-Type": "application/json" },
  226. body: JSON.stringify(body),
  227. signal: ac.signal,
  228. });
  229. if (!res.ok) {
  230. const text = await res.text();
  231. setError("HTTP " + res.status + " " + text.slice(0, 200));
  232. setStatus("");
  233. return;
  234. }
  235. const ct = res.headers.get("content-type") || "";
  236. if (!ct.includes("text/event-stream")) {
  237. setError("服务器未返回事件流,请重启 Web 服务后重试。");
  238. setStatus("");
  239. return;
  240. }
  241. const ok = await consumeSseStream(res, state);
  242. state.completed = ok;
  243. if (!ok && !$("output-error").textContent) {
  244. setError("流式输出意外结束,请查看日志或重试。");
  245. }
  246. } catch (e) {
  247. const msg = e.name === "AbortError" ? "等待超时,请重试或缩小议题。" : String(e);
  248. setError(msg);
  249. setStatus("");
  250. appendLog(msg);
  251. } finally {
  252. clearTimeout(to);
  253. $("btn-run").disabled = false;
  254. }
  255. }
  256. $("btn-run").addEventListener("click", runDebate);
  257. $("btn-save-config").addEventListener("click", () => {
  258. if ($("cfg-remember").checked) {
  259. saveStorage();
  260. setStatus("已保存到本机浏览器");
  261. } else {
  262. setStatus("请勾选「记住配置」后再保存,或仅使用当前页面临时配置");
  263. }
  264. });
  265. $("btn-clear-config").addEventListener("click", () => {
  266. clearStorage();
  267. $("cfg-api-key").value = "";
  268. setStatus("已清除本地保存的配置");
  269. });
  270. $("btn-toggle-config").addEventListener("click", () => {
  271. const panel = $("config-panel");
  272. const collapsed = panel.classList.toggle("collapsed");
  273. $("btn-toggle-config").setAttribute("aria-expanded", String(!collapsed));
  274. $("btn-toggle-config").textContent = collapsed ? "展开配置" : "收起配置";
  275. });
  276. loadStorage();
  277. })();