meeting_memory/webui/app.js

294 lines
10 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

const state = {
runtime: null,
knowledgeBases: [],
activeKnowledgeBaseId: null,
conversationId: null,
isSending: false,
};
const elements = {
kbList: document.getElementById("knowledge-base-list"),
refreshKbs: document.getElementById("refresh-kbs"),
importKb: document.getElementById("import-kb"),
importPath: document.getElementById("import-path"),
importSubmit: document.getElementById("import-submit"),
importStatus: document.getElementById("import-status"),
runtimeMode: document.getElementById("runtime-mode"),
runtimeModel: document.getElementById("runtime-model"),
threadTitle: document.getElementById("thread-title"),
threadSubtitle: document.getElementById("thread-subtitle"),
activeKbBadge: document.getElementById("active-kb-badge"),
newThread: document.getElementById("new-thread"),
messageList: document.getElementById("message-list"),
composer: document.getElementById("composer"),
sendMessage: document.getElementById("send-message"),
};
function escapeHtml(text) {
return String(text)
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;");
}
async function fetchJson(url, options) {
const response = await fetch(url, options);
if (!response.ok) {
const text = await response.text();
throw new Error(text || `HTTP ${response.status}`);
}
return response.json();
}
async function bootstrap() {
const [runtime, kbPayload] = await Promise.all([
fetchJson("/api/runtime"),
fetchJson("/api/knowledge-bases"),
]);
state.runtime = runtime;
state.knowledgeBases = kbPayload.knowledge_bases || [];
state.activeKnowledgeBaseId =
runtime.default_knowledge_base_id ||
kbPayload.default_knowledge_base_id ||
state.knowledgeBases[0]?.knowledge_base_id ||
null;
renderRuntime();
renderKnowledgeBases();
updateThreadHeader();
}
function renderRuntime() {
elements.runtimeMode.textContent = state.runtime?.mode || "unknown";
elements.runtimeModel.textContent = state.runtime?.model_name || "offline";
}
function renderKnowledgeBases() {
const cards = state.knowledgeBases;
elements.kbList.innerHTML = "";
if (!cards.length) {
elements.kbList.innerHTML = '<div class="micro-copy">还没有知识库,先从上面导入一份会议文本。</div>';
return;
}
for (const kb of cards) {
const button = document.createElement("button");
button.type = "button";
button.className = `kb-item${kb.knowledge_base_id === state.activeKnowledgeBaseId ? " active" : ""}`;
button.innerHTML = `
<div class="kb-name">${escapeHtml(kb.title)}</div>
<div class="kb-meta">最近会议:${escapeHtml(kb.last_meeting_date || "暂无")} · ${kb.meeting_count} 次导入</div>
<div class="kb-meta">指标 ${kb.counts.metrics} · 待办 ${kb.counts.actions} · 风险 ${kb.counts.risks}</div>
<div class="kb-summary">${escapeHtml((kb.latest_summary || []).join(" "))}</div>
`;
button.addEventListener("click", async () => {
state.activeKnowledgeBaseId = kb.knowledge_base_id;
state.conversationId = null;
renderKnowledgeBases();
updateThreadHeader();
appendSystemMessage(`已切换到知识库“${kb.knowledge_base_id}”。`);
await ensureConversation();
});
elements.kbList.appendChild(button);
}
}
function updateThreadHeader() {
const active = state.knowledgeBases.find((item) => item.knowledge_base_id === state.activeKnowledgeBaseId);
if (!active) {
elements.threadTitle.textContent = "选择一个知识库开始提问";
elements.threadSubtitle.textContent = "左侧选择知识库,右侧像 nanobot 一样按对话线程提问。";
elements.activeKbBadge.textContent = "未选择知识库";
return;
}
elements.threadTitle.textContent = active.title;
elements.threadSubtitle.textContent = `最近会议:${active.last_meeting_date || "暂无"},待办 ${active.counts.actions},风险 ${active.counts.risks}`;
elements.activeKbBadge.textContent = `知识库:${active.knowledge_base_id}`;
elements.importKb.value = active.knowledge_base_id;
}
function clearHeroCard() {
const hero = elements.messageList.querySelector(".hero-card");
if (hero) {
hero.remove();
}
}
function appendMessage(role, text) {
clearHeroCard();
const article = document.createElement("article");
article.className = `message ${role}`;
article.innerHTML = `
<div class="message-role">${role === "user" ? "我" : "AI"}</div>
<div class="message-body">${escapeHtml(text)}</div>
`;
elements.messageList.appendChild(article);
elements.messageList.scrollTop = elements.messageList.scrollHeight;
return article;
}
function appendSystemMessage(text) {
appendMessage("assistant", text);
}
async function ensureConversation() {
if (state.conversationId) {
return state.conversationId;
}
const payload = await fetchJson("/api/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ knowledge_base_id: state.activeKnowledgeBaseId }),
});
state.conversationId = payload.conversation_id;
return state.conversationId;
}
async function refreshKnowledgeBases() {
const kbPayload = await fetchJson("/api/knowledge-bases");
state.knowledgeBases = kbPayload.knowledge_bases || [];
if (!state.activeKnowledgeBaseId && state.knowledgeBases[0]) {
state.activeKnowledgeBaseId = state.knowledgeBases[0].knowledge_base_id;
}
renderKnowledgeBases();
updateThreadHeader();
}
async function importTranscript() {
const knowledgeBaseId = elements.importKb.value.trim();
const filePath = elements.importPath.value.trim();
if (!knowledgeBaseId || !filePath) {
elements.importStatus.textContent = "请输入知识库 ID 和文本路径。";
return;
}
elements.importStatus.textContent = "正在导入...";
try {
const payload = await fetchJson("/api/knowledge-bases/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
knowledge_base_id: knowledgeBaseId,
file_path: filePath,
}),
});
elements.importStatus.textContent = `导入完成:${payload.knowledge_base_id}meeting_id=${payload.meeting_id}`;
state.activeKnowledgeBaseId = payload.knowledge_base_id;
state.conversationId = null;
await refreshKnowledgeBases();
appendSystemMessage(`已导入到知识库“${payload.knowledge_base_id}”,现在可以直接提问。`);
} catch (error) {
elements.importStatus.textContent = String(error.message || error);
}
}
async function sendMessage() {
const message = elements.composer.value.trim();
if (!message || state.isSending) {
return;
}
state.isSending = true;
elements.sendMessage.disabled = true;
appendMessage("user", message);
elements.composer.value = "";
const assistantCard = appendMessage("assistant", "正在思考...");
const body = assistantCard.querySelector(".message-body");
const activityNode = document.createElement("div");
activityNode.className = "message-activity";
body.appendChild(activityNode);
try {
const conversationId = await ensureConversation();
const response = await fetch(`/api/conversations/${conversationId}/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
message,
knowledge_base_id: state.activeKnowledgeBaseId,
}),
});
if (!response.ok || !response.body) {
throw new Error((await response.text()) || `HTTP ${response.status}`);
}
let finalText = "";
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 chunks = buffer.split("\n\n");
buffer = chunks.pop() || "";
for (const chunk of chunks) {
const line = chunk.split("\n").find((item) => item.startsWith("data: "));
if (!line) {
continue;
}
const payload = JSON.parse(line.slice(6));
if (payload.type === "meta" && payload.knowledge_base_id) {
state.activeKnowledgeBaseId = payload.knowledge_base_id;
updateThreadHeader();
renderKnowledgeBases();
}
if (payload.type === "tool_call") {
activityNode.textContent = `工具调用:${payload.tool_name}`;
}
if (payload.type === "tool_result") {
activityNode.textContent = `工具已返回:${payload.tool_name}`;
}
if (payload.type === "final") {
finalText = payload.final_response || "";
body.textContent = finalText;
if (activityNode.textContent) {
body.appendChild(activityNode);
}
}
}
}
if (!finalText) {
body.textContent = "这次没有拿到有效回答。";
}
await refreshKnowledgeBases();
} catch (error) {
body.textContent = `请求失败:${error.message || error}`;
} finally {
state.isSending = false;
elements.sendMessage.disabled = false;
elements.messageList.scrollTop = elements.messageList.scrollHeight;
}
}
elements.refreshKbs.addEventListener("click", () => {
refreshKnowledgeBases().catch((error) => appendSystemMessage(`刷新失败:${error.message || error}`));
});
elements.importSubmit.addEventListener("click", () => {
importTranscript().catch((error) => {
elements.importStatus.textContent = String(error.message || error);
});
});
elements.sendMessage.addEventListener("click", () => {
sendMessage().catch((error) => appendSystemMessage(`发送失败:${error.message || error}`));
});
elements.newThread.addEventListener("click", async () => {
state.conversationId = null;
await ensureConversation();
appendSystemMessage("已新建一个对话线程。");
});
elements.composer.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key === "Enter") {
event.preventDefault();
sendMessage().catch((error) => appendSystemMessage(`发送失败:${error.message || error}`));
}
});
bootstrap().catch((error) => {
appendSystemMessage(`初始化失败:${error.message || error}`);
});