feat(chat): 支持流式回复与用户节点导航
This commit is contained in:
346
static/js/app.js
346
static/js/app.js
@@ -4,6 +4,14 @@
|
||||
var mobileSidebarToggle = document.getElementById("mobileSidebarToggle");
|
||||
var userMenu = document.getElementById("userMenu");
|
||||
var userMenuTrigger = document.getElementById("userMenuTrigger");
|
||||
var chatScroll = document.getElementById("chatScroll");
|
||||
var nodeRail = document.getElementById("nodeRail");
|
||||
var composer = document.getElementById("chatComposer");
|
||||
var promptInput = document.getElementById("prompt");
|
||||
var sendButton = document.getElementById("sendButton");
|
||||
var conversationIdInput = document.getElementById("conversationIdInput");
|
||||
var chatStage = document.querySelector(".chat-stage");
|
||||
var nodeAnchors = [];
|
||||
|
||||
if (!workspace) {
|
||||
return;
|
||||
@@ -32,6 +40,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
function refreshNodeAnchors() {
|
||||
nodeAnchors = Array.prototype.slice.call(document.querySelectorAll(".node-anchor"));
|
||||
}
|
||||
|
||||
if (sidebarToggle) {
|
||||
sidebarToggle.addEventListener("click", toggleSidebar);
|
||||
}
|
||||
@@ -54,6 +66,340 @@
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveNode() {
|
||||
if (!chatScroll || !nodeAnchors.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var activeTarget = nodeAnchors[0].getAttribute("data-target");
|
||||
var scrollTop = chatScroll.scrollTop;
|
||||
var threshold = 80;
|
||||
|
||||
nodeAnchors.forEach(function (anchor) {
|
||||
var targetId = anchor.getAttribute("data-target");
|
||||
var target = document.getElementById(targetId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.offsetTop - threshold <= scrollTop) {
|
||||
activeTarget = targetId;
|
||||
}
|
||||
});
|
||||
|
||||
nodeAnchors.forEach(function (anchor) {
|
||||
anchor.classList.toggle("active", anchor.getAttribute("data-target") === activeTarget);
|
||||
});
|
||||
}
|
||||
|
||||
function bindNodeAnchorClicks() {
|
||||
if (!chatScroll) {
|
||||
return;
|
||||
}
|
||||
nodeAnchors.forEach(function (anchor) {
|
||||
if (anchor.dataset.bound === "true") {
|
||||
return;
|
||||
}
|
||||
anchor.dataset.bound = "true";
|
||||
anchor.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
var targetId = anchor.getAttribute("data-target");
|
||||
var target = document.getElementById(targetId);
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
chatScroll.scrollTo({
|
||||
top: Math.max(target.offsetTop - 20, 0),
|
||||
behavior: "smooth",
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureNodeRailVisible() {
|
||||
if (nodeRail) {
|
||||
nodeRail.classList.remove("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function syncNodeRailVisibility() {
|
||||
if (!nodeRail) {
|
||||
return;
|
||||
}
|
||||
refreshNodeAnchors();
|
||||
if (nodeAnchors.length) {
|
||||
nodeRail.classList.remove("hidden");
|
||||
} else {
|
||||
nodeRail.classList.add("hidden");
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
return text
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/\"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function nl2br(text) {
|
||||
return escapeHtml(text).replace(/\n/g, "<br>");
|
||||
}
|
||||
|
||||
function scrollChatToBottom() {
|
||||
if (chatScroll) {
|
||||
chatScroll.scrollTop = chatScroll.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
function createMessage(role, content, messageId, label) {
|
||||
var article = document.createElement("article");
|
||||
article.className = "message " + role;
|
||||
article.id = messageId;
|
||||
if (label) {
|
||||
article.setAttribute("data-node-label", label);
|
||||
}
|
||||
|
||||
var avatar = document.createElement("div");
|
||||
avatar.className = "message-avatar" + (role === "user" ? " user-mark" : "");
|
||||
avatar.textContent = role === "assistant" ? "AI" : userMenuTrigger.querySelector(".avatar").textContent.trim();
|
||||
|
||||
var bubble = document.createElement("div");
|
||||
bubble.className = "message-bubble";
|
||||
|
||||
var text = document.createElement("p");
|
||||
text.innerHTML = nl2br(content);
|
||||
bubble.appendChild(text);
|
||||
|
||||
article.appendChild(avatar);
|
||||
article.appendChild(bubble);
|
||||
chatScroll.appendChild(article);
|
||||
return { article: article, bubble: bubble, text: text };
|
||||
}
|
||||
|
||||
function appendNode(targetId, title, isLatest) {
|
||||
if (!nodeRail) {
|
||||
return;
|
||||
}
|
||||
ensureNodeRailVisible();
|
||||
var anchor = document.createElement("a");
|
||||
anchor.className = "node-anchor" + (isLatest ? " latest" : "");
|
||||
anchor.href = "#" + targetId;
|
||||
anchor.setAttribute("data-target", targetId);
|
||||
anchor.title = title;
|
||||
|
||||
var dot = document.createElement("span");
|
||||
dot.className = "node-dot";
|
||||
anchor.appendChild(dot);
|
||||
nodeRail.appendChild(anchor);
|
||||
syncNodeRailVisibility();
|
||||
bindNodeAnchorClicks();
|
||||
setActiveNode();
|
||||
}
|
||||
|
||||
function updateSidebarConversation(conversationId, title) {
|
||||
if (!conversationId || !title) {
|
||||
return;
|
||||
}
|
||||
var encodedTitle = title;
|
||||
var existing = document.querySelector('.history-item[href*="conversation=' + conversationId + '"]');
|
||||
var list = document.querySelector(".history-list");
|
||||
var currentTime = new Date();
|
||||
var month = String(currentTime.getMonth() + 1).padStart(2, "0");
|
||||
var day = String(currentTime.getDate()).padStart(2, "0");
|
||||
var hours = String(currentTime.getHours()).padStart(2, "0");
|
||||
var minutes = String(currentTime.getMinutes()).padStart(2, "0");
|
||||
var meta = month + "月" + day + "日 " + hours + ":" + minutes;
|
||||
|
||||
document.querySelectorAll(".history-item.active").forEach(function (item) {
|
||||
item.classList.remove("active");
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
existing.classList.add("active");
|
||||
existing.querySelector(".history-title").textContent = encodedTitle;
|
||||
existing.querySelector(".history-meta").textContent = meta;
|
||||
if (list.firstElementChild !== existing) {
|
||||
list.prepend(existing);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!list) {
|
||||
return;
|
||||
}
|
||||
|
||||
var empty = list.querySelector(".history-empty");
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
}
|
||||
|
||||
var item = document.createElement("a");
|
||||
item.className = "history-item active";
|
||||
item.href = "/?conversation=" + conversationId;
|
||||
item.innerHTML =
|
||||
'<span class="history-title">' +
|
||||
escapeHtml(encodedTitle) +
|
||||
'</span><span class="history-meta">' +
|
||||
meta +
|
||||
"</span>";
|
||||
list.prepend(item);
|
||||
}
|
||||
|
||||
function setConversationTitle(title) {
|
||||
if (!title) {
|
||||
return;
|
||||
}
|
||||
var header = document.querySelector(".conversation-header h1");
|
||||
var empty = document.querySelector(".empty-state");
|
||||
if (empty) {
|
||||
empty.remove();
|
||||
var headerWrap = document.createElement("div");
|
||||
headerWrap.className = "conversation-header";
|
||||
headerWrap.id = "conversation-top";
|
||||
headerWrap.setAttribute("data-node-label", "会话开始");
|
||||
headerWrap.innerHTML =
|
||||
'<div><p class="eyebrow">审核智能体</p><h1>' +
|
||||
escapeHtml(title) +
|
||||
'</h1></div><span class="conversation-meta">正在生成回复</span>';
|
||||
chatScroll.prepend(headerWrap);
|
||||
return;
|
||||
}
|
||||
if (header) {
|
||||
header.textContent = title;
|
||||
}
|
||||
}
|
||||
|
||||
async function streamChat(event) {
|
||||
event.preventDefault();
|
||||
if (!composer || !promptInput || !sendButton || !chatStage) {
|
||||
return;
|
||||
}
|
||||
|
||||
var prompt = promptInput.value.trim();
|
||||
if (!prompt || sendButton.disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
sendButton.disabled = true;
|
||||
sendButton.textContent = "生成中...";
|
||||
|
||||
var formData = new FormData(composer);
|
||||
var csrfToken = formData.get("csrfmiddlewaretoken");
|
||||
var streamUrl = chatStage.getAttribute("data-stream-url");
|
||||
var tempUserId = "message-user-temp-" + Date.now();
|
||||
var tempAssistantId = "message-ai-temp-" + (Date.now() + 1);
|
||||
var userLabel = "用户 " + (document.querySelectorAll(".message").length + 1);
|
||||
|
||||
setConversationTitle((prompt || "").slice(0, 24));
|
||||
var userMessage = createMessage("user", prompt, tempUserId, userLabel);
|
||||
var assistantMessage = createMessage("assistant", "", tempAssistantId, "");
|
||||
assistantMessage.bubble.classList.add("streaming");
|
||||
appendNode(userMessage.article.id, userLabel, false);
|
||||
scrollChatToBottom();
|
||||
promptInput.value = "";
|
||||
|
||||
try {
|
||||
var response = await fetch(streamUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"X-CSRFToken": csrfToken,
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok || !response.body) {
|
||||
throw new Error("流式请求失败。");
|
||||
}
|
||||
|
||||
var reader = response.body.getReader();
|
||||
var decoder = new TextDecoder("utf-8");
|
||||
var buffer = "";
|
||||
var assistantText = "";
|
||||
|
||||
while (true) {
|
||||
var readResult = await reader.read();
|
||||
if (readResult.done) {
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(readResult.value, { stream: true });
|
||||
var events = buffer.split("\n\n");
|
||||
buffer = events.pop();
|
||||
|
||||
events.forEach(function (frame) {
|
||||
var eventName = "";
|
||||
var dataText = "";
|
||||
frame.split("\n").forEach(function (line) {
|
||||
if (line.indexOf("event:") === 0) {
|
||||
eventName = line.slice(6).trim();
|
||||
}
|
||||
if (line.indexOf("data:") === 0) {
|
||||
dataText += line.slice(5).trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (!eventName || !dataText) {
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = JSON.parse(dataText);
|
||||
if (eventName === "meta") {
|
||||
if (payload.conversation_id) {
|
||||
conversationIdInput.value = payload.conversation_id;
|
||||
window.history.replaceState({}, "", "/?conversation=" + payload.conversation_id);
|
||||
}
|
||||
if (payload.title) {
|
||||
setConversationTitle(payload.title);
|
||||
updateSidebarConversation(payload.conversation_id, payload.title);
|
||||
}
|
||||
} else if (eventName === "chunk") {
|
||||
assistantText += payload.delta || "";
|
||||
assistantMessage.text.innerHTML = nl2br(assistantText);
|
||||
scrollChatToBottom();
|
||||
} else if (eventName === "error") {
|
||||
assistantText = payload.message || "模型调用失败。";
|
||||
assistantMessage.text.innerHTML = nl2br(assistantText);
|
||||
} else if (eventName === "done") {
|
||||
if (payload.assistant_message_id) {
|
||||
assistantMessage.article.id = "message-" + payload.assistant_message_id;
|
||||
}
|
||||
if (payload.title) {
|
||||
setConversationTitle(payload.title);
|
||||
updateSidebarConversation(payload.conversation_id, payload.title);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
assistantMessage.bubble.classList.remove("streaming");
|
||||
syncNodeRailVisibility();
|
||||
bindNodeAnchorClicks();
|
||||
setActiveNode();
|
||||
scrollChatToBottom();
|
||||
} catch (error) {
|
||||
assistantMessage.bubble.classList.remove("streaming");
|
||||
assistantMessage.text.textContent = "请求失败,请稍后重试。";
|
||||
} finally {
|
||||
sendButton.disabled = false;
|
||||
sendButton.textContent = "发送";
|
||||
promptInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
syncNodeRailVisibility();
|
||||
bindNodeAnchorClicks();
|
||||
|
||||
if (chatScroll) {
|
||||
chatScroll.addEventListener("scroll", setActiveNode, { passive: true });
|
||||
setActiveNode();
|
||||
}
|
||||
|
||||
if (composer) {
|
||||
composer.addEventListener("submit", streamChat);
|
||||
}
|
||||
|
||||
window.addEventListener("resize", syncSidebarState);
|
||||
syncSidebarState();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user