feat: add streaming unified review assistant

This commit is contained in:
leefer
2026-07-23 00:25:43 +08:00
parent 7d8d270e98
commit 0f4bed17c9
11 changed files with 638 additions and 1 deletions
+162
View File
@@ -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) {