| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746 |
- <!doctype html>
- <html lang="zh-CN">
- <head>
- <meta charset="utf-8">
- <meta name="viewport" content="width=device-width, initial-scale=1">
- <title>申勤物业知识助手</title>
- <style>
- :root {
- --bg: #f5f7fb;
- --panel: #ffffff;
- --border: #d9e1ec;
- --primary: #2f5597;
- --primary-dark: #24426f;
- --text: #22303f;
- --muted: #7a8794;
- --user-bubble: #e7f0ff;
- --assistant-bubble: #ffffff;
- --danger: #c0392b;
- --warn-bg: #fff7e6;
- }
- * { box-sizing: border-box; }
- body {
- margin: 0;
- background: var(--bg);
- color: var(--text);
- font-family: "Microsoft YaHei", "PingFang SC", system-ui, sans-serif;
- height: 100vh;
- display: flex;
- justify-content: center;
- align-items: center;
- }
- .app {
- width: min(960px, 96vw);
- height: min(900px, 94vh);
- background: var(--panel);
- border: 1px solid var(--border);
- border-radius: 16px;
- box-shadow: 0 18px 50px rgba(35, 48, 63, 0.12);
- display: flex;
- flex-direction: column;
- overflow: hidden;
- }
- header {
- padding: 14px 18px;
- border-bottom: 1px solid var(--border);
- background: #ffffff;
- }
- header h1 {
- margin: 0 0 10px;
- font-size: 18px;
- color: var(--primary-dark);
- }
- .controls {
- display: flex;
- flex-wrap: wrap;
- gap: 10px;
- align-items: center;
- }
- .controls .spacer {
- flex: 1;
- }
- label {
- display: flex;
- flex-direction: column;
- gap: 6px;
- font-size: 13px;
- color: var(--muted);
- }
- input[type="text"],
- input[type="url"],
- textarea {
- width: 100%;
- border: 1px solid var(--border);
- border-radius: 9px;
- padding: 9px 10px;
- font-size: 14px;
- font-family: inherit;
- resize: vertical;
- background: #fff;
- }
- input:focus,
- textarea:focus {
- outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(47, 85, 151, 0.12);
- }
- .check-line {
- flex-direction: row;
- align-items: center;
- gap: 7px;
- color: var(--text);
- font-size: 13px;
- white-space: nowrap;
- }
- .composer {
- display: flex;
- gap: 10px;
- padding: 12px 16px;
- border-top: 1px solid var(--border);
- background: #ffffff;
- }
- .composer textarea {
- flex: 1;
- min-height: 42px;
- max-height: 150px;
- }
- .btn {
- border: 0;
- border-radius: 9px;
- padding: 10px 14px;
- font-size: 14px;
- cursor: pointer;
- transition: background 0.2s ease;
- font-family: inherit;
- white-space: nowrap;
- }
- .btn-primary {
- background: var(--primary);
- color: #ffffff;
- }
- .btn-primary:hover { background: var(--primary-dark); }
- .btn-primary:disabled { opacity: 0.55; cursor: not-allowed; }
- .btn-ghost {
- background: #eef2f7;
- color: var(--text);
- }
- .btn-ghost:hover { background: #e0e7f0; }
- #chat {
- flex: 1;
- overflow-y: auto;
- padding: 22px;
- background: var(--bg);
- position: relative;
- }
- .message-row {
- position: relative;
- z-index: 2;
- }
- .message-row {
- display: flex;
- align-items: flex-start;
- gap: 10px;
- margin-bottom: 16px;
- }
- .message-row.user {
- flex-direction: row-reverse;
- }
- .avatar {
- width: 34px;
- height: 34px;
- border-radius: 50%;
- object-fit: cover;
- flex: none;
- border: 1px solid var(--border);
- }
- .user-avatar {
- display: flex;
- align-items: center;
- justify-content: center;
- background: var(--primary);
- color: #fff;
- font-size: 13px;
- }
- .message {
- max-width: 78%;
- padding: 12px 14px;
- border-radius: 12px;
- line-height: 1.65;
- font-size: 14px;
- white-space: pre-wrap;
- word-break: break-word;
- border: 1px solid var(--border);
- }
- .message.user {
- background: var(--user-bubble);
- border-color: #c9dcff;
- }
- .message.assistant {
- background: var(--assistant-bubble);
- border-color: var(--border);
- }
- .message.error {
- background: #fdecea;
- border-color: #f5c6c0;
- color: var(--danger);
- }
- .message.system {
- max-width: 100%;
- background: #ffffff;
- color: var(--muted);
- font-size: 13px;
- border-style: dashed;
- }
- #status {
- padding: 8px 22px;
- min-height: 26px;
- border-top: 1px solid var(--border);
- color: var(--muted);
- font-size: 13px;
- background: #ffffff;
- }
- .progress-flow {
- display: flex;
- flex-wrap: wrap;
- gap: 6px;
- padding: 0 0 8px;
- background: transparent;
- border: none;
- }
- .answer-text {
- min-height: 1em;
- }
- .step-chip {
- display: inline-flex;
- align-items: center;
- gap: 4px;
- border: 1px solid var(--border);
- border-radius: 999px;
- padding: 4px 9px;
- font-size: 12px;
- color: var(--muted);
- background: #ffffff;
- }
- .step-chip.active {
- border-color: var(--primary);
- color: var(--primary-dark);
- background: #e7f0ff;
- }
- .step-chip.done {
- opacity: 0.72;
- background: #f0f3f7;
- }
- #confirmPanel {
- display: none;
- margin: 0 22px 16px;
- padding: 14px;
- border: 1px solid #f1d18b;
- background: var(--warn-bg);
- border-radius: 12px;
- }
- #confirmPanel.show { display: block; }
- #confirmMessage {
- white-space: pre-wrap;
- margin-bottom: 12px;
- font-size: 14px;
- line-height: 1.6;
- }
- .option-list {
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- margin-bottom: 12px;
- }
- .option-chip {
- border: 1px solid #d9b76c;
- background: #fff8e1;
- border-radius: 999px;
- padding: 6px 10px;
- font-size: 13px;
- cursor: pointer;
- }
- .option-chip:hover { background: #ffedb3; }
- .option-chip.selected {
- color: #5f4300;
- border-color: #b57b00;
- background: #ffd86b;
- box-shadow: 0 0 0 2px rgba(181, 123, 0, .12);
- }
- .confirm-actions {
- display: flex;
- gap: 8px;
- align-items: center;
- }
- .confirm-actions input {
- flex: 1;
- }
- </style>
- <link rel="stylesheet" href="data_quality.css?v=20260904">
- </head>
- <body>
- <main class="app">
- <header>
- <h1>申勤物业知识助手</h1>
- <div class="controls">
- <label class="check-line" title="开启后系统自动确认查询条件与查询计划;关闭后需要你在页面确认或修改。">
- <input id="autoConfirm" type="checkbox" autocomplete="off" />
- <span>自动确认</span>
- </label>
- <label class="check-line" title="开启后优先判断是否可复用同一会话中的历史回答;关闭后每次都重新查询知识图谱。">
- <input id="reuseCheck" type="checkbox" checked />
- <span>复用历史</span>
- </label>
- <div class="spacer"></div>
- <button id="newSessionBtn" class="btn btn-ghost">新会话</button>
- </div>
- </header>
- <div id="chat" aria-live="polite"></div>
- <div id="confirmPanel">
- <div id="confirmMessage"></div>
- <div id="confirmOptions" class="option-list"></div>
- <div class="confirm-actions">
- <input id="confirmReply" type="text" placeholder="回复:确认 / 或说明修改" />
- <button id="confirmSendBtn" class="btn btn-primary">继续</button>
- </div>
- </div>
- <footer class="composer">
- <textarea id="questionInput" rows="1" placeholder="输入问题,Ctrl + Enter 发送"></textarea>
- <button id="sendBtn" class="btn btn-primary">发送</button>
- </footer>
- <div id="status">准备就绪</div>
- </main>
- <link rel="stylesheet" href="answer_markdown.css?v=20260904-qa">
- <script src="vendor/marked.umd.js?v=17.0.5"></script>
- <script src="answer_markdown.js?v=20260904-qa"></script>
- <link rel="stylesheet" href="answer_cache.css?v=20260907-cache">
- <script src="answer_cache.js?v=20260907-cache"></script>
- <script>
- const $ = (id) => document.getElementById(id);
- const chatEl = $("chat");
- const statusEl = $("status");
- const sendBtn = $("sendBtn");
- const questionInput = $("questionInput");
- const autoConfirmInput = $("autoConfirm");
- const reuseCheckInput = $("reuseCheck");
- const confirmPanel = $("confirmPanel");
- function resetAutoConfirmDefault() {
- autoConfirmInput.checked = false;
- }
- resetAutoConfirmDefault();
- window.addEventListener("pageshow", resetAutoConfirmDefault);
- let activeProgressEl = null;
- const THREAD_KEY = "kg_thread_id";
- function getThreadId() {
- let id = sessionStorage.getItem(THREAD_KEY);
- if (!id) {
- id = typeof crypto !== "undefined" && crypto.randomUUID
- ? crypto.randomUUID()
- : "thread-" + Date.now() + "-" + Math.random().toString(16).slice(2);
- sessionStorage.setItem(THREAD_KEY, id);
- }
- return id;
- }
- function newThreadId() {
- const id = typeof crypto !== "undefined" && crypto.randomUUID
- ? crypto.randomUUID()
- : "thread-" + Date.now() + "-" + Math.random().toString(16).slice(2);
- sessionStorage.setItem(THREAD_KEY, id);
- return id;
- }
- function apiBase() {
- if (location.protocol === "http:" || location.protocol === "https:") {
- return location.origin;
- }
- return "http://127.0.0.1:8000";
- }
- function setStatus(text) {
- statusEl.textContent = text;
- }
- function addProgressStep(node, label, container) {
- const chips = Array.from(container.querySelectorAll(".step-chip"));
- chips.forEach((chip) => chip.classList.remove("active"));
- chips.forEach((chip) => chip.classList.add("done"));
- const chip = document.createElement("span");
- chip.className = "step-chip active";
- chip.textContent = label || node;
- chip.title = node;
- container.appendChild(chip);
- container.scrollLeft = container.scrollWidth;
- }
- function finishProgressFlow(container, label) {
- const chips = Array.from(container.querySelectorAll(".step-chip"));
- chips.forEach((chip) => chip.classList.remove("active"));
- chips.forEach((chip) => chip.classList.add("done"));
- const chip = document.createElement("span");
- chip.className = "step-chip done";
- chip.textContent = label || "完成";
- container.appendChild(chip);
- container.scrollLeft = container.scrollWidth;
- }
- function appendMessage(role, text) {
- const row = document.createElement("div");
- row.className = `message-row ${role}`;
- if (role === "assistant") {
- const img = document.createElement("img");
- img.className = "avatar";
- img.src = "微信图片_20260817170012_14_2.png";
- img.alt = "AI";
- row.appendChild(img);
- } else if (role === "user") {
- const avatar = document.createElement("div");
- avatar.className = "avatar user-avatar";
- avatar.textContent = "我";
- row.appendChild(avatar);
- }
- const div = document.createElement("div");
- div.className = `message ${role}`;
- if (role === "assistant") renderAnswer(div, text);
- else div.textContent = text || "";
- row.appendChild(div);
- chatEl.appendChild(row);
- chatEl.scrollTop = chatEl.scrollHeight;
- return div;
- }
- function appendSystem(text) {
- return appendMessage("system", text);
- }
- function appendError(text) {
- const div = appendMessage("error", text);
- setStatus("处理失败");
- return div;
- }
- function hideConfirm() {
- confirmPanel.classList.remove("show");
- $("confirmReply").value = "";
- $("confirmOptions").innerHTML = "";
- }
- function showConfirm(message, options, confirmType = "") {
- $("confirmMessage").textContent = message || "请确认查询条件";
- const box = $("confirmOptions");
- const replyInput = $("confirmReply");
- const multiple = confirmType === "clarify_value";
- replyInput.placeholder = multiple ? "可多选候选,或输入多个序号(逗号分隔)" : "回复:确认 / 或说明修改";
- const selected = new Set();
- box.innerHTML = "";
- replyInput.value = "";
- (options || []).forEach((opt) => {
- const chip = document.createElement("button");
- chip.type = "button";
- chip.className = "option-chip";
- chip.textContent = opt;
- chip.setAttribute("aria-pressed", "false");
- chip.onclick = () => {
- if (!multiple) {
- replyInput.value = opt;
- return;
- }
- if (opt === "都不是") {
- selected.clear();
- box.querySelectorAll(".option-chip").forEach(item => {
- item.classList.remove("selected");
- item.setAttribute("aria-pressed", "false");
- });
- selected.add(opt);
- } else {
- selected.delete("都不是");
- const decline = Array.from(box.children).find(item => item.textContent === "都不是");
- if (decline) {
- decline.classList.remove("selected");
- decline.setAttribute("aria-pressed", "false");
- }
- if (selected.has(opt)) selected.delete(opt);
- else selected.add(opt);
- }
- chip.classList.toggle("selected", selected.has(opt));
- chip.setAttribute("aria-pressed", selected.has(opt) ? "true" : "false");
- replyInput.value = Array.from(selected).join(",");
- };
- box.appendChild(chip);
- });
- confirmPanel.classList.add("show");
- replyInput.focus();
- setStatus("等待人工确认");
- confirmPanel.scrollIntoView({ behavior: "smooth", block: "center" });
- }
- function parseSseLine(line) {
- if (line.startsWith("event: ")) return ["event", line.slice(7).trim()];
- if (line.startsWith("data: ")) return ["data", line.slice(6)];
- return null;
- }
- async function askStream(question, autoConfirm, reuseCheck) {
- const threadId = getThreadId();
- const controller = new AbortController();
- const response = await fetch(`${apiBase()}/ask/stream`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({
- thread_id: threadId,
- query: question,
- auto_confirm: autoConfirm,
- reuse_check: reuseCheck,
- }),
- signal: controller.signal,
- });
- if (!response.ok || !response.body) {
- throw new Error(`HTTP ${response.status}`);
- }
- const reader = response.body.getReader();
- const decoder = new TextDecoder();
- let buffer = "";
- let event = "";
- let data = "";
- const assistantRow = document.createElement("div");
- assistantRow.className = "message-row assistant";
- const avatar = document.createElement("img");
- avatar.className = "avatar";
- avatar.src = "微信图片_20260817170012_14_2.png";
- avatar.alt = "AI";
- assistantRow.appendChild(avatar);
- const assistantBubble = document.createElement("div");
- assistantBubble.className = "message assistant";
- const progressEl = document.createElement("div");
- progressEl.className = "progress-flow";
- activeProgressEl = progressEl;
- const answerEl = document.createElement("div");
- answerEl.className = "answer-text";
- assistantBubble.appendChild(progressEl);
- assistantBubble.appendChild(answerEl);
- assistantRow.appendChild(assistantBubble);
- chatEl.appendChild(assistantRow);
- chatEl.scrollTop = chatEl.scrollHeight;
- let gotAnswer = false;
- let answerText = "";
- const handleEvent = () => {
- if (!event) return;
- if (event === "progress") {
- try {
- const payload = JSON.parse(data);
- addProgressStep(payload.node, payload.label, progressEl);
- setStatus(payload.label || "处理中");
- } catch (_) {}
- } else if (event === "answer_chunk") {
- try {
- const payload = JSON.parse(data);
- if (!gotAnswer) {
- addProgressStep("answer", "正在生成回答", progressEl);
- setStatus("正在生成回答");
- }
- answerText += payload.text || "";
- renderAnswer(answerEl, answerText);
- chatEl.scrollTop = chatEl.scrollHeight;
- gotAnswer = true;
- } catch (_) {}
- } else if (event === "confirm") {
- try {
- const payload = JSON.parse(data);
- addProgressStep("confirm", "等待人工确认", progressEl);
- showConfirm(payload.message, payload.options, payload.confirm_type || payload.type);
- } catch (_) {}
- } else if (event === "done") {
- try {
- const payload = JSON.parse(data);
- if (typeof payload.answer === "string") answerText = payload.answer;
- renderAnswer(answerEl, answerText);
- AnswerCacheUI.attach(assistantBubble, payload, threadId, apiBase());
- finishProgressFlow(progressEl, "完成");
- setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
- } catch (_) {}
- } else if (event === "error") {
- try {
- const payload = JSON.parse(data);
- assistantBubble.className = "message error";
- progressEl.style.display = "none";
- answerEl.textContent = payload.message || "请求失败";
- finishProgressFlow(progressEl, "失败");
- progressEl.style.display = "flex";
- setStatus("处理失败");
- } catch (_) {}
- }
- event = "";
- data = "";
- };
- while (true) {
- const { value, done } = await reader.read();
- if (done) break;
- buffer += decoder.decode(value, { stream: true });
- let idx;
- while ((idx = buffer.indexOf("\n\n")) >= 0) {
- const raw = buffer.slice(0, idx);
- buffer = buffer.slice(idx + 2);
- const lines = raw.split("\n");
- event = "";
- data = "";
- for (const line of lines) {
- const parsed = parseSseLine(line);
- if (!parsed) continue;
- if (parsed[0] === "event") event = parsed[1];
- else if (parsed[0] === "data") data = parsed[1];
- }
- handleEvent();
- }
- }
- // 处理最后可能没有结尾空行的数据
- if (buffer.trim()) {
- const parsed = parseSseLine(buffer.trim());
- if (parsed) {
- if (parsed[0] === "event") event = parsed[1];
- else if (parsed[0] === "data") data = parsed[1];
- handleEvent();
- }
- }
- return { threadId };
- }
- async function resumeThread(reply) {
- const threadId = getThreadId();
- const res = await fetch(`${apiBase()}/threads/${encodeURIComponent(threadId)}/resume`, {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ reply }),
- });
- const payload = await res.json();
- if (payload.status === "need_confirm") {
- if (activeProgressEl) addProgressStep("confirm", "等待人工确认", activeProgressEl);
- showConfirm(payload.message, payload.options, payload.confirm_type || payload.type);
- } else if (payload.status === "ok") {
- hideConfirm();
- if (activeProgressEl) finishProgressFlow(activeProgressEl, "完成");
- const answerBubble = appendMessage("assistant", payload.answer || "");
- AnswerCacheUI.attach(answerBubble, payload, threadId, apiBase());
- setStatus(`完成,耗时 ${payload.elapsed_sec || 0} 秒`);
- } else {
- hideConfirm();
- if (activeProgressEl) finishProgressFlow(activeProgressEl, "失败");
- appendError(payload.message || "恢复会话失败");
- }
- }
- async function sendQuestion() {
- const question = questionInput.value.trim();
- if (!question) return;
- appendMessage("user", question);
- questionInput.value = "";
- hideConfirm();
- sendBtn.disabled = true;
- setStatus("正在发送...");
- try {
- await askStream(question, autoConfirmInput.checked, reuseCheckInput.checked);
- } catch (err) {
- appendError(err.message || String(err));
- } finally {
- sendBtn.disabled = false;
- questionInput.focus();
- }
- }
- sendBtn.addEventListener("click", sendQuestion);
- questionInput.addEventListener("keydown", (e) => {
- if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
- e.preventDefault();
- sendQuestion();
- }
- });
- $("confirmSendBtn").addEventListener("click", async () => {
- const btn = $("confirmSendBtn");
- btn.disabled = true;
- btn.textContent = "处理中...";
- const reply = $("confirmReply").value.trim() || "确认";
- setStatus("正在恢复会话...");
- try {
- await resumeThread(reply);
- } catch (err) {
- appendError(err.message || String(err));
- } finally {
- btn.disabled = false;
- btn.textContent = "继续";
- }
- });
- $("newSessionBtn").addEventListener("click", () => {
- newThreadId();
- chatEl.innerHTML = "";
- hideConfirm();
- setStatus(`新会话已创建:${getThreadId()}`);
- appendSystem("已创建新会话。当前 thread_id 会在本浏览器标签页内保持不变。");
- });
- appendSystem(`当前会话 thread_id:${getThreadId()}`);
- </script>
- <script src="data_quality.js?v=20260904"></script>
- </body>
- </html>
|