/* AI 聊天界面只调用后端接口,密钥始终留在 Python 服务端。 */
"use strict";
const chatState = {busy:false, count:0};
const chatElement = id => document.getElementById(id);
const safeText = value => String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
function inlineText(text) { return safeText(text).replace(/\*\*([^*]+)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"); }
// 仅支持常用文字、列表和表格;不执行模型回答中的HTML或脚本。
function answerMarkup(text) {
const lines = text.replace(/\r\n/g, "\n").split("\n");
const parts = [];
const cells = line => line.trim().replace(/^\|/, "").replace(/\|$/, "").split("|").map(v => v.trim());
for (let i = 0; i < lines.length; i++) {
if (lines[i].includes("|") && i + 1 < lines.length && /^\s*\|?\s*:?-{3,}/.test(lines[i + 1])) {
const header = cells(lines[i]); i += 2; const rows = [];
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) { rows.push(cells(lines[i])); i++; }
i--;
parts.push(`
| ${inlineText(c)} | `).join("")}
|---|
| ${inlineText(c)} | `).join("")}
• ${inlineText(lines[i].replace(/^\s*[-*]\s+/, ""))}
`); } else if (lines[i].trim()) parts.push(`${inlineText(lines[i])}
`); } return parts.join(""); } function scrollChat() { const area = chatElement("ai-messages"); area.scrollTop = area.scrollHeight; } function appendChat(role, text) { const article = document.createElement("article"); article.className = `chat-message ${role}`; const label = document.createElement("div"); label.className = "chat-label"; label.textContent = role === "user" ? "你" : "沃林 AI 助手"; const body = document.createElement("div"); body.className = "chat-bubble"; if (role === "user") body.textContent = text; else body.innerHTML = answerMarkup(text); article.append(label, body); chatElement("ai-messages").append(article); scrollChat(); return body; } function sourceLabel(source) { if (source.startsWith("需求文档")) return "项目需求文档"; if (source.startsWith("MySQL")) return "业务数据库"; return source; } function appendEvidence(body, result) { const evidence = document.createElement("div"); evidence.className = "ai-evidence"; const label = document.createElement("span"); label.className = "ai-evidence-title"; label.textContent = "本次参考资料"; evidence.append(label); for (const source of Array.isArray(result.sources) ? result.sources : []) { const tag = document.createElement("span"); tag.className = "source-tag"; tag.textContent = sourceLabel(String(source)); evidence.append(tag); } if (result.data && typeof result.data === "object") { const details = document.createElement("details"); const summary = document.createElement("summary"); summary.textContent = "查看实际查询结果"; const pre = document.createElement("pre"); pre.textContent = JSON.stringify(result.data, null, 2); details.append(summary, pre); evidence.append(details); chatElement("ai-evidence-status").textContent = "本次已查询业务数据库,可展开回答下方的查询结果核对。"; } else { const note = document.createElement("p"); note.className = "ai-data-note"; note.textContent = "本次未查询数据库。"; evidence.append(note); chatElement("ai-evidence-status").textContent = "本次没有数据库查询结果,回答请以所提供的参考资料为依据。"; } body.append(evidence); } function setChatBusy(busy) { chatState.busy = busy; chatElement("ai-send").disabled = busy; chatElement("ai-clear").disabled = busy; chatElement("ai-question").readOnly = busy; chatElement("ai-send").textContent = busy ? "正在回答…" : "发送问题 ↗"; chatElement("ai-progress").hidden = !busy; chatElement("ai-progress").textContent = busy ? "正在分析问题、准备资料并生成回答,请稍候…" : ""; document.querySelectorAll("[data-ai-question]").forEach(button => button.disabled = busy); } async function sendQuestion() { if (chatState.busy) return; const question = chatElement("ai-question").value.trim(); if (!question || question.length > 2000) {chatElement("ai-error").textContent = "请输入 1 至 2000 个字符的问题。"; chatElement("ai-error").hidden = false; return;} chatElement("ai-error").hidden = true; chatElement("ai-welcome").hidden = true; chatElement("ai-empty-hint").hidden = true; appendChat("user", question); chatState.count++; setChatBusy(true); try { // 后端可能调用模型两次;AI的等待时间独立于普通业务接口。 const response = await fetch("/api/studentsManagement/ai/chat", { method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify({question}), signal:AbortSignal.timeout(150000), }); let result; try {result = await response.json();} catch {throw new Error("服务未返回有效回答,请稍后重试。");} 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 = appendChat("assistant", result.answer); appendEvidence(body, result); chatElement("ai-question").value = ""; chatElement("ai-length").textContent = "0 / 2000"; } catch (error) { const message = error.name === "TimeoutError" ? "等待回答超时,请稍后重试。" : error instanceof TypeError ? "无法连接 AI 服务,请确认后端服务已启动。" : error.message || "AI 服务调用失败,请稍后重试。"; const body = appendChat("assistant", "本次未能完成回答。"); body.classList.add("chat-failed"); const reason = document.createElement("p"); reason.textContent = message; body.append(reason); chatElement("ai-error").textContent = message + " 原问题已保留,处理后可重新发送。"; chatElement("ai-error").hidden = false; chatElement("ai-evidence-status").textContent = "最近一次请求未完成,没有新的查询结果。"; } finally {setChatBusy(false); scrollChat(); if (location.hash === "#ai") chatElement("ai-question").focus();} } chatElement("ai-form").addEventListener("submit", event => {event.preventDefault(); sendQuestion();}); chatElement("ai-question").addEventListener("input", () => {chatElement("ai-length").textContent = `${chatElement("ai-question").value.length} / 2000`;}); chatElement("ai-question").addEventListener("keydown", event => {if ((event.ctrlKey || event.metaKey) && event.key === "Enter" && !event.isComposing) {event.preventDefault(); sendQuestion();}}); document.querySelectorAll("[data-ai-question]").forEach(button => button.addEventListener("click", () => {if (!chatState.busy) {chatElement("ai-question").value = button.dataset.aiQuestion; chatElement("ai-length").textContent = `${button.dataset.aiQuestion.length} / 2000`; chatElement("ai-question").focus();}})); chatElement("ai-clear").addEventListener("click", () => { if (chatState.busy) return; chatElement("ai-messages").replaceChildren(); chatState.count = 0; chatElement("ai-welcome").hidden = false; chatElement("ai-empty-hint").hidden = false; chatElement("ai-question").value = ""; chatElement("ai-length").textContent = "0 / 2000"; chatElement("ai-error").hidden = true; chatElement("ai-evidence-status").textContent = "回答后可查看本次参考资料及实际查询结果。"; });