finai/frontend/app/app.js

543 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 = `
<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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
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));