294 lines
10 KiB
JavaScript
294 lines
10 KiB
JavaScript
|
|
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("<", "<")
|
|||
|
|
.replaceAll(">", ">");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
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}`);
|
|||
|
|
});
|