Files

109 lines
7.8 KiB
JavaScript

/* AI 聊天界面只调用后端接口,密钥始终留在 Python 服务端。 */
"use strict";
const chatState = {busy:false, count:0};
const chatElement = id => document.getElementById(id);
const safeText = value => String(value ?? "").replace(/[&<>"']/g, c => ({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
function inlineText(text) { return safeText(text).replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/`([^`]+)`/g, "<code>$1</code>"); }
// 仅支持常用文字、列表和表格;不执行模型回答中的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(`<div class="ai-table-wrap"><table><thead><tr>${header.map(c => `<th>${inlineText(c)}</th>`).join("")}</tr></thead><tbody>${rows.map(row => `<tr>${row.map(c => `<td>${inlineText(c)}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`);
} else if (/^\s*#{1,6}\s+/.test(lines[i])) {
parts.push(`<h3>${inlineText(lines[i].replace(/^\s*#{1,6}\s+/, ""))}</h3>`);
} else if (/^\s*[-*]\s+/.test(lines[i])) {
parts.push(`<p class="ai-list-line">• ${inlineText(lines[i].replace(/^\s*[-*]\s+/, ""))}</p>`);
} else if (lines[i].trim()) parts.push(`<p>${inlineText(lines[i])}</p>`);
}
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 = "回答后可查看本次参考资料及实际查询结果。";
});