102 lines
3.4 KiB
JavaScript
102 lines
3.4 KiB
JavaScript
/* Shared SSE client for /api/analyze/stream.
|
|
* 统一 analyze.html 内联逻辑与 ai-panel.js 的 SSE 逻辑.
|
|
* 事件契约 (后端 services 层下发):
|
|
* step: {step:'context'|'prompt'|'complete', content:string}
|
|
* token: {text:string} — 流式 LLM 输出, 分隔符已被后端剥离
|
|
* reasoning_start / conclusion_start: 无 payload, 切换当前 section
|
|
* error: {error:string} — 业务错误帧
|
|
* onerror: 连接层错误 (非 JSON)
|
|
*
|
|
* callbacks 全部可选:
|
|
* {onStep, onToken, onSectionStart, onError, onComplete}
|
|
* onStep(step, content) — step === 'complete' 时也会先调 onStep 再调 onComplete
|
|
* onToken(text, section) — section: 'reasoning' | 'conclusion'
|
|
* onSectionStart(section) — 'reasoning' | 'conclusion'
|
|
* onError(message, kind) — kind: 'server' (业务帧) | 'connection'
|
|
* onComplete(content) — step === 'complete'
|
|
*
|
|
* 返回句柄 {close(), getSection()}, close() 幂等.
|
|
* complete / error 帧后 client 自动 close 并失效 (与原两个实现一致).
|
|
*/
|
|
|
|
export function createAnalysisStream(scope, question, preset, callbacks = {}) {
|
|
const { onStep, onToken, onSectionStart, onError, onComplete } = callbacks;
|
|
|
|
let currentSection = "reasoning"; // 'reasoning' | 'conclusion', 由 SSE section 事件驱动
|
|
let closed = false;
|
|
|
|
const params = new URLSearchParams({ scope });
|
|
if (question) params.append("question", question);
|
|
if (preset) params.append("preset", preset);
|
|
|
|
const es = new EventSource(`/api/analyze/stream?${params.toString()}`);
|
|
|
|
function close() {
|
|
if (closed) return;
|
|
closed = true;
|
|
es.close();
|
|
}
|
|
|
|
es.addEventListener("step", (e) => {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(e.data);
|
|
} catch (err) {
|
|
return; // 非 JSON step 帧, 忽略
|
|
}
|
|
const { step, content } = data;
|
|
if (onStep) onStep(step, content);
|
|
if (step === "complete") {
|
|
if (onComplete) onComplete(content);
|
|
close();
|
|
}
|
|
});
|
|
|
|
// Section 事件: 后端识别 ===REASONING=== / ===CONCLUSION=== 后显式下发
|
|
es.addEventListener("reasoning_start", () => {
|
|
currentSection = "reasoning";
|
|
if (onSectionStart) onSectionStart("reasoning");
|
|
});
|
|
|
|
es.addEventListener("conclusion_start", () => {
|
|
currentSection = "conclusion";
|
|
if (onSectionStart) onSectionStart("conclusion");
|
|
});
|
|
|
|
es.addEventListener("token", (e) => {
|
|
let data;
|
|
try {
|
|
data = JSON.parse(e.data);
|
|
} catch (err) {
|
|
return;
|
|
}
|
|
if (onToken) onToken(data.text || "", currentSection);
|
|
});
|
|
|
|
es.addEventListener("error", (e) => {
|
|
let msg = "未知错误";
|
|
try {
|
|
const data = JSON.parse(e.data);
|
|
msg = data.error || msg;
|
|
} catch (ignore) {
|
|
/* 非 JSON 错误帧 */
|
|
}
|
|
if (onError) onError(msg, "server");
|
|
close();
|
|
});
|
|
|
|
es.onerror = () => {
|
|
// 与旧实现一致: 仅当 stream 仍处于活跃状态时才上报连接错误,
|
|
// 避免 complete 后浏览器触发 onerror 造成的误报.
|
|
if (!closed) {
|
|
if (onError) onError("连接错误, 请检查后端日志", "connection");
|
|
close();
|
|
}
|
|
};
|
|
|
|
return {
|
|
close,
|
|
getSection: () => currentSection,
|
|
};
|
|
}
|