Files
qiming/qiming-mobile/utils/streamRequest.uts

646 lines
20 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { StreamRequestConfig, StreamChunk } from "@/types/interfaces/chat";
import { createSSEConnection } from "@/servers/useRequest.uts";
import { utf8ArrayToString } from "@/utils/utf8.uts";
import { t } from "@/utils/i18n";
// 全局错误处理 - 捕获未处理的 AbortError
// #ifdef H5
if (typeof window !== "undefined") {
window.addEventListener("unhandledrejection", (event) => {
if (event.reason && event.reason.name === "AbortError") {
console.log("捕获到未处理的 AbortError已静默处理");
event.preventDefault(); // 阻止默认的错误处理
}
});
}
// #endif
// 流式请求工具类
export class StreamRequest {
private requestTask: RequestTask | null = null;
private isAborted: boolean = false;
private abortController: AbortController | null = null;
private buffer: string = ""; // 用于缓存不完整的 SSE 消息(以 \n\n 为完整标记)
private pendingBytes: Uint8Array = new Uint8Array(0); // 用于缓存不完整的 UTF-8 字节序列
// 微信小程序数据流超时监控
private lastChunkReceivedTime: number = 0; // 最后一次接收数据的时间戳
private timeoutCheckTimer: any = null; // 超时检查定时器(使用 any 类型以兼容不同平台)
private readonly TIMEOUT_DURATION: number = 60000; // 超时时长 60 秒
private timeoutCallback: (() => void) | null = null; // 超时回调
// 发起流式请求
async request(config: StreamRequestConfig): Promise<void> {
this.isAborted = false;
this.buffer = ""; // 重置缓冲区
this.pendingBytes = new Uint8Array(0); // 重置字节缓冲区
// #ifdef H5
// 只在 H5 环境中创建 AbortController
this.abortController = new AbortController();
// #endif
// 清除可能存在的旧定时器(重要:避免定时器泄漏)
console.log("[超时监控] 新请求开始,清除旧定时器");
this.clearTimeoutMonitor();
// 保存超时回调
this.timeoutCallback = config.onTimeout || null;
try {
// #ifdef H5 || WEB
await this.requestH5(config);
// #endif
// #ifdef MP-WEIXIN
await this.requestWeapp(config);
// #endif
// #ifdef APP-PLUS
await this.requestApp(config);
// #endif
} catch (error) {
// 如果是用户主动中止,不触发错误回调
// #ifdef H5
if (error.name === "AbortError" || this.isAborted) {
console.log("请求已被中止");
return;
}
// #endif
// #ifdef MP-WEIXIN || APP-PLUS
if (this.isAborted) {
console.log("请求已被中止");
return;
}
// #endif
config.onError?.(error);
}
}
// H5环境下的流式请求
private async requestH5(config: StreamRequestConfig): Promise<void> {
try {
// 关键修复:在创建 requestTask 后立即启动超时监控
// 因为 onChunkReceived 可能比 success 回调更早触发
console.log("[请求] requestTask 已创建,立即启动超时监控");
this.startTimeoutMonitor();
console.log("[请求] 超时监控启动完成");
await createSSEConnection({
url: config.url,
method: config.method,
headers: config.headers as any,
body: config.body,
abortController: this.abortController,
onMessage: (data: any) => {
if (this.isAborted) return;
// 更新最后接收数据的时间
this.updateLastChunkTime();
try {
// 检查是否完成
if (data && data.completed) {
console.log("[SSE] 收到完成标记 completed");
// 清除超时监控
console.log(
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] completed 标记处理完成,定时器已清除");
config.onComplete?.();
return;
}
// 处理数据块
const chunk: StreamChunk = {
type: "content",
data: data ? JSON.stringify(data) : "",
};
config.onChunk?.(chunk);
} catch (e) {
console.error("Parse chunk error:", e);
}
},
onError: (error: Error) => {
// 清除超时监控
this.clearTimeoutMonitor();
// 如果是中止错误,不触发错误回调
if (error.name === "AbortError" || this.isAborted) {
console.log("SSE连接已被中止");
return;
}
config.onError?.(error);
},
onOpen: (response: any) => {
console.log("SSE连接已建立:", response);
},
onClose: () => {
console.log("SSE连接已关闭");
// 调用超时回调-关闭loading状态以及恢复停止按钮
config.onTimeout?.();
},
});
} catch (error) {
// 捕获所有可能的错误,包括 AbortError
if (
error.message === "Connection aborted by user" ||
error.name === "AbortError" ||
this.isAborted
) {
console.log("SSE连接已被中止");
return;
}
// 只有真正的错误才触发错误回调
config.onError?.(error);
throw error; // 重新抛出非中止错误
}
}
// 处理完整的 SSE 消息
private processSSEMessage(
content: string,
config: StreamRequestConfig,
resolve: (value: void | PromiseLike<void>) => void,
): void {
// 检查是否完成
if (content === "[DONE]") {
console.log("[SSE] 收到完成标记 [DONE]");
// 清除超时监控
console.log(
"[SSE] [DONE] 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] [DONE] 标记处理完成,定时器已清除");
config.onComplete?.();
resolve();
return;
}
// 检查是否看起来像 JSON (以 { 或 [ 开头)
const looksLikeJSON = content.startsWith("{") || content.startsWith("[");
if (looksLikeJSON) {
// 尝试解析 JSON 数据
try {
const data = JSON.parse(content);
if (data && data.completed) {
console.log("[SSE] 收到完成标记 completed");
// 清除超时监控
console.log(
"[SSE] completed 标记触发,准备清除定时器,当前 timer ID:",
this.timeoutCheckTimer,
);
this.clearTimeoutMonitor();
console.log("[SSE] completed 标记处理完成,定时器已清除");
config.onComplete?.();
resolve();
return;
}
// 处理数据块
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
} catch (e) {
console.log("[SSE] JSON 解析失败,内容:", content);
}
} else {
// 不是 JSON 格式,直接作为内容处理
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
}
}
// 启动超时监控
private startTimeoutMonitor(): void {
console.log(
"[超时监控] 准备启动超时监控,当前 timer ID:",
this.timeoutCheckTimer,
);
// 记录初始时间
this.lastChunkReceivedTime = Date.now();
// 先清除可能存在的旧定时器
if (
this.timeoutCheckTimer !== null &&
this.timeoutCheckTimer !== undefined
) {
console.log(
"[超时监控] 发现旧定时器先清除ID:",
this.timeoutCheckTimer,
);
clearInterval(this.timeoutCheckTimer);
this.timeoutCheckTimer = null;
}
// 每 10 秒检查一次是否超时
const timerId = setInterval(() => {
const now = Date.now();
const timeSinceLastChunk = now - this.lastChunkReceivedTime;
console.log(
`[超时监控] 检查中... 距离上次接收数据: ${timeSinceLastChunk}ms定时器 ID: ${this.timeoutCheckTimer}`,
);
if (timeSinceLastChunk >= this.TIMEOUT_DURATION) {
console.log(
`[超时监控] 数据流超时: ${timeSinceLastChunk}ms 未收到数据`,
);
this.handleTimeout();
}
}, 10000);
this.timeoutCheckTimer = timerId;
console.log("[超时监控] 定时器已创建,新 ID:", this.timeoutCheckTimer);
}
// 清除超时监控
private clearTimeoutMonitor(): void {
console.log("[超时监控] 尝试清除,当前 timer ID:", this.timeoutCheckTimer);
if (
this.timeoutCheckTimer !== null &&
this.timeoutCheckTimer !== undefined
) {
console.log("[超时监控] 清除超时监控timer ID:", this.timeoutCheckTimer);
clearInterval(this.timeoutCheckTimer);
this.timeoutCheckTimer = null;
} else {
console.log("[超时监控] 定时器 ID 为空,无需清除");
}
}
// 更新最后接收数据的时间
private updateLastChunkTime(): void {
this.lastChunkReceivedTime = Date.now();
}
// 处理超时
private handleTimeout(): void {
console.log("[超时监控] 触发超时处理");
// 清除定时器
this.clearTimeoutMonitor();
// 标记为中止状态
this.isAborted = true;
// 中止请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// 显示轻提示
uni.showToast({
title: t("Mobile.Stream.timeoutDisconnected"),
icon: "none",
duration: 2000,
});
// 调用超时回调
if (this.timeoutCallback) {
this.timeoutCallback();
}
}
// 处理 UTF-8 字节流,处理分片造成的字符截断问题
private decodeUTF8(chunk: ArrayBuffer): string {
const newBytes = new Uint8Array(chunk);
let combined = newBytes;
if (this.pendingBytes.length > 0) {
combined = new Uint8Array(this.pendingBytes.length + newBytes.length);
combined.set(this.pendingBytes);
combined.set(newBytes, this.pendingBytes.length);
this.pendingBytes = new Uint8Array(0);
}
if (combined.length === 0) return "";
// 寻找最后一个安全的字节边界
let safeEnd = combined.length;
let i = combined.length - 1;
let backtrack = 0;
// 向前回溯,检查末尾是否被截断
// UTF-8 最大字符长度为 4 字节
while (backtrack < 4 && i >= 0) {
const b = combined[i];
// ASCII (0xxxxxxx) - 字符结束,且是单字节,安全
if ((b & 0x80) === 0) {
// 如果我们是回溯过程中遇到的 ASCII说明之前回溯过的字节在 ASCII 之后)是孤立的或不完整的?
// 比如 [ASCII, Cont, Cont],我们在 Cont 回溯。
// 遇到 ASCII说明后面的 Cont 没有 Start byte是无效的。
// 但如果是 [ASCII]backtrack=0safeEnd=length。
// 这里的逻辑:遇到 ASCII说明前面的序列都结束了。
// 那么从 i+1 到 length 的字节就是我们回溯过的“剩余字节”。
safeEnd = combined.length - backtrack;
break;
}
// Start byte (11xxxxxx)
if ((b & 0xc0) === 0xc0) {
let need = 2;
if ((b & 0xe0) === 0xe0) need = 3;
else if ((b & 0xf0) === 0xf0) need = 4;
const have = combined.length - i;
if (have < need) {
// 不完整,需要保留从 i 开始的所有字节
safeEnd = i;
} else {
// 完整
safeEnd = combined.length;
}
break;
}
i--;
backtrack++;
}
// 如果 safeEnd 小于总长度,保存剩余字节
if (safeEnd < combined.length) {
this.pendingBytes = combined.slice(safeEnd);
return utf8ArrayToString(combined.slice(0, safeEnd));
}
return utf8ArrayToString(combined);
}
// 微信小程序环境下的流式请求
private async requestWeapp(config: StreamRequestConfig): Promise<void> {
return new Promise((resolve, reject) => {
this.requestTask = uni.request({
url: config.url,
method: config.method,
header: config.headers as any,
data: config.body,
enableChunked: true, // 启用分块传输
responseType: "arraybuffer", // 响应类型
timeout: 1000 * 60 * 60 * 12, // 设置超时时间为 12小时
success: (res) => {
console.log("[请求] success 回调触发statusCode:", res.statusCode);
if (res.statusCode === 200) {
// 流式数据通过 onChunkReceived 接收success 仅表示请求建立成功
// 实际的流式数据处理在 onChunkReceived 回调中
config.onOpen?.(res);
resolve(); // 请求成功建立连接后即可 resolve
} else {
console.log("[请求] 请求失败statusCode:", res.statusCode);
// 清除超时监控
this.clearTimeoutMonitor();
reject(
new Error(
t("Mobile.Stream.httpErrorWithCode", {
code: res.statusCode,
}),
),
);
}
},
fail: (err) => {
// 清除超时监控
this.clearTimeoutMonitor();
if (!this.isAborted) {
// 忽略超时错误,因为对于长连接流式请求,超时是预期内的行为
// 只要 connection 还在接收数据,就不应该视为真正的错误
if (
err.errMsg &&
(err.errMsg.includes("time out") ||
err.errMsg.includes("timeout"))
) {
console.log("请求超时警告(流式请求正常现象):", err);
// 不 reject也不触发 onError保持连接继续接收数据
return;
}
config.onError?.(err);
reject(err);
}
},
});
// 关键修复:在创建 requestTask 后立即启动超时监控
// 因为 onChunkReceived 可能比 success 回调更早触发
console.log("[请求] requestTask 已创建,立即启动超时监控");
this.startTimeoutMonitor();
console.log("[请求] 超时监控启动完成");
// 监听分块数据接收
this.requestTask?.onChunkReceived?.((res) => {
// console.log('[数据接收] onChunkReceived 触发')
if (this.isAborted) return;
// 更新最后接收数据的时间
this.updateLastChunkTime();
try {
// 使用 decodeUTF8 处理分块数据,解决字符截断问题
const str = this.decodeUTF8(res.data as ArrayBuffer);
// console.log('[SSE] 接收到分块数据, 长度:', str.length)
// 将新数据添加到缓冲区
this.buffer += str;
// SSE 协议:完整的消息以 \n\n 结尾
// 按 \n\n 分割出完整的消息块
const messages = this.buffer.split("\n\n");
// 保存最后一个可能不完整的消息块
this.buffer = messages.pop() || "";
// 处理所有完整的消息块
for (const message of messages) {
if (!message.trim()) continue;
// 按行分割消息块,处理多行的情况
const lines = message.split("\n");
let dataContent = "";
// 提取所有 data: 开头的行
for (const line of lines) {
if (line.startsWith("data:")) {
// 移除 "data:" 前缀,保留后面的内容
const content = line.slice(5).trim();
// 拼接多个 data: 行的内容(用换行符连接)
if (dataContent.length > 0) {
dataContent += "\n" + content;
} else {
dataContent = content;
}
}
}
// 如果提取到了数据内容,进行处理
if (dataContent.length > 0) {
// 统一由 processSSEMessage 处理所有消息,包括 [DONE] 标记
this.processSSEMessage(dataContent, config, resolve);
}
}
} catch (error) {
console.error("处理分块数据失败:", error);
if (!this.isAborted) {
config.onError?.(error);
}
}
});
});
}
// App环境下的流式请求
private async requestApp(config: StreamRequestConfig): Promise<void> {
// #ifdef APP-PLUS
return new Promise((resolve, reject) => {
this.requestTask = uni.request({
url: config.url,
method: config.method,
header: config.headers as any,
data: config.body,
success: (res) => {
if (res.statusCode === 200) {
// 处理流式响应
this.handleStreamResponse(res.data as string, config);
resolve();
} else {
reject(
new Error(
t("Mobile.Stream.httpErrorWithCode", {
code: res.statusCode,
}),
),
);
}
},
fail: (err) => {
if (!this.isAborted) {
reject(err);
}
},
});
});
// #endif
}
// 处理流式响应数据
private handleStreamResponse(
data: string,
config: StreamRequestConfig,
): void {
console.log("[SSE-APP] 处理流式响应数据, 长度:", data.length);
const lines = data.split("\n");
for (const line of lines) {
if (line.trim() === "") continue;
if (line.startsWith("data:")) {
const content = line.slice(5).trim();
// 跳过空内容
if (!content) {
console.log("[SSE-APP] 跳过空内容行");
continue;
}
console.log("[SSE-APP] 处理数据行:", content);
// 检查是否完成
if (content === "[DONE]") {
console.log("[SSE-APP] 收到完成标记 [DONE]");
config.onComplete?.();
return;
}
// 检查是否看起来像 JSON (以 { 或 [ 开头)
const looksLikeJSON =
content.startsWith("{") || content.startsWith("[");
if (looksLikeJSON) {
// 尝试解析 JSON 数据
try {
const parseData = JSON.parse(content);
if (parseData && parseData.completed) {
console.log("[SSE-APP] 收到完成标记 completed");
config.onComplete?.();
return;
}
} catch (e) {
// JSON 解析失败,继续处理为普通数据
console.log("[SSE-APP] JSON 解析失败,作为普通内容处理");
}
} else {
console.log("[SSE-APP] 非 JSON 格式,直接处理");
}
// 处理数据块
try {
const chunk: StreamChunk = {
type: "content",
data: content,
};
config.onChunk?.(chunk);
} catch (e) {
console.error("[SSE-APP] 处理数据块失败:", e);
}
}
}
}
// 中止请求
abort(): void {
this.isAborted = true;
this.buffer = ""; // 清空缓冲区
// 清除超时监控(所有平台都需要)
this.clearTimeoutMonitor();
// #ifdef H5
// 中止 H5 的 SSE 连接
if (this.abortController) {
this.abortController.abort();
this.abortController = null;
}
// #endif
// #ifdef MP-WEIXIN
// 中止小程序的请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// #endif
// #ifdef APP-PLUS
// 中止 App 的请求
if (this.requestTask) {
this.requestTask.abort();
this.requestTask = null;
}
// #endif
}
}
// 创建流式请求实例
export const streamRequest = new StreamRequest();