meeting_memory/webui/app.js

294 lines
10 KiB
JavaScript
Raw Permalink Normal View History

2026-06-24 07:05:19 +00:00
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}`);
});