161 lines
9.3 KiB
JavaScript
161 lines
9.3 KiB
JavaScript
/* 全页面悬浮问答:复用后端 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();
|
|
});
|
|
})();
|