index.html 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>申勤物业知识助手</title>
  7. <style>
  8. :root {
  9. --bg: #f5f7fb;
  10. --panel: #ffffff;
  11. --border: #d9e1ec;
  12. --primary: #2f5597;
  13. --primary-dark: #24426f;
  14. --text: #22303f;
  15. --muted: #7a8794;
  16. --user-bubble: #e7f0ff;
  17. --assistant-bubble: #ffffff;
  18. --danger: #c0392b;
  19. --warn-bg: #fff7e6;
  20. }
  21. * { box-sizing: border-box; }
  22. body {
  23. margin: 0;
  24. background: var(--bg);
  25. color: var(--text);
  26. font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
  27. height: 100vh;
  28. display: flex;
  29. justify-content: center;
  30. align-items: center;
  31. }
  32. .app {
  33. width: min(960px, 96vw);
  34. height: min(900px, 94vh);
  35. background: var(--panel);
  36. border: 1px solid var(--border);
  37. border-radius: 16px;
  38. box-shadow: 0 18px 50px rgba(35, 48, 63, 0.12);
  39. display: flex;
  40. flex-direction: column;
  41. overflow: hidden;
  42. }
  43. header {
  44. padding: 14px 18px;
  45. border-bottom: 1px solid var(--border);
  46. background: #ffffff;
  47. }
  48. header h1 {
  49. margin: 0 0 10px;
  50. font-size: 18px;
  51. color: var(--primary-dark);
  52. }
  53. .controls {
  54. display: flex;
  55. flex-wrap: wrap;
  56. gap: 10px;
  57. align-items: center;
  58. }
  59. .controls .spacer {
  60. flex: 1;
  61. }
  62. label {
  63. display: flex;
  64. flex-direction: column;
  65. gap: 6px;
  66. font-size: 13px;
  67. color: var(--muted);
  68. }
  69. input[type="text"],
  70. input[type="url"],
  71. textarea {
  72. width: 100%;
  73. border: 1px solid var(--border);
  74. border-radius: 9px;
  75. padding: 9px 10px;
  76. font-size: 14px;
  77. font-family: inherit;
  78. resize: vertical;
  79. background: #fff;
  80. }
  81. input:focus,
  82. textarea:focus {
  83. outline: none;
  84. border-color: var(--primary);
  85. box-shadow: 0 0 0 3px rgba(47, 85, 151, 0.12);
  86. }
  87. .check-line {
  88. flex-direction: row;
  89. align-items: center;
  90. gap: 7px;
  91. color: var(--text);
  92. font-size: 13px;
  93. white-space: nowrap;
  94. }
  95. .composer {
  96. display: flex;
  97. gap: 10px;
  98. padding: 12px 16px;
  99. border-top: 1px solid var(--border);
  100. background: #ffffff;
  101. }
  102. .composer textarea {
  103. flex: 1;
  104. min-height: 42px;
  105. max-height: 150px;
  106. }
  107. .btn {
  108. border: 0;
  109. border-radius: 9px;
  110. padding: 10px 14px;
  111. font-size: 14px;
  112. cursor: pointer;
  113. transition: background 0.2s ease;
  114. font-family: inherit;
  115. white-space: nowrap;
  116. }
  117. .btn-primary {
  118. background: var(--primary);
  119. color: #ffffff;
  120. }
  121. .btn-primary:hover { background: var(--primary-dark); }
  122. .btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
  123. .btn-ghost {
  124. background: #eef2f7;
  125. color: var(--text);
  126. }
  127. .btn-ghost:hover { background: #e0e7f0; }
  128. #chat {
  129. flex: 1;
  130. overflow-y: auto;
  131. padding: 22px;
  132. background: var(--bg);
  133. position: relative;
  134. }
  135. .message-row {
  136. position: relative;
  137. z-index: 2;
  138. }
  139. .message-row {
  140. display: flex;
  141. align-items: flex-start;
  142. gap: 10px;
  143. margin-bottom: 16px;
  144. }
  145. .message-row.user {
  146. flex-direction: row-reverse;
  147. }
  148. .avatar {
  149. width: 34px;
  150. height: 34px;
  151. border-radius: 50%;
  152. object-fit: cover;
  153. flex: none;
  154. border: 1px solid var(--border);
  155. }
  156. .user-avatar {
  157. display: flex;
  158. align-items: center;
  159. justify-content: center;
  160. background: var(--primary);
  161. color: #fff;
  162. font-size: 13px;
  163. }
  164. .message {
  165. max-width: 78%;
  166. padding: 12px 14px;
  167. border-radius: 12px;
  168. line-height: 1.65;
  169. font-size: 14px;
  170. white-space: pre-wrap;
  171. word-break: break-word;
  172. border: 1px solid var(--border);
  173. }
  174. .message.user {
  175. background: var(--user-bubble);
  176. border-color: #c9dcff;
  177. }
  178. .message.assistant {
  179. background: var(--assistant-bubble);
  180. border-color: var(--border);
  181. }
  182. .message.error {
  183. background: #fdecea;
  184. border-color: #f5c6c0;
  185. color: var(--danger);
  186. }
  187. .message.system {
  188. max-width: 100%;
  189. background: #ffffff;
  190. color: var(--muted);
  191. font-size: 13px;
  192. border-style: dashed;
  193. }
  194. #status {
  195. padding: 8px 22px;
  196. min-height: 26px;
  197. border-top: 1px solid var(--border);
  198. color: var(--muted);
  199. font-size: 13px;
  200. background: #ffffff;
  201. }
  202. .progress-flow {
  203. display: flex;
  204. flex-wrap: wrap;
  205. gap: 6px;
  206. padding: 0 0 8px;
  207. background: transparent;
  208. border: none;
  209. }
  210. .answer-text {
  211. min-height: 1em;
  212. }
  213. .step-chip {
  214. display: inline-flex;
  215. align-items: center;
  216. gap: 4px;
  217. border: 1px solid var(--border);
  218. border-radius: 999px;
  219. padding: 4px 9px;
  220. font-size: 12px;
  221. color: var(--muted);
  222. background: #ffffff;
  223. }
  224. .step-chip.active {
  225. border-color: var(--primary);
  226. color: var(--primary-dark);
  227. background: #e7f0ff;
  228. }
  229. .step-chip.done {
  230. opacity: 0.72;
  231. background: #f0f3f7;
  232. }
  233. #confirmPanel {
  234. display: none;
  235. margin: 0 22px 16px;
  236. padding: 14px;
  237. border: 1px solid #f1d18b;
  238. background: var(--warn-bg);
  239. border-radius: 12px;
  240. }
  241. #confirmPanel.show { display: block; }
  242. #confirmMessage {
  243. white-space: pre-wrap;
  244. margin-bottom: 12px;
  245. font-size: 14px;
  246. line-height: 1.6;
  247. }
  248. .option-list {
  249. display: flex;
  250. flex-wrap: wrap;
  251. gap: 8px;
  252. margin-bottom: 12px;
  253. }
  254. .option-chip {
  255. border: 1px solid #d9b76c;
  256. background: #fff8e1;
  257. border-radius: 999px;
  258. padding: 6px 10px;
  259. font-size: 13px;
  260. cursor: pointer;
  261. }
  262. .option-chip:hover { background: #ffedb3; }
  263. .confirm-actions {
  264. display: flex;
  265. gap: 8px;
  266. align-items: center;
  267. }
  268. .confirm-actions input {
  269. flex: 1;
  270. }
  271. </style>
  272. </head>
  273. <body>
  274. <main class="app">
  275. <header>
  276. <h1>申勤物业知识助手</h1>
  277. <div class="controls">
  278. <label class="check-line" title="开启后系统自动确认查询条件与查询计划;关闭后需要你在页面确认或修改。">
  279. <input id="autoConfirm" type="checkbox" checked />
  280. <span>自动确认</span>
  281. </label>
  282. <label class="check-line" title="开启后优先判断是否可复用同一会话中的历史回答;关闭后每次都重新查询知识图谱。">
  283. <input id="reuseCheck" type="checkbox" checked />
  284. <span>复用历史</span>
  285. </label>
  286. <div class="spacer"></div>
  287. <button id="newSessionBtn" class="btn btn-ghost">新会话</button>
  288. </div>
  289. </header>
  290. <div id="chat" aria-live="polite"></div>
  291. <div id="confirmPanel">
  292. <div id="confirmMessage"></div>
  293. <div id="confirmOptions" class="option-list"></div>
  294. <div class="confirm-actions">
  295. <input id="confirmReply" type="text" placeholder="回复:确认 / 或说明修改" />
  296. <button id="confirmSendBtn" class="btn btn-primary">继续</button>
  297. </div>
  298. </div>
  299. <footer class="composer">
  300. <textarea id="questionInput" rows="1" placeholder="输入问题,Ctrl + Enter 发送"></textarea>
  301. <button id="sendBtn" class="btn btn-primary">发送</button>
  302. </footer>
  303. <div id="status">准备就绪</div>
  304. </main>
  305. <script>
  306. const $ = (id) => document.getElementById(id);
  307. const chatEl = $("chat");
  308. const statusEl = $("status");
  309. const sendBtn = $("sendBtn");
  310. const questionInput = $("questionInput");
  311. const autoConfirmInput = $("autoConfirm");
  312. const reuseCheckInput = $("reuseCheck");
  313. const confirmPanel = $("confirmPanel");
  314. let activeProgressEl = null;
  315. const THREAD_KEY = "kg_thread_id";
  316. function getThreadId() {
  317. let id = sessionStorage.getItem(THREAD_KEY);
  318. if (!id) {
  319. id = typeof crypto !== "undefined" && crypto.randomUUID
  320. ? crypto.randomUUID()
  321. : "thread-" + Date.now() + "-" + Math.random().toString(16).slice(2);
  322. sessionStorage.setItem(THREAD_KEY, id);
  323. }
  324. return id;
  325. }
  326. function newThreadId() {
  327. const id = typeof crypto !== "undefined" && crypto.randomUUID
  328. ? crypto.randomUUID()
  329. : "thread-" + Date.now() + "-" + Math.random().toString(16).slice(2);
  330. sessionStorage.setItem(THREAD_KEY, id);
  331. return id;
  332. }
  333. function apiBase() {
  334. if (location.protocol === "http:" || location.protocol === "https:") {
  335. return location.origin;
  336. }
  337. return "http://127.0.0.1:8000";
  338. }
  339. function setStatus(text) {
  340. statusEl.textContent = text;
  341. }
  342. function addProgressStep(node, label, container) {
  343. const chips = Array.from(container.querySelectorAll(".step-chip"));
  344. chips.forEach((chip) => chip.classList.remove("active"));
  345. chips.forEach((chip) => chip.classList.add("done"));
  346. const chip = document.createElement("span");
  347. chip.className = "step-chip active";
  348. chip.textContent = label || node;
  349. chip.title = node;
  350. container.appendChild(chip);
  351. container.scrollLeft = container.scrollWidth;
  352. }
  353. function finishProgressFlow(container, label) {
  354. const chips = Array.from(container.querySelectorAll(".step-chip"));
  355. chips.forEach((chip) => chip.classList.remove("active"));
  356. chips.forEach((chip) => chip.classList.add("done"));
  357. const chip = document.createElement("span");
  358. chip.className = "step-chip done";
  359. chip.textContent = label || "完成";
  360. container.appendChild(chip);
  361. container.scrollLeft = container.scrollWidth;
  362. }
  363. function appendMessage(role, text) {
  364. const row = document.createElement("div");
  365. row.className = `message-row ${role}`;
  366. if (role === "assistant") {
  367. const img = document.createElement("img");
  368. img.className = "avatar";
  369. img.src = "微信图片_20260817170012_14_2.png";
  370. img.alt = "AI";
  371. row.appendChild(img);
  372. } else if (role === "user") {
  373. const avatar = document.createElement("div");
  374. avatar.className = "avatar user-avatar";
  375. avatar.textContent = "我";
  376. row.appendChild(avatar);
  377. }
  378. const div = document.createElement("div");
  379. div.className = `message ${role}`;
  380. div.textContent = text || "";
  381. row.appendChild(div);
  382. chatEl.appendChild(row);
  383. chatEl.scrollTop = chatEl.scrollHeight;
  384. return div;
  385. }
  386. function appendSystem(text) {
  387. return appendMessage("system", text);
  388. }
  389. function appendError(text) {
  390. const div = appendMessage("error", text);
  391. setStatus("处理失败");
  392. return div;
  393. }
  394. function hideConfirm() {
  395. confirmPanel.classList.remove("show");
  396. $("confirmReply").value = "";
  397. $("confirmOptions").innerHTML = "";
  398. }
  399. function showConfirm(message, options) {
  400. $("confirmMessage").textContent = message || "请确认查询条件";
  401. const box = $("confirmOptions");
  402. box.innerHTML = "";
  403. (options || []).forEach((opt) => {
  404. const chip = document.createElement("span");
  405. chip.className = "option-chip";
  406. chip.textContent = opt;
  407. chip.onclick = () => { $("confirmReply").value = opt; };
  408. box.appendChild(chip);
  409. });
  410. confirmPanel.classList.add("show");
  411. $("confirmReply").focus();
  412. setStatus("等待人工确认");
  413. confirmPanel.scrollIntoView({ behavior: "smooth", block: "center" });
  414. }
  415. function parseSseLine(line) {
  416. if (line.startsWith("event: ")) return ["event", line.slice(7).trim()];
  417. if (line.startsWith("data: ")) return ["data", line.slice(6)];
  418. return null;
  419. }
  420. async function askStream(question, autoConfirm, reuseCheck) {
  421. const threadId = getThreadId();
  422. const controller = new AbortController();
  423. const response = await fetch(`${apiBase()}/ask/stream`, {
  424. method: "POST",
  425. headers: { "Content-Type": "application/json" },
  426. body: JSON.stringify({
  427. thread_id: threadId,
  428. query: question,
  429. auto_confirm: autoConfirm,
  430. reuse_check: reuseCheck,
  431. }),
  432. signal: controller.signal,
  433. });
  434. if (!response.ok || !response.body) {
  435. throw new Error(`HTTP ${response.status}`);
  436. }
  437. const reader = response.body.getReader();
  438. const decoder = new TextDecoder();
  439. let buffer = "";
  440. let event = "";
  441. let data = "";
  442. const assistantRow = document.createElement("div");
  443. assistantRow.className = "message-row assistant";
  444. const avatar = document.createElement("img");
  445. avatar.className = "avatar";
  446. avatar.src = "微信图片_20260817170012_14_2.png";
  447. avatar.alt = "AI";
  448. assistantRow.appendChild(avatar);
  449. const assistantBubble = document.createElement("div");
  450. assistantBubble.className = "message assistant";
  451. const progressEl = document.createElement("div");
  452. progressEl.className = "progress-flow";
  453. activeProgressEl = progressEl;
  454. const answerEl = document.createElement("div");
  455. answerEl.className = "answer-text";
  456. assistantBubble.appendChild(progressEl);
  457. assistantBubble.appendChild(answerEl);
  458. assistantRow.appendChild(assistantBubble);
  459. chatEl.appendChild(assistantRow);
  460. chatEl.scrollTop = chatEl.scrollHeight;
  461. let gotAnswer = false;
  462. const handleEvent = () => {
  463. if (!event) return;
  464. if (event === "progress") {
  465. try {
  466. const payload = JSON.parse(data);
  467. addProgressStep(payload.node, payload.label, progressEl);
  468. setStatus(payload.label || "处理中");
  469. } catch (_) {}
  470. } else if (event === "answer_chunk") {
  471. try {
  472. const payload = JSON.parse(data);
  473. if (!gotAnswer) {
  474. addProgressStep("answer", "正在生成回答", progressEl);
  475. setStatus("正在生成回答");
  476. }
  477. answerEl.textContent += payload.text || "";
  478. chatEl.scrollTop = chatEl.scrollHeight;
  479. gotAnswer = true;
  480. } catch (_) {}
  481. } else if (event === "confirm") {
  482. try {
  483. const payload = JSON.parse(data);
  484. addProgressStep("confirm", "等待人工确认", progressEl);
  485. showConfirm(payload.message, payload.options);
  486. } catch (_) {}
  487. } else if (event === "done") {
  488. try {
  489. const payload = JSON.parse(data);
  490. if (!gotAnswer && payload.answer) {
  491. answerEl.textContent = payload.answer;
  492. }
  493. finishProgressFlow(progressEl, "完成");
  494. setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
  495. } catch (_) {}
  496. } else if (event === "error") {
  497. try {
  498. const payload = JSON.parse(data);
  499. assistantBubble.className = "message error";
  500. progressEl.style.display = "none";
  501. answerEl.textContent = payload.message || "请求失败";
  502. finishProgressFlow(progressEl, "失败");
  503. progressEl.style.display = "flex";
  504. setStatus("处理失败");
  505. } catch (_) {}
  506. }
  507. event = "";
  508. data = "";
  509. };
  510. while (true) {
  511. const { value, done } = await reader.read();
  512. if (done) break;
  513. buffer += decoder.decode(value, { stream: true });
  514. let idx;
  515. while ((idx = buffer.indexOf("\n\n")) >= 0) {
  516. const raw = buffer.slice(0, idx);
  517. buffer = buffer.slice(idx + 2);
  518. const lines = raw.split("\n");
  519. event = "";
  520. data = "";
  521. for (const line of lines) {
  522. const parsed = parseSseLine(line);
  523. if (!parsed) continue;
  524. if (parsed[0] === "event") event = parsed[1];
  525. else if (parsed[0] === "data") data = parsed[1];
  526. }
  527. handleEvent();
  528. }
  529. }
  530. // 处理最后可能没有结尾空行的数据
  531. if (buffer.trim()) {
  532. const parsed = parseSseLine(buffer.trim());
  533. if (parsed) {
  534. if (parsed[0] === "event") event = parsed[1];
  535. else if (parsed[0] === "data") data = parsed[1];
  536. handleEvent();
  537. }
  538. }
  539. return { threadId };
  540. }
  541. async function resumeThread(reply) {
  542. const threadId = getThreadId();
  543. const res = await fetch(`${apiBase()}/threads/${encodeURIComponent(threadId)}/resume`, {
  544. method: "POST",
  545. headers: { "Content-Type": "application/json" },
  546. body: JSON.stringify({ reply }),
  547. });
  548. const payload = await res.json();
  549. if (payload.status === "need_confirm") {
  550. if (activeProgressEl) addProgressStep("confirm", "等待人工确认", activeProgressEl);
  551. showConfirm(payload.message, payload.options);
  552. } else if (payload.status === "ok") {
  553. hideConfirm();
  554. if (activeProgressEl) finishProgressFlow(activeProgressEl, "完成");
  555. appendMessage("assistant", payload.answer || "");
  556. setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
  557. } else {
  558. hideConfirm();
  559. if (activeProgressEl) finishProgressFlow(activeProgressEl, "失败");
  560. appendError(payload.message || "恢复会话失败");
  561. }
  562. }
  563. async function sendQuestion() {
  564. const question = questionInput.value.trim();
  565. if (!question) return;
  566. appendMessage("user", question);
  567. questionInput.value = "";
  568. hideConfirm();
  569. sendBtn.disabled = true;
  570. setStatus("正在发送...");
  571. try {
  572. await askStream(question, autoConfirmInput.checked, reuseCheckInput.checked);
  573. } catch (err) {
  574. appendError(err.message || String(err));
  575. } finally {
  576. sendBtn.disabled = false;
  577. questionInput.focus();
  578. }
  579. }
  580. sendBtn.addEventListener("click", sendQuestion);
  581. questionInput.addEventListener("keydown", (e) => {
  582. if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
  583. e.preventDefault();
  584. sendQuestion();
  585. }
  586. });
  587. $("confirmSendBtn").addEventListener("click", async () => {
  588. const btn = $("confirmSendBtn");
  589. btn.disabled = true;
  590. btn.textContent = "处理中...";
  591. const reply = $("confirmReply").value.trim() || "确认";
  592. setStatus("正在恢复会话...");
  593. try {
  594. await resumeThread(reply);
  595. } catch (err) {
  596. appendError(err.message || String(err));
  597. } finally {
  598. btn.disabled = false;
  599. btn.textContent = "继续";
  600. }
  601. });
  602. $("newSessionBtn").addEventListener("click", () => {
  603. newThreadId();
  604. chatEl.innerHTML = "";
  605. hideConfirm();
  606. setStatus(`新会话已创建:${getThreadId()}`);
  607. appendSystem("已创建新会话。当前 thread_id 会在本浏览器标签页内保持不变。");
  608. });
  609. appendSystem(`当前会话 thread_id:${getThreadId()}`);
  610. </script>
  611. </body>
  612. </html>