app.js 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629
  1. const API_BASE = "http://127.0.0.1:8000";
  2. const USER_ID_STORAGE_KEY = "healthRecordAgent_userId";
  3. const LAST_DIET_RUN_KEY = "healthRecordAgent_lastDietRunId";
  4. const DEV_MODE_STORAGE_KEY = "healthRecordAgent_devMode";
  5. /** 兼容旧版「技术详情」开关 */
  6. const LEGACY_TECH_STORAGE_KEY = "healthRecordAgent_showTech";
  7. function isDeveloperMode() {
  8. const el = document.getElementById("devModeToggle");
  9. return !!(el && el.checked);
  10. }
  11. function getUserIdOrEmpty() {
  12. return document.getElementById("userId")?.value?.trim() || "";
  13. }
  14. /** 体检分析进度:默认对用户显示中文步骤名 */
  15. function getHealthProgressAgents() {
  16. if (isDeveloperMode()) {
  17. return [
  18. { key: "PlannerAgent", label: "PlannerAgent 规划" },
  19. { key: "HealthIndicatorAgent", label: "HealthIndicatorAgent 指标" },
  20. { key: "RiskAssessmentAgent", label: "RiskAssessmentAgent 风险" },
  21. { key: "AdviceAgent", label: "AdviceAgent 建议" },
  22. { key: "ReportAgent", label: "ReportAgent 报告" },
  23. ];
  24. }
  25. return [
  26. { key: "PlannerAgent", label: "规划" },
  27. { key: "HealthIndicatorAgent", label: "指标解读" },
  28. { key: "RiskAssessmentAgent", label: "风险评估" },
  29. { key: "AdviceAgent", label: "建议" },
  30. { key: "ReportAgent", label: "汇总报告" },
  31. ];
  32. }
  33. function getUserId() {
  34. const el = document.getElementById("userId");
  35. const raw = el ? el.value.trim() : "";
  36. if (!raw) {
  37. alert("请填写用户 ID");
  38. return null;
  39. }
  40. try {
  41. localStorage.setItem(USER_ID_STORAGE_KEY, raw);
  42. } catch (_) { /* ignore */ }
  43. return raw;
  44. }
  45. function setTab(name) {
  46. const tabs = ["analysis", "diet", "history"];
  47. const n = tabs.includes(name) ? name : "analysis";
  48. tabs.forEach((t) => {
  49. const panel = document.getElementById(`tab-${t}`);
  50. if (panel) panel.classList.toggle("hidden", t !== n);
  51. });
  52. document.querySelectorAll(".tab-segment [role='tab']").forEach((btn) => {
  53. const on = btn.dataset.tab === n;
  54. btn.setAttribute("aria-selected", on ? "true" : "false");
  55. });
  56. if (`#${n}` !== location.hash) {
  57. history.replaceState(null, "", `#${n}`);
  58. }
  59. if (n === "diet") {
  60. refreshReflectRunOptions();
  61. }
  62. }
  63. function tabFromHash() {
  64. const h = (location.hash || "").replace(/^#/, "").toLowerCase();
  65. if (h === "diet" || h === "history" || h === "analysis") return h;
  66. return "analysis";
  67. }
  68. document.addEventListener("DOMContentLoaded", () => {
  69. const el = document.getElementById("userId");
  70. if (el) {
  71. try {
  72. const saved = localStorage.getItem(USER_ID_STORAGE_KEY);
  73. if (saved) el.value = saved;
  74. } catch (_) { /* ignore */ }
  75. }
  76. setTab(tabFromHash());
  77. window.addEventListener("hashchange", () => setTab(tabFromHash()));
  78. document.querySelectorAll(".tab-segment [data-tab]").forEach((btn) => {
  79. btn.addEventListener("click", () => setTab(btn.dataset.tab || "analysis"));
  80. });
  81. const devCb = document.getElementById("devModeToggle");
  82. if (devCb) {
  83. try {
  84. const dm = localStorage.getItem(DEV_MODE_STORAGE_KEY);
  85. const legacy = localStorage.getItem(LEGACY_TECH_STORAGE_KEY);
  86. if (dm === "1" || legacy === "1") devCb.checked = true;
  87. } catch (_) { /* ignore */ }
  88. devCb.addEventListener("change", () => {
  89. try {
  90. localStorage.setItem(DEV_MODE_STORAGE_KEY, devCb.checked ? "1" : "0");
  91. } catch (_) { /* ignore */ }
  92. refreshReflectRunOptions();
  93. });
  94. }
  95. const dlg = document.getElementById("reflectPromptDialog");
  96. const go = document.getElementById("reflectDialogGo");
  97. const later = document.getElementById("reflectDialogLater");
  98. if (go) {
  99. go.addEventListener("click", () => {
  100. if (dlg && typeof dlg.close === "function") dlg.close();
  101. focusFeedbackSection();
  102. });
  103. }
  104. if (later) {
  105. later.addEventListener("click", () => {
  106. if (dlg && typeof dlg.close === "function") dlg.close();
  107. });
  108. }
  109. document.querySelectorAll('input[name="reflectFollowedChoice"]').forEach((el) => {
  110. el.addEventListener("change", syncReflectReasonVisibility);
  111. });
  112. syncReflectReasonVisibility();
  113. });
  114. /** 选「否」时展示未执行原因;选「是」时隐藏并清空原因(后端会将 reason 置为 executed_ok)。 */
  115. function syncReflectReasonVisibility() {
  116. const yes = document.getElementById("reflectFollowedYes");
  117. const no = document.getElementById("reflectFollowedNo");
  118. const block = document.getElementById("reflectReasonBlock");
  119. const sel = document.getElementById("reflectReasonCode");
  120. const detail = document.getElementById("reflectDetail");
  121. if (!block || !sel) return;
  122. if (no?.checked) {
  123. block.classList.remove("hidden");
  124. } else {
  125. block.classList.add("hidden");
  126. sel.value = "";
  127. if (detail) detail.value = "";
  128. }
  129. }
  130. /** 拉取近期饮食推荐,填充「反馈」下拉的选项;preferredRunId 优先选中(如刚生成的一条)。 */
  131. async function refreshReflectRunOptions(preferredRunId) {
  132. const sel = document.getElementById("reflectRunSelect");
  133. if (!sel) return;
  134. const userId = getUserIdOrEmpty();
  135. sel.innerHTML = "";
  136. const addPlaceholder = (text, disabled = true) => {
  137. const o = document.createElement("option");
  138. o.value = "";
  139. o.textContent = text;
  140. if (disabled) o.disabled = true;
  141. o.selected = true;
  142. sel.appendChild(o);
  143. };
  144. if (!userId) {
  145. addPlaceholder("请先填写用户 ID");
  146. return;
  147. }
  148. try {
  149. const res = await fetch(
  150. `${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/runs?limit=20`
  151. );
  152. const data = await res.json().catch(() => ({}));
  153. const items = data.items || [];
  154. if (!items.length) {
  155. addPlaceholder("暂无推荐记录,请先生成一次饮食推荐");
  156. return;
  157. }
  158. const dev = isDeveloperMode();
  159. items.forEach((row) => {
  160. const o = document.createElement("option");
  161. o.value = row.run_id;
  162. let label = "";
  163. try {
  164. const t = row.created_at
  165. ? new Date(row.created_at).toLocaleString("zh-CN", {
  166. month: "2-digit",
  167. day: "2-digit",
  168. hour: "2-digit",
  169. minute: "2-digit",
  170. })
  171. : "";
  172. const tp =
  173. row.total_protein != null
  174. ? `约 ${row.total_protein} g 蛋白`
  175. : "饮食推荐";
  176. label = t ? `${t} · ${tp}` : tp;
  177. if (dev) label += ` · ${row.run_id}`;
  178. } catch (_) {
  179. label = row.run_id;
  180. }
  181. o.textContent = label;
  182. sel.appendChild(o);
  183. });
  184. const pick =
  185. preferredRunId ||
  186. (() => {
  187. try {
  188. return localStorage.getItem(LAST_DIET_RUN_KEY);
  189. } catch (_) {
  190. return null;
  191. }
  192. })();
  193. if (pick && Array.from(sel.options).some((opt) => opt.value === pick)) {
  194. sel.value = pick;
  195. }
  196. } catch (e) {
  197. console.error(e);
  198. addPlaceholder("加载推荐列表失败,请稍后重试");
  199. }
  200. }
  201. function openReflectPromptDialog() {
  202. const dlg = document.getElementById("reflectPromptDialog");
  203. if (dlg && typeof dlg.showModal === "function") {
  204. dlg.showModal();
  205. } else {
  206. focusFeedbackSection();
  207. }
  208. }
  209. function focusFeedbackSection() {
  210. const h = document.getElementById("feedbackSectionTitle");
  211. h?.scrollIntoView({ behavior: "smooth", block: "start" });
  212. const first = document.getElementById("reflectRunSelect");
  213. if (first) {
  214. setTimeout(() => first.focus(), 400);
  215. }
  216. }
  217. function renderMealPlan(mp) {
  218. if (!mp) return "<p>(无 meal_plan)</p>";
  219. const tips = Array.isArray(mp.tips) ? mp.tips.filter(Boolean).join(";") : "";
  220. let h = `<p><strong>估算总蛋白</strong>:${mp.total_est_protein_g ?? "—"} g</p><ul class="meal-plan-list">`;
  221. (mp.items || []).forEach((it) => {
  222. h += `<li><strong>${escapeHtml(it.name || "")}</strong> — ${escapeHtml(it.portion || "")}`;
  223. if (it.est_protein_g != null) h += `(约 <strong>${it.est_protein_g}</strong> g 蛋白)`;
  224. if (it.why) h += `<br><span class="muted-why">${escapeHtml(it.why)}</span>`;
  225. h += "</li>";
  226. });
  227. h += "</ul>";
  228. if (tips) h += `<p class="meal-tips"><strong>提示</strong>:${escapeHtml(tips)}</p>`;
  229. return h;
  230. }
  231. function escapeHtml(s) {
  232. if (!s) return "";
  233. const div = document.createElement("div");
  234. div.textContent = s;
  235. return div.innerHTML;
  236. }
  237. async function recommendDiet() {
  238. const userId = getUserId();
  239. if (!userId) return;
  240. const statusEl = document.getElementById("dietStatus");
  241. const outEl = document.getElementById("dietResult");
  242. if (!statusEl || !outEl) return;
  243. statusEl.textContent = isDeveloperMode()
  244. ? "⏳ 正在调用 Planning + ReAct(可能需多次 LLM,请稍候)…"
  245. : "⏳ 正在生成推荐,请稍候…";
  246. outEl.classList.add("hidden");
  247. outEl.innerHTML = "";
  248. const foodLog = document.getElementById("dietFoodLog")?.value?.trim() || "";
  249. if (!foodLog) {
  250. statusEl.textContent = "⚠️ 请先填写今天吃了什么";
  251. return;
  252. }
  253. const body = {
  254. user_id: userId,
  255. context: {
  256. today_food_log_text: foodLog,
  257. goal: document.getElementById("dietGoal")?.value || "muscle_gain",
  258. channels: ["convenience_store", "delivery"],
  259. activity_context: document.getElementById("dietActivityContext")?.value?.trim() || "",
  260. free_notes: document.getElementById("dietNotes")?.value?.trim() || "",
  261. },
  262. };
  263. try {
  264. const res = await fetch(`${API_BASE}/api/diet/recommend`, {
  265. method: "POST",
  266. headers: { "Content-Type": "application/json" },
  267. body: JSON.stringify(body),
  268. });
  269. const data = await res.json().catch(() => ({}));
  270. if (!res.ok) {
  271. throw new Error(data.detail ? JSON.stringify(data.detail) : `HTTP ${res.status}`);
  272. }
  273. const runId = data.run_id;
  274. try {
  275. localStorage.setItem(LAST_DIET_RUN_KEY, runId);
  276. } catch (_) { /* ignore */ }
  277. const planning = data.planning || {};
  278. const ver = data.schema_version || "1";
  279. const mode = data.pipeline_mode || "legacy";
  280. const tech = isDeveloperMode();
  281. let html = "";
  282. if (tech) {
  283. html += `<p><strong>run_id</strong>:<code>${escapeHtml(runId)}</code> &nbsp; <small>schema=${escapeHtml(String(ver))} / ${escapeHtml(String(mode))}</small></p>`;
  284. }
  285. if (data.degraded) {
  286. html += tech
  287. ? `<p class="banner banner-warning"><strong>降级</strong>:部分阶段使用规则/模板兜底,请查看 <code>errors</code>。</p>`
  288. : `<p class="banner banner-warning"><strong>说明</strong>:部分内容由规则自动补齐,请以列表中的可执行项为准。</p>`;
  289. }
  290. if (planning.reasoning) {
  291. html += tech
  292. ? `<p><strong>Planning(Nutritionist 摘要)</strong>:${escapeHtml(planning.reasoning)}</p>`
  293. : `<p><strong>营养分析摘要</strong>:${escapeHtml(planning.reasoning)}</p>`;
  294. }
  295. const ns = data.nutrition_summary || {};
  296. if (!tech) {
  297. html += `<p><strong>今日营养估算</strong>:蛋白 ${escapeHtml(String(ns.protein_g ?? 0))}g,碳水 ${escapeHtml(String(ns.carb_g ?? 0))}g,脂肪 ${escapeHtml(String(ns.fat_g ?? 0))}g,热量 ${escapeHtml(String(ns.calories_kcal ?? 0))} kcal</p>`;
  298. } else {
  299. html += `<details class="diet-trace"><summary>食物解析与营养估算</summary><pre style="white-space:pre-wrap;max-height:220px;overflow:auto;">${escapeHtml(JSON.stringify({ food_parse: data.food_parse, nutrition_summary: data.nutrition_summary }, null, 2))}</pre></details>`;
  300. }
  301. const hx = data.habit_extras;
  302. if (hx && hx.reflect_alignment) {
  303. html += tech
  304. ? `<p><strong>Habit · Reflect 对齐</strong>:${escapeHtml(hx.reflect_alignment)}</p>`
  305. : `<p><strong>与历史反馈对齐</strong>:${escapeHtml(hx.reflect_alignment)}</p>`;
  306. if (hx.execution_hints && hx.execution_hints.length) {
  307. html += `<p><strong>执行提示</strong>:${escapeHtml(hx.execution_hints.join(";"))}</p>`;
  308. }
  309. }
  310. html += `<h4>推荐方案</h4>${renderMealPlan(data.meal_plan)}`;
  311. if (tech) {
  312. if (data.errors && data.errors.length) {
  313. html += `<details class="diet-trace"><summary>错误记录(${data.errors.length})</summary><pre style="white-space:pre-wrap;max-height:200px;overflow:auto;">${escapeHtml(JSON.stringify(data.errors, null, 2))}</pre></details>`;
  314. }
  315. if (data.reflect_memory_used) {
  316. html += `<details class="diet-trace"><summary>已注入的 Reflect 记忆摘要</summary><pre style="white-space:pre-wrap;">${escapeHtml(String(data.reflect_memory_used))}</pre></details>`;
  317. }
  318. if (data.react_trace && data.react_trace.length) {
  319. html += `<details class="diet-trace"><summary>流水线轨迹(${data.react_trace.length} 段)</summary><pre style="white-space:pre-wrap;max-height:280px;overflow:auto;">${escapeHtml(JSON.stringify(data.react_trace, null, 2))}</pre></details>`;
  320. }
  321. }
  322. outEl.innerHTML = html;
  323. outEl.classList.remove("hidden");
  324. statusEl.textContent = data.degraded
  325. ? tech
  326. ? "⚠️ 推荐完成(含降级,已写入 diet_runs)"
  327. : "⚠️ 推荐已保存(部分内容已自动处理)"
  328. : tech
  329. ? "✅ 推荐完成(已写入 diet_runs)"
  330. : "✅ 推荐已保存";
  331. await refreshReflectRunOptions(runId);
  332. openReflectPromptDialog();
  333. } catch (e) {
  334. console.error(e);
  335. statusEl.textContent = "❌ 请求失败";
  336. outEl.innerHTML = `<p class="banner-error">${escapeHtml(e.message || String(e))}</p>`;
  337. outEl.classList.remove("hidden");
  338. }
  339. }
  340. async function submitDietReflect() {
  341. const userId = getUserId();
  342. if (!userId) return;
  343. const runId = document.getElementById("reflectRunSelect")?.value?.trim();
  344. if (!runId) {
  345. alert("请先在列表里选择一条要反馈的推荐,或先生成一次饮食推荐");
  346. return;
  347. }
  348. const yes = document.getElementById("reflectFollowedYes")?.checked;
  349. const no = document.getElementById("reflectFollowedNo")?.checked;
  350. if (!yes && !no) {
  351. alert("请先选择「是否按这条推荐执行」");
  352. return;
  353. }
  354. const followed = !!yes;
  355. let reasonCode = null;
  356. let detail = null;
  357. if (followed) {
  358. reasonCode = null;
  359. detail = null;
  360. } else {
  361. reasonCode = document.getElementById("reflectReasonCode")?.value?.trim() || null;
  362. if (!reasonCode) {
  363. alert("请选择未执行的主要原因");
  364. return;
  365. }
  366. detail = document.getElementById("reflectDetail")?.value?.trim() || null;
  367. }
  368. const statusEl = document.getElementById("dietStatus");
  369. if (statusEl) statusEl.textContent = "⏳ 正在保存反馈…";
  370. try {
  371. const res = await fetch(`${API_BASE}/api/diet/reflect`, {
  372. method: "POST",
  373. headers: { "Content-Type": "application/json" },
  374. body: JSON.stringify({
  375. user_id: userId,
  376. diet_run_id: runId,
  377. followed,
  378. reason_code: reasonCode,
  379. reason_detail: detail,
  380. }),
  381. });
  382. const data = await res.json().catch(() => ({}));
  383. if (!res.ok) {
  384. throw new Error(data.detail ? JSON.stringify(data.detail) : `HTTP ${res.status}`);
  385. }
  386. if (statusEl) {
  387. statusEl.textContent = isDeveloperMode()
  388. ? `✅ Reflect 已保存(id=${data.reflect_id}),下次推荐会读取`
  389. : "✅ 反馈已保存,将在下次推荐时参考";
  390. }
  391. await loadDietHistory();
  392. } catch (e) {
  393. console.error(e);
  394. if (statusEl) statusEl.textContent = "❌ 保存失败:" + (e.message || e);
  395. }
  396. }
  397. async function loadDietHistory() {
  398. const userId = getUserId();
  399. if (!userId) return;
  400. const pre = document.getElementById("dietHistoryPre");
  401. const hint = document.getElementById("historyEmptyHint");
  402. const summaryEl = document.getElementById("historySummary");
  403. const rawDetails = document.getElementById("historyRawDetails");
  404. if (!pre) return;
  405. if (hint) hint.classList.add("hidden");
  406. if (summaryEl) {
  407. summaryEl.classList.remove("hidden");
  408. summaryEl.textContent = "加载中…";
  409. }
  410. if (rawDetails) {
  411. rawDetails.classList.add("hidden");
  412. rawDetails.open = false;
  413. }
  414. pre.textContent = "";
  415. try {
  416. const [r1, r2] = await Promise.all([
  417. fetch(`${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/runs?limit=15`).then((r) => r.json()),
  418. fetch(`${API_BASE}/api/diet/users/${encodeURIComponent(userId)}/reflect_history?limit=15`).then((r) => r.json()),
  419. ]);
  420. const n1 = (r1.items || []).length;
  421. const n2 = (r2.items || []).length;
  422. if (summaryEl) {
  423. summaryEl.textContent = `已加载 ${n1} 条饮食推荐记录、${n2} 条反馈记录。`;
  424. }
  425. pre.textContent = JSON.stringify({ diet_runs: r1, reflect: r2 }, null, 2);
  426. if (rawDetails) {
  427. if (isDeveloperMode()) {
  428. rawDetails.classList.remove("hidden");
  429. } else {
  430. rawDetails.classList.add("hidden");
  431. }
  432. }
  433. } catch (e) {
  434. if (summaryEl) {
  435. summaryEl.textContent = "加载失败:" + (e.message || e);
  436. }
  437. pre.textContent = "";
  438. }
  439. }
  440. /**
  441. * 显示 / 更新多 Agent 进度。仅在 agents 数量变化时重建 DOM,轮询时只更新状态文案,避免整表闪烁。
  442. */
  443. function showAgentProgress(agentContainer, agents, statusFunc) {
  444. const getStatus =
  445. typeof statusFunc === "function" ? statusFunc : () => statusFunc;
  446. const needRebuild =
  447. agentContainer.children.length !== agents.length ||
  448. agents.some((a, i) => agentContainer.children[i]?.dataset?.agentKey !== a.key);
  449. if (needRebuild) {
  450. agentContainer.innerHTML = "";
  451. agents.forEach((agent) => {
  452. const li = document.createElement("li");
  453. li.dataset.agentKey = agent.key;
  454. const labelSpan = document.createElement("span");
  455. labelSpan.className = "agent-progress-label";
  456. labelSpan.textContent = agent.label;
  457. const statusSpan = document.createElement("span");
  458. statusSpan.className = "agent-progress-status";
  459. statusSpan.textContent = getStatus(agent.key);
  460. li.appendChild(labelSpan);
  461. li.appendChild(document.createTextNode(":"));
  462. li.appendChild(statusSpan);
  463. agentContainer.appendChild(li);
  464. });
  465. return;
  466. }
  467. agents.forEach((agent, i) => {
  468. const li = agentContainer.children[i];
  469. const statusSpan = li?.querySelector?.(".agent-progress-status");
  470. if (statusSpan) statusSpan.textContent = getStatus(agent.key);
  471. });
  472. }
  473. // 公共函数:提交任务并轮询状态
  474. async function submitAndPollTask(url, body, agents, resultCard, reportDiv, analysisDiv, progressList, loadingText, doneText, errorText) {
  475. reportDiv.innerHTML = "";
  476. analysisDiv.innerText = loadingText;
  477. progressList.classList.remove("hidden");
  478. showAgentProgress(progressList, agents, () => "⏳ 执行中...");
  479. resultCard.classList.add("hidden");
  480. try {
  481. const response = await fetch(url, body);
  482. if (!response.ok) throw new Error(`服务器返回错误状态:${response.status}`);
  483. const data = await response.json();
  484. const taskId = data.task_id;
  485. let taskStatus = await fetch(`${API_BASE}/api/health/task_status/${taskId}`).then(r => r.json());
  486. while (taskStatus.state !== "completed") {
  487. showAgentProgress(progressList, agents, agentKey => taskStatus.agents?.[agentKey] ?? "⏳ 执行中...");
  488. await new Promise(res => setTimeout(res, 1000));
  489. taskStatus = await fetch(`${API_BASE}/api/health/task_status/${taskId}`).then(r => r.json());
  490. }
  491. // 任务完成后刷新一次 agent 状态,保证 ReportAgent 也显示 completed
  492. showAgentProgress(progressList, agents, agentKey => taskStatus.agents?.[agentKey] ?? "⏳ 执行中...");
  493. // 显示最终报告
  494. const summary = taskStatus.report?.report?.summary || "<p>❌ 未返回报告内容</p>";
  495. reportDiv.innerHTML = typeof summary === "string" ? summary : JSON.stringify(summary, null, 2);
  496. analysisDiv.innerText = doneText;
  497. resultCard.classList.remove("hidden");
  498. } catch (error) {
  499. const errorMessage = error?.message || JSON.stringify(error);
  500. console.error("任务提交或轮询出错:", errorMessage);
  501. reportDiv.innerHTML = `<p>❌ ${errorText}: ${errorMessage}</p>`;
  502. analysisDiv.innerText = `❌ ${errorText}`;
  503. progressList.innerHTML = "";
  504. }
  505. }
  506. // 文本报告分析
  507. async function analyze() {
  508. const userId = getUserId();
  509. if (!userId) return;
  510. const reportText = document.getElementById("reportText").value;
  511. if (!reportText) {
  512. alert("请输入体检报告内容");
  513. return;
  514. }
  515. const resultCard = document.getElementById("resultCard");
  516. const reportDiv = document.getElementById("report");
  517. const analysisDiv = document.getElementById("analysis");
  518. const progressList = document.getElementById("progressList");
  519. const agents = getHealthProgressAgents();
  520. await submitAndPollTask(
  521. `${API_BASE}/api/health/analysis`,
  522. {
  523. method: "POST",
  524. headers: { "Content-Type": "application/json" },
  525. body: JSON.stringify({ report_text: reportText, user_id: userId })
  526. },
  527. agents,
  528. resultCard,
  529. reportDiv,
  530. analysisDiv,
  531. progressList,
  532. isDeveloperMode() ? "⏳ 正在分析文本报告,请稍候…" : "⏳ 正在分析,请稍候…",
  533. "✅ 分析完成",
  534. "报告生成失败"
  535. );
  536. }
  537. // PDF报告分析
  538. async function uploadPDF() {
  539. const userId = getUserId();
  540. if (!userId) return;
  541. const fileInput = document.getElementById("pdfFile");
  542. const file = fileInput.files[0];
  543. if (!file) {
  544. alert("请选择PDF文件");
  545. return;
  546. }
  547. const formData = new FormData();
  548. formData.append("user_id", userId);
  549. formData.append("file", file);
  550. const resultCard = document.getElementById("resultCard");
  551. const reportDiv = document.getElementById("report");
  552. const analysisDiv = document.getElementById("analysis");
  553. const progressList = document.getElementById("progressList");
  554. const agents = getHealthProgressAgents();
  555. await submitAndPollTask(
  556. `${API_BASE}/api/health/analysis/pdf`,
  557. { method: "POST", body: formData },
  558. agents,
  559. resultCard,
  560. reportDiv,
  561. analysisDiv,
  562. progressList,
  563. isDeveloperMode() ? "⏳ 正在分析 PDF 报告,请稍候…" : "⏳ 正在分析 PDF,请稍候…",
  564. "✅ 分析完成",
  565. "上传失败"
  566. );
  567. }