const state = { apiBase: localStorage.getItem("financeAiApiBase") || "http://127.0.0.1:8000", 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:8000"; 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 = ` ${escapeHtml(state.currentUser.name)} ${escapeHtml(state.currentUser.phone)} ${escapeHtml(state.currentUser.roles.join(", ") || "employee")} `; } 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 = '
暂无会话
'; 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 = ` `; 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 = '
输入一个财务制度或流程问题开始问答
'; 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 ? `第 ${escapeHtml(ref.page_no)} 页` : ""; const snippet = (ref.snippet || "").trim(); return `
📄${idx + 1}. ${title}${pageHint} ${snippet ? `
${escapeHtml(snippet)}
` : ""}
`; }).join(""); box.innerHTML = `
参考知识片段
${itemsHtml}
`; 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 = '加载中…'; try { const result = await request("/api/documents/adp?page=1&page_size=100"); const list = result.list || []; if (!list.length) { elements.documentRows.innerHTML = 'ADP 知识库为空'; return; } elements.documentRows.innerHTML = list.map((doc) => ` ${escapeHtml(doc.file_name)} ${escapeHtml((doc.file_type || "-").toUpperCase())} ${formatFileSize(doc.size_bytes || 0)} ${escapeHtml(doc.status_desc || "-")}${doc.is_disabled ? '已停用' : ""} ${escapeHtml(formatEnableScope(doc.enable_scope))} ${escapeHtml(formatAdpTime(doc.update_time))} `).join(""); } catch (error) { elements.documentRows.innerHTML = `加载失败:${escapeHtml(error.message)}`; } } 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) => ` ${formatTime(log.created_at)} ${escapeHtml(log.action)} ${escapeHtml(log.target_type || "-")} ${escapeHtml(log.target_id || "")} ${escapeHtml(JSON.stringify(log.detail_json || {}))} `).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));