feat: add streaming unified review assistant
This commit is contained in:
+162
@@ -50,6 +50,9 @@ const state = {
|
||||
alerts: [],
|
||||
alertFilter: "all",
|
||||
alertUnreadCount: 0,
|
||||
assistantMessages: [],
|
||||
assistantLoading: false,
|
||||
assistantController: null,
|
||||
screenerMobileView: "strategy",
|
||||
sentimentHistory: null,
|
||||
sentimentRange: 20,
|
||||
@@ -93,6 +96,7 @@ const elements = {
|
||||
toast: document.querySelector("#toast"),
|
||||
stockDialog: document.querySelector("#stockDialog"),
|
||||
alertsDialog: document.querySelector("#alertsDialog"),
|
||||
assistantDialog: document.querySelector("#assistantDialog"),
|
||||
globalSearchDialog: document.querySelector("#globalSearchDialog"),
|
||||
globalSearchInput: document.querySelector("#globalSearchInput"),
|
||||
globalSearchResults: document.querySelector("#globalSearchResults"),
|
||||
@@ -141,6 +145,7 @@ let stockPreviewAnchor = null;
|
||||
let sentimentChartAnimationFrame = null;
|
||||
let heavenResizeTimer = null;
|
||||
let globalSearchTimer = null;
|
||||
let assistantRenderFrame = 0;
|
||||
|
||||
const heartSound = {
|
||||
enabled: false,
|
||||
@@ -385,6 +390,14 @@ function bindEvents() {
|
||||
});
|
||||
document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch);
|
||||
document.querySelector("#alertButton").addEventListener("click", openAlerts);
|
||||
document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant);
|
||||
document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close());
|
||||
document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion);
|
||||
document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse);
|
||||
document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation);
|
||||
document.querySelectorAll("[data-assistant-prompt]").forEach((button) => {
|
||||
button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt));
|
||||
});
|
||||
document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close());
|
||||
document.querySelector("#alertForm").addEventListener("submit", saveAlert);
|
||||
document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead);
|
||||
@@ -4498,6 +4511,152 @@ function renderAlerts() {
|
||||
refreshIcons();
|
||||
}
|
||||
|
||||
async function openReviewAssistant() {
|
||||
if (!hasMemberAccess()) {
|
||||
showToast("复盘助手仅对会员开放");
|
||||
openSettings("membership");
|
||||
return;
|
||||
}
|
||||
toggleHeaderCommandMenu(false);
|
||||
toggleAccountDropdown(false);
|
||||
if (!elements.assistantDialog.open) elements.assistantDialog.showModal();
|
||||
try {
|
||||
const payload = await apiRequest("/api/assistant/messages");
|
||||
state.assistantMessages = payload.items || [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录加载失败");
|
||||
}
|
||||
document.querySelector("#assistantQuestion").focus();
|
||||
}
|
||||
|
||||
function useAssistantPrompt(prompt) {
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
input.value = prompt;
|
||||
input.focus();
|
||||
}
|
||||
|
||||
async function sendAssistantQuestion(event) {
|
||||
event.preventDefault();
|
||||
if (state.assistantLoading) return;
|
||||
const input = document.querySelector("#assistantQuestion");
|
||||
const question = input.value.trim();
|
||||
if (!question) return;
|
||||
input.value = "";
|
||||
state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") });
|
||||
state.assistantLoading = true;
|
||||
state.assistantController = new AbortController();
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
try {
|
||||
await streamAssistantRequest(question, state.assistantController.signal, (chunk) => {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") message.content += chunk;
|
||||
scheduleAssistantRender();
|
||||
});
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message) message.streaming = false;
|
||||
setStatus("复盘助手回答完成");
|
||||
} catch (error) {
|
||||
const message = state.assistantMessages.at(-1);
|
||||
if (message?.role === "assistant") {
|
||||
message.streaming = false;
|
||||
message.error = true;
|
||||
if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。";
|
||||
}
|
||||
if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败");
|
||||
} finally {
|
||||
state.assistantLoading = false;
|
||||
state.assistantController = null;
|
||||
updateAssistantControls();
|
||||
renderAssistantMessages();
|
||||
input.focus();
|
||||
}
|
||||
}
|
||||
|
||||
async function streamAssistantRequest(question, signal, onDelta) {
|
||||
const response = await fetch("/api/assistant/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}),
|
||||
},
|
||||
body: JSON.stringify({ question, trade_date: elements.tradeDate.value }),
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => ({}));
|
||||
throw new Error(payload.error || "复盘助手暂不可用");
|
||||
}
|
||||
if (!response.body) throw new Error("当前浏览器不支持流式回答");
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
buffer += decoder.decode(value || new Uint8Array(), { stream: !done });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
for (const line of lines) {
|
||||
if (!line.trim()) continue;
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||||
}
|
||||
if (done) break;
|
||||
}
|
||||
if (buffer.trim()) {
|
||||
const event = JSON.parse(buffer);
|
||||
if (event.type === "delta") onDelta(String(event.content || ""));
|
||||
if (event.type === "error") throw new Error(event.error || "复盘助手回答失败");
|
||||
}
|
||||
}
|
||||
|
||||
function stopAssistantResponse() {
|
||||
state.assistantController?.abort();
|
||||
}
|
||||
|
||||
async function clearAssistantConversation() {
|
||||
if (state.assistantLoading || !state.assistantMessages.length) return;
|
||||
if (!window.confirm("确定清空复盘助手的对话记录吗?")) return;
|
||||
try {
|
||||
await apiRequest("/api/assistant/messages", "DELETE");
|
||||
state.assistantMessages = [];
|
||||
renderAssistantMessages();
|
||||
} catch (error) {
|
||||
showToast(error.message || "对话记录清空失败");
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleAssistantRender() {
|
||||
if (assistantRenderFrame) return;
|
||||
assistantRenderFrame = requestAnimationFrame(() => {
|
||||
assistantRenderFrame = 0;
|
||||
renderAssistantMessages();
|
||||
});
|
||||
}
|
||||
|
||||
function renderAssistantMessages() {
|
||||
const container = document.querySelector("#assistantMessages");
|
||||
container.innerHTML = state.assistantMessages.map((message) => `
|
||||
<article class="assistant-message ${message.role} ${message.error ? "is-error" : ""}">
|
||||
<div class="assistant-message-label">${message.role === "user" ? "我" : "复盘助手"}${message.context_date ? `<time>${displayCompactDate(message.context_date)}</time>` : ""}</div>
|
||||
<div class="assistant-message-content">${message.role === "assistant" ? (message.content ? formatMentorAnswer(message.content) : '<span class="assistant-thinking">正在整理复盘数据</span>') : escapeHtml(message.content)}</div>
|
||||
${message.streaming ? '<span class="assistant-stream-caret" aria-hidden="true"></span>' : ""}
|
||||
</article>
|
||||
`).join("") || '<div class="empty-state">可以从市场、策略或自己的交易记录开始复盘</div>';
|
||||
updateAssistantControls();
|
||||
requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; });
|
||||
}
|
||||
|
||||
function updateAssistantControls() {
|
||||
document.querySelector("#assistantQuestion").disabled = state.assistantLoading;
|
||||
document.querySelector("#sendAssistant").disabled = state.assistantLoading;
|
||||
document.querySelector("#stopAssistant").hidden = !state.assistantLoading;
|
||||
document.querySelector("#clearAssistantMessages").disabled = state.assistantLoading || !state.assistantMessages.length;
|
||||
}
|
||||
|
||||
function openGlobalSearch() {
|
||||
if (!state.user) return;
|
||||
toggleHeaderCommandMenu(false);
|
||||
@@ -4869,6 +5028,9 @@ function applyMembershipAccess() {
|
||||
control.disabled = !unlocked;
|
||||
});
|
||||
});
|
||||
const assistantButton = document.querySelector("#assistantButton");
|
||||
assistantButton.classList.toggle("member-locked-control", !unlocked);
|
||||
assistantButton.title = unlocked ? "复盘助手" : "复盘助手(会员可用)";
|
||||
}
|
||||
|
||||
function openView(viewId, updateHash = true) {
|
||||
|
||||
Reference in New Issue
Block a user