543 lines
19 KiB
JavaScript
543 lines
19 KiB
JavaScript
const state = {
|
||
apiBase: localStorage.getItem("financeAiApiBase") || "http://127.0.0.1:8775",
|
||
token: localStorage.getItem("financeAiToken") || "",
|
||
currentUser: null,
|
||
currentSessionId: null,
|
||
selectedDocumentFile: null,
|
||
stats: null,
|
||
};
|
||
|
||
const $ = (selector) => document.querySelector(selector);
|
||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||
|
||
const elements = {
|
||
apiBaseInput: $("#apiBaseInput"),
|
||
phoneInput: $("#phoneInput"),
|
||
loginForm: $("#loginForm"),
|
||
loginState: $("#loginState"),
|
||
logoutBtn: $("#logoutBtn"),
|
||
healthBadge: $("#healthBadge"),
|
||
pageTitle: $("#pageTitle"),
|
||
pageSubtitle: $("#pageSubtitle"),
|
||
sessionList: $("#sessionList"),
|
||
messages: $("#messages"),
|
||
questionInput: $("#questionInput"),
|
||
documentRows: $("#documentRows"),
|
||
documentModal: $("#documentModal"),
|
||
docFileInput: $("#docFileInput"),
|
||
dropZone: $("#dropZone"),
|
||
dropTitle: $("#dropTitle"),
|
||
dropHint: $("#dropHint"),
|
||
auditRows: $("#auditRows"),
|
||
toast: $("#toast"),
|
||
};
|
||
|
||
function toast(message) {
|
||
elements.toast.textContent = message;
|
||
elements.toast.classList.add("show");
|
||
window.setTimeout(() => elements.toast.classList.remove("show"), 2600);
|
||
}
|
||
|
||
function apiUrl(path) {
|
||
return `${state.apiBase.replace(/\/$/, "")}${path}`;
|
||
}
|
||
|
||
async function request(path, options = {}) {
|
||
const headers = {
|
||
"Content-Type": "application/json",
|
||
...(options.headers || {}),
|
||
};
|
||
if (state.token) {
|
||
headers.Authorization = `Bearer ${state.token}`;
|
||
}
|
||
const response = await fetch(apiUrl(path), { ...options, headers });
|
||
if (!response.ok) {
|
||
const text = await response.text();
|
||
throw new Error(text || response.statusText);
|
||
}
|
||
if (response.status === 204) {
|
||
return null;
|
||
}
|
||
return response.json();
|
||
}
|
||
|
||
function setApiBase(value) {
|
||
state.apiBase = value.trim() || "http://127.0.0.1:8775";
|
||
localStorage.setItem("financeAiApiBase", state.apiBase);
|
||
}
|
||
|
||
async function checkHealth() {
|
||
try {
|
||
const response = await fetch(apiUrl("/health"));
|
||
if (!response.ok) throw new Error("health failed");
|
||
elements.healthBadge.textContent = "已连接";
|
||
elements.healthBadge.classList.remove("muted");
|
||
} catch (error) {
|
||
elements.healthBadge.textContent = "未连接";
|
||
elements.healthBadge.classList.add("muted");
|
||
}
|
||
}
|
||
|
||
function setView(view) {
|
||
$$(".nav-item").forEach((item) => item.classList.toggle("active", item.dataset.view === view));
|
||
$$(".view").forEach((panel) => panel.classList.remove("is-visible"));
|
||
$(`#${view}View`).classList.add("is-visible");
|
||
|
||
const titles = {
|
||
chat: ["智能问答工作台", "基于正式制度文档回答,答案保留来源引用和问答留痕。"],
|
||
documents: ["知识文档", "实时拉取 ADP 知识库(默认知识库)下的文档列表,增删改请到 ADP 控制台操作。"],
|
||
audit: ["审计与用量", "查看登录、文档、问答和接口调用留痕。"],
|
||
};
|
||
elements.pageTitle.textContent = titles[view][0];
|
||
elements.pageSubtitle.textContent = titles[view][1];
|
||
|
||
if (view === "documents") loadDocuments();
|
||
if (view === "audit") loadAuditAndStats();
|
||
}
|
||
|
||
function renderLoginState() {
|
||
if (state.currentUser) {
|
||
elements.loginForm.hidden = true;
|
||
elements.logoutBtn.hidden = false;
|
||
elements.loginState.innerHTML = `
|
||
<strong>${escapeHtml(state.currentUser.name)}</strong>
|
||
<span>${escapeHtml(state.currentUser.phone)}</span>
|
||
<span>${escapeHtml(state.currentUser.roles.join(", ") || "employee")}</span>
|
||
`;
|
||
} else if (state.token) {
|
||
elements.loginForm.hidden = false;
|
||
elements.logoutBtn.hidden = true;
|
||
elements.loginState.textContent = "已保存 Token,待验证";
|
||
} else {
|
||
elements.loginForm.hidden = false;
|
||
elements.logoutBtn.hidden = true;
|
||
elements.loginState.textContent = "未登录";
|
||
}
|
||
}
|
||
|
||
async function sendCode() {
|
||
setApiBase(elements.apiBaseInput.value);
|
||
const phone = elements.phoneInput.value.trim();
|
||
const result = await request("/api/auth/sms-code", {
|
||
method: "POST",
|
||
body: JSON.stringify({ phone }),
|
||
});
|
||
toast(`验证码已发送:${result.mock_code || "请查看短信"}`);
|
||
}
|
||
|
||
async function login() {
|
||
setApiBase(elements.apiBaseInput.value);
|
||
const phone = elements.phoneInput.value.trim();
|
||
const result = await request("/api/auth/login", {
|
||
method: "POST",
|
||
body: JSON.stringify({ phone, code: "123456" }),
|
||
});
|
||
state.token = result.access_token;
|
||
localStorage.setItem("financeAiToken", state.token);
|
||
await loadCurrentUser();
|
||
await Promise.all([loadSessions(), loadStats()]);
|
||
toast("登录成功");
|
||
}
|
||
|
||
async function logout() {
|
||
if (state.token) {
|
||
try {
|
||
await request("/api/auth/logout", { method: "POST" });
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
}
|
||
state.token = "";
|
||
state.currentUser = null;
|
||
state.currentSessionId = null;
|
||
localStorage.removeItem("financeAiToken");
|
||
renderLoginState();
|
||
renderMessages([]);
|
||
toast("已退出当前账户");
|
||
}
|
||
|
||
async function loadCurrentUser() {
|
||
if (!state.token) {
|
||
renderLoginState();
|
||
return;
|
||
}
|
||
try {
|
||
state.currentUser = await request("/api/auth/me");
|
||
} catch (error) {
|
||
state.currentUser = null;
|
||
state.token = "";
|
||
localStorage.removeItem("financeAiToken");
|
||
}
|
||
renderLoginState();
|
||
}
|
||
|
||
async function loadStats() {
|
||
if (!state.token) return;
|
||
try {
|
||
state.stats = await request("/api/stats/overview");
|
||
const setText = (sel, value) => {
|
||
const el = document.querySelector(sel);
|
||
if (el) el.textContent = value;
|
||
};
|
||
setText("#auditQuestions", state.stats.question_count);
|
||
setText("#auditUsers", state.stats.active_user_count);
|
||
setText("#auditDocs", state.stats.document_count);
|
||
setText("#auditCount", state.stats.audit_count);
|
||
} catch (error) {
|
||
console.warn(error);
|
||
}
|
||
}
|
||
|
||
async function loadSessions() {
|
||
if (!state.token) return;
|
||
const sessions = await request("/api/chat/sessions");
|
||
elements.sessionList.innerHTML = "";
|
||
if (!sessions.length) {
|
||
elements.sessionList.innerHTML = '<div class="empty-state">暂无会话</div>';
|
||
renderMessages([]);
|
||
return;
|
||
}
|
||
if (!state.currentSessionId) {
|
||
state.currentSessionId = sessions[0].id;
|
||
}
|
||
sessions.forEach((session) => {
|
||
const item = document.createElement("div");
|
||
item.className = `session-item ${session.id === state.currentSessionId ? "active" : ""}`;
|
||
item.innerHTML = `
|
||
<button class="session-body" type="button">
|
||
<div class="session-title">${escapeHtml(session.title)}</div>
|
||
<div class="session-meta">${formatTime(session.last_message_at || session.created_at)}</div>
|
||
</button>
|
||
<button class="session-delete" type="button" title="删除会话" aria-label="删除会话">×</button>
|
||
`;
|
||
item.querySelector(".session-body").addEventListener("click", async () => {
|
||
state.currentSessionId = session.id;
|
||
await loadSessions();
|
||
await loadMessages(session.id);
|
||
});
|
||
item.querySelector(".session-delete").addEventListener("click", (event) => {
|
||
event.stopPropagation();
|
||
deleteSession(session).catch((error) => toast(error.message));
|
||
});
|
||
elements.sessionList.appendChild(item);
|
||
});
|
||
await loadMessages(state.currentSessionId);
|
||
}
|
||
|
||
async function deleteSession(session) {
|
||
if (!window.confirm(`确认删除会话「${session.title}」?该会话下所有问答与引用记录会一并删除,不可恢复。`)) return;
|
||
await request(`/api/chat/sessions/${session.id}`, { method: "DELETE" });
|
||
if (state.currentSessionId === session.id) {
|
||
state.currentSessionId = null;
|
||
renderMessages([]);
|
||
}
|
||
await Promise.all([loadSessions(), loadStats()]);
|
||
toast("会话已删除");
|
||
}
|
||
|
||
async function createSession() {
|
||
const session = await request("/api/chat/sessions", {
|
||
method: "POST",
|
||
body: JSON.stringify({ title: "新会话" }),
|
||
});
|
||
state.currentSessionId = session.id;
|
||
await loadSessions();
|
||
}
|
||
|
||
async function loadMessages(sessionId) {
|
||
if (!sessionId) {
|
||
renderMessages([]);
|
||
return;
|
||
}
|
||
const messages = await request(`/api/chat/sessions/${sessionId}/messages`);
|
||
renderMessages(messages);
|
||
}
|
||
|
||
function renderMessages(messages) {
|
||
elements.messages.innerHTML = "";
|
||
if (!messages.length) {
|
||
elements.messages.innerHTML = '<div class="empty-state">输入一个财务制度或流程问题开始问答</div>';
|
||
return;
|
||
}
|
||
messages.forEach((message) => {
|
||
appendMessage("user", message.question);
|
||
const { bubble } = appendMessage("assistant", message.answer || "回答生成中...");
|
||
appendReferences(bubble, message.references || []);
|
||
});
|
||
}
|
||
|
||
function appendMessage(kind, text) {
|
||
const wrapper = document.createElement("div");
|
||
wrapper.className = `message ${kind === "user" ? "user" : ""}`;
|
||
const bubble = document.createElement("div");
|
||
bubble.className = "bubble";
|
||
const body = document.createElement("div");
|
||
body.className = "bubble-body";
|
||
body.textContent = text;
|
||
bubble.appendChild(body);
|
||
wrapper.appendChild(bubble);
|
||
elements.messages.appendChild(wrapper);
|
||
elements.messages.scrollTop = elements.messages.scrollHeight;
|
||
return { wrapper, bubble, body };
|
||
}
|
||
|
||
function appendReferences(bubble, refs) {
|
||
if (!refs || !refs.length) return;
|
||
const box = document.createElement("div");
|
||
box.className = "bubble-references";
|
||
const itemsHtml = refs.map((ref, idx) => {
|
||
const title = escapeHtml(ref.source_title || "未知来源");
|
||
const pageHint = ref.page_no ? `<small style="color:var(--muted);margin-left:6px">第 ${escapeHtml(ref.page_no)} 页</small>` : "";
|
||
const snippet = (ref.snippet || "").trim();
|
||
return `
|
||
<details class="bubble-reference-item">
|
||
<summary><span class="ref-icon">📄</span><span>${idx + 1}. ${title}</span>${pageHint}</summary>
|
||
${snippet ? `<div class="ref-snippet">${escapeHtml(snippet)}</div>` : ""}
|
||
</details>
|
||
`;
|
||
}).join("");
|
||
box.innerHTML = `
|
||
<div class="bubble-references-title">参考知识片段</div>
|
||
<div class="bubble-references-body">${itemsHtml}</div>
|
||
`;
|
||
bubble.appendChild(box);
|
||
elements.messages.scrollTop = elements.messages.scrollHeight;
|
||
}
|
||
|
||
async function sendQuestion(event) {
|
||
event.preventDefault();
|
||
if (!state.token) {
|
||
toast("请先登录");
|
||
return;
|
||
}
|
||
const question = elements.questionInput.value.trim();
|
||
if (!question) return;
|
||
elements.questionInput.value = "";
|
||
|
||
appendMessage("user", question);
|
||
const { bubble: answerBubble, body: answerBody } = appendMessage("assistant", "");
|
||
const currentRefs = [];
|
||
|
||
const response = await fetch(apiUrl("/api/chat/stream"), {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Authorization: `Bearer ${state.token}`,
|
||
},
|
||
body: JSON.stringify({ question, conversation_id: state.currentSessionId }),
|
||
});
|
||
if (!response.ok || !response.body) {
|
||
answerBody.textContent = "请求失败,请稍后重试。";
|
||
return;
|
||
}
|
||
|
||
const conversationId = Number(response.headers.get("X-Conversation-Id"));
|
||
if (conversationId) state.currentSessionId = conversationId;
|
||
|
||
const reader = response.body.getReader();
|
||
const decoder = new TextDecoder("utf-8");
|
||
let buffer = "";
|
||
while (true) {
|
||
const { done, value } = await reader.read();
|
||
if (done) break;
|
||
buffer += decoder.decode(value, { stream: true });
|
||
const events = buffer.split("\n\n");
|
||
buffer = events.pop() || "";
|
||
events.forEach((rawEvent) => handleSseEvent(rawEvent, answerBody, currentRefs));
|
||
}
|
||
if (buffer) handleSseEvent(buffer, answerBody, currentRefs);
|
||
appendReferences(answerBubble, currentRefs);
|
||
await Promise.all([loadSessions(), loadStats()]);
|
||
}
|
||
|
||
function handleSseEvent(rawEvent, answerBody, currentRefs) {
|
||
const eventLine = rawEvent.split("\n").find((line) => line.startsWith("event:"));
|
||
const dataLine = rawEvent.split("\n").find((line) => line.startsWith("data:"));
|
||
if (!eventLine || !dataLine) return;
|
||
const eventName = eventLine.replace("event:", "").trim();
|
||
const data = JSON.parse(dataLine.replace("data:", "").trim() || "{}");
|
||
if (eventName === "answer.delta") {
|
||
answerBody.textContent += data;
|
||
elements.messages.scrollTop = elements.messages.scrollHeight;
|
||
}
|
||
if (eventName === "answer.reference") {
|
||
currentRefs.push(data);
|
||
}
|
||
}
|
||
|
||
async function loadDocuments() {
|
||
if (!state.token) return;
|
||
elements.documentRows.innerHTML = '<tr><td colspan="6" class="empty-state">加载中…</td></tr>';
|
||
try {
|
||
const result = await request("/api/documents/adp?page=1&page_size=100");
|
||
const list = result.list || [];
|
||
if (!list.length) {
|
||
elements.documentRows.innerHTML = '<tr><td colspan="6" class="empty-state">ADP 知识库为空</td></tr>';
|
||
return;
|
||
}
|
||
elements.documentRows.innerHTML = list.map((doc) => `
|
||
<tr>
|
||
<td>${escapeHtml(doc.file_name)}</td>
|
||
<td>${escapeHtml((doc.file_type || "-").toUpperCase())}</td>
|
||
<td>${formatFileSize(doc.size_bytes || 0)}</td>
|
||
<td>${escapeHtml(doc.status_desc || "-")}${doc.is_disabled ? '<span class="tag" style="margin-left:6px;background:#fae0de;color:var(--danger)">已停用</span>' : ""}</td>
|
||
<td>${escapeHtml(formatEnableScope(doc.enable_scope))}</td>
|
||
<td>${escapeHtml(formatAdpTime(doc.update_time))}</td>
|
||
</tr>
|
||
`).join("");
|
||
} catch (error) {
|
||
elements.documentRows.innerHTML = `<tr><td colspan="6" class="empty-state">加载失败:${escapeHtml(error.message)}</td></tr>`;
|
||
}
|
||
}
|
||
|
||
function formatEnableScope(scope) {
|
||
return ({ 1: "全部", 2: "答案推荐", 3: "搜索来源", 4: "答案推荐 + 搜索来源" })[scope] || "-";
|
||
}
|
||
|
||
function formatAdpTime(value) {
|
||
if (!value) return "-";
|
||
const num = Number(value);
|
||
if (!num) return String(value);
|
||
return formatTime(new Date(num * 1000));
|
||
}
|
||
|
||
async function createDocument(event) {
|
||
event.preventDefault();
|
||
if (!state.selectedDocumentFile) {
|
||
toast("请先选择或拖拽一个文件");
|
||
return;
|
||
}
|
||
const tags = $("#docTagsInput").value.split(",").map((tag) => tag.trim()).filter(Boolean);
|
||
const formData = new FormData();
|
||
formData.append("file", state.selectedDocumentFile);
|
||
formData.append("tags", tags.join(","));
|
||
formData.append("secrecy_level", $("#docLevelInput").value);
|
||
formData.append("owner_dept", "财务部");
|
||
const headers = {};
|
||
if (state.token) {
|
||
headers.Authorization = `Bearer ${state.token}`;
|
||
}
|
||
const response = await fetch(apiUrl("/api/documents/upload"), {
|
||
method: "POST",
|
||
headers,
|
||
body: formData,
|
||
});
|
||
if (!response.ok) {
|
||
throw new Error(await response.text());
|
||
}
|
||
closeDocumentModal();
|
||
$("#docTagsInput").value = "";
|
||
await Promise.all([loadDocuments(), loadStats()]);
|
||
toast("文档已上传");
|
||
}
|
||
|
||
function openDocumentModal() {
|
||
state.selectedDocumentFile = null;
|
||
elements.docFileInput.value = "";
|
||
elements.dropTitle.textContent = "拖拽文件到这里,或点击选择文件";
|
||
elements.dropHint.textContent = "支持 PDF、DOCX、XLSX、TXT、PNG、JPG 等知识材料";
|
||
elements.dropZone.classList.remove("has-file");
|
||
elements.documentModal.hidden = false;
|
||
}
|
||
|
||
function closeDocumentModal() {
|
||
elements.documentModal.hidden = true;
|
||
state.selectedDocumentFile = null;
|
||
}
|
||
|
||
function selectDocumentFile(file) {
|
||
if (!file) return;
|
||
state.selectedDocumentFile = file;
|
||
elements.dropTitle.textContent = file.name;
|
||
elements.dropHint.textContent = `${formatFileSize(file.size)} · 已准备上传`;
|
||
elements.dropZone.classList.add("has-file");
|
||
}
|
||
|
||
function formatFileSize(size) {
|
||
if (size < 1024) return `${size} B`;
|
||
if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
|
||
return `${(size / 1024 / 1024).toFixed(1)} MB`;
|
||
}
|
||
|
||
async function loadAuditAndStats() {
|
||
if (!state.token) return;
|
||
await loadStats();
|
||
const logs = await request("/api/audit-logs");
|
||
elements.auditRows.innerHTML = logs.map((log) => `
|
||
<tr>
|
||
<td>${formatTime(log.created_at)}</td>
|
||
<td>${escapeHtml(log.action)}</td>
|
||
<td>${escapeHtml(log.target_type || "-")} ${escapeHtml(log.target_id || "")}</td>
|
||
<td>${escapeHtml(JSON.stringify(log.detail_json || {}))}</td>
|
||
</tr>
|
||
`).join("");
|
||
}
|
||
|
||
function formatTime(value) {
|
||
if (!value) return "-";
|
||
return new Intl.DateTimeFormat("zh-CN", {
|
||
month: "2-digit",
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
}).format(new Date(value));
|
||
}
|
||
|
||
function escapeHtml(value) {
|
||
return String(value)
|
||
.replaceAll("&", "&")
|
||
.replaceAll("<", "<")
|
||
.replaceAll(">", ">")
|
||
.replaceAll('"', """)
|
||
.replaceAll("'", "'");
|
||
}
|
||
|
||
function bindEvents() {
|
||
elements.apiBaseInput.value = state.apiBase;
|
||
$$(".nav-item").forEach((item) => item.addEventListener("click", () => setView(item.dataset.view)));
|
||
$("#sendCodeBtn").addEventListener("click", () => sendCode().catch((error) => toast(error.message)));
|
||
$("#loginBtn").addEventListener("click", () => login().catch((error) => toast(error.message)));
|
||
$("#logoutBtn").addEventListener("click", () => logout().catch((error) => toast(error.message)));
|
||
$("#refreshBtn").addEventListener("click", () => bootstrap().catch((error) => toast(error.message)));
|
||
$("#newSessionBtn").addEventListener("click", () => createSession().catch((error) => toast(error.message)));
|
||
$("#chatForm").addEventListener("submit", (event) => sendQuestion(event).catch((error) => toast(error.message)));
|
||
const refreshDocsBtn = document.querySelector("#refreshDocsBtn");
|
||
if (refreshDocsBtn) {
|
||
refreshDocsBtn.addEventListener("click", () => loadDocuments().catch((error) => toast(error.message)));
|
||
}
|
||
const openDocumentModalBtn = document.querySelector("#openDocumentModalBtn");
|
||
if (openDocumentModalBtn) openDocumentModalBtn.addEventListener("click", openDocumentModal);
|
||
$("#closeDocumentModalBtn").addEventListener("click", closeDocumentModal);
|
||
$("#cancelDocumentModalBtn").addEventListener("click", closeDocumentModal);
|
||
elements.documentModal.addEventListener("click", (event) => {
|
||
if (event.target === elements.documentModal) closeDocumentModal();
|
||
});
|
||
elements.docFileInput.addEventListener("change", (event) => selectDocumentFile(event.target.files[0]));
|
||
elements.dropZone.addEventListener("dragover", (event) => {
|
||
event.preventDefault();
|
||
elements.dropZone.classList.add("is-dragging");
|
||
});
|
||
elements.dropZone.addEventListener("dragleave", () => elements.dropZone.classList.remove("is-dragging"));
|
||
elements.dropZone.addEventListener("drop", (event) => {
|
||
event.preventDefault();
|
||
elements.dropZone.classList.remove("is-dragging");
|
||
selectDocumentFile(event.dataTransfer.files[0]);
|
||
});
|
||
$("#documentForm").addEventListener("submit", (event) => createDocument(event).catch((error) => toast(error.message)));
|
||
$("#loadAuditBtn").addEventListener("click", () => loadAuditAndStats().catch((error) => toast(error.message)));
|
||
}
|
||
|
||
async function bootstrap() {
|
||
setApiBase(elements.apiBaseInput.value);
|
||
await checkHealth();
|
||
await loadCurrentUser();
|
||
if (state.currentUser) {
|
||
await Promise.all([loadSessions(), loadStats()]);
|
||
} else {
|
||
renderMessages([]);
|
||
}
|
||
}
|
||
|
||
bindEvents();
|
||
bootstrap().catch((error) => toast(error.message));
|