Files
student_system/frontend/floating-assistant.js
2026-09-21 19:22:31 +08:00

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();
});
})();