/* 全页面悬浮问答:复用后端 AI 接口,不在前端保存密钥。 */ "use strict"; (() => { const byId = id => document.getElementById(id); const trigger = byId("floating-ai-trigger"); const panel = byId("floating-ai-panel"); const input = byId("floating-ai-question"); const messages = byId("floating-ai-messages"); let busy = false; // 超过 6 像素才视为拖动,轻点仍打开聊天;鼠标和触屏共用 Pointer Events。 let drag = null; let suppressClick = false; const margin = 12; const clamp = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max)); function viewport() { const view = window.visualViewport; return {left: view ? view.offsetLeft : 0, top: view ? view.offsetTop : 0, width: view ? view.width : document.documentElement.clientWidth, height: view ? view.height : window.innerHeight}; } function placeTrigger(left, top) { const view = viewport(), box = trigger.getBoundingClientRect(); trigger.style.right = "auto"; trigger.style.bottom = "auto"; trigger.style.left = `${clamp(left, view.left + margin, view.left + view.width - box.width - margin)}px`; trigger.style.top = `${clamp(top, view.top + margin, view.top + view.height - box.height - margin)}px`; placePanel(); } function placePanel() { if (panel.hidden) return; const view = viewport(), anchor = trigger.getBoundingClientRect(); const leftEdge = view.left + margin, topEdge = view.top + margin; const rightEdge = view.left + view.width - margin, bottomEdge = view.top + view.height - margin; const gap = 10; panel.style.right = "auto"; panel.style.bottom = "auto"; panel.style.maxWidth = `${Math.max(0, view.width - margin * 2)}px`; const width = panel.getBoundingClientRect().width; let left, top; // 宽屏优先放在头像旁边,窄屏在头像上方或下方展开。 if (anchor.left - gap - width >= leftEdge || anchor.right + gap + width <= rightEdge) { left = anchor.left - gap - width >= leftEdge ? anchor.left - gap - width : anchor.right + gap; panel.style.maxHeight = `${Math.max(0, view.height - margin * 2)}px`; top = clamp(anchor.bottom - panel.getBoundingClientRect().height, topEdge, bottomEdge - panel.getBoundingClientRect().height); } else { const above = anchor.top - gap - topEdge, below = bottomEdge - anchor.bottom - gap; const useAbove = above >= below; panel.style.maxHeight = `${Math.max(0, useAbove ? above : below)}px`; top = useAbove ? anchor.top - gap - panel.getBoundingClientRect().height : anchor.bottom + gap; left = clamp(anchor.right - width, leftEdge, rightEdge - width); } panel.style.left = `${left}px`; panel.style.top = `${top}px`; } trigger.addEventListener("pointerdown", event => { if (!event.isPrimary || event.button !== 0) return; const box = trigger.getBoundingClientRect(); suppressClick = false; drag = {id: event.pointerId, x: event.clientX, y: event.clientY, left: box.left, top: box.top, moved: false}; trigger.setPointerCapture(event.pointerId); }); trigger.addEventListener("pointermove", event => { if (!drag || drag.id !== event.pointerId) return; const dx = event.clientX - drag.x, dy = event.clientY - drag.y; if (!drag.moved && Math.hypot(dx, dy) < 6) return; drag.moved = true; trigger.classList.add("dragging"); placeTrigger(drag.left + dx, drag.top + dy); }); function endDrag(event) { if (!drag || drag.id !== event.pointerId) return; suppressClick = drag.moved; drag = null; trigger.classList.remove("dragging"); if (trigger.hasPointerCapture(event.pointerId)) trigger.releasePointerCapture(event.pointerId); } trigger.addEventListener("pointerup", endDrag); trigger.addEventListener("pointercancel", endDrag); trigger.addEventListener("lostpointercapture", endDrag); trigger.addEventListener("dragstart", event => event.preventDefault()); function fitViewport() { const box = trigger.getBoundingClientRect(); placeTrigger(box.left, box.top); } window.addEventListener("resize", fitViewport); if (window.visualViewport) { window.visualViewport.addEventListener("resize", fitViewport); window.visualViewport.addEventListener("scroll", fitViewport); } // 回答变长、输入框调整高度时,聊天框也跟随头像保持在可视区域。 if (typeof ResizeObserver !== "undefined") new ResizeObserver(placePanel).observe(panel); function setOpen(open, returnFocus = true) { panel.hidden = !open; if (open) placePanel(); trigger.setAttribute("aria-expanded", String(open)); if (open) {if (!busy) input.focus();} else if (returnFocus) trigger.focus(); } function scrollBottom() {const area = messages.parentElement; area.scrollTop = area.scrollHeight;} function appendMessage(role, text, failed = false) { const article = document.createElement("article"); article.className = `floating-message ${role}${failed ? " failed" : ""}`; const label = document.createElement("span"); label.className = "floating-message-label"; label.textContent = role === "user" ? "你" : "沃林 AI 助手"; const body = document.createElement("div"); body.className = "floating-message-body"; if (role === "assistant" && !failed && typeof answerMarkup === "function") body.innerHTML = answerMarkup(text); else body.textContent = text; article.append(label, body); messages.append(article); scrollBottom(); return body; } function setBusy(value) { busy = value; input.readOnly = value; byId("floating-ai-send").disabled = value; byId("floating-ai-clear").disabled = value; byId("floating-ai-send").textContent = value ? "回答中…" : "发送 ↗"; byId("floating-ai-progress").hidden = !value; trigger.classList.toggle("thinking", value); } async function submitQuestion() { if (busy) return; const question = input.value.trim(); const error = byId("floating-ai-error"); error.hidden = true; if (!question || question.length > 1800) {error.textContent = "请输入 1 至 1800 个字符的问题。"; error.hidden = false; return;} byId("floating-ai-greeting").hidden = true; appendMessage("user", question); setBusy(true); try { const response = await fetch("/api/studentsManagement/ai/chat", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({question:question + "\n请简短回答,优先给出结论和必要数据,尽量控制在150字内;如果数据不足请说明,不要省略影响结论的条件。"}), signal:AbortSignal.timeout(150000), }); let result; try {result = await response.json();} catch {throw new Error("AI 服务没有返回有效回答,请稍后重试。");} if (!response.ok) throw new Error(typeof result.detail === "string" ? result.detail : "AI 服务暂时不可用,请稍后重试。"); if (typeof result.answer !== "string" || !result.answer.trim()) throw new Error("AI 返回了空回答,请稍后重试。"); const body = appendMessage("assistant", result.answer); const sources = document.createElement("small"); sources.className = "floating-sources"; sources.textContent = result.data ? "依据:本次数据库查询与需求资料" : "依据:本次参考资料 · 未查询数据库"; body.append(sources); input.value = ""; byId("floating-ai-count").textContent = "0 / 1800"; } catch (reason) { const message = reason.name === "TimeoutError" ? "等待回答超时,请稍后重试。" : reason instanceof TypeError ? "无法连接服务,请确认后端已启动。" : reason.message || "请求失败,请稍后重试。"; appendMessage("assistant", message, true); error.textContent = "原问题已保留,可以重试。"; error.hidden = false; } finally {setBusy(false); scrollBottom(); if (!panel.hidden && panel.contains(document.activeElement)) input.focus();} } trigger.addEventListener("click", event => { if (suppressClick && event.detail !== 0) {suppressClick = false; return;} suppressClick = false; setOpen(panel.hidden); }); byId("floating-ai-close").addEventListener("click", () => setOpen(false)); panel.addEventListener("keydown", event => {if (event.key === "Escape") {event.preventDefault(); setOpen(false);}}); byId("floating-ai-form").addEventListener("submit", event => {event.preventDefault(); submitQuestion();}); input.addEventListener("input", () => byId("floating-ai-count").textContent = `${input.value.length} / 1800`); input.addEventListener("keydown", event => {if (event.key === "Enter" && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.isComposing) {event.preventDefault(); submitQuestion();}}); byId("floating-ai-clear").addEventListener("click", () => { if (busy) return; messages.replaceChildren(); byId("floating-ai-greeting").hidden = false; byId("floating-ai-error").hidden = true; input.value = ""; byId("floating-ai-count").textContent = "0 / 1800"; input.focus(); }); byId("floating-ai-full").addEventListener("click", () => { // 跳转到现有完整助手,不重复发送,也不扩大数据库操作权限。 if (input.value.trim()) {byId("ai-question").value = input.value.trim(); byId("ai-length").textContent = `${input.value.trim().length} / 2000`;} location.hash = "ai"; setOpen(false, false); byId("ai-question").focus(); }); })();